feat: Allow user to update the schema directly from the dashboard
Includes: - Dashboard "Update Schema" action - Multiple notification/alert fixes and enhancements - This allow to update the schema for the latest update of environment, this update include with multiple fixes Closes The notification function is not available #138 Still can't enable and create notifications, And A new problems #136 Can not turn on notification (Telegram , Mail....) #120 Notification type 'slack' not supported #26 REQUEST: webhook notification #43 Notification can't be enabled (Discord) #77
This commit is contained in:
@@ -1,50 +1,293 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
Card, CardContent, CardDescription, CardHeader, CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Github, FileText, Twitter, MessageCircle, Code2, ServerIcon,
|
||||
} from "lucide-react";
|
||||
import { Github, FileText, Twitter, MessageCircle, Code2, ServerIcon, FolderOpen, Database, CheckCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
|
||||
export const AboutSystem: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { theme } = useTheme();
|
||||
const { systemName } = useSystemSettings();
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [mergeFields, setMergeFields] = useState(true);
|
||||
const [importResult, setImportResult] = useState<{
|
||||
success: boolean;
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
} | null>(null);
|
||||
|
||||
const [version, setVersion] = useState<string>('...');
|
||||
const [releaseDate, setReleaseDate] = useState<string>('...');
|
||||
// Helper function to check if a collection is a system collection
|
||||
const isSystemCollection = (name: string): boolean => {
|
||||
return name.startsWith('_') || ['users'].includes(name);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLatestRelease = async () => {
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/operacle/checkcle/releases/latest');
|
||||
const data = await res.json();
|
||||
setVersion(data.tag_name || 'v1.x.x');
|
||||
setReleaseDate(data.published_at ? format(new Date(data.published_at), 'MMMM d, yyyy') : t('unknown'));
|
||||
} catch (err) {
|
||||
setVersion('v1.x.x');
|
||||
setReleaseDate(t('unknown'));
|
||||
// Helper function to validate collection schema
|
||||
const validateCollectionSchema = (collection: any): string | null => {
|
||||
if (!collection.name) return "Collection name is required";
|
||||
if (typeof collection.name !== 'string') return "Collection name must be a string";
|
||||
if (!/^[a-z][a-z0-9_]*$/.test(collection.name)) {
|
||||
return "Collection name must start with a letter and contain only lowercase letters, numbers, and underscores";
|
||||
}
|
||||
if (collection.name.length < 3 || collection.name.length > 32) {
|
||||
return "Collection name must be between 3 and 32 characters";
|
||||
}
|
||||
|
||||
// Validate schema array if present
|
||||
if (collection.schema && !Array.isArray(collection.schema)) {
|
||||
return "Collection schema must be an array";
|
||||
}
|
||||
|
||||
// Validate each field in the schema
|
||||
if (collection.schema) {
|
||||
for (const field of collection.schema) {
|
||||
if (!field.name || typeof field.name !== 'string') {
|
||||
return `Invalid field name in collection '${collection.name}'`;
|
||||
}
|
||||
if (!field.type || typeof field.type !== 'string') {
|
||||
return `Invalid field type for '${field.name}' in collection '${collection.name}'`;
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchLatestRelease();
|
||||
}, [t]);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSchemaImport = async (schemaData: string) => {
|
||||
if (!schemaData.trim()) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "No schema data provided",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsImporting(true);
|
||||
setImportResult(null);
|
||||
|
||||
// Parse the JSON to validate it
|
||||
const collections = JSON.parse(schemaData);
|
||||
|
||||
if (!Array.isArray(collections)) {
|
||||
throw new Error("Schema must be an array of collections");
|
||||
}
|
||||
|
||||
// Check if user is authenticated as admin
|
||||
if (!pb.authStore.isValid) {
|
||||
throw new Error("Authentication required. Please log in as an admin.");
|
||||
}
|
||||
// Create/update collections one by one using PocketBase client
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const collection of collections) {
|
||||
try {
|
||||
// Skip system collections
|
||||
if (isSystemCollection(collection.name)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate collection schema
|
||||
const validationError = validateCollectionSchema(collection);
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
// Check if collection already exists
|
||||
const existingCollections = await pb.collections.getFullList();
|
||||
const existingCollection = existingCollections.find(c => c.name === collection.name);
|
||||
|
||||
if (existingCollection) {
|
||||
if (mergeFields) {
|
||||
|
||||
try {
|
||||
// Get existing schema
|
||||
const existingSchema = existingCollection.schema || [];
|
||||
const newSchema = collection.schema || [];
|
||||
// Merge schemas - add new fields, keep existing ones
|
||||
const mergedSchema = [...existingSchema];
|
||||
let fieldsAdded = 0;
|
||||
|
||||
for (const newField of newSchema) {
|
||||
const existingFieldIndex = mergedSchema.findIndex(f => f.name === newField.name);
|
||||
if (existingFieldIndex >= 0) {
|
||||
} else {
|
||||
// Add new field
|
||||
mergedSchema.push(newField);
|
||||
fieldsAdded++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the collection with merged schema
|
||||
const updateData = {
|
||||
...collection,
|
||||
schema: mergedSchema
|
||||
};
|
||||
|
||||
const updatedCollection = await pb.collections.update(existingCollection.id, updateData);
|
||||
updatedCount++;
|
||||
} catch (mergeError) {
|
||||
throw new Error(`Failed to merge fields: ${mergeError.message || mergeError}`);
|
||||
}
|
||||
} else {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Create new collection
|
||||
try {
|
||||
const newCollection = await pb.collections.create(collection);
|
||||
successCount++;
|
||||
} catch (createError) {
|
||||
|
||||
// Extract more detailed error information
|
||||
let errorMessage = createError.message || createError;
|
||||
if (createError.data) {
|
||||
// Try to extract field-specific errors
|
||||
if (createError.data.data) {
|
||||
const fieldErrors = Object.entries(createError.data.data).map(([field, msgs]) =>
|
||||
`${field}: ${Array.isArray(msgs) ? msgs.join(', ') : msgs}`
|
||||
).join('; ');
|
||||
if (fieldErrors) {
|
||||
errorMessage += ` - Field errors: ${fieldErrors}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to create collection: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
const errorMsg = `Failed to process collection '${collection.name}': ${error.message || error}`;
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
// Set import result for display
|
||||
setImportResult({
|
||||
success: successCount + updatedCount > 0,
|
||||
created: successCount,
|
||||
updated: updatedCount,
|
||||
skipped: skippedCount
|
||||
});
|
||||
|
||||
// Show results
|
||||
const totalProcessed = successCount + updatedCount;
|
||||
if (totalProcessed > 0 || skippedCount > 0) {
|
||||
let description = '';
|
||||
if (totalProcessed > 0) {
|
||||
description = `Successfully processed ${totalProcessed} collection(s)`;
|
||||
if (successCount > 0) description += ` (${successCount} created)`;
|
||||
if (updatedCount > 0) description += ` (${updatedCount} updated)`;
|
||||
}
|
||||
if (skippedCount > 0) {
|
||||
if (description) description += `, `;
|
||||
description += `${skippedCount} skipped (system collections or existing)`;
|
||||
}
|
||||
if (errorCount > 0) description += `. ${errorCount} failed.`;
|
||||
|
||||
toast({
|
||||
title: "Import Complete",
|
||||
description,
|
||||
variant: totalProcessed > errorCount ? "default" : "destructive",
|
||||
});
|
||||
} else if (errorCount > 0) {
|
||||
toast({
|
||||
title: "Import Failed",
|
||||
description: "No collections were processed successfully.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "No Changes",
|
||||
description: "All collections already exist and no changes were made.",
|
||||
variant: "default",
|
||||
});
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
// Show detailed errors for debugging
|
||||
if (errors.length <= 3) {
|
||||
toast({
|
||||
title: "Detailed Errors",
|
||||
description: errors.join('; '),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Import Failed",
|
||||
description: error instanceof Error ? error.message : "Failed to import schema",
|
||||
variant: "destructive",
|
||||
});
|
||||
setImportResult({
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
skipped: 0
|
||||
});
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadLocalSchema = async () => {
|
||||
try {
|
||||
setIsImporting(true);
|
||||
setImportResult(null);
|
||||
|
||||
const response = await fetch('/upload/data/pb_schema_latest.json');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load local schema: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const schemaText = await response.text();
|
||||
|
||||
// Validate that it's valid JSON
|
||||
JSON.parse(schemaText);
|
||||
|
||||
// Directly import the schema
|
||||
await handleSchemaImport(schemaText);
|
||||
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Import Failed",
|
||||
description: error instanceof Error ? error.message : "Failed to load and import local schema file",
|
||||
variant: "destructive",
|
||||
});
|
||||
setImportResult({
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
skipped: 0
|
||||
});
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('aboutSystem')}</h1>
|
||||
<p className="text-muted-foreground text-base leading-relaxed mt-2">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<Separator />
|
||||
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
|
||||
<CardHeader className="bg-muted/50 pb-4">
|
||||
@@ -58,7 +301,7 @@ export const AboutSystem: React.FC = () => {
|
||||
<div className="flex flex-col space-y-3 pt-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">{t('systemVersion')}</span>
|
||||
<span className="text-foreground font-medium">{version}</span>
|
||||
<span className="text-foreground font-medium">{t('version')} 1.5.1</span>
|
||||
</div>
|
||||
<Separator className="my-1" />
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -68,22 +311,20 @@ export const AboutSystem: React.FC = () => {
|
||||
<Separator className="my-1" />
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">{t('releasedOn')}</span>
|
||||
<span className="text-foreground font-medium">{releaseDate}</span>
|
||||
<span className="text-foreground font-medium">Auguest 21, 2025</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
|
||||
<CardHeader className="bg-muted/50 pb-4">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Code2 className={`h-5 w-5 ${theme === 'dark' ? 'text-emerald-400' : 'text-emerald-600'}`} />
|
||||
<span>{t('links')}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="font-medium text-base">
|
||||
{systemName || 'CheckCle'} {t('resources').toLowerCase()}
|
||||
</CardDescription>
|
||||
<CardDescription className="font-medium text-base">{systemName || 'CheckCle'} {t('resources').toLowerCase()}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
@@ -107,8 +348,90 @@ export const AboutSystem: React.FC = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
|
||||
<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>
|
||||
</CardTitle>
|
||||
<CardDescription className="font-medium text-base">Automatic import collections schema</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="merge-fields"
|
||||
checked={mergeFields}
|
||||
onCheckedChange={(checked) => setMergeFields(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="merge-fields" className="text-sm font-medium">
|
||||
Merge fields with existing collections (safe - preserves data)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
onClick={handleLoadLocalSchema}
|
||||
disabled={isImporting}
|
||||
className={`flex items-center gap-2 transition-all duration-200 ${
|
||||
isImporting ? 'animate-pulse' : ''
|
||||
}`}
|
||||
>
|
||||
<Database className={`h-4 w-4 ${isImporting ? 'animate-spin' : ''}`} />
|
||||
{isImporting ? 'Importing...' : 'Click to update Schema'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{importResult && (
|
||||
<div className={`mt-4 p-4 rounded-lg border ${
|
||||
importResult.success
|
||||
? 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800'
|
||||
: 'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800'
|
||||
} animate-in fade-in-0 slide-in-from-bottom-2 duration-300`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className={`h-5 w-5 ${
|
||||
importResult.success
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`} />
|
||||
<span className={`font-medium ${
|
||||
importResult.success
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: 'text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{importResult.success ? 'Import Successful' : 'Import Failed'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`mt-2 text-sm ${
|
||||
importResult.success
|
||||
? '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`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="mb-2">
|
||||
<strong>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>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutSystem;
|
||||
export default AboutSystem;
|
||||
Reference in New Issue
Block a user