fix: system notifications, test buttons, apprise in Docker, and logo icon (#391)

This commit is contained in:
Carlos Florêncio
2026-03-08 18:59:56 +00:00
committed by GitHub
parent c70e97c104
commit fe9d91ddb3
10 changed files with 219 additions and 38 deletions
+6 -2
View File
@@ -61,8 +61,12 @@ RUN apt-get update && \
git \
ca-certificates \
gosu \
tini && \
rm -rf /var/lib/apt/lists/*
tini \
python3 \
python3-venv \
pipx && \
rm -rf /var/lib/apt/lists/* && \
PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install apprise
# Replace the node base image's default user so we own uid 1000
RUN userdel -r node && groupadd -r kandev && useradd -r -g kandev -u 1000 -m kandev
@@ -84,6 +84,10 @@ func (c *Controller) DeleteProvider(ctx context.Context, providerID string) erro
return c.service.DeleteProvider(ctx, providerID)
}
func (c *Controller) TestProvider(ctx context.Context, providerID string) error {
return c.service.TestProvider(ctx, providerID)
}
func defaultNameForType(providerType string) string {
switch providerType {
case string(models.ProviderTypeApprise):
@@ -1,6 +1,7 @@
package handlers
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
@@ -8,6 +9,7 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/notifications/controller"
"github.com/kandev/kandev/internal/notifications/dto"
"github.com/kandev/kandev/internal/notifications/service"
"go.uber.org/zap"
)
@@ -26,6 +28,7 @@ func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, log *logger
api.POST("/notification-providers", h.httpCreateProvider)
api.PATCH("/notification-providers/:id", h.httpUpdateProvider)
api.DELETE("/notification-providers/:id", h.httpDeleteProvider)
api.POST("/notification-providers/:id/test", h.httpTestProvider)
}
func (h *Handlers) httpListProviders(c *gin.Context) {
@@ -69,6 +72,20 @@ func (h *Handlers) httpUpdateProvider(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
func (h *Handlers) httpTestProvider(c *gin.Context) {
providerID := c.Param("id")
if err := h.controller.TestProvider(c.Request.Context(), providerID); err != nil {
if errors.Is(err, service.ErrProviderNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
h.logger.Error("test notification failed", zap.String("provider_id", providerID), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send test notification"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func (h *Handlers) httpDeleteProvider(c *gin.Context) {
providerID := c.Param("id")
if err := h.controller.DeleteProvider(c.Request.Context(), providerID); err != nil {
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -8,38 +8,69 @@ import (
"sync"
)
//go:embed assets/toast-notification.ps1
//go:embed assets/toast-notification.ps1 assets/icon.png
var systemAssetsFS embed.FS
const assetDir = "kandev-notifications"
type systemAssets struct {
once sync.Once
scriptOnce sync.Once
scriptPath string
err error
scriptErr error
iconOnce sync.Once
iconPath string
iconErr error
}
func (a *systemAssets) ensureScript() (string, error) {
a.once.Do(func() {
dir := filepath.Join(os.TempDir(), "kandev-notifications")
a.scriptOnce.Do(func() {
dir := filepath.Join(os.TempDir(), assetDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
a.err = fmt.Errorf("create notifications temp dir: %w", err)
a.scriptErr = fmt.Errorf("create notifications temp dir: %w", err)
return
}
const scriptName = "toast-notification.ps1"
scriptPath := filepath.Join(dir, scriptName)
scriptPath := filepath.Join(dir, "toast-notification.ps1")
if _, err := os.Stat(scriptPath); err == nil {
a.scriptPath = scriptPath
return
}
data, err := systemAssetsFS.ReadFile("assets/toast-notification.ps1")
if err != nil {
a.err = fmt.Errorf("read toast script: %w", err)
a.scriptErr = fmt.Errorf("read toast script: %w", err)
return
}
if err := os.WriteFile(scriptPath, data, 0o644); err != nil {
a.err = fmt.Errorf("write toast script: %w", err)
a.scriptErr = fmt.Errorf("write toast script: %w", err)
return
}
a.scriptPath = scriptPath
})
return a.scriptPath, a.err
return a.scriptPath, a.scriptErr
}
func (a *systemAssets) ensureIcon() (string, error) {
a.iconOnce.Do(func() {
dir := filepath.Join(os.TempDir(), assetDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
a.iconErr = fmt.Errorf("create notifications temp dir: %w", err)
return
}
iconPath := filepath.Join(dir, "icon.png")
if _, err := os.Stat(iconPath); err == nil {
a.iconPath = iconPath
return
}
data, err := systemAssetsFS.ReadFile("assets/icon.png")
if err != nil {
a.iconErr = fmt.Errorf("read icon: %w", err)
return
}
if err := os.WriteFile(iconPath, data, 0o644); err != nil {
a.iconErr = fmt.Errorf("write icon: %w", err)
return
}
a.iconPath = iconPath
})
return a.iconPath, a.iconErr
}
@@ -194,14 +194,19 @@ func parseTimeoutMS(cfg *systemConfig, raw map[string]interface{}) error {
}
func (p *SystemProvider) sendNotification(ctx context.Context, cfg systemConfig, title, body string) error {
if cfg.IconPath == "" {
if iconPath, err := p.assets.ensureIcon(); err == nil {
cfg.IconPath = iconPath
}
}
switch runtime.GOOS {
case osDarwin:
return runCommand(ctx, "osascript", "-e", buildAppleScript(title, body))
return p.sendDarwinNotification(ctx, cfg, title, body)
case osLinux:
if isWSL() {
return p.sendWindowsNotification(ctx, cfg, title, body)
}
return sendLinuxNotification(ctx, cfg, title, body)
return p.sendLinuxNotification(ctx, cfg, title, body)
case osWindows:
return p.sendWindowsNotification(ctx, cfg, title, body)
default:
@@ -209,6 +214,17 @@ func (p *SystemProvider) sendNotification(ctx context.Context, cfg systemConfig,
}
}
func (p *SystemProvider) sendDarwinNotification(ctx context.Context, cfg systemConfig, title, body string) error {
if _, err := exec.LookPath("terminal-notifier"); err == nil {
args := []string{"-title", title, "-message", body, "-group", "kandev"}
if cfg.IconPath != "" {
args = append(args, "-contentImage", cfg.IconPath)
}
return runCommand(ctx, "terminal-notifier", args...)
}
return runCommand(ctx, "osascript", "-e", buildAppleScript(title, body))
}
func (p *SystemProvider) sendWindowsNotification(ctx context.Context, cfg systemConfig, title, body string) error {
scriptPath, err := p.assets.ensureScript()
if err != nil {
@@ -247,13 +263,22 @@ func (p *SystemProvider) sendWindowsNotification(ctx context.Context, cfg system
return runCommand(ctx, "powershell.exe", args...)
}
func sendLinuxNotification(ctx context.Context, cfg systemConfig, title, body string) error {
func (p *SystemProvider) sendLinuxNotification(ctx context.Context, cfg systemConfig, title, body string) error {
if _, err := exec.LookPath("notify-send"); err == nil {
return runCommand(ctx, "notify-send", "-t", strconv.Itoa(cfg.TimeoutMS), title, body)
args := []string{"-t", strconv.Itoa(cfg.TimeoutMS)}
if cfg.IconPath != "" {
args = append(args, "-i", cfg.IconPath)
}
args = append(args, title, body)
return runCommand(ctx, "notify-send", args...)
}
if _, err := exec.LookPath("zenity"); err == nil {
message := strings.TrimSpace(fmt.Sprintf("%s\n%s", title, body))
return runCommand(ctx, "zenity", "--notification", "--text", message)
args := []string{"--notification", "--text", message}
if cfg.IconPath != "" {
args = append(args, "--window-icon="+cfg.IconPath)
}
return runCommand(ctx, "zenity", args...)
}
return fmt.Errorf("notify-send or zenity is required for system notifications")
}
@@ -321,7 +346,7 @@ func runCommand(ctx context.Context, name string, args ...string) error {
defer cancel()
}
cmd := exec.CommandContext(ctx, name, args...)
return cmd.Start()
return cmd.Run()
}
func isWSL() bool {
@@ -301,6 +301,23 @@ func (s *Service) ensureSystemProvider(ctx context.Context, userID string) error
return s.repo.ReplaceSubscriptions(ctx, provider.ID, userID, []string{EventTaskSessionWaitingForInput})
}
func (s *Service) TestProvider(ctx context.Context, providerID string) error {
provider, err := s.repo.GetProvider(ctx, providerID)
if err != nil {
return ErrProviderNotFound
}
adapter := s.providers[provider.Type]
if adapter == nil {
return fmt.Errorf("unknown provider type: %s", provider.Type)
}
return adapter.Send(ctx, providers.Message{
EventType: EventTaskSessionWaitingForInput,
Title: "Test notification",
Body: "If you can read this, notifications are working.",
Config: provider.Config,
})
}
func (s *Service) validateProvider(providerType models.ProviderType, config map[string]interface{}) error {
adapter := s.providers[providerType]
if adapter == nil {
@@ -4,6 +4,7 @@ import { useState } from "react";
import {
createNotificationProvider,
deleteNotificationProvider,
testNotificationProvider,
updateNotificationProvider,
} from "@/lib/api";
import { useRequest } from "@/lib/http/use-request";
@@ -346,11 +347,20 @@ export function useNotificationsActions(state: NotificationsState, bumpPermissio
});
};
const handleTestProvider = async (providerId: string) => {
try {
await testNotificationProvider(providerId);
} catch (error) {
console.error("[NotificationsSettings] Test notification failed", error);
}
};
return {
handleToggleEvent,
handleRequestPermission,
handleRefreshPermission,
handleTestNotification,
handleTestProvider,
...appriseActions,
};
}
@@ -110,12 +110,14 @@ type NotificationEventsTableProps = {
tableProviders: NotificationProvider[];
tableEvents: string[];
onToggleEvent: (provider: NotificationProvider, eventType: string) => void;
onTestProvider: (providerId: string) => Promise<void>;
};
function NotificationEventsTable({
tableProviders,
tableEvents,
onToggleEvent,
onTestProvider,
}: NotificationEventsTableProps) {
if (tableProviders.length === 0) {
return <p className="text-sm text-muted-foreground">No providers configured yet.</p>;
@@ -129,7 +131,27 @@ function NotificationEventsTable({
<th className="px-4 py-3 text-left font-medium">Notification type</th>
{tableProviders.map((provider) => (
<th key={provider.id} className="px-4 py-3 text-center font-medium">
{provider.name}
<div className="flex items-center justify-center gap-1.5">
<span>{provider.name}</span>
{provider.type !== "local" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 cursor-pointer"
aria-label={`Send test notification for ${provider.name}`}
onClick={() => void onTestProvider(provider.id)}
>
<IconBell className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Send test notification</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</th>
))}
</tr>
@@ -165,6 +187,55 @@ function NotificationEventsTable({
);
}
function AppriseProviderCardActions({
provider,
onOpenForm,
onDeleteProvider,
onTestProvider,
}: {
provider: NotificationProvider;
onOpenForm: (mode: AppriseFormMode, provider: NotificationProvider) => void;
onDeleteProvider: (providerId: string) => void;
onTestProvider: (providerId: string) => Promise<void>;
}) {
return (
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 cursor-pointer"
aria-label={`Send test notification for ${provider.name}`}
onClick={() => void onTestProvider(provider.id)}
>
<IconBell className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Send test notification</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onOpenForm("edit", provider)}
>
Edit
</Button>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onDeleteProvider(provider.id)}
>
Remove
</Button>
</div>
);
}
function AppriseProviderList({
providers,
appriseFormMode,
@@ -178,6 +249,7 @@ function AppriseProviderList({
onOpenForm,
onCloseForm,
onDeleteProvider,
onTestProvider,
onTextareaInput,
}: {
providers: NotificationProvider[];
@@ -192,6 +264,7 @@ function AppriseProviderList({
onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void;
onCloseForm: () => void;
onDeleteProvider: (providerId: string) => void;
onTestProvider: (providerId: string) => Promise<void>;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
}) {
return (
@@ -223,24 +296,12 @@ function AppriseProviderList({
<div className="font-medium">{provider.name}</div>
<div className="text-xs text-muted-foreground">Apprise</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onOpenForm("edit", provider)}
>
Edit
</Button>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onDeleteProvider(provider.id)}
>
Remove
</Button>
</div>
<AppriseProviderCardActions
provider={provider}
onOpenForm={onOpenForm}
onDeleteProvider={onDeleteProvider}
onTestProvider={onTestProvider}
/>
</div>
)}
</div>
@@ -279,6 +340,7 @@ type ExternalProvidersSectionProps = {
onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void;
onCloseForm: () => void;
onDeleteProvider: (id: string) => void;
onTestProvider: (id: string) => Promise<void>;
onTextareaInput: (e: FormEvent<HTMLTextAreaElement>) => void;
onCreateAppriseProvider: () => Promise<void>;
};
@@ -298,6 +360,7 @@ function ExternalProvidersSection({
onOpenForm,
onCloseForm,
onDeleteProvider,
onTestProvider,
onTextareaInput,
onCreateAppriseProvider,
}: ExternalProvidersSectionProps) {
@@ -339,6 +402,7 @@ function ExternalProvidersSection({
onOpenForm={onOpenForm}
onCloseForm={onCloseForm}
onDeleteProvider={onDeleteProvider}
onTestProvider={onTestProvider}
onTextareaInput={onTextareaInput}
/>
{appriseAvailable && (
@@ -448,6 +512,7 @@ export function NotificationsSettings() {
onOpenForm={actions.openAppriseForm}
onCloseForm={actions.closeAppriseForm}
onDeleteProvider={actions.handleDeleteProvider}
onTestProvider={actions.handleTestProvider}
onTextareaInput={handleTextareaInput}
onCreateAppriseProvider={actions.handleCreateAppriseProvider}
/>
@@ -464,6 +529,7 @@ export function NotificationsSettings() {
tableProviders={tableProviders}
tableEvents={tableEvents}
onToggleEvent={actions.handleToggleEvent}
onTestProvider={actions.handleTestProvider}
/>
)}
</div>
+7
View File
@@ -331,6 +331,13 @@ export async function updateNotificationProvider(
});
}
export async function testNotificationProvider(providerId: string, options?: ApiRequestOptions) {
return fetchJson<void>(`/api/v1/notification-providers/${providerId}/test`, {
...options,
init: { method: "POST", ...(options?.init ?? {}) },
});
}
export async function deleteNotificationProvider(providerId: string, options?: ApiRequestOptions) {
return fetchJson<void>(`/api/v1/notification-providers/${providerId}`, {
...options,