feat: add system info and backup functionalities to settings page
- Integrated system info fetching in `SettingsPage` for improved user insights. - Enhanced `SettingsIndexPageClient` with manual backup creation and Jira connection testing features. - Added loading states and auto-dismiss messages for user feedback during actions. - Updated UI to display system info and backup statistics dynamically.
This commit is contained in:
16
actions/system-info.ts
Normal file
16
actions/system-info.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
'use server';
|
||||
|
||||
import { SystemInfoService } from '@/services/system-info';
|
||||
|
||||
export async function getSystemInfo() {
|
||||
try {
|
||||
const systemInfo = await SystemInfoService.getSystemInfo();
|
||||
return { success: true, data: systemInfo };
|
||||
} catch (error) {
|
||||
console.error('Error getting system info:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get system info'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,102 @@ import { Header } from '@/components/ui/Header';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { UserPreferencesProvider } from '@/contexts/UserPreferencesContext';
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect, useTransition } from 'react';
|
||||
import { backupClient } from '@/clients/backup-client';
|
||||
import { jiraClient } from '@/clients/jira-client';
|
||||
import { getSystemInfo } from '@/actions/system-info';
|
||||
import { SystemInfo } from '@/services/system-info';
|
||||
|
||||
interface SettingsIndexPageClientProps {
|
||||
initialPreferences: UserPreferences;
|
||||
initialSystemInfo?: SystemInfo;
|
||||
}
|
||||
|
||||
export function SettingsIndexPageClient({ initialPreferences }: SettingsIndexPageClientProps) {
|
||||
export function SettingsIndexPageClient({ initialPreferences, initialSystemInfo }: SettingsIndexPageClientProps) {
|
||||
// États pour les actions
|
||||
const [isBackupLoading, setIsBackupLoading] = useState(false);
|
||||
const [isJiraTestLoading, setIsJiraTestLoading] = useState(false);
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(initialSystemInfo || null);
|
||||
const [messages, setMessages] = useState<{
|
||||
backup?: { type: 'success' | 'error', text: string };
|
||||
jira?: { type: 'success' | 'error', text: string };
|
||||
}>({});
|
||||
|
||||
// useTransition pour le server action
|
||||
const [isSystemInfoLoading, startTransition] = useTransition();
|
||||
|
||||
// Fonction pour recharger les infos système (server action)
|
||||
const loadSystemInfo = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await getSystemInfo();
|
||||
if (result.success && result.data) {
|
||||
setSystemInfo(result.data);
|
||||
} else {
|
||||
console.error('Error loading system info:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading system info:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Fonction pour créer une sauvegarde manuelle
|
||||
const handleCreateBackup = async () => {
|
||||
setIsBackupLoading(true);
|
||||
try {
|
||||
const backup = await backupClient.createBackup();
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
backup: { type: 'success', text: `Sauvegarde créée: ${backup.filename}` }
|
||||
}));
|
||||
|
||||
// Recharger les infos système pour mettre à jour le nombre de sauvegardes
|
||||
loadSystemInfo();
|
||||
} catch {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
backup: { type: 'error', text: 'Erreur lors de la création de la sauvegarde' }
|
||||
}));
|
||||
} finally {
|
||||
setIsBackupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour tester la connexion Jira
|
||||
const handleTestJira = async () => {
|
||||
setIsJiraTestLoading(true);
|
||||
try {
|
||||
const status = await jiraClient.testConnection();
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
jira: {
|
||||
type: status.connected ? 'success' : 'error',
|
||||
text: status.connected ? 'Connexion Jira réussie !' : `Erreur: ${status.message || 'Connexion échouée'}`
|
||||
}
|
||||
}));
|
||||
} catch {
|
||||
setMessages(prev => ({
|
||||
...prev,
|
||||
jira: { type: 'error', text: 'Erreur lors du test de connexion Jira' }
|
||||
}));
|
||||
} finally {
|
||||
setIsJiraTestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-dismiss des messages après 5 secondes
|
||||
useEffect(() => {
|
||||
Object.keys(messages).forEach(key => {
|
||||
if (messages[key as keyof typeof messages]) {
|
||||
const timer = setTimeout(() => {
|
||||
setMessages(prev => ({ ...prev, [key]: undefined }));
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const settingsPages = [
|
||||
{
|
||||
href: '/settings/general',
|
||||
@@ -63,7 +153,7 @@ export function SettingsIndexPageClient({ initialPreferences }: SettingsIndexPag
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -82,9 +172,14 @@ export function SettingsIndexPageClient({ initialPreferences }: SettingsIndexPag
|
||||
<span className="text-2xl">🔌</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Jira</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">
|
||||
{initialPreferences.jiraConfig.enabled ? 'Configuré' : 'Non configuré'}
|
||||
</p>
|
||||
{initialPreferences.jiraConfig.enabled && (
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" title="Jira configuré"></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -101,6 +196,20 @@ export function SettingsIndexPageClient({ initialPreferences }: SettingsIndexPag
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">💾</span>
|
||||
<div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Sauvegardes</p>
|
||||
<p className="font-medium">
|
||||
{systemInfo ? systemInfo.database.totalBackups : '...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Settings Sections */}
|
||||
@@ -168,9 +277,22 @@ export function SettingsIndexPageClient({ initialPreferences }: SettingsIndexPag
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Créer une sauvegarde des données
|
||||
</p>
|
||||
{messages.backup && (
|
||||
<p className={`text-xs mt-1 ${
|
||||
messages.backup.type === 'success'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{messages.backup.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm">
|
||||
Sauvegarder
|
||||
<button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={isBackupLoading}
|
||||
className="px-3 py-1.5 bg-[var(--primary)] text-[var(--primary-foreground)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isBackupLoading ? 'En cours...' : 'Sauvegarder'}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -184,12 +306,22 @@ export function SettingsIndexPageClient({ initialPreferences }: SettingsIndexPag
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Tester la connexion Jira
|
||||
</p>
|
||||
{messages.jira && (
|
||||
<p className={`text-xs mt-1 ${
|
||||
messages.jira.type === 'success'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{messages.jira.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm"
|
||||
disabled={!initialPreferences.jiraConfig.enabled}
|
||||
onClick={handleTestJira}
|
||||
disabled={!initialPreferences.jiraConfig.enabled || isJiraTestLoading}
|
||||
className="px-3 py-1.5 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Tester
|
||||
{isJiraTestLoading ? 'Test...' : 'Tester'}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -200,23 +332,66 @@ export function SettingsIndexPageClient({ initialPreferences }: SettingsIndexPag
|
||||
{/* System Info */}
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">ℹ️ Informations système</h2>
|
||||
<button
|
||||
onClick={loadSystemInfo}
|
||||
disabled={isSystemInfoLoading}
|
||||
className="text-xs px-2 py-1 bg-[var(--card)] border border-[var(--border)] rounded hover:bg-[var(--card-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSystemInfoLoading ? '🔄 Chargement...' : '🔄 Actualiser'}
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
{systemInfo ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm mb-4">
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Version</p>
|
||||
<p className="font-medium">TowerControl v1.0.0</p>
|
||||
<p className="font-medium">TowerControl v{systemInfo.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Dernière maj</p>
|
||||
<p className="font-medium">Il y a 2 jours</p>
|
||||
<p className="font-medium">{systemInfo.lastUpdate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Env</p>
|
||||
<p className="font-medium">Development</p>
|
||||
<p className="text-[var(--muted-foreground)]">Environnement</p>
|
||||
<p className="font-medium capitalize">{systemInfo.environment}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Uptime</p>
|
||||
<p className="font-medium">{systemInfo.uptime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border)] pt-4">
|
||||
<h3 className="text-sm font-medium mb-3 text-[var(--muted-foreground)]">Base de données</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Tâches</p>
|
||||
<p className="font-medium">{systemInfo.database.totalTasks}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Utilisateurs</p>
|
||||
<p className="font-medium">{systemInfo.database.totalUsers}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Sauvegardes</p>
|
||||
<p className="font-medium">{systemInfo.database.totalBackups}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--muted-foreground)]">Taille DB</p>
|
||||
<p className="font-medium">{systemInfo.database.databaseSize}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-[var(--muted-foreground)]">Chargement des informations système...</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
191
services/system-info.ts
Normal file
191
services/system-info.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { prisma } from './database';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
export interface SystemInfo {
|
||||
version: string;
|
||||
environment: string;
|
||||
database: {
|
||||
totalTasks: number;
|
||||
totalUsers: number;
|
||||
totalBackups: number;
|
||||
databaseSize: string;
|
||||
};
|
||||
uptime: string;
|
||||
lastUpdate: string;
|
||||
}
|
||||
|
||||
export class SystemInfoService {
|
||||
/**
|
||||
* Récupère les informations système complètes
|
||||
*/
|
||||
static async getSystemInfo(): Promise<SystemInfo> {
|
||||
try {
|
||||
const [packageInfo, dbStats] = await Promise.all([
|
||||
this.getPackageInfo(),
|
||||
this.getDatabaseStats()
|
||||
]);
|
||||
|
||||
return {
|
||||
version: packageInfo.version,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
database: dbStats,
|
||||
uptime: this.getUptime(),
|
||||
lastUpdate: this.getLastUpdate()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting system info:', error);
|
||||
throw new Error('Failed to get system information');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit les informations du package.json
|
||||
*/
|
||||
private static async getPackageInfo(): Promise<{ version: string; name: string }> {
|
||||
try {
|
||||
const packagePath = join(process.cwd(), 'package.json');
|
||||
const packageContent = await readFile(packagePath, 'utf-8');
|
||||
const packageJson = JSON.parse(packageContent);
|
||||
|
||||
return {
|
||||
name: packageJson.name || 'TowerControl',
|
||||
version: packageJson.version || '1.0.0'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error reading package.json:', error);
|
||||
return {
|
||||
name: 'TowerControl',
|
||||
version: '1.0.0'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les statistiques de la base de données
|
||||
*/
|
||||
private static async getDatabaseStats() {
|
||||
try {
|
||||
const [totalTasks, totalUsers, totalBackups] = await Promise.all([
|
||||
prisma.task.count(),
|
||||
prisma.userPreferences.count(),
|
||||
// Pour les backups, on compte les fichiers via le service backup
|
||||
this.getBackupCount()
|
||||
]);
|
||||
|
||||
return {
|
||||
totalTasks,
|
||||
totalUsers,
|
||||
totalBackups,
|
||||
databaseSize: await this.getDatabaseSize()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting database stats:', error);
|
||||
return {
|
||||
totalTasks: 0,
|
||||
totalUsers: 0,
|
||||
totalBackups: 0,
|
||||
databaseSize: 'N/A'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte le nombre de sauvegardes
|
||||
*/
|
||||
private static async getBackupCount(): Promise<number> {
|
||||
try {
|
||||
// Import dynamique pour éviter les dépendances circulaires
|
||||
const { backupService } = await import('./backup');
|
||||
const backups = await backupService.listBackups();
|
||||
return backups.length;
|
||||
} catch (error) {
|
||||
console.error('Error counting backups:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estime la taille de la base de données
|
||||
*/
|
||||
private static async getDatabaseSize(): Promise<string> {
|
||||
try {
|
||||
const { stat } = await import('fs/promises');
|
||||
const { resolve } = await import('path');
|
||||
|
||||
// Utiliser la même logique que le service de backup pour trouver la DB
|
||||
let dbPath: string;
|
||||
if (process.env.BACKUP_DATABASE_PATH) {
|
||||
dbPath = resolve(process.cwd(), process.env.BACKUP_DATABASE_PATH);
|
||||
} else if (process.env.DATABASE_URL) {
|
||||
dbPath = resolve(process.env.DATABASE_URL.replace('file:', ''));
|
||||
} else {
|
||||
dbPath = resolve(process.cwd(), 'prisma', 'dev.db');
|
||||
}
|
||||
|
||||
const stats = await stat(dbPath);
|
||||
|
||||
// Convertir en format lisible
|
||||
const bytes = stats.size;
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
} catch (error) {
|
||||
console.error('Error getting database size:', error);
|
||||
return 'N/A';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule l'uptime du processus
|
||||
*/
|
||||
private static getUptime(): string {
|
||||
const uptime = process.uptime();
|
||||
const hours = Math.floor(uptime / 3600);
|
||||
const minutes = Math.floor((uptime % 3600) / 60);
|
||||
const seconds = Math.floor(uptime % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne une date de dernière mise à jour fictive
|
||||
* (dans un vrai projet, cela viendrait d'un système de déploiement)
|
||||
*/
|
||||
private static getLastUpdate(): string {
|
||||
// Pour l'instant, on utilise la date de modification du package.json
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const packagePath = join(process.cwd(), 'package.json');
|
||||
const stats = fs.statSync(packagePath);
|
||||
const now = new Date();
|
||||
const lastModified = new Date(stats.mtime);
|
||||
const diffTime = Math.abs(now.getTime() - lastModified.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
return 'Il y a 1 jour';
|
||||
} else if (diffDays < 7) {
|
||||
return `Il y a ${diffDays} jours`;
|
||||
} else if (diffDays < 30) {
|
||||
const weeks = Math.floor(diffDays / 7);
|
||||
return `Il y a ${weeks} semaine${weeks > 1 ? 's' : ''}`;
|
||||
} else {
|
||||
const months = Math.floor(diffDays / 30);
|
||||
return `Il y a ${months} mois`;
|
||||
}
|
||||
} catch {
|
||||
return 'Il y a 2 jours';
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/actions/system-info.ts
Normal file
16
src/actions/system-info.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
'use server';
|
||||
|
||||
import { SystemInfoService } from '@/services/system-info';
|
||||
|
||||
export async function getSystemInfo() {
|
||||
try {
|
||||
const systemInfo = await SystemInfoService.getSystemInfo();
|
||||
return { success: true, data: systemInfo };
|
||||
} catch (error) {
|
||||
console.error('Error getting system info:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get system info'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
import { userPreferencesService } from '@/services/user-preferences';
|
||||
import { SystemInfoService } from '@/services/system-info';
|
||||
import { SettingsIndexPageClient } from '@/components/settings/SettingsIndexPageClient';
|
||||
|
||||
// Force dynamic rendering (no static generation)
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function SettingsPage() {
|
||||
// Fetch basic data for the index page
|
||||
const preferences = await userPreferencesService.getAllPreferences();
|
||||
// Fetch data in parallel for better performance
|
||||
const [preferences, systemInfo] = await Promise.all([
|
||||
userPreferencesService.getAllPreferences(),
|
||||
SystemInfoService.getSystemInfo()
|
||||
]);
|
||||
|
||||
return <SettingsIndexPageClient initialPreferences={preferences} />;
|
||||
return (
|
||||
<SettingsIndexPageClient
|
||||
initialPreferences={preferences}
|
||||
initialSystemInfo={systemInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user