fix: system notifications, test buttons, apprise in Docker, and logo icon (#391)
This commit is contained in:
+6
-2
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user