✨ feat(ansible): Ajoute les tâches cron pour recherche et sauvegarde
📝 style(templates): Crée un template de mail pour notifications de sauvegarde 🐛 fix(.gitignore): Exclut les fichiers de sauvegarde .zip 🎨 style(dashboard): Crée une page pour la gestion des sauvegardes ✨ feat(command): Implémente la commande de sauvegarde avec notification et rétention 🎨 style(dashboard): Améliore l'interface de recherche avec des effets visuels ✨ feat(dashboard): Ajoute une page pour la gestion des sauvegardes ✅ test(controller): Ajoute la logique de téléchargement et suppression des sauvegardes
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -48,3 +48,4 @@ backup/*.sql
|
||||
/public/site.*.webmanifest
|
||||
/public/sw.js
|
||||
###< spomky-labs/pwa-bundle ###
|
||||
/sauvegarde/*.zip
|
||||
|
||||
@@ -207,6 +207,18 @@
|
||||
become: false
|
||||
args:
|
||||
chdir: "{{ path }}"
|
||||
- name: "Cron Task Search"
|
||||
ansible.builtin.cron:
|
||||
name: "Intranet Ludikevent - Search"
|
||||
minute: "*/5"
|
||||
job: "php {{ path }}/bin/console app:search"
|
||||
user: root
|
||||
- name: "Cron Task Search"
|
||||
ansible.builtin.cron:
|
||||
name: "Intranet Ludikevent - Backup"
|
||||
minute: "*/5"
|
||||
job: "php {{ path }}/bin/console app:backup"
|
||||
user: root
|
||||
- name: Set correct permissions for Symfony cache and logs directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
|
||||
33
migrations/Version20260116094522.php
Normal file
33
migrations/Version20260116094522.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260116094522 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE backup (id SERIAL NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, status VARCHAR(255) NOT NULL, error_message TEXT DEFAULT NULL, size_mb DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))');
|
||||
$this->addSql('COMMENT ON COLUMN backup.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE SCHEMA public');
|
||||
$this->addSql('DROP TABLE backup');
|
||||
}
|
||||
}
|
||||
32
migrations/Version20260116094541.php
Normal file
32
migrations/Version20260116094541.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260116094541 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE backup ALTER size_mb DROP NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE SCHEMA public');
|
||||
$this->addSql('ALTER TABLE backup ALTER size_mb SET NOT NULL');
|
||||
}
|
||||
}
|
||||
0
sauvegarde/.gitignore
vendored
Normal file
0
sauvegarde/.gitignore
vendored
Normal file
161
src/Command/BackupCommand.php
Normal file
161
src/Command/BackupCommand.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Backup;
|
||||
use App\Service\Mailer\Mailer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
use ZipArchive;
|
||||
|
||||
#[AsCommand(name: 'app:backup', description: 'Sauvegarde complète PostgreSQL avec historique et notification')]
|
||||
class BackupCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Mailer $mailer,
|
||||
?string $name = null
|
||||
) {
|
||||
parent::__construct($name);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title('Démarrage de la sauvegarde PostgreSQL');
|
||||
|
||||
// 1. Initialisation du log en base de données
|
||||
$backupEntry = new Backup();
|
||||
$this->entityManager->persist($backupEntry);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$projectDir = $this->kernel->getProjectDir();
|
||||
$backupDir = $projectDir . '/sauvegarde';
|
||||
$sqlDumpPath = $projectDir . '/var/temp_db.sql';
|
||||
|
||||
try {
|
||||
if (!is_dir($backupDir)) {
|
||||
mkdir($backupDir, 0777, true);
|
||||
}
|
||||
|
||||
// 2. Extraction des paramètres DATABASE_URL
|
||||
$dbUrl = parse_url($_ENV['DATABASE_URL'] ?? '');
|
||||
if (!$dbUrl) throw new \Exception("DATABASE_URL non configurée dans le .env");
|
||||
|
||||
$dbName = ltrim($dbUrl['path'], '/');
|
||||
$dbUser = $dbUrl['user'] ?? '';
|
||||
$dbPass = $dbUrl['pass'] ?? '';
|
||||
$dbHost = $dbUrl['host'] ?? '127.0.0.1';
|
||||
$dbPort = $dbUrl['port'] ?? 5432;
|
||||
|
||||
// 3. Exécution du dump PostgreSQL
|
||||
$io->text('Extraction des données via pg_dump...');
|
||||
$command = sprintf(
|
||||
'PGPASSWORD=%s pg_dump -h %s -p %s -U %s -F p %s > %s',
|
||||
escapeshellarg($dbPass),
|
||||
escapeshellarg($dbHost),
|
||||
escapeshellarg((string)$dbPort),
|
||||
escapeshellarg($dbUser),
|
||||
escapeshellarg($dbName),
|
||||
escapeshellarg($sqlDumpPath)
|
||||
);
|
||||
|
||||
exec($command, $outputExec, $returnCode);
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
throw new \Exception("Échec de pg_dump (Code erreur: $returnCode)");
|
||||
}
|
||||
|
||||
// 4. Création de l'archive ZIP
|
||||
$fileName = sprintf('db_backup_%s.zip', date('Y-m-d_H-i'));
|
||||
$zipPath = $backupDir . '/' . $fileName;
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
|
||||
$zip->addFile($sqlDumpPath, 'database.sql');
|
||||
$zip->close();
|
||||
} else {
|
||||
throw new \Exception("Impossible d'écrire le fichier ZIP dans $backupDir");
|
||||
}
|
||||
|
||||
// 5. Enregistrement du succès
|
||||
$fileSizeMb = round(filesize($zipPath) / (1024 * 1024), 2);
|
||||
$backupEntry->setStatus('SUCCESS');
|
||||
$backupEntry->setSizeMb($fileSizeMb);
|
||||
|
||||
$io->success("Sauvegarde réussie : $fileName ($fileSizeMb MB)");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// 6. Gestion de l'échec
|
||||
$backupEntry->setStatus('ERROR');
|
||||
$backupEntry->setErrorMessage($e->getMessage());
|
||||
$io->error("Échec : " . $e->getMessage());
|
||||
}
|
||||
|
||||
// On enregistre les résultats finaux en BDD
|
||||
$this->entityManager->flush();
|
||||
|
||||
// 7. Envoi de la notification Mail
|
||||
$statusEmoji = ($backupEntry->getStatus() === 'SUCCESS') ? '✅' : '❌';
|
||||
$this->mailer->send(
|
||||
'notification@siteconseil.fr',
|
||||
'Intranet Ludievent',
|
||||
$statusEmoji . ' Sauvegarde - ' . date('d/m/Y'),
|
||||
'mails/backup_notification.twig',
|
||||
['backup' => $backupEntry]
|
||||
);
|
||||
|
||||
// 8. Nettoyage final
|
||||
if (file_exists($sqlDumpPath)) unlink($sqlDumpPath);
|
||||
$this->cleanOldFiles($backupDir, $io);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function cleanOldFiles(string $backupDir, SymfonyStyle $io): void
|
||||
{
|
||||
$io->text('Nettoyage des anciennes données (Fichiers et BDD)...');
|
||||
|
||||
// 1. Définition de la date limite (7 jours en arrière)
|
||||
$retentionDays = 7;
|
||||
$limitDate = new \DateTimeImmutable('-' . $retentionDays . ' days');
|
||||
$retentionTimestamp = time() - ($retentionDays * 24 * 60 * 60);
|
||||
|
||||
// 2. Suppression des fichiers ZIP physiques
|
||||
$files = glob($backupDir . '/*.zip');
|
||||
$deletedFiles = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (filemtime($file) <= $retentionTimestamp) {
|
||||
if (unlink($file)) {
|
||||
$deletedFiles++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Suppression des logs en base de données via une requête DQL
|
||||
// Cela évite de charger des milliers d'objets en mémoire
|
||||
$query = $this->entityManager->createQuery(
|
||||
'DELETE FROM App\Entity\Backup b WHERE b.createdAt <= :limitDate'
|
||||
)->setParameter('limitDate', $limitDate);
|
||||
|
||||
$deletedRows = $query->execute();
|
||||
|
||||
// 4. Rapport dans la console
|
||||
if ($deletedFiles > 0 || $deletedRows > 0) {
|
||||
$io->note(sprintf(
|
||||
"Nettoyage effectué :\n- Fichiers ZIP supprimés : %d\n- Historique BDD supprimé : %d entrées",
|
||||
$deletedFiles,
|
||||
$deletedRows
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/Controller/Dashboard/BackupController.php
Normal file
102
src/Controller/Dashboard/BackupController.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Dashboard;
|
||||
|
||||
use App\Entity\Backup;
|
||||
use App\Logger\AppLogger;
|
||||
use App\Repository\BackupRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class BackupController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AppLogger $appLogger,
|
||||
private readonly EntityManagerInterface $entityManager
|
||||
) {}
|
||||
|
||||
#[Route(path: '/crm/sauvegarde', name: 'app_crm_backup', methods: ['GET'])]
|
||||
public function crmSauvegarde(BackupRepository $backupRepository): Response
|
||||
{
|
||||
return $this->render('dashboard/backup.twig', [
|
||||
'backups' => $backupRepository->findBy([], ['createdAt' => 'DESC']),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/sauvegarde/download/{id}', name: 'app_crm_backup_download', methods: ['GET'])]
|
||||
public function download(Backup $backup): Response
|
||||
{
|
||||
$projectDir = $this->getParameter('kernel.project_dir');
|
||||
$fileName = sprintf('db_backup_%s.zip', $backup->getCreatedAt()->format('Y-m-d_H-i'));
|
||||
$filePath = $projectDir . '/sauvegarde/' . $fileName;
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
// Utilisation de record(string, string)
|
||||
$this->appLogger->record(
|
||||
'SECURITY_ALERT',
|
||||
"Fichier de sauvegarde introuvable : $fileName (ID: {$backup->getId()})"
|
||||
);
|
||||
|
||||
$this->addFlash('error', "Le fichier physique est introuvable sur le serveur.");
|
||||
return $this->redirectToRoute('app_crm_backup');
|
||||
}
|
||||
|
||||
$this->appLogger->record(
|
||||
'INFO',
|
||||
"Téléchargement de la sauvegarde : $fileName"
|
||||
);
|
||||
|
||||
$response = new BinaryFileResponse($filePath);
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $fileName);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/sauvegarde/delete/{id}', name: 'app_crm_backup_delete', methods: ['GET'])]
|
||||
public function delete(Backup $backup): Response
|
||||
{
|
||||
if (!$this->isGranted('ROLE_ROOT')) {
|
||||
$this->appLogger->record(
|
||||
'SECURITY_ALERT',
|
||||
"Tentative de suppression de sauvegarde sans droits ROOT (ID Backup: {$backup->getId()})"
|
||||
);
|
||||
|
||||
$this->addFlash('error', "Accès refusé. Rôle ROOT requis.");
|
||||
return $this->redirectToRoute('app_crm_backup');
|
||||
}
|
||||
|
||||
$projectDir = $this->getParameter('kernel.project_dir');
|
||||
$fileName = sprintf('db_backup_%s.zip', $backup->getCreatedAt()->format('Y-m-d_H-i'));
|
||||
$filePath = $projectDir . '/sauvegarde/' . $fileName;
|
||||
|
||||
try {
|
||||
if (file_exists($filePath)) {
|
||||
unlink($filePath);
|
||||
}
|
||||
|
||||
$backupId = $backup->getId();
|
||||
$this->entityManager->remove($backup);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->appLogger->record(
|
||||
'SECURITY_ALERT',
|
||||
"Suppression définitive de la sauvegarde ID: $backupId et du fichier $fileName"
|
||||
);
|
||||
|
||||
$this->addFlash('success', "La sauvegarde a été supprimée.");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->appLogger->record(
|
||||
'ERROR',
|
||||
"Erreur lors de la suppression de la sauvegarde : " . $e->getMessage()
|
||||
);
|
||||
$this->addFlash('error', "Erreur lors de la suppression.");
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_crm_backup');
|
||||
}
|
||||
}
|
||||
87
src/Entity/Backup.php
Normal file
87
src/Entity/Backup.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\BackupRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: BackupRepository::class)]
|
||||
class Backup
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $status = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $errorMessage = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?float $sizeMb = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->status = 'PENDING';
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(\DateTimeImmutable $createdAt): static
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): ?string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(string $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getErrorMessage(): ?string
|
||||
{
|
||||
return $this->errorMessage;
|
||||
}
|
||||
|
||||
public function setErrorMessage(?string $errorMessage): static
|
||||
{
|
||||
$this->errorMessage = $errorMessage;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSizeMb(): ?float
|
||||
{
|
||||
return $this->sizeMb;
|
||||
}
|
||||
|
||||
public function setSizeMb(float $sizeMb): static
|
||||
{
|
||||
$this->sizeMb = $sizeMb;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
43
src/Repository/BackupRepository.php
Normal file
43
src/Repository/BackupRepository.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Backup;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Backup>
|
||||
*/
|
||||
class BackupRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Backup::class);
|
||||
}
|
||||
|
||||
// /**
|
||||
// * @return Backup[] Returns an array of Backup objects
|
||||
// */
|
||||
// public function findByExampleField($value): array
|
||||
// {
|
||||
// return $this->createQueryBuilder('b')
|
||||
// ->andWhere('b.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->orderBy('b.id', 'ASC')
|
||||
// ->setMaxResults(10)
|
||||
// ->getQuery()
|
||||
// ->getResult()
|
||||
// ;
|
||||
// }
|
||||
|
||||
// public function findOneBySomeField($value): ?Backup
|
||||
// {
|
||||
// return $this->createQueryBuilder('b')
|
||||
// ->andWhere('b.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->getQuery()
|
||||
// ->getOneOrNullResult()
|
||||
// ;
|
||||
// }
|
||||
}
|
||||
121
templates/dashboard/backup.twig
Normal file
121
templates/dashboard/backup.twig
Normal file
@@ -0,0 +1,121 @@
|
||||
{% extends 'dashboard/base.twig' %}
|
||||
|
||||
{% block title %}Sauvegardes{% endblock %}
|
||||
|
||||
{% block title_header %}Gestion des Sauvegardes{% endblock %}
|
||||
|
||||
{% block actions %}
|
||||
<div class="px-5 py-2 bg-blue-600/10 border border-blue-500/30 text-blue-400 text-[10px] font-bold uppercase tracking-widest rounded-full shadow-lg shadow-blue-500/10">
|
||||
Rétention : 7 Jours
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="relative min-h-[60vh]">
|
||||
{# Orbes de lumière en arrière-plan pour l'effet de profondeur #}
|
||||
<div class="absolute -top-20 -left-10 w-64 h-64 bg-blue-600/10 rounded-full blur-[100px] -z-10 pointer-events-none"></div>
|
||||
<div class="absolute bottom-10 right-0 w-80 h-80 bg-indigo-600/5 rounded-full blur-[120px] -z-10 pointer-events-none"></div>
|
||||
|
||||
<div class="space-y-8 relative z-10">
|
||||
<h2 class="text-2xl font-black text-white uppercase tracking-tighter">
|
||||
Historique des <span class="text-blue-600">Sauvegardes</span>
|
||||
</h2>
|
||||
|
||||
{# Conteneur principal Glassmorphism Sombre #}
|
||||
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] shadow-2xl overflow-hidden">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-slate-900/50 border-b border-white/5">
|
||||
<th class="p-6 text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500">Identité de la Sauvegarde</th>
|
||||
<th class="p-6 text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500 text-center">État</th>
|
||||
<th class="p-6 text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500 text-center">Taille</th>
|
||||
<th class="p-6 text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5">
|
||||
{% for backup in backups %}
|
||||
<tr class="hover:bg-white/[0.02] transition-all duration-300 group">
|
||||
{# Identité #}
|
||||
<td class="p-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-12 h-12 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500 border border-blue-500/20 group-hover:scale-110 transition-transform duration-500">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-white tracking-tight">Base de données SQL</p>
|
||||
<p class="text-[11px] text-slate-500 font-medium">Réalisé le {{ backup.createdAt|date('d/m/Y') }} à {{ backup.createdAt|date('H:i') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{# Statut en Français #}
|
||||
<td class="p-6 text-center">
|
||||
<div class="flex justify-center">
|
||||
{% if backup.status == 'SUCCESS' %}
|
||||
<div class="inline-flex items-center px-4 py-1.5 bg-emerald-500/10 border border-emerald-500/20 rounded-full backdrop-blur-md">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-2 animate-pulse"></span>
|
||||
<span class="text-[9px] font-black text-emerald-500 uppercase tracking-widest leading-none">Saine</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="inline-flex items-center px-4 py-1.5 bg-red-500/10 border border-red-500/20 rounded-full backdrop-blur-md">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-500 mr-2"></span>
|
||||
<span class="text-[9px] font-black text-red-500 uppercase tracking-widest leading-none">Échec</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{# Taille #}
|
||||
<td class="p-6 text-center">
|
||||
<span class="text-xs font-bold text-slate-300 font-mono bg-black/20 px-3 py-1 rounded-lg border border-white/5">
|
||||
{{ backup.sizeMb|default('0.00') }} <span class="text-[9px] opacity-40 uppercase tracking-tighter">MO</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{# Actions #}
|
||||
<td class="p-6 text-right">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
{% if backup.status == 'SUCCESS' %}
|
||||
<a href="{{ path('app_crm_backup_download', {id: backup.id}) }}"
|
||||
download="sauvegarde.zip"
|
||||
data-turbo="false"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition-all shadow-lg shadow-blue-600/20 group/btn">
|
||||
<svg class="w-4 h-4 mr-2 group-hover/btn:-translate-y-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
<span class="text-[10px] font-black uppercase tracking-widest">Télécharger</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_ROOT') %}
|
||||
<a href="{{ path('app_crm_backup_delete', {id: backup.id}) }}"
|
||||
onclick="return confirm('Supprimer définitivement cette sauvegarde ?')"
|
||||
class="inline-flex items-center justify-center w-10 h-10 bg-white/5 border border-white/10 rounded-xl text-slate-400 hover:bg-red-500 hover:text-white hover:border-red-500 transition-all">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="p-20 text-center">
|
||||
<div class="flex flex-col items-center opacity-30 text-white">
|
||||
<svg class="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path>
|
||||
</svg>
|
||||
<p class="text-[10px] font-black uppercase tracking-[0.3em]">Historique vide</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -58,6 +58,7 @@
|
||||
<div class="mt-2 space-y-1">
|
||||
<a href="{{ path('app_crm_administrateur') }}" class="block px-12 py-2 text-sm hover:text-blue-600 transition-colors">Gestion Admins</a>
|
||||
<a href="{{ path('app_crm_audit_logs') }}" class="block px-12 py-2 text-sm hover:text-blue-600 transition-colors">Audit Logs</a>
|
||||
<a href="{{ path('app_crm_backup') }}" class="block px-12 py-2 text-sm hover:text-blue-600 transition-colors">Sauvegarde</a>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -2,63 +2,98 @@
|
||||
|
||||
{% block title %}Recherche : {{ query }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="page-transition">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between mb-10 gap-6">
|
||||
<div>
|
||||
<p class="text-blue-600 font-bold text-[10px] uppercase tracking-[0.4em] mb-2">Recherche Multicritères</p>
|
||||
<h1 class="text-3xl font-black text-slate-900 dark:text-white uppercase tracking-tighter">
|
||||
Résultats pour <span class="text-blue-600">"{{ query }}"</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 px-6 py-3 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm">
|
||||
<span class="text-xs font-bold text-slate-500 uppercase tracking-widest">
|
||||
<span class="text-blue-600 text-lg mr-1">{{ results|length }}</span> correspondance(s)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% block title_header %}Moteur de <span class="text-blue-500">Recherche</span>{% endblock %}
|
||||
|
||||
{# Formulaire de mise à jour #}
|
||||
<div class="mb-12">
|
||||
<form action="{{ path('app_crm_search') }}" method="GET" class="relative max-w-2xl">
|
||||
<input type="text" name="q" value="{{ query }}" placeholder="Rechercher à nouveau..."
|
||||
class="w-full pl-6 pr-40 py-4 bg-white dark:bg-slate-900 border-2 border-slate-100 dark:border-slate-800 focus:border-blue-600 focus:ring-0 rounded-2xl text-slate-900 dark:text-white font-medium shadow-xl shadow-slate-200/40 dark:shadow-none transition-all outline-none">
|
||||
<button type="submit" class="absolute right-2 top-2 bottom-2 px-6 bg-slate-900 dark:bg-blue-600 text-white text-[10px] font-bold uppercase tracking-widest rounded-xl hover:bg-blue-600 transition-all">
|
||||
Actualiser
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if results is not empty %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for item in results %}
|
||||
<div class="bg-white dark:bg-[#1e293b] p-6 rounded-[2.5rem] border border-slate-200 dark:border-slate-800 shadow-[0_10px_40px_rgba(0,0,0,0.02)] hover:shadow-xl hover:translate-y-[-4px] transition-all group">
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<div class="w-14 h-14 bg-slate-100 dark:bg-slate-800 rounded-2xl flex items-center justify-center text-slate-900 dark:text-white font-black text-xl border border-slate-200 dark:border-slate-700 uppercase">
|
||||
{{ item.initials }}
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<span class="px-2 py-0.5 bg-indigo-50 dark:bg-indigo-900/30 text-[8px] font-bold text-indigo-600 uppercase rounded-md border border-indigo-100 dark:border-indigo-800">
|
||||
{{ item.type }}
|
||||
</span>
|
||||
<h3 class="font-bold text-slate-900 dark:text-white text-lg leading-none truncate mt-2">{{ item.title }}</h3>
|
||||
<p class="text-xs text-slate-400 mt-1 truncate">{{ item.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-5 border-t border-slate-50 dark:border-slate-800">
|
||||
<div class="text-[10px] font-mono text-slate-400">#{{ item.id }}</div>
|
||||
<a href="{{ item.link }}" class="flex items-center space-x-2 px-4 py-2 bg-slate-900 dark:bg-slate-700 text-white rounded-xl text-[10px] font-bold uppercase tracking-widest hover:bg-blue-600 transition-colors">
|
||||
<span>Accéder</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-20 bg-white dark:bg-slate-900 rounded-[3rem] text-center border-2 border-dashed border-slate-100 dark:border-slate-800">
|
||||
<p class="text-slate-400 font-medium italic">Aucune donnée trouvée pour cette recherche.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block actions %}
|
||||
<div class="backdrop-blur-md bg-white/5 border border-white/10 px-5 py-2 rounded-xl shadow-xl">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||
<span class="text-blue-500 text-lg mr-1">{{ results|length }}</span> Résultat(s)
|
||||
</span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="relative min-h-screen">
|
||||
{# Orbes décoratifs pour l'effet Glass #}
|
||||
<div class="absolute -top-24 -left-20 w-72 h-72 bg-blue-600/10 rounded-full blur-[120px] -z-10 pointer-events-none"></div>
|
||||
<div class="absolute top-1/2 right-0 w-96 h-96 bg-indigo-600/5 rounded-full blur-[130px] -z-10 pointer-events-none"></div>
|
||||
|
||||
<div class="space-y-10 relative z-10">
|
||||
|
||||
{# Barre de recherche flottante #}
|
||||
<div class="max-w-3xl">
|
||||
<form action="{{ path('app_crm_search') }}" method="GET" class="relative group">
|
||||
<div class="absolute inset-y-0 left-6 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-slate-500 group-focus-within:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<input type="text" name="q" value="{{ query }}" placeholder="Rechercher un client, un devis, une tâche..."
|
||||
class="w-full pl-16 pr-40 py-5 bg-[#1e293b]/40 backdrop-blur-xl border border-white/10 focus:border-blue-500/50 focus:ring-0 rounded-[2rem] text-white font-medium shadow-2xl transition-all outline-none">
|
||||
<button type="submit" class="absolute right-3 top-3 bottom-3 px-8 bg-blue-600 text-white text-[10px] font-black uppercase tracking-[0.2em] rounded-2xl hover:bg-blue-700 hover:shadow-[0_0_20px_rgba(37,99,235,0.4)] transition-all">
|
||||
Actualiser
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if results is not empty %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{% for item in results %}
|
||||
<div class="backdrop-blur-xl bg-[#1e293b]/30 hover:bg-[#1e293b]/60 p-8 rounded-[2.5rem] border border-white/5 hover:border-blue-500/30 shadow-xl transition-all duration-500 group relative overflow-hidden">
|
||||
|
||||
{# Effet de lueur au survol #}
|
||||
<div class="absolute -right-10 -top-10 w-32 h-32 bg-blue-600/5 rounded-full blur-3xl group-hover:bg-blue-600/20 transition-all"></div>
|
||||
|
||||
<div class="flex items-start justify-between mb-8 relative z-10">
|
||||
<div class="w-14 h-14 bg-white/5 rounded-2xl flex items-center justify-center text-blue-500 font-black text-xl border border-white/10 group-hover:scale-110 group-hover:border-blue-500/50 transition-all duration-500 uppercase shadow-inner">
|
||||
{{ item.initials }}
|
||||
</div>
|
||||
<span class="px-4 py-1.5 bg-blue-500/10 text-[9px] font-black text-blue-400 uppercase tracking-widest rounded-full border border-blue-500/20 backdrop-blur-md">
|
||||
{{ item.type }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 mb-8 relative z-10">
|
||||
<h3 class="font-bold text-white text-xl leading-tight group-hover:text-blue-400 transition-colors tracking-tight truncate">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<p class="text-xs text-slate-400 font-medium tracking-wide truncate">
|
||||
{{ item.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-6 border-t border-white/5 relative z-10">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[9px] font-bold text-slate-600 uppercase tracking-[0.2em]">Identifiant</span>
|
||||
<span class="text-xs font-mono text-slate-400">#{{ item.id }}</span>
|
||||
</div>
|
||||
<a href="{{ item.link }}" class="flex items-center space-x-2 px-6 py-3 bg-white/5 hover:bg-blue-600 text-white border border-white/10 hover:border-blue-500 rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] transition-all duration-300 shadow-lg">
|
||||
<span>Voir Fiche</span>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="py-32 backdrop-blur-xl bg-[#1e293b]/20 rounded-[3rem] text-center border-2 border-dashed border-white/5 shadow-2xl">
|
||||
<div class="w-20 h-20 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-white font-bold text-xl uppercase tracking-tighter">Aucun résultat trouvé</h3>
|
||||
<p class="text-slate-500 text-sm mt-2">Désolé, nous n'avons trouvé aucune correspondance pour "{{ query }}".</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Texture subtile de grain pour le fond Glassmorphism */
|
||||
.backdrop-blur-xl {
|
||||
background-image: radial-gradient(rgba(255,255,255,0.03) 1px, transparent 0);
|
||||
background-size: 8px 8px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
39
templates/mails/backup_notification.twig
Normal file
39
templates/mails/backup_notification.twig
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends 'mails/base.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<mj-section background-color="#ffffff" padding-bottom="20px" padding-top="20px">
|
||||
<mj-column width="100%">
|
||||
<mj-text align="center" font-size="20px" font-weight="900" font-family="Helvetica, Arial, sans-serif" text-transform="uppercase" letter-spacing="1px">
|
||||
Rapport de Sauvegarde
|
||||
</mj-text>
|
||||
|
||||
<mj-text align="center" color="{{ datas.backup.status == 'SUCCESS' ? '#10b981' : '#ef4444' }}" font-size="16px" font-weight="bold">
|
||||
Statut : {{ datas.backup.status == 'SUCCESS' ? 'OPÉRATION RÉUSSIE ✅' : 'ÉCHEC DÉTECTÉ ❌' }}
|
||||
</mj-text>
|
||||
|
||||
<mj-table padding="20px 40px">
|
||||
<tr style="border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<th style="padding: 10px 0; color: #4b5563;">Date</th>
|
||||
<td style="padding: 10px 0; text-align: right; font-weight: bold;">{{ datas.backup.createdAt|date('d/m/Y H:i') }}</td>
|
||||
</tr>
|
||||
{% if datas.backup.status == 'SUCCESS' %}
|
||||
<tr style="border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<th style="padding: 10px 0; color: #4b5563;">Taille de l'archive</th>
|
||||
<td style="padding: 10px 0; text-align: right; font-weight: bold; color: #2563eb;">{{ datas.backup.sizeMb }} MB</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr style="border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<th style="padding: 10px 0; color: #4b5563;">ID Log</th>
|
||||
<td style="padding: 10px 0; text-align: right; font-family: monospace;">#{{ datas.backup.id }}</td>
|
||||
</tr>
|
||||
</mj-table>
|
||||
|
||||
{% if datas.backup.status == 'ERROR' %}
|
||||
<mj-text background-color="#fef2f2" color="#991b1b" padding="20px" font-family="monospace" font-size="12px" border-radius="8px">
|
||||
<strong>Message d'erreur :</strong><br/>
|
||||
{{ datas.backup.errorMessage }}
|
||||
</mj-text>
|
||||
{% endif %}
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user