v0.12.0 — Report tool auto-deploy from PROSERVE ZIP

Each PROSERVE ZIP can ship a _report/ subfolder alongside _migrations/.
After install + DB migration, the launcher copies _report/ to the local
XAMPP htdocs (default C:\xampp\htdocs\ProserveReport\) so the Reports
tab in the sidebar always serves the matching version.

Service (PSLauncher.Core/ReportTool)
- IReportToolDeployer + ReportToolDeployer.
- Atomic deploy: copy to {target}.new/ → rename current to .old/ →
  rename .new → final → cleanup .old. Apache never sees a half-state.
- Skip silencieux si _report/ absent du ZIP (PROSERVE release qui ne
  touche pas au Report).
- DeployStatus enum (SkippedNoSourceFolder, XamppNotFound, Failed,
  Deployed) renvoyé pour gestion UI claire.

Config (LocalConfig)
- ReportToolConfig (HtdocsRoot, FolderName, AutoDeploy). Defaults
  matchent l'install standard ASTERION (C:\xampp\htdocs\ProserveReport).

Install pipeline (MainViewModel)
- Étape 6 après extraction + migrations. Progress reporté via le footer
  Library : « 📂 Déploiement Report : 47/120 — assets/main.js ».
  XAMPP introuvable → dialog avec lien vers Settings → Avancés.

Settings UI (SettingsDialog → Avancés)
- Nouveau bloc « OUTIL REPORT (XAMPP htdocs) » : 2 champs path + checkbox
  AutoDeploy + bouton « 📂 Re-déployer maintenant » qui rejoue le deploy
  sur la dernière version installée. Utile après changement de chemin
  htdocs ou si l'auto-deploy avait foiré (XAMPP éteint).

Versions bumped to 0.12.0 (App + Updater + installer .iss).

Côté release : tu ajoutes _report/ dans le source de PROSERVE, tu zippes,
tu uploades. Les clients récupèrent ZIP + migrations + Report en une
seule install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 09:05:40 +02:00
parent a2e67d5405
commit 67f422539d
11 changed files with 396 additions and 5 deletions

View File

@@ -468,6 +468,49 @@ public static class Strings
$"🗄 الترقية {done}/{total}: {filename}"
);
public static string MsgBoxMigrationFailed => T("Migration DB échouée", "DB migration failed", "数据库迁移失败", "การโยกย้าย DB ล้มเหลว", "فشل ترقية قاعدة البيانات");
// ==================== REPORT TOOL DEPLOY ====================
public static string StatusDeployingReport(string version) => T(
$"Déploiement de l'outil Report v{version}…",
$"Deploying Report tool for v{version}…",
$"正在部署 v{version} 的报告工具…",
$"กำลังปรับใช้เครื่องมือรายงาน v{version}…",
$"جارٍ نشر أداة التقارير v{version}…"
);
public static string ProgressReportDeploy(int done, int total, string filename) => T(
$"📂 Déploiement Report : {done}/{total} — {filename}",
$"📂 Deploying Report: {done}/{total} — {filename}",
$"📂 部署报告:{done}/{total} — {filename}",
$"📂 ปรับใช้รายงาน: {done}/{total} — {filename}",
$"📂 نشر التقرير: {done}/{total} — {filename}"
);
public static string MsgBoxReportDeployFailed => T("Déploiement Report échoué", "Report deploy failed", "报告部署失败", "การปรับใช้รายงานล้มเหลว", "فشل نشر التقرير");
public static string MsgReportXamppNotFound(string path) => T(
$"L'outil Report n'a pas pu être déployé : le dossier XAMPP {path} est introuvable.\n\nVérifie que XAMPP est installé à cet emplacement (par défaut C:\\xampp) ou ajuste le chemin dans Paramètres → Avancés → Outil Report.",
$"The Report tool could not be deployed: XAMPP folder {path} not found.\n\nMake sure XAMPP is installed there (default C:\\xampp) or adjust the path in Settings → Advanced → Report Tool.",
$"无法部署报告工具:找不到 XAMPP 文件夹 {path}。\n\n请确保 XAMPP 安装在该位置(默认 C:\\xampp或在 设置 → 高级 → 报告工具 中调整路径。",
$"ไม่สามารถปรับใช้เครื่องมือรายงานได้: ไม่พบโฟลเดอร์ XAMPP {path}\n\nตรวจสอบว่า XAMPP ติดตั้งที่ตำแหน่งนั้น (ค่าเริ่มต้น C:\\xampp) หรือปรับเส้นทางใน ตั้งค่า → ขั้นสูง → เครื่องมือรายงาน",
$"تعذر نشر أداة التقارير: مجلد XAMPP {path} غير موجود.\n\nتأكد من تثبيت XAMPP في هذا الموقع (الافتراضي C:\\xampp) أو اضبط المسار في الإعدادات → متقدم → أداة التقارير."
);
public static string MsgReportDeployFailed(string detail) => T(
$"Le déploiement de l'outil Report a échoué :\n\n{detail}\n\nLa version PROSERVE est installée mais l'onglet Statistiques pourrait être désynchronisé. Tu peux retenter via Paramètres → Avancés → Re-déployer le Report.",
$"Report tool deploy failed:\n\n{detail}\n\nThe PROSERVE version is installed but the Reports tab might be out of sync. You can retry via Settings → Advanced → Re-deploy Report.",
$"报告工具部署失败:\n\n{detail}\n\nPROSERVE 版本已安装,但报告选项卡可能不同步。您可以通过 设置 → 高级 → 重新部署报告 重试。",
$"การปรับใช้เครื่องมือรายงานล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วแต่แท็บรายงานอาจไม่ซิงค์ คุณสามารถลองใหม่ผ่าน ตั้งค่า → ขั้นสูง → ปรับใช้รายงานใหม่",
$"فشل نشر أداة التقارير:\n\n{detail}\n\nنسخة PROSERVE مثبتة لكن علامة التبويب التقارير قد لا تكون متزامنة. يمكنك إعادة المحاولة عبر الإعدادات → متقدم → إعادة نشر التقرير."
);
public static string SettingsReportTool => T("OUTIL REPORT (XAMPP htdocs)", "REPORT TOOL (XAMPP htdocs)", "报告工具 (XAMPP htdocs)", "เครื่องมือรายงาน (XAMPP htdocs)", "أداة التقارير (XAMPP htdocs)");
public static string SettingsReportHtdocs => T("Racine XAMPP htdocs", "XAMPP htdocs root", "XAMPP htdocs 根目录", "รากของ XAMPP htdocs", "جذر XAMPP htdocs");
public static string SettingsReportFolder => T("Nom du dossier de déploiement", "Deploy folder name", "部署文件夹名称", "ชื่อโฟลเดอร์ปรับใช้", "اسم مجلد النشر");
public static string SettingsReportAutoDeploy => T(
"Déployer automatiquement l'outil Report à l'install d'une nouvelle version",
"Automatically deploy the Report tool when installing a new version",
"安装新版本时自动部署报告工具",
"ปรับใช้เครื่องมือรายงานโดยอัตโนมัติเมื่อติดตั้งเวอร์ชันใหม่",
"نشر أداة التقارير تلقائياً عند تثبيت نسخة جديدة"
);
public static string SettingsReportRedeploy => T("📂 Re-déployer le Report maintenant", "📂 Re-deploy Report now", "📂 立即重新部署报告", "📂 ปรับใช้รายงานใหม่เดี๋ยวนี้", "📂 إعادة نشر التقرير الآن");
public static string MsgMigrationFailed(string detail) => T(
$"Une migration de la base de données a échoué :\n\n{detail}\n\nLa version a été extraite mais la base n'a PAS été modifiée (rollback automatique).\nVérifie que XAMPP est démarré et que les paramètres de connexion sont corrects (Settings → Base de données), puis clique « Rejouer les migrations » dans Settings.",
$"A database migration failed:\n\n{detail}\n\nThe version was extracted but the database was NOT modified (automatic rollback).\nMake sure XAMPP is running and the connection settings are correct (Settings → Database), then click « Replay migrations » in Settings.",

View File

@@ -0,0 +1,47 @@
namespace PSLauncher.Core.ReportTool;
/// <summary>
/// Déploie l'outil Report (page web hébergée dans XAMPP) depuis le sous-dossier
/// <c>_report/</c> bundled dans chaque ZIP PROSERVE vers le htdocs local.
/// </summary>
public interface IReportToolDeployer
{
/// <summary>
/// Si <c>{installFolder}/_report/</c> existe, le copie vers
/// <c>{HtdocsRoot}/{FolderName}/</c> via un rename atomique :
/// <list type="number">
/// <item>copie récursive vers <c>{target}.new/</c></item>
/// <item>rename de l'existant vers <c>{target}.old/</c> (s'il existait)</item>
/// <item>rename <c>.new</c> → final</item>
/// <item>delete de <c>.old</c> en best-effort</item>
/// </list>
/// Si _report/ absent du ZIP : skip silencieux. Si htdocs introuvable :
/// <see cref="DeployStatus.XamppNotFound"/>. Si erreur disque :
/// <see cref="DeployStatus.Failed"/> + message d'erreur.
/// </summary>
Task<DeployResult> DeployAsync(
string installFolder,
IProgress<DeployProgress>? progress,
CancellationToken ct);
}
public enum DeployStatus
{
/// <summary>Le ZIP n'avait pas de sous-dossier <c>_report/</c>, rien à faire.</summary>
SkippedNoSourceFolder,
/// <summary>HtdocsRoot configuré n'existe pas (XAMPP pas installé là).</summary>
XamppNotFound,
/// <summary>Erreur I/O pendant la copie ou le rename.</summary>
Failed,
/// <summary>Déploiement réussi.</summary>
Deployed,
}
public sealed record DeployResult(
DeployStatus Status,
int FilesDeployed,
long BytesDeployed,
string TargetPath,
string? ErrorMessage = null);
public sealed record DeployProgress(int FilesDone, int FilesTotal, string CurrentFilename);

View File

@@ -0,0 +1,107 @@
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
namespace PSLauncher.Core.ReportTool;
/// <summary>
/// Implémentation simple : copy récursif + atomic rename. L'atomicité du rename
/// garantit qu'Apache (qui sert les fichiers en parallèle) ne tombera jamais sur
/// un état mi-déployé. Au pire il sert l'ancienne version pendant 1ms le temps
/// du rename, puis sert la nouvelle.
/// </summary>
public sealed class ReportToolDeployer : IReportToolDeployer
{
private const string SourceSubdir = "_report";
private readonly Func<ReportToolConfig> _configProvider;
private readonly ILogger<ReportToolDeployer> _logger;
public ReportToolDeployer(
Func<ReportToolConfig> configProvider,
ILogger<ReportToolDeployer> logger)
{
_configProvider = configProvider;
_logger = logger;
}
public async Task<DeployResult> DeployAsync(
string installFolder,
IProgress<DeployProgress>? progress,
CancellationToken ct)
{
var cfg = _configProvider();
var source = Path.Combine(installFolder, SourceSubdir);
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
if (!Directory.Exists(source))
{
_logger.LogInformation("No {Sub}/ directory in {Folder}, skipping report deploy", SourceSubdir, installFolder);
return new DeployResult(DeployStatus.SkippedNoSourceFolder, 0, 0, target);
}
if (!Directory.Exists(cfg.HtdocsRoot))
{
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
}
var stagingTarget = target + ".new";
var oldBackup = target + ".old";
try
{
// Nettoyage d'un éventuel résidu de tentative précédente
if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true);
if (Directory.Exists(oldBackup)) Directory.Delete(oldBackup, recursive: true);
// Énumère pour avoir un total + reporter la progression
var allFiles = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).ToList();
int total = allFiles.Count;
long bytesTotal = 0;
await Task.Run(() =>
{
Directory.CreateDirectory(stagingTarget);
int done = 0;
foreach (var srcFile in allFiles)
{
ct.ThrowIfCancellationRequested();
var rel = Path.GetRelativePath(source, srcFile);
var dstFile = Path.Combine(stagingTarget, rel);
var dstDir = Path.GetDirectoryName(dstFile);
if (!string.IsNullOrEmpty(dstDir) && !Directory.Exists(dstDir))
Directory.CreateDirectory(dstDir);
File.Copy(srcFile, dstFile, overwrite: true);
bytesTotal += new FileInfo(srcFile).Length;
done++;
progress?.Report(new DeployProgress(done, total, rel));
}
}, ct).ConfigureAwait(false);
// Atomic swap
if (Directory.Exists(target)) Directory.Move(target, oldBackup);
Directory.Move(stagingTarget, target);
// Cleanup ancienne version (best-effort, fichiers peuvent être verrouillés
// par Apache qui les sert encore — pas grave, sera nettoyé au prochain deploy)
if (Directory.Exists(oldBackup))
{
try { Directory.Delete(oldBackup, recursive: true); }
catch (Exception ex) { _logger.LogDebug(ex, "Could not clean .old backup, will retry next deploy"); }
}
_logger.LogInformation("Report tool deployed to {Target} ({Files} files, {Bytes} bytes)",
target, total, bytesTotal);
return new DeployResult(DeployStatus.Deployed, total, bytesTotal, target);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deploy report tool to {Target}", target);
// Best-effort cleanup pour ne pas laisser de staging à moitié écrit
try { if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); }
catch { /* ignore */ }
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
}
}
}