feat: Add notification, integrate data retention service, and improve i18n support (#164)

* fix(ssl):  Ensure edit form saves notification_id and template_id in DB.

- The SSL edit form was not saving the `notification_id` and `template_id` fields to the PB database when re-assigning the Notification Channel and Alert Template.

* chore(partners): add Cloudflare to Ecosystem & Community Partner section

- Cloudflare has been added to the Ecosystem & Community Partner list to acknowledge their support and contribution.

*  chore(project): update development status and progress

- fix(ssl): ensure edit form saves notification_id and template_id in DB
- feat(notifications): add Notifiarr notification channel (planned)

* feat: Add Notifiarr to notification channels (UI)

- Add Notifiarr as a new channel type in the notification settings. The Notifiarr channel will use the `api_token` field for API key storage in PB.

* feat: Add channel_id field for Notifiarr

- Add channel_id field for Notifiarr to store record of the discord channel_id into the PB.

* feat: Implement Notifiarr notification service

- The Notifiarr notification functionality has been implemented. The alert configuration channel will now use the `api_token` and `channel_id` field for send to the Notifiarr API endpoint.

*  chore(project): update development status and progress

- feat: Add channel_id field for Notifiarr
- feat: Implement Notifiarr notification service

Closes: #133

* feat: add SSL history and Notifiarr notification channel schema

* Fix: Remove duplicate close button

- Removed the duplicate close button from the test email dialog.

* feat: Add Gotify to notification channels

- Add Gotify  as a new channel type in the notification settings. The Gotify channel will use the `api_token` and `server_url` field for API key storage in PB.

* feat: Implement Gotify notification service

- The Gotify notification functionality has been implemented. The alert configuration channel will now use the `api_token` and `server_url` field for send to the Gotify API endpoint.

*  chore(project): update development status and progress
- feat: Add server_url field for Gotify
- feat: Implement Gotify notification service

* refactory(i18n): Improve internationalization configuration and add new translations

- Added multiple new translation items
- Updated the two language files: en and zhcn
- Added type definitions for new translation items in types/services.ts
- Modified some components to use the new translation items

* refactory(i18n): Added architecture import function translation for the "About" page

- Added translation items related to schema import in English, Chinese, and type definition files
- Replaced hard-coded text with new translation items in the AboutSystem component

* refactory(i18n): Added architecture import function translation for the "About" page

- Added translation items related to schema import in English, Chinese, and type definition files
- Replaced hard-coded text with new translation items in the AboutSystem component

* refactory(i18n): Add internationalization support for the service statistics card

* feat: Add NTFY API token field for Token-based authentication to ntfy server

- Add the api_token field for NTFY notifications in both the dialog and service to save it to PB. NTFY schema to include api_token field.

* feat(auth): add token support for ntfy

- Updated the NTFY service to support API token authentication by adding Authorization Bearer header when api_token is provided.

Closes: #89

* refactor(i18n): Added Chinese translations for Create Event and Maintenance

- Supplemented missing Chinese translations
- Added translation entries related to Create Event and Maintenance in both English and Chinese translation files

* chore: deactivate GitHub donation

* refactor(i18n): Optimize internationalization configuration and add new translation items

- Add internationalization support to the LoadingState component
- Implement internationalization translations in the Profile page
- Use internationalized text in the TestEmailDialog component
- Update English and Chinese translation files by adding new translation entries

* docs(readme): deactivate donations, accept only infra support (cloud, domain, hosting)

* feat(i18n): Enhanced internationalization support with variable interpolation

- Refactored the `t` function in LanguageContext to support multiple parameter combinations
- Added variable interpolation feature, allowing variables in translated texts
- Optimized translation logic to improve accuracy and performance
- Enhanced type safety by using TypeScript generics to ensure correct typing

* refactor(i18n): Optimize service pagination internationalization copy

- Added service pagination related copy in English and Chinese translation files
- Updated type definitions to include new translation fields
- Modified the ServicesPagination component to use the newly added translation fields

* docs(README_zhcn): Update documentation content and sponsorship policy

- Update image links in the documentation
- Revise the sponsorship policy to no longer accept individual sponsorships, only enterprise-level collaborations
- Update the display method for supporters and partners
- Add Cloudflare and DigitalOcean as new partners

* feat: Integrate data retention service

- Implemented the data retention service in Go (service-operation) that manages cleanup of old records based on configured retention periods. The service runs once per day to ensure outdated data is removed automatically.

* refactor: Remove manual cleanup buttons from Data Retention Settings

- The three manual cleanup buttons have been removed from the dashboard.
- The data retention service now automatically runs once per day, cleaning up older data based on the configured retention settings.

---------

Co-authored-by: YiZixuan <sqkkyzx@qq.com>
Co-authored-by: YiZixuan <sqkkyzx@outlook.com>
This commit is contained in:
Tola Leng
2025-09-13 21:38:38 +07:00
committed by GitHub
parent c1464202c7
commit 257718e9d6
54 changed files with 2148 additions and 237 deletions
@@ -355,9 +355,9 @@ export const AboutSystem: React.FC = () => {
<CardHeader className="bg-muted/50 pb-4">
<CardTitle className="flex items-center gap-2">
<Database className={`h-5 w-5 ${theme === 'dark' ? 'text-blue-400' : 'text-blue-600'}`} />
<span>Update Schema</span>
<span>{t('updateSchema')}</span>
</CardTitle>
<CardDescription className="font-medium text-base">Automatic import collections schema</CardDescription>
<CardDescription className="font-medium text-base">{t('updateSchemaDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-6">
<div className="space-y-3">
@@ -368,7 +368,7 @@ export const AboutSystem: React.FC = () => {
onCheckedChange={(checked) => setMergeFields(checked === true)}
/>
<Label htmlFor="merge-fields" className="text-sm font-medium">
Merge fields with existing collections (safe - preserves data)
{t('mergeFieldsLabel')}
</Label>
</div>
</div>
@@ -382,7 +382,7 @@ export const AboutSystem: React.FC = () => {
}`}
>
<Database className={`h-4 w-4 ${isImporting ? 'animate-spin' : ''}`} />
{isImporting ? 'Importing...' : 'Click to update Schema'}
{isImporting ? t('importing') : t('clickToUpdateSchema')}
</Button>
</div>
@@ -403,7 +403,7 @@ export const AboutSystem: React.FC = () => {
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}>
{importResult.success ? 'Import Successful' : 'Import Failed'}
{importResult.success ? t('importSuccessful') : t('importFailed')}
</span>
</div>
<div className={`mt-2 text-sm ${
@@ -411,21 +411,21 @@ export const AboutSystem: React.FC = () => {
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}>
{importResult.created > 0 && `${importResult.created} collections created`}
{importResult.updated > 0 && (importResult.created > 0 ? ', ' : '') + `${importResult.updated} collections updated`}
{importResult.skipped > 0 && ((importResult.created > 0 || importResult.updated > 0) ? ', ' : '') + `${importResult.skipped} collections skipped`}
{importResult.created > 0 && t('collectionsCreatedCount').replace('{count}', String(importResult.created))}
{importResult.updated > 0 && (importResult.created > 0 ? ', ' : '') + t('collectionsUpdatedCount').replace('{count}', String(importResult.updated))}
{importResult.skipped > 0 && ((importResult.created > 0 || importResult.updated > 0) ? ', ' : '') + t('collectionsSkippedCount').replace('{count}', String(importResult.skipped))}
</div>
</div>
)}
<div className="text-sm text-muted-foreground">
<p className="mb-2">
<strong>Instructions:</strong>
<strong>{t('instructions')}:</strong>
</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li><strong>Merge fields:</strong> Safely add new fields to existing collections, preserves all data</li>
<li>System collections (starting with _) and users collection will be skipped automatically</li>
<li>Only authenticated admins can perform schema imports</li>
<li><strong>{t('mergeFields')}:</strong> {t('instructionsMergeFields')}</li>
<li>{t('instructionsCollections')}</li>
<li>{t('instructionsImportAuth')}</li>
</ul>
</div>
</CardContent>
@@ -16,6 +16,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { TemplateType, templateTypeConfigs } from "@/services/templateService";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import {z} from "zod";
interface TemplateDialogProps {
open: boolean;
@@ -81,6 +82,7 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
};
const renderPlaceholderGuide = () => {
const config = templateTypeConfigs[selectedTemplateType];
if (!config) return null;
@@ -52,7 +52,6 @@ const DataRetentionSettings = () => {
setLastCleanup(result.lastCleanup);
}
} catch (error) {
console.error("Error loading retention settings:", error);
toast({
title: "Error",
description: "Failed to load retention settings",
@@ -72,7 +71,6 @@ const DataRetentionSettings = () => {
description: "Data retention settings have been updated",
});
} catch (error) {
console.error("Error saving retention settings:", error);
toast({
title: "Error",
description: "Failed to save retention settings",
@@ -96,7 +94,6 @@ const DataRetentionSettings = () => {
// Reload settings to get updated last cleanup time
await loadSettings();
} catch (error) {
console.error("Error during uptime cleanup:", error);
toast({
title: "Error",
description: "Failed to perform uptime data cleanup",
@@ -120,7 +117,6 @@ const DataRetentionSettings = () => {
// Reload settings to get updated last cleanup time
await loadSettings();
} catch (error) {
console.error("Error during server cleanup:", error);
toast({
title: "Error",
description: "Failed to perform server data cleanup",
@@ -144,7 +140,6 @@ const DataRetentionSettings = () => {
// Reload settings to get updated last cleanup time
await loadSettings();
} catch (error) {
console.error("Error during manual cleanup:", error);
toast({
title: "Error",
description: "Failed to perform database cleanup",
@@ -170,7 +165,7 @@ const DataRetentionSettings = () => {
<Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950 dark:border-blue-800">
<AlertTriangle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<AlertDescription className="text-blue-700 dark:text-blue-300">
<span className="font-medium">{t("permissionNotice")}</span> As an admin user, you do not have access to data retention settings. These settings can only be accessed and modified by Super Admins.
<span className="font-medium">Permission Notice:</span> As an admin user, you do not have access to data retention settings. These settings can only be accessed and modified by Super Admins.
</AlertDescription>
</Alert>
</CardContent>
@@ -210,10 +205,16 @@ const DataRetentionSettings = () => {
min="1"
max="365"
value={settings.uptimeRetentionDays}
onChange={(e) => setSettings(prev => ({
...prev,
uptimeRetentionDays: parseInt(e.target.value) || 30
}))}
onChange={(e) => {
const value = e.target.value;
if (value === '' || isNaN(Number(value))) {
return;
}
setSettings(prev => ({
...prev,
uptimeRetentionDays: Number(value)
}));
}}
className="mt-1"
/>
<p className="text-sm text-muted-foreground mt-1">
@@ -229,10 +230,16 @@ const DataRetentionSettings = () => {
min="1"
max="365"
value={settings.serverRetentionDays}
onChange={(e) => setSettings(prev => ({
...prev,
serverRetentionDays: parseInt(e.target.value) || 30
}))}
onChange={(e) => {
const value = e.target.value;
if (value === '' || isNaN(Number(value))) {
return;
}
setSettings(prev => ({
...prev,
serverRetentionDays: Number(value)
}));
}}
className="mt-1"
/>
<p className="text-sm text-muted-foreground mt-1">
@@ -250,63 +257,17 @@ const DataRetentionSettings = () => {
</Alert>
)}
</CardContent>
<CardFooter className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2 w-full">
<Button
variant="outline"
onClick={handleUptimeShrink}
disabled={isUptimeShrinking}
className="flex items-center gap-2"
>
{isUptimeShrinking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Globe className="h-4 w-4" />
)}
Shrink Uptime Data
</Button>
<Button
variant="outline"
onClick={handleServerShrink}
disabled={isServerShrinking}
className="flex items-center gap-2"
>
{isServerShrinking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Server className="h-4 w-4" />
)}
Shrink Server Data
</Button>
<Button
variant="outline"
onClick={handleFullShrink}
disabled={isFullShrinking}
className="flex items-center gap-2"
>
{isFullShrinking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
Full Database Shrink
</Button>
</div>
<div className="flex justify-end w-full">
<Button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
Save Changes
</Button>
</div>
<CardFooter className="flex justify-end">
<Button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
Save Changes
</Button>
</CardFooter>
</Card>
</div>
@@ -126,15 +126,6 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
<Mail className="h-5 w-5" />
{t("sendTestEmail", "settings")}
</DialogTitle>
<Button
variant="ghost"
size="icon"
className="absolute right-4 top-4"
onClick={handleClose}
disabled={isLoading}
>
<X className="h-4 w-4" />
</Button>
</DialogHeader>
<div className="space-y-4 py-4">
@@ -203,9 +194,7 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
{/* Info message */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This will send a test email using your configured SMTP settings. Make sure SMTP is properly configured first.
</AlertDescription>
<AlertDescription>{t("testEmailAlert", "settings")}</AlertDescription>
</Alert>
</div>
@@ -223,7 +212,7 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
) : (
<Mail className="h-4 w-4" />
)}
{isLoading ? t("sending", "settings") : t("send", "common")}
{isLoading ? t("sending", "settings") : t("send", "settings")}
</Button>
</DialogFooter>
</DialogContent>
@@ -27,6 +27,7 @@ import { Switch } from "@/components/ui/switch";
import { Loader2, Copy } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "@/hooks/use-toast";
import { useLanguage } from "@/contexts/LanguageContext";
interface NotificationChannelDialogProps {
open: boolean;
@@ -34,9 +35,10 @@ interface NotificationChannelDialogProps {
editingConfig: AlertConfiguration | null;
}
const baseSchema = z.object({
notify_name: z.string().min(1, "Name is required"),
notification_type: z.enum(["telegram", "discord", "slack", "signal", "google_chat", "email", "ntfy", "pushover", "webhook"]),
notification_type: z.enum(["telegram", "discord", "slack", "signal", "google_chat", "email", "ntfy", "pushover", "notifiarr", "gotify", "webhook"]),
enabled: z.boolean().default(true),
service_id: z.string().default("global"),
template_id: z.string().optional(),
@@ -81,6 +83,19 @@ const emailSchema = baseSchema.extend({
const ntfySchema = baseSchema.extend({
notification_type: z.literal("ntfy"),
ntfy_endpoint: z.string().url("Must be a valid NTFY endpoint URL"),
api_token: z.string().optional(),
});
const pushoverSchema = baseSchema.extend({
notification_type: z.literal("pushover"),
api_token: z.string().min(1, "API token is required"),
user_key: z.string().min(1, "User key is required"),
});
const notifiarrSchema = baseSchema.extend({
notification_type: z.literal("notifiarr"),
api_token: z.string().min(1, "API token is required"),
channel_id: z.string().min(1, "Channel ID is required"),
});
const webhookSchema = baseSchema.extend({
@@ -89,10 +104,10 @@ const webhookSchema = baseSchema.extend({
webhook_payload_template: z.string().optional(),
});
const pushoverSchema = baseSchema.extend({
notification_type: z.literal("pushover"),
const gotifySchema = baseSchema.extend({
notification_type: z.literal("gotify"),
api_token: z.string().min(1, "API token is required"),
user_key: z.string().min(1, "User key is required"),
server_url: z.string().url("Must be a valid server URL"),
});
const formSchema = z.discriminatedUnion("notification_type", [
@@ -104,6 +119,8 @@ const formSchema = z.discriminatedUnion("notification_type", [
emailSchema,
ntfySchema,
pushoverSchema,
notifiarrSchema,
gotifySchema,
webhookSchema,
]);
@@ -158,6 +175,19 @@ const notificationTypeOptions = [
description: "Send push notifications via Pushover",
icon: "/upload/notification/pushover.png"
},
{
value: "notifiarr",
label: "Notifiarr",
description: "Send notifications via Notifiarr",
icon: "/upload/notification/notifiarr.png"
},
{
value: "gotify",
label: "Gotify",
description: "Send push notifications via Gotify",
icon: "/upload/notification/gotify.png"
},
{
value: "webhook",
label: "Webhook",
@@ -566,22 +596,40 @@ export const NotificationChannelDialog = ({
)}
{notificationType === "ntfy" && (
<FormField
control={form.control}
name="ntfy_endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>NTFY Endpoint</FormLabel>
<FormControl>
<Input placeholder="https://ntfy.sh/your-topic" {...field} />
</FormControl>
<FormDescription>
The NTFY endpoint URL including your topic (e.g., https://ntfy.sh/checkcle)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<>
<FormField
control={form.control}
name="ntfy_endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>NTFY Endpoint</FormLabel>
<FormControl>
<Input placeholder="https://ntfy.sh/your-topic" {...field} />
</FormControl>
<FormDescription>
The NTFY endpoint URL including your topic (e.g., https://ntfy.sh/checkcle)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token (Optional)</FormLabel>
<FormControl>
<Input placeholder="Enter NTFY API token" {...field} type="password" />
</FormControl>
<FormDescription>
Optional API token for authentication with NTFY server
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "pushover" && (
@@ -620,6 +668,80 @@ export const NotificationChannelDialog = ({
/>
</>
)}
{notificationType === "notifiarr" && (
<>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input placeholder="Your Notifiarr API token" {...field} type="password" />
</FormControl>
<FormDescription>
Your Notifiarr API token for sending notifications
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="channel_id"
render={({ field }) => (
<FormItem>
<FormLabel>Channel ID</FormLabel>
<FormControl>
<Input placeholder="Discord Channel ID" {...field} />
</FormControl>
<FormDescription>
The Discord channel ID where notifications will be sent
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "gotify" && (
<>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input placeholder="Your Gotify API token" {...field} type="password" />
</FormControl>
<FormDescription>
Your Gotify application API token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="server_url"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input placeholder="https://your-gotify-server.com" {...field} />
</FormControl>
<FormDescription>
The URL of your Gotify server
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "webhook" && (
<>
@@ -67,6 +67,8 @@ export const NotificationChannelList = ({
case "signal": return "Signal";
case "google_chat": return "Google Chat";
case "email": return "Email";
case "pushover": return "Pushover";
case "notifiarr": return "Notifiarr";
case "webhook": return "Webhook";
default: return type || "Unknown";
}