feat: add external notification templates management

- Introduced NotificationTemplate model for reusable external notification templates.
- Implemented CRUD operations for external templates in NotificationService.
- Added routes for managing external templates in the API.
- Created frontend API methods for external templates.
- Enhanced Notifications page to manage external templates with a form and list view.
- Updated layout and login pages to improve UI consistency.
- Added integration tests for proxy host management with improved error handling.
This commit is contained in:
CI
2025-11-29 20:51:46 +00:00
parent 82dad8d9cb
commit 5cea5755a0
23 changed files with 889 additions and 19 deletions

View File

@@ -0,0 +1,97 @@
package handlers
import (
"net/http"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type NotificationTemplateHandler struct {
service *services.NotificationService
}
func NewNotificationTemplateHandler(s *services.NotificationService) *NotificationTemplateHandler {
return &NotificationTemplateHandler{service: s}
}
func (h *NotificationTemplateHandler) List(c *gin.Context) {
list, err := h.service.ListTemplates()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list templates"})
return
}
c.JSON(http.StatusOK, list)
}
func (h *NotificationTemplateHandler) Create(c *gin.Context) {
var t models.NotificationTemplate
if err := c.ShouldBindJSON(&t); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.CreateTemplate(&t); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create template"})
return
}
c.JSON(http.StatusCreated, t)
}
func (h *NotificationTemplateHandler) Update(c *gin.Context) {
id := c.Param("id")
var t models.NotificationTemplate
if err := c.ShouldBindJSON(&t); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
t.ID = id
if err := h.service.UpdateTemplate(&t); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update template"})
return
}
c.JSON(http.StatusOK, t)
}
func (h *NotificationTemplateHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.service.DeleteTemplate(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete template"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// Preview allows rendering an arbitrary template (provided in request) or a stored template by id.
func (h *NotificationTemplateHandler) Preview(c *gin.Context) {
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var tmplStr string
if id, ok := raw["template_id"].(string); ok && id != "" {
t, err := h.service.GetTemplate(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "template not found"})
return
}
tmplStr = t.Config
} else if s, ok := raw["template"].(string); ok {
tmplStr = s
}
data := map[string]interface{}{}
if d, ok := raw["data"].(map[string]interface{}); ok {
data = d
}
// Build a fake provider to leverage existing RenderTemplate logic
provider := models.NotificationProvider{Template: "custom", Config: tmplStr}
rendered, parsed, err := h.service.RenderTemplate(provider, data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered})
return
}
c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed})
}

View File

@@ -0,0 +1,52 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"io"
"testing"
"strings"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
db.AutoMigrate(&models.NotificationTemplate{})
return db
}
func TestNotificationTemplateCRUD(t *testing.T) {
db := setupDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationTemplateHandler(svc)
// Create
payload := `{"name":"Simple","config":"{\"title\": \"{{.Title}}\"}","template":"custom"}`
req := httptest.NewRequest("POST", "/", nil)
req.Body = io.NopCloser(strings.NewReader(payload))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
h.Create(c)
require.Equal(t, http.StatusCreated, w.Code)
// List
req2 := httptest.NewRequest("GET", "/", nil)
w2 := httptest.NewRecorder()
c2, _ := gin.CreateTestContext(w2)
c2.Request = req2
h.List(c2)
require.Equal(t, http.StatusOK, w2.Code)
var list []models.NotificationTemplate
require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &list))
require.Len(t, list, 1)
}

View File

@@ -32,6 +32,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
&models.ImportSession{},
&models.Notification{},
&models.NotificationProvider{},
&models.NotificationTemplate{},
&models.UptimeMonitor{},
&models.UptimeHeartbeat{},
&models.UptimeHost{},
@@ -163,6 +164,14 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
protected.POST("/notifications/providers/preview", notificationProviderHandler.Preview)
protected.GET("/notifications/templates", notificationProviderHandler.Templates)
// External notification templates (saved templates for providers)
notificationTemplateHandler := handlers.NewNotificationTemplateHandler(notificationService)
protected.GET("/notifications/external-templates", notificationTemplateHandler.List)
protected.POST("/notifications/external-templates", notificationTemplateHandler.Create)
protected.PUT("/notifications/external-templates/:id", notificationTemplateHandler.Update)
protected.DELETE("/notifications/external-templates/:id", notificationTemplateHandler.Delete)
protected.POST("/notifications/external-templates/preview", notificationTemplateHandler.Preview)
// Start background checker (every 1 minute)
go func() {
// Wait a bit for server to start

View File

@@ -0,0 +1,30 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// NotificationTemplate represents a reusable external notification template
// that can be applied when sending webhooks or other external notifications.
type NotificationTemplate struct {
ID string `gorm:"primaryKey" json:"id"`
Name string `json:"name"`
Description string `json:"description"`
// Config holds the JSON/template body for external webhook payloads
Config string `json:"config"`
// Template is a hint: minimal|detailed|custom (optional)
Template string `json:"template" gorm:"default:minimal"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (t *NotificationTemplate) BeforeCreate(tx *gorm.DB) (err error) {
if t.ID == "" {
t.ID = uuid.New().String()
}
return
}

View File

@@ -226,8 +226,17 @@ func (s *NotificationService) sendCustomWebhook(p models.NotificationProvider, d
port = "80"
}
}
targetURL := fmt.Sprintf("%s://%s%s", u.Scheme, net.JoinHostPort(selectedIP.String(), port), u.RequestURI())
req, err := http.NewRequest("POST", targetURL, &body)
// Construct a safe URL using the resolved IP:port for the Host component,
// while preserving the original path and query from the user-provided URL.
// This makes the destination hostname unambiguously an IP that we resolved
// and prevents accidental requests to private/internal addresses.
safeURL := &neturl.URL{
Scheme: u.Scheme,
Host: net.JoinHostPort(selectedIP.String(), port),
Path: u.Path,
RawQuery: u.RawQuery,
}
req, err := http.NewRequest("POST", safeURL.String(), &body)
if err != nil {
return fmt.Errorf("failed to create webhook request: %w", err)
}
@@ -328,6 +337,35 @@ func (s *NotificationService) TestProvider(provider models.NotificationProvider)
return shoutrrr.Send(url, "Test notification from Charon")
}
// Templates (external notification templates) management
func (s *NotificationService) ListTemplates() ([]models.NotificationTemplate, error) {
var list []models.NotificationTemplate
if err := s.DB.Order("created_at desc").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (s *NotificationService) GetTemplate(id string) (*models.NotificationTemplate, error) {
var t models.NotificationTemplate
if err := s.DB.First(&t, "id = ?", id).Error; err != nil {
return nil, err
}
return &t, nil
}
func (s *NotificationService) CreateTemplate(t *models.NotificationTemplate) error {
return s.DB.Create(t).Error
}
func (s *NotificationService) UpdateTemplate(t *models.NotificationTemplate) error {
return s.DB.Save(t).Error
}
func (s *NotificationService) DeleteTemplate(id string) error {
return s.DB.Delete(&models.NotificationTemplate{}, "id = ?", id).Error
}
// RenderTemplate renders a provider template with provided data and returns
// the rendered JSON string and the parsed object for previewing/validation.
func (s *NotificationService) RenderTemplate(p models.NotificationProvider, data map[string]interface{}) (string, interface{}, error) {

View File

@@ -0,0 +1,58 @@
{
"apps": {
"http": {
"servers": {
"charon_server": {
"listen": [
":80",
":443"
],
"routes": [
{
"handle": [
{
"handler": "rewrite",
"uri": "/unknown.html"
},
{
"handler": "file_server",
"root": "/app/frontend/dist"
}
],
"terminal": true
}
],
"automatic_https": {},
"logs": {
"default_logger_name": "access_log"
}
}
}
}
},
"logging": {
"logs": {
"access": {
"writer": {
"output": "file",
"filename": "/app/data/logs/access.log",
"roll": true,
"roll_size_mb": 10,
"roll_keep": 5,
"roll_keep_days": 7
},
"encoder": {
"format": "json"
},
"level": "INFO",
"include": [
"http.log.access.access_log"
]
}
}
},
"storage": {
"module": "file_system",
"root": "/app/data/caddy/data"
}
}

View File

@@ -0,0 +1,58 @@
{
"apps": {
"http": {
"servers": {
"charon_server": {
"listen": [
":80",
":443"
],
"routes": [
{
"handle": [
{
"handler": "rewrite",
"uri": "/unknown.html"
},
{
"handler": "file_server",
"root": "/app/frontend/dist"
}
],
"terminal": true
}
],
"automatic_https": {},
"logs": {
"default_logger_name": "access_log"
}
}
}
}
},
"logging": {
"logs": {
"access": {
"writer": {
"output": "file",
"filename": "/app/data/logs/access.log",
"roll": true,
"roll_size_mb": 10,
"roll_keep": 5,
"roll_keep_days": 7
},
"encoder": {
"format": "json"
},
"level": "INFO",
"include": [
"http.log.access.access_log"
]
}
}
},
"storage": {
"module": "file_system",
"root": "/app/data/caddy/data"
}
}

View File

@@ -0,0 +1,75 @@
{
"apps": {
"http": {
"servers": {
"charon_server": {
"listen": [
":80",
":443"
],
"routes": [
{
"handle": [
{
"handler": "rewrite",
"uri": "/unknown.html"
},
{
"handler": "file_server",
"root": "/app/frontend/dist"
}
],
"terminal": true
}
],
"automatic_https": {},
"logs": {
"default_logger_name": "access_log"
}
}
}
},
"tls": {
"automation": {
"policies": [
{
"issuers": [
{
"email": "admin@example.com",
"module": "acme"
},
{
"module": "zerossl"
}
]
}
]
}
}
},
"logging": {
"logs": {
"access": {
"writer": {
"output": "file",
"filename": "/app/data/logs/access.log",
"roll": true,
"roll_size_mb": 10,
"roll_keep": 5,
"roll_keep_days": 7
},
"encoder": {
"format": "json"
},
"level": "INFO",
"include": [
"http.log.access.access_log"
]
}
}
},
"storage": {
"module": "file_system",
"root": "/app/data/caddy/data"
}
}

View File

@@ -0,0 +1,99 @@
{
"apps": {
"http": {
"servers": {
"charon_server": {
"listen": [
":80",
":443"
],
"routes": [
{
"match": [
{
"host": [
"test2.localhost"
]
}
],
"handle": [
{
"handler": "vars"
},
{
"flush_interval": -1,
"handler": "reverse_proxy",
"upstreams": [
{
"dial": "host.docker.internal:8081"
}
]
}
],
"terminal": true
},
{
"handle": [
{
"handler": "rewrite",
"uri": "/unknown.html"
},
{
"handler": "file_server",
"root": "/app/frontend/dist"
}
],
"terminal": true
}
],
"automatic_https": {},
"logs": {
"default_logger_name": "access_log"
}
}
}
},
"tls": {
"automation": {
"policies": [
{
"issuers": [
{
"email": "admin@example.com",
"module": "acme"
},
{
"module": "zerossl"
}
]
}
]
}
}
},
"logging": {
"logs": {
"access": {
"writer": {
"output": "file",
"filename": "/app/data/logs/access.log",
"roll": true,
"roll_size_mb": 10,
"roll_keep": 5,
"roll_keep_days": 7
},
"encoder": {
"format": "json"
},
"level": "INFO",
"include": [
"http.log.access.access_log"
]
}
}
},
"storage": {
"module": "file_system",
"root": "/app/data/caddy/data"
}
}

View File

@@ -0,0 +1,24 @@
-----BEGIN CERTIFICATE-----
MIIBxDCCAWqgAwIBAgIRAKxvOpSX7dY/DFcxlxeVYlwwCgYIKoZIzj0EAwIwMzEx
MC8GA1UEAxMoQ2FkZHkgTG9jYWwgQXV0aG9yaXR5IC0gRUNDIEludGVybWVkaWF0
ZTAeFw0yNTExMjkxODI1NDdaFw0yNTExMzAwNjI1NDdaMAAwWTATBgcqhkjOPQIB
BggqhkjOPQMBBwNCAARW60CpqeJ5U4xDgS0qtdxoxImMBtfgBQdL84thZUu2aUbn
/PwNsnplo1zK6T3XUQPs6oMp4vT3Ay0HhkZJI8u3o4GRMIGOMA4GA1UdDwEB/wQE
AwIHgDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFC+t
WqIT91x5K/dJmk4048hU+lFMMB8GA1UdIwQYMBaAFMhhDbgnCp960HTlyVla/ULK
skuUMB0GA1UdEQEB/wQTMBGCD3Rlc3QyLmxvY2FsaG9zdDAKBggqhkjOPQQDAgNI
ADBFAiAJycukC7hroy2QaM+ORchMwba7A83f5qSjdnDmM/h8AQIhAMx0AbU4nJlF
j2iAKlVsZaPze+F3OfBbm0Jg7emfFmx4
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIBxzCCAW2gAwIBAgIQLEy0I3NtCyk+vKrWiqWa9TAKBggqhkjOPQQDAjAwMS4w
LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI1IEVDQyBSb290MB4X
DTI1MTEyOTE4MjU0N1oXDTI1MTIwNjE4MjU0N1owMzExMC8GA1UEAxMoQ2FkZHkg
TG9jYWwgQXV0aG9yaXR5IC0gRUNDIEludGVybWVkaWF0ZTBZMBMGByqGSM49AgEG
CCqGSM49AwEHA0IABBzP8BZUlO8uEk7c09Sl3I68CS+AC60w+l+DIKuaqhi+sCJM
ksM3MFZ6SfGs8rURi6MZqqkRfJqsF6ma/ko/oiyjZjBkMA4GA1UdDwEB/wQEAwIB
BjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBTIYQ24JwqfetB05clZWv1C
yrJLlDAfBgNVHSMEGDAWgBREcndLnTskIjkt5DalMgkrk+/+iDAKBggqhkjOPQQD
AgNIADBFAiAZ1KKvFsJGdbCSbTpEl5CQQrPf7PQzYN7w9AFpcGl3iQIhAKMy7uy8
Hr0w5vrl/1R9FcrvNZKsDwquCBVr/BKAAIsk
-----END CERTIFICATE-----

View File

@@ -0,0 +1,6 @@
{
"sans": [
"test2.localhost"
],
"issuer_data": null
}

View File

@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIHuTybGDaH2llLl0Ye/IRlcL7UEluaswZWqFHo7A4WZyoAoGCCqGSM49
AwEHoUQDQgAEVutAqanieVOMQ4EtKrXcaMSJjAbX4AUHS/OLYWVLtmlG5/z8DbJ6
ZaNcyuk911ED7OqDKeL09wMtB4ZGSSPLtw==
-----END EC PRIVATE KEY-----

View File

@@ -0,0 +1 @@
{"tls":{"timestamp":"2025-11-29T18:19:07.702634586Z","instance_id":"2acc9ef3-fc3e-40f5-9462-d6682722eb94"}}

View File

@@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIBxzCCAW2gAwIBAgIQLEy0I3NtCyk+vKrWiqWa9TAKBggqhkjOPQQDAjAwMS4w
LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI1IEVDQyBSb290MB4X
DTI1MTEyOTE4MjU0N1oXDTI1MTIwNjE4MjU0N1owMzExMC8GA1UEAxMoQ2FkZHkg
TG9jYWwgQXV0aG9yaXR5IC0gRUNDIEludGVybWVkaWF0ZTBZMBMGByqGSM49AgEG
CCqGSM49AwEHA0IABBzP8BZUlO8uEk7c09Sl3I68CS+AC60w+l+DIKuaqhi+sCJM
ksM3MFZ6SfGs8rURi6MZqqkRfJqsF6ma/ko/oiyjZjBkMA4GA1UdDwEB/wQEAwIB
BjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBTIYQ24JwqfetB05clZWv1C
yrJLlDAfBgNVHSMEGDAWgBREcndLnTskIjkt5DalMgkrk+/+iDAKBggqhkjOPQQD
AgNIADBFAiAZ1KKvFsJGdbCSbTpEl5CQQrPf7PQzYN7w9AFpcGl3iQIhAKMy7uy8
Hr0w5vrl/1R9FcrvNZKsDwquCBVr/BKAAIsk
-----END CERTIFICATE-----

View File

@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIK62nlQXS+XmU6lLY1dkxanQW+5C+hDMRkAyMeLVPDrioAoGCCqGSM49
AwEHoUQDQgAEHM/wFlSU7y4STtzT1KXcjrwJL4ALrTD6X4Mgq5qqGL6wIkySwzcw
VnpJ8azytRGLoxmqqRF8mqwXqZr+Sj+iLA==
-----END EC PRIVATE KEY-----

View File

@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBojCCAUmgAwIBAgIQFfTjqoMpNZnTSWKmX53qCzAKBggqhkjOPQQDAjAwMS4w
LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI1IEVDQyBSb290MB4X
DTI1MTEyOTE4MjU0N1oXDTM1MTAwODE4MjU0N1owMDEuMCwGA1UEAxMlQ2FkZHkg
TG9jYWwgQXV0aG9yaXR5IC0gMjAyNSBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG
SM49AwEHA0IABPSnVwHAJdl5JJN8JT2K0VxGmtXMx1qMeQIq3bG891mR/Fa889/k
PS8lb/txO1kDbkS46ZJZn1+iWRYGroHM9iejRTBDMA4GA1UdDwEB/wQEAwIBBjAS
BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBREcndLnTskIjkt5DalMgkrk+/+
iDAKBggqhkjOPQQDAgNHADBEAiBcfxd1wNE1WakMLWMYU2kGCUTyB/S9MD0vlYtL
AmTaUQIgJQ4Og2/PSGhG0UYGpICBI/dhxVkm7HQGKDiTaUNDHcE=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIE4Z8xnl0mswc0hJile7xtFbVWhqcvgYS8ofcOY9rhJhoAoGCCqGSM49
AwEHoUQDQgAE9KdXAcAl2Xkkk3wlPYrRXEaa1czHWox5Airdsbz3WZH8Vrzz3+Q9
LyVv+3E7WQNuRLjpklmfX6JZFgaugcz2Jw==
-----END EC PRIVATE KEY-----

View File

@@ -50,3 +50,41 @@ export const previewProvider = async (provider: Partial<NotificationProvider>, d
const response = await client.post('/notifications/providers/preview', payload);
return response.data;
};
// External (saved) templates API
export interface ExternalTemplate {
id: string;
name: string;
description?: string;
config?: string;
template?: string;
created_at?: string;
}
export const getExternalTemplates = async () => {
const response = await client.get<ExternalTemplate[]>('/notifications/external-templates');
return response.data;
};
export const createExternalTemplate = async (data: Partial<ExternalTemplate>) => {
const response = await client.post<ExternalTemplate>('/notifications/external-templates', data);
return response.data;
};
export const updateExternalTemplate = async (id: string, data: Partial<ExternalTemplate>) => {
const response = await client.put<ExternalTemplate>(`/notifications/external-templates/${id}`, data);
return response.data;
};
export const deleteExternalTemplate = async (id: string) => {
await client.delete(`/notifications/external-templates/${id}`);
};
export const previewExternalTemplate = async (templateId?: string, template?: string, data?: Record<string, any>) => {
const payload: any = {};
if (templateId) payload.template_id = templateId;
if (template) payload.template = template;
if (data) payload.data = data;
const response = await client.post('/notifications/external-templates/preview', payload);
return response.data;
};

View File

@@ -94,7 +94,9 @@ export default function Layout({ children }: LayoutProps) {
`}>
<div className={`h-20 flex items-center justify-center border-b border-gray-200 dark:border-gray-800`}>
{isCollapsed ? (
<img src="/logo.png" alt="Charon" className="h-12 w-10" />
<img src="/logo.png" alt="Charon" style={{ height: '150px', width: 'auto' }}/>
) : (
<img src="/banner.png" alt="Charon" className="h-16 w-auto" />
)}

View File

@@ -60,7 +60,9 @@ export default function Login() {
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-4">
<div className="flex items-center justify-center">
<img src="/logo.png" alt="Charon" className="h-12 w-auto" />
<img src="/logo.png" alt="Charon" style={{ height: '150px', width: 'auto' }}/>
</div>
<Card className="w-full" title="Login">
<form onSubmit={handleSubmit} className="space-y-6">

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider } from '../api/notifications';
import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider, getExternalTemplates, previewExternalTemplate, ExternalTemplate, createExternalTemplate, updateExternalTemplate, deleteExternalTemplate } from '../api/notifications';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { Bell, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react';
@@ -51,15 +51,22 @@ const ProviderForm: React.FC<{
setPreviewContent(null);
setPreviewError(null);
try {
const res = await previewProvider(formData as Partial<NotificationProvider>);
if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered);
// If using an external saved template (id), call previewExternalTemplate with template_id
if (formData.template && typeof formData.template === 'string' && formData.template.length === 36) {
const res = await previewExternalTemplate(formData.template, undefined, undefined);
if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered);
} else {
const res = await previewProvider(formData as Partial<NotificationProvider>);
if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered);
}
} catch (err: any) {
setPreviewError(err?.response?.data?.error || err?.message || 'Failed to generate preview');
}
};
const type = watch('type');
const { data: templatesList } = useQuery({ queryKey: ['notificationTemplates'], queryFn: getTemplates });
const { data: builtins } = useQuery({ queryKey: ['notificationTemplates'], queryFn: getTemplates });
const { data: externalTemplates } = useQuery({ queryKey: ['externalTemplates'], queryFn: getExternalTemplates });
const template = watch('template');
const setTemplate = (templateStr: string, templateName?: string) => {
@@ -125,7 +132,12 @@ const ProviderForm: React.FC<{
<div className="mt-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Template</label>
<select {...register('template')} className="mt-1 block w-full rounded-md border-gray-300">
{templatesList?.map((t: any) => (
{/* Built-in template options */}
{builtins?.map((t: any) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
{/* External saved templates (id values are UUIDs) */}
{externalTemplates?.map((t: any) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
@@ -213,16 +225,82 @@ const ProviderForm: React.FC<{
);
};
const TemplateForm: React.FC<{
initialData?: Partial<ExternalTemplate>;
onClose: () => void;
onSubmit: (data: Partial<ExternalTemplate>) => void;
}> = ({ initialData, onClose, onSubmit }) => {
const { register, handleSubmit, watch } = useForm({
defaultValues: initialData || { template: 'custom', config: '' }
});
const [preview, setPreview] = useState<string | null>(null);
const [previewErr, setPreviewErr] = useState<string | null>(null);
const handlePreview = async () => {
setPreview(null);
setPreviewErr(null);
const form = watch();
try {
const res = await previewExternalTemplate(undefined, form.config, { Title: 'Preview Title', Message: 'Preview Message', Time: new Date().toISOString(), EventType: 'preview' });
if (res.parsed) setPreview(JSON.stringify(res.parsed, null, 2)); else setPreview(res.rendered);
} catch (err: any) {
setPreviewErr(err?.response?.data?.error || err?.message || 'Preview failed');
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input {...register('name', { required: true })} className="mt-1 block w-full rounded-md" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<input {...register('description')} className="mt-1 block w-full rounded-md" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Template Type</label>
<select {...register('template')} className="mt-1 block w-full rounded-md">
<option value="minimal">Minimal</option>
<option value="detailed">Detailed</option>
<option value="custom">Custom</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Config (JSON/template)</label>
<textarea {...register('config')} rows={6} className="mt-1 block w-full font-mono text-xs rounded-md" />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="button" variant="secondary" onClick={handlePreview}>Preview</Button>
<Button type="submit">Save</Button>
</div>
{previewErr && <div className="text-sm text-red-600">{previewErr}</div>}
{preview && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Preview</label>
<pre className="mt-1 p-2 bg-gray-50 dark:bg-gray-800 rounded text-xs overflow-auto whitespace-pre-wrap">{preview}</pre>
</div>
)}
</form>
);
};
const Notifications: React.FC = () => {
const queryClient = useQueryClient();
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [managingTemplates, setManagingTemplates] = useState(false);
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
const { data: providers, isLoading } = useQuery({
queryKey: ['notificationProviders'],
queryFn: getProviders,
});
const { data: externalTemplates } = useQuery({ queryKey: ['externalTemplates'], queryFn: getExternalTemplates });
const createMutation = useMutation({
mutationFn: createProvider,
onSuccess: () => {
@@ -246,6 +324,21 @@ const Notifications: React.FC = () => {
},
});
const createTemplateMutation = useMutation({
mutationFn: (data: Partial<ExternalTemplate>) => createExternalTemplate(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['externalTemplates'] }),
});
const updateTemplateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<ExternalTemplate> }) => updateExternalTemplate(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['externalTemplates'] }),
});
const deleteTemplateMutation = useMutation({
mutationFn: (id: string) => deleteExternalTemplate(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['externalTemplates'] }),
});
const testMutation = useMutation({
mutationFn: testProvider,
onSuccess: () => alert('Test notification sent!'),
@@ -267,6 +360,72 @@ const Notifications: React.FC = () => {
</Button>
</div>
{/* External Templates Management */}
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">External Templates</h2>
<div className="flex items-center gap-2">
<Button onClick={() => setManagingTemplates(!managingTemplates)} variant="secondary" size="sm">
{managingTemplates ? 'Hide' : 'Manage Templates'}
</Button>
<Button onClick={() => { setEditingTemplateId(null); setManagingTemplates(true); }}>
<Plus className="w-4 h-4 mr-2" />New Template
</Button>
</div>
</div>
{managingTemplates && (
<div className="space-y-4">
{/* Template Form area */}
{editingTemplateId !== null && (
<Card className="p-4">
<TemplateForm
initialData={externalTemplates?.find((t: any) => t.id === editingTemplateId) as Partial<ExternalTemplate>}
onClose={() => setEditingTemplateId(null)}
onSubmit={(data) => {
if (editingTemplateId) updateTemplateMutation.mutate({ id: editingTemplateId, data });
else createTemplateMutation.mutate(data as Partial<ExternalTemplate>);
}}
/>
</Card>
)}
{/* Create new when editingTemplateId is null and Manage Templates open -> show form */}
{editingTemplateId === null && (
<Card className="p-4">
<h3 className="font-medium mb-2">Create Template</h3>
<TemplateForm
onClose={() => setManagingTemplates(false)}
onSubmit={(data) => createTemplateMutation.mutate(data as Partial<ExternalTemplate>)}
/>
</Card>
)}
{/* List of templates */}
<div className="grid gap-3">
{externalTemplates?.map((t: ExternalTemplate) => (
<Card key={t.id} className="p-4 flex justify-between items-start">
<div>
<h4 className="font-medium text-gray-900 dark:text-white">{t.name}</h4>
<p className="text-sm text-gray-500 mt-1">{t.description}</p>
<pre className="mt-2 text-xs font-mono bg-gray-50 dark:bg-gray-800 p-2 rounded max-h-44 overflow-auto">{t.config}</pre>
</div>
<div className="flex flex-col gap-2 ml-4">
<Button size="sm" variant="secondary" onClick={() => setEditingTemplateId(t.id)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button size="sm" variant="danger" onClick={() => { if (confirm('Delete template?')) deleteTemplateMutation.mutate(t.id); }}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Card>
))}
{externalTemplates?.length === 0 && (
<div className="text-sm text-gray-500">No external templates. Use the form above to create one.</div>
)}
</div>
</div>
)}
{isAdding && (
<Card className="p-6 mb-6 border-blue-500 border-2">
<h3 className="text-lg font-medium mb-4">Add New Provider</h3>

View File

@@ -92,7 +92,9 @@ const Setup: React.FC = () => {
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md">
<div className="flex flex-col items-center">
<img src="/logo.png" alt="Charon" className="h-12 w-auto mb-4" />
<img src="/logo.png" alt="Charon" style={{ height: '150px', width: 'auto' }}/>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
Welcome to Charon
</h2>

View File

@@ -54,9 +54,26 @@ EXISTING_ID=$(curl -s -H "Authorization: Bearer $TOKEN" $API_URL/proxy-hosts | j
if [ -n "$EXISTING_ID" ]; then
echo "Found existing proxy host (ID: $EXISTING_ID), deleting..."
curl -s -X DELETE $API_URL/proxy-hosts/$EXISTING_ID -H "Authorization: Bearer $TOKEN"
# Wait until the host is removed and Caddy has reloaded
for i in $(seq 1 10); do
sleep 1
STILL_EXISTS=$(curl -s -H "Authorization: Bearer $TOKEN" $API_URL/proxy-hosts | jq -r --arg domain "test.localhost" '.[] | select(.domain_names == $domain) | .uuid' | head -n1)
if [ -z "$STILL_EXISTS" ]; then
break
fi
echo "Waiting for API to delete existing proxy host..."
done
fi
# Start a lightweight test upstream server to ensure proxy has a target (local-only)
python3 -c "import http.server, socketserver
# Start a lightweight test upstream server to ensure proxy has a target (local-only). If a
# whoami container is already running on the Docker network, prefer using that.
USE_HOST_WHOAMI=false
if command -v docker >/dev/null 2>&1; then
if docker ps --format '{{.Names}}' | grep -q '^whoami$'; then
USE_HOST_WHOAMI=true
fi
fi
if [ "$USE_HOST_WHOAMI" = "false" ]; then
python3 -c "import http.server, socketserver
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
@@ -64,20 +81,60 @@ class Handler(http.server.BaseHTTPRequestHandler):
self.wfile.write(b'Hostname: local-test')
def log_message(self, format, *args):
pass
httpd=socketserver.TCPServer((\"0.0.0.0\", 8081), Handler)
httpd=socketserver.TCPServer(('0.0.0.0', 8081), Handler)
import threading
threading.Thread(target=httpd.serve_forever, daemon=True).start()
" &
else
echo "Using existing whoami container for upstream tests"
fi
# We use 'whoami' as the forward host because they are on the same docker network
RESPONSE=$(curl -s -X POST $API_URL/proxy-hosts \
# Prefer "whoami" when running inside CI/docker (it resolves on the docker network).
# For local runs, default to 127.0.0.1 since we start the test upstream on the host —
# but if charon runs inside Docker and the upstream is bound to the host, we must
# use host.docker.internal so Caddy inside the container can reach the host service.
FORWARD_HOST="127.0.0.1"
FORWARD_PORT="8081"
if [ "$USE_HOST_WHOAMI" = "true" ]; then
FORWARD_HOST="whoami"
FORWARD_PORT="80"
fi
if [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; then
FORWARD_HOST="whoami"
# whoami image listens on port 80 inside its container
FORWARD_PORT="80"
fi
# If we're running charon in Docker locally and we didn't choose whoami, prefer
# host.docker.internal so that the containerized Caddy can reach a host-bound upstream.
if command -v docker >/dev/null 2>&1; then
if docker ps --format '{{.Names}}' | grep -q '^charon-debug$' || docker ps --format '{{.Image}}' | grep -q 'charon:local'; then
if [ "$FORWARD_HOST" = "127.0.0.1" ]; then
FORWARD_HOST="host.docker.internal"
fi
fi
fi
echo "Using forward host: $FORWARD_HOST:$FORWARD_PORT"
# Adjust the Caddy/Caddy proxy test port for local runs to avoid conflicts with
# host services on port 80.
CADDY_PORT="80"
if [ -z "$CI" ] && [ -z "$GITHUB_ACTIONS" ]; then
# Use a non-privileged port locally when binding to host: 8082
CADDY_PORT="8082"
fi
echo "Using Caddy host port: $CADDY_PORT"
# Retry creation up to 5 times if the apply config call fails due to Caddy reloads
RESPONSE=""
for attempt in 1 2 3 4 5; do
RESPONSE=$(curl -s -X POST $API_URL/proxy-hosts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"domain_names": "test.localhost",
"forward_scheme": "http",
"forward_host": "127.0.0.1",
"forward_port": 8081,
"forward_host": "'"$FORWARD_HOST"'",
"forward_port": '"$FORWARD_PORT"',
"access_list_id": null,
"certificate_id": null,
"ssl_forced": false,
@@ -89,6 +146,21 @@ RESPONSE=$(curl -s -X POST $API_URL/proxy-hosts \
"hsts_subdomains": false,
"locations": []
}')
# If Response contains a failure message indicating caddy apply failed, retry
if echo "$RESPONSE" | grep -q "Failed to apply configuration"; then
echo "Warning: failed to apply config on attempt $attempt, retrying..."
# Wait for Caddy admin API on host to respond to /config to reduce collisions
for i in $(seq 1 10); do
if curl -s -o /dev/null -w "%{http_code}" http://localhost:${CADDY_ADMIN_PORT:-20194}/config/ >/dev/null 2>&1; then
break
fi
sleep 1
done
sleep $attempt
continue
fi
break
done
ID=$(echo $RESPONSE | jq -r .uuid)
if [ -z "$ID" ] || [ "$ID" = "null" ]; then
@@ -100,8 +172,18 @@ echo "✅ Proxy Host created (ID: $ID)"
echo "Testing Proxy..."
# We use Host header to route to the correct proxy host
# We hit localhost:80 (Caddy) which should route to whoami
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: test.localhost" http://localhost:80)
CONTENT=$(curl -s -H "Host: test.localhost" http://localhost:80)
HTTP_CODE=0
CONTENT=""
# Retry probing Caddy for the new route for up to 10 seconds
for i in $(seq 1 10); do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: test.localhost" http://localhost:${CADDY_PORT} || true)
CONTENT=$(curl -s -H "Host: test.localhost" http://localhost:${CADDY_PORT} || true)
if [ "$HTTP_CODE" = "200" ] && echo "$CONTENT" | grep -q "Hostname:"; then
break
fi
echo "Waiting for Caddy to pick up new route ($i/10)..."
sleep 1
done
if [ "$HTTP_CODE" = "200" ] && echo "$CONTENT" | grep -q "Hostname:"; then
echo "✅ Proxy test passed! Content received from whoami."