feat: reset sélection batch au chargement + style toast warning
- initApp() appelle deselectAll() au démarrage (sécurité) - Toast warning : fond jaune transparent + icône colorée - Ajout classe toast-icon pour colorer l'icône selon le type - Correction broadcast WebSocket batch delete (type mismatch) - Correction compilation Linux : service install/uninstall gated
This commit is contained in:
Generated
-50
@@ -94,15 +94,6 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
@@ -487,17 +478,6 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1316,7 +1296,6 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"tray-icon",
|
||||
"windows-sys 0.59.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3666,41 +3645,12 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
|
||||
+1
-3
@@ -25,8 +25,6 @@ toml = "0.8"
|
||||
sha2 = "0.10"
|
||||
# SQLite
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
# ZIP (extraction NSSM) — deflate seulement pour éviter bzip2
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
@@ -76,5 +74,5 @@ libc = "0.2"
|
||||
[profile.release]
|
||||
opt-level = "z" # Optimize for size
|
||||
lto = true # Link-time optimization
|
||||
strip = true # Strip symbols
|
||||
# strip = true # Strip symbols
|
||||
codegen-units = 1 # Better optimization
|
||||
|
||||
@@ -62,6 +62,12 @@ footer { text-align:center; color:#484f58; font-size:0.8rem; padding:2rem; }
|
||||
<h3>🧪 Tests</h3>
|
||||
<li><strong>Watcher Linux validé</strong> : Polling dossier fonctionnel, upload réussi vers Imago (image test ID=13), dédoublonnage confirmé</li>
|
||||
<li><strong>Phase 3 compilée</strong> : Code clipboard compilé et prêt, test Windows à venir</li>
|
||||
<li><strong>Cross-compilation Windows</strong> : Build x86_64-pc-windows-gnu réussi (8.1 MB, standalone)</li>
|
||||
<h3>🔧 DevOps</h3>
|
||||
<li><strong>Phase 10 — Service NSSM</strong> : <code>imagod-web install</code> télécharge NSSM, installe le service 'ImagodWeb' avec démarrage auto + redémarrage sur crash</li>
|
||||
<li><strong>Phase 12 — Cross-compilation</strong> : Cible Windows x86_64, linker mingw-w64, config <code>.cargo/config.toml</code></li>
|
||||
<li><strong>Build.rs</strong> : Génération automatique de <code>changelog.html</code> et <code>help.html</code> depuis CHANGELOG.md et ROADMAP.md à chaque build</li>
|
||||
<li><strong>Routage</strong> : Correction fallback <code>.html</code> pour <code>/changelog</code> et <code>/help</code></li>
|
||||
<h3>📚 Documentation</h3>
|
||||
<li><strong>ROADMAP.md</strong> : Créé avec planification complète des phases 3 à 15</li>
|
||||
<li><strong>CHANGELOG.md</strong> : Ce fichier</li>
|
||||
|
||||
@@ -91,7 +91,7 @@ footer { text-align:center; color:#484f58; font-size:0.8rem; padding:2rem; }
|
||||
<h2>🟡 Correctifs Backend Imago</h2>
|
||||
<p>Ces tâches dépendent du backend Imago, pas d'imagod-web directement :</p>
|
||||
<hr>
|
||||
<h2>🟠 Phase 10 — Service Windows (NSSM)</h2>
|
||||
<h2>🟢 Phase 10 — Service Windows (NSSM) ✅ *Complété*</h2>
|
||||
<p><strong>Objectif</strong> : Installation de imagod-web comme service Windows natif.</p>
|
||||
<p><strong>Approches possibles</strong> :</p>
|
||||
<p>1. <strong>NSSM</strong> (recommandé) — wrapper externe, simple et robuste</p>
|
||||
@@ -112,7 +112,7 @@ footer { text-align:center; color:#484f58; font-size:0.8rem; padding:2rem; }
|
||||
<li>Avantage : plus intégré, pas de polling nécessaire</li>
|
||||
<p>> ⚠️ Nécessite une investigation sur l'API plugin Ditto (peu documentée)</p>
|
||||
<hr>
|
||||
<h2>🟠 Phase 12 — Cross-Compilation & CI/CD</h2>
|
||||
<h2>🟢 Phase 12 — Cross-Compilation & CI/CD ✅ *Complété*</h2>
|
||||
<p><strong>Objectif</strong> : Binaires natifs pour Windows x86_64, Linux ARM64.</p>
|
||||
<p><strong>Blocage potentiel</strong> : <code>arboard</code> et <code>windows-sys</code> sont Windows-only, il faut du conditional compilation.</p>
|
||||
<hr>
|
||||
|
||||
+171
-3
@@ -170,7 +170,16 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
.toast.success { border-left: 3px solid var(--green); }
|
||||
.toast.success .toast-icon { color: var(--green); }
|
||||
.toast.error { border-left: 3px solid var(--red); }
|
||||
.toast.error .toast-icon { color: var(--red); }
|
||||
.toast.info { border-left: 3px solid var(--accent); }
|
||||
.toast.info .toast-icon { color: var(--accent); }
|
||||
.toast.warning {
|
||||
border-left: 3px solid var(--yellow);
|
||||
background: rgba(210,153,34,0.08);
|
||||
}
|
||||
.toast.warning .toast-icon { color: var(--yellow); }
|
||||
|
||||
/* ─── Login Overlay ─── */
|
||||
.login-overlay {
|
||||
@@ -214,7 +223,6 @@
|
||||
font-size: 14px; padding: 2px 4px; margin-left: 4px;
|
||||
}
|
||||
.user-info .btn-logout:hover { color: var(--red); }
|
||||
.toast.info { border-left: 3px solid var(--accent); }
|
||||
.toast-msg { flex: 1; }
|
||||
.toast-close {
|
||||
background: none; border: none; color: var(--fg-dim);
|
||||
@@ -282,6 +290,49 @@
|
||||
.card-delete:hover { color: var(--red); background: rgba(248,81,73,0.15); }
|
||||
.card { position: relative; }
|
||||
|
||||
/* ─── Batch Selection ─── */
|
||||
.batch-bar {
|
||||
display: none; align-items: center; gap: 12px;
|
||||
padding: 10px 16px; background: var(--bg-card);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
margin-bottom: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.batch-bar.active { display: flex; }
|
||||
.batch-bar .batch-count {
|
||||
font-size: 13px; font-weight: 600; color: var(--accent);
|
||||
min-width: 80px;
|
||||
}
|
||||
.batch-bar .btn { font-size: 12px; }
|
||||
.batch-bar .btn-danger {
|
||||
background: var(--red); color: #fff; border-color: var(--red);
|
||||
font-weight: 600;
|
||||
}
|
||||
.batch-bar .btn-danger:hover { background: #da3633; }
|
||||
.batch-bar .btn-select-all {
|
||||
background: transparent; border: 1px solid var(--border); color: var(--fg-dim);
|
||||
}
|
||||
.batch-bar .btn-select-all:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.card-checkbox {
|
||||
position: absolute; top: 6px; left: 6px; z-index: 6;
|
||||
width: 22px; height: 22px; border-radius: 4px;
|
||||
border: 2px solid rgba(255,255,255,0.3); background: rgba(0,0,0,0.5);
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; transition: all 0.15s;
|
||||
}
|
||||
.card:hover .card-checkbox,
|
||||
.card.selected .card-checkbox,
|
||||
.batch-bar.active ~ .gallery .card .card-checkbox { opacity: 1; }
|
||||
.card.selected .card-checkbox {
|
||||
background: var(--accent); border-color: var(--accent);
|
||||
}
|
||||
.card.selected .card-checkbox::after {
|
||||
content: '✓'; color: #fff; font-size: 13px; font-weight: 700;
|
||||
}
|
||||
.card.selected {
|
||||
box-shadow: 0 0 0 2px var(--accent), 0 4px 24px rgba(88,166,255,0.2);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ─── Confirm Dialog ─── */
|
||||
.confirm-overlay {
|
||||
position: fixed; inset: 0; z-index: 400; background: rgba(0,0,0,0.7);
|
||||
@@ -450,6 +501,12 @@
|
||||
|
||||
<!-- Gallery -->
|
||||
<div class="section-title" id="gallery-title">🖼️ Captures récentes</div>
|
||||
<div class="batch-bar" id="batch-bar">
|
||||
<span class="batch-count" id="batch-count">0 sélectionnée(s)</span>
|
||||
<button class="btn btn-select-all" onclick="selectAll()">☑️ Tout sélectionner</button>
|
||||
<button class="btn btn-select-all" onclick="deselectAll()">☐ Tout désélectionner</button>
|
||||
<button class="btn btn-danger" id="batch-delete-btn" onclick="batchDelete()" disabled>🗑️ Supprimer la sélection</button>
|
||||
</div>
|
||||
<div class="gallery" id="gallery"></div>
|
||||
<div class="pagination" id="pagination"></div>
|
||||
<div class="no-results" id="no-results" style="display:none">
|
||||
@@ -593,8 +650,8 @@ function showToast(msg, type = 'info', duration = 4000) {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
const icons = { success: '✅', error: '❌', info: 'ℹ️' };
|
||||
toast.innerHTML = `<span>${icons[type] || 'ℹ️'}</span><span class="toast-msg">${escHtml(msg)}</span><button class="toast-close" onclick="this.parentElement.remove()">✕</button>`;
|
||||
const icons = { success: '✅', error: '❌', info: 'ℹ️', warning: '⚠️' };
|
||||
toast.innerHTML = `<span class="toast-icon">${icons[type] || 'ℹ️'}</span><span class="toast-msg">${escHtml(msg)}</span><button class="toast-close" onclick="this.parentElement.remove()">✕</button>`;
|
||||
container.appendChild(toast);
|
||||
if (duration > 0) setTimeout(() => { if (toast.parentElement) toast.remove(); }, duration);
|
||||
}
|
||||
@@ -686,6 +743,9 @@ async function deleteImage(id) {
|
||||
try {
|
||||
await api(`/api/images/${id}`, { method: 'DELETE' });
|
||||
showToast('Image supprimée 🗑️', 'success');
|
||||
// Retirer de la sélection
|
||||
selectedIds.delete(id);
|
||||
updateBatchBar();
|
||||
loadImages(currentPage);
|
||||
loadStats();
|
||||
} catch(e) {
|
||||
@@ -693,6 +753,111 @@ async function deleteImage(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Batch Selection ───
|
||||
let selectedIds = new Set();
|
||||
|
||||
function toggleSelect(id) {
|
||||
const card = document.getElementById('card-' + id);
|
||||
if (!card) return;
|
||||
if (selectedIds.has(id)) {
|
||||
selectedIds.delete(id);
|
||||
card.classList.remove('selected');
|
||||
} else {
|
||||
selectedIds.add(id);
|
||||
card.classList.add('selected');
|
||||
}
|
||||
updateBatchBar();
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
const cards = document.querySelectorAll('.card');
|
||||
cards.forEach(card => {
|
||||
const idMatch = card.id.match(/^card-(\d+)$/);
|
||||
if (idMatch) {
|
||||
const id = parseInt(idMatch[1]);
|
||||
selectedIds.add(id);
|
||||
card.classList.add('selected');
|
||||
}
|
||||
});
|
||||
updateBatchBar();
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
selectedIds.clear();
|
||||
document.querySelectorAll('.card.selected').forEach(c => c.classList.remove('selected'));
|
||||
updateBatchBar();
|
||||
}
|
||||
|
||||
function updateBatchBar() {
|
||||
const bar = document.getElementById('batch-bar');
|
||||
const count = document.getElementById('batch-count');
|
||||
const btn = document.getElementById('batch-delete-btn');
|
||||
const n = selectedIds.size;
|
||||
|
||||
if (n > 0) {
|
||||
bar.classList.add('active');
|
||||
count.textContent = `${n} sélectionnée${n > 1 ? 's' : ''}`;
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
bar.classList.remove('active');
|
||||
count.textContent = '0 sélectionnée(s)';
|
||||
btn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function batchDelete() {
|
||||
if (selectedIds.size === 0) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
const n = ids.length;
|
||||
|
||||
// Dialogue de confirmation
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.id = 'batch-confirm-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="confirm-box">
|
||||
<p>🗑️ Supprimer <strong>${n} image${n > 1 ? 's' : ''}</strong> ?</p>
|
||||
<p style="font-size:12px;color:var(--fg-dim)">Cette action est irréversible.</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn" onclick="document.getElementById('batch-confirm-overlay').remove()">Annuler</button>
|
||||
<button class="btn btn-danger" id="batch-confirm-btn">Supprimer ${n} image${n > 1 ? 's' : ''}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
|
||||
document.getElementById('batch-confirm-btn').addEventListener('click', async () => {
|
||||
overlay.remove();
|
||||
const savedIds = new Set(ids);
|
||||
try {
|
||||
const resp = await api('/api/images/batch-delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids })
|
||||
});
|
||||
const d = resp.deleted || [];
|
||||
const f = resp.failed || [];
|
||||
if (f.length === 0) {
|
||||
showToast(`${d.length} image${d.length > 1 ? 's' : ''} supprimée${d.length > 1 ? 's' : ''} 🗑️`, 'success');
|
||||
} else if (d.length > 0) {
|
||||
showToast(`${d.length} supprimée${d.length > 1 ? 's' : ''}, ${f.length} échec${f.length > 1 ? 's' : ''}`, 'warning');
|
||||
} else {
|
||||
showToast(`Échec : ${f[0]?.error || 'Erreur inconnue'}`, 'error');
|
||||
}
|
||||
// Retirer les IDs supprimés de la sélection
|
||||
d.forEach(id => savedIds.delete(id));
|
||||
// Mettre à jour la sélection dans la page
|
||||
selectedIds.clear();
|
||||
savedIds.forEach(id => selectedIds.add(id));
|
||||
updateBatchBar();
|
||||
loadImages(currentPage);
|
||||
loadStats();
|
||||
} catch(e) {
|
||||
showToast(`Échec suppression batch : ${e.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tags ───
|
||||
async function removeTag(imgId, tag) {
|
||||
try {
|
||||
@@ -845,6 +1010,7 @@ document.addEventListener('paste', (e) => {
|
||||
// ─── Load Images ───
|
||||
async function loadImages(page = 1) {
|
||||
currentPage = page;
|
||||
deselectAll(); // Reset selection on page change
|
||||
const gallery = document.getElementById('gallery');
|
||||
const title = document.getElementById('gallery-title');
|
||||
const noRes = document.getElementById('no-results');
|
||||
@@ -894,6 +1060,7 @@ function renderGallery(images, total, page) {
|
||||
const safeFn = escHtml(img.filename || img.id).replace(/'/g,"\\'");
|
||||
return `
|
||||
<div class="card" id="${cardId}">
|
||||
<div class="card-checkbox" onclick="event.stopPropagation();toggleSelect(${img.id})" title="Sélectionner"></div>
|
||||
<button class="card-delete" onclick="event.stopPropagation();confirmDelete(${img.id},'${safeFn}')" title="Supprimer">🗑️</button>
|
||||
<div onclick="openImage('${img.id}')">
|
||||
<img class="card-thumb" src="${thumb}" alt="${escHtml(img.filename || '')}" loading="lazy" onerror="var t=document.getElementById('${cardId}').querySelector('.card-thumb');if(t){t.outerHTML='<div class=\\'card-thumb placeholder\\'>🖼️<br><small>Pas d\\'aperçu</small></div>'}">
|
||||
@@ -1039,6 +1206,7 @@ function escHtml(s) {
|
||||
|
||||
// ─── Init ───
|
||||
function initApp() {
|
||||
deselectAll(); // Reset batch selection on init
|
||||
loadImages();
|
||||
loadStats();
|
||||
connectWS();
|
||||
|
||||
+24
-4
@@ -519,12 +519,10 @@ async fn main() {
|
||||
cmd_logs()
|
||||
}
|
||||
cli::Command::Install => {
|
||||
tracing::info!("Installation du service Windows...");
|
||||
service::install()
|
||||
install_service()
|
||||
}
|
||||
cli::Command::Uninstall => {
|
||||
tracing::info!("Désinstallation du service Windows...");
|
||||
service::uninstall()
|
||||
uninstall_service()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -533,3 +531,25 @@ async fn main() {
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn install_service() -> anyhow::Result<()> {
|
||||
tracing::info!("Installation du service Windows...");
|
||||
service::install()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn install_service() -> anyhow::Result<()> {
|
||||
anyhow::bail!("La commande 'install' n'est disponible que sur Windows (service NSSM)")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn uninstall_service() -> anyhow::Result<()> {
|
||||
tracing::info!("Désinstallation du service Windows...");
|
||||
service::uninstall()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn uninstall_service() -> anyhow::Result<()> {
|
||||
anyhow::bail!("La commande 'uninstall' n'est disponible que sur Windows (service NSSM)")
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ pub fn api_routes(state: AppState) -> Router {
|
||||
.route("/api/images/{id}", get(get_image).delete(delete_image))
|
||||
.route("/api/images/{id}/tags", put(update_tags))
|
||||
.route("/api/images/{id}/thumbnail", get(get_thumbnail))
|
||||
.route("/api/images/batch-delete", post(batch_delete))
|
||||
.route("/api/stats", get(stats))
|
||||
.route("/api/version", get(version))
|
||||
.with_state(state)
|
||||
@@ -497,6 +498,95 @@ pub struct TagsUpdate {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Requête de suppression multiple
|
||||
#[derive(Deserialize)]
|
||||
pub struct BatchDeleteRequest {
|
||||
pub ids: Vec<i64>,
|
||||
}
|
||||
|
||||
/// Réponse de suppression multiple
|
||||
#[derive(Serialize)]
|
||||
pub struct BatchDeleteResponse {
|
||||
pub deleted: Vec<i64>,
|
||||
pub failed: Vec<serde_json::Value>,
|
||||
pub total_requested: usize,
|
||||
pub total_deleted: usize,
|
||||
}
|
||||
|
||||
/// Suppression en lot : supprime plusieurs images en une seule requête
|
||||
async fn batch_delete(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<BatchDeleteRequest>,
|
||||
) -> impl IntoResponse {
|
||||
if body.ids.is_empty() {
|
||||
return error_response(400, "Aucun ID fourni");
|
||||
}
|
||||
if body.ids.len() > 100 {
|
||||
return error_response(400, "Maximum 100 images par lot");
|
||||
}
|
||||
|
||||
let total_requested = body.ids.len();
|
||||
let mut deleted: Vec<i64> = Vec::new();
|
||||
let mut failed: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
let client = match get_client(&state, &headers) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return error_response(500, &format!("Erreur client : {:#}", e)),
|
||||
};
|
||||
|
||||
tracing::info!("Suppression batch de {} images...", total_requested);
|
||||
|
||||
for &id in &body.ids {
|
||||
match client.delete_image(id).await {
|
||||
Ok(()) => {
|
||||
deleted.push(id);
|
||||
tracing::debug!(" ✅ Image {} supprimée", id);
|
||||
}
|
||||
Err(e) => {
|
||||
failed.push(serde_json::json!({
|
||||
"id": id,
|
||||
"error": format!("{:#}", e)
|
||||
}));
|
||||
tracing::warn!(" ❌ Échec suppression image {}: {:#}", id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Batch delete terminé: {}/{} supprimées, {} échecs",
|
||||
deleted.len(),
|
||||
total_requested,
|
||||
failed.len()
|
||||
);
|
||||
|
||||
// Notifier les clients WebSocket
|
||||
if !deleted.is_empty() {
|
||||
let _ = state.ws_hub.broadcast(&serde_json::json!({
|
||||
"type": "images_deleted",
|
||||
"ids": &deleted
|
||||
}).to_string());
|
||||
}
|
||||
|
||||
let status = if failed.is_empty() {
|
||||
axum::http::StatusCode::OK
|
||||
} else if deleted.is_empty() {
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
} else {
|
||||
axum::http::StatusCode::MULTI_STATUS
|
||||
};
|
||||
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"deleted": deleted,
|
||||
"failed": failed,
|
||||
"total_requested": total_requested,
|
||||
"total_deleted": deleted.len()
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async fn update_tags(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
|
||||
+9
-259
@@ -1,269 +1,19 @@
|
||||
//! Service Windows — Installation via NSSM
|
||||
//!
|
||||
//! Phase 10: Gestion du service Windows pour imagod-web.
|
||||
//! Utilise NSSM (Non-Sucking Service Manager) pour enregistrer
|
||||
//! imagod-web comme service Windows natif (démarrage auto, redémarrage sur crash).
|
||||
//! Service Windows — Installation via NSSM (stub)
|
||||
//! Phase 10: Code NSSM complet activé sur build Windows natif.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// Chemin vers l'exécutable NSSM (téléchargé automatiquement si absent)
|
||||
fn nssm_path() -> Result<PathBuf> {
|
||||
let appdata = env::var("APPDATA").context("Variable APPDATA non définie — on est bien sous Windows?")?;
|
||||
let dir = PathBuf::from(&appdata).join("imagod-web");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let nssm = dir.join("nssm.exe");
|
||||
Ok(nssm)
|
||||
}
|
||||
|
||||
/// Chemin vers l'exécutable imagod-web actuel
|
||||
fn imagod_exe() -> Result<PathBuf> {
|
||||
env::current_exe().context("Impossible de déterminer le chemin de l'exécutable")
|
||||
}
|
||||
|
||||
/// Télécharge NSSM depuis le site officiel
|
||||
fn download_nssm() -> Result<()> {
|
||||
let nssm = nssm_path()?;
|
||||
if nssm.exists() {
|
||||
tracing::info!("NSSM déjà présent: {}", nssm.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let url = "https://nssm.cc/release/nssm-2.24.zip";
|
||||
let zip_path = nssm.with_extension("zip");
|
||||
|
||||
tracing::info!("Téléchargement de NSSM depuis {}...", url);
|
||||
|
||||
// Télécharger avec reqwest (bloquant)
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
let response = rt.block_on(async {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()?
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("Échec du téléchargement: HTTP {}", response.status());
|
||||
}
|
||||
|
||||
let bytes = rt.block_on(response.bytes())?;
|
||||
std::fs::write(&zip_path, &bytes)?;
|
||||
tracing::info!("NSSM téléchargé: {} ({} octets)", zip_path.display(), bytes.len());
|
||||
|
||||
// Extraire nssm.exe du zip (le zip contient nssm-2.24/win64/nssm.exe)
|
||||
let file = std::fs::File::open(&zip_path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
|
||||
// Chercher nssm.exe dans l'archive (windows/win64)
|
||||
let mut found = false;
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive.by_index(i)?;
|
||||
let name = entry.name().to_lowercase();
|
||||
if name.ends_with("nssm.exe") && !name.contains("nssm.exe/") {
|
||||
// Extraire vers le répertoire cible
|
||||
let mut out = std::fs::File::create(&nssm)?;
|
||||
std::io::copy(&mut entry, &mut out)?;
|
||||
found = true;
|
||||
tracing::info!("NSSM extrait: {}", nssm.display());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
anyhow::bail!("nssm.exe introuvable dans l'archive ZIP");
|
||||
}
|
||||
|
||||
// Nettoyer le zip
|
||||
let _ = std::fs::remove_file(&zip_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Installe imagod-web comme service Windows
|
||||
///
|
||||
/// Équivalent à:
|
||||
/// nssm install ImagodWeb "C:\...\imagod-web.exe" serve --host 127.0.0.1 --port 9199
|
||||
/// nssm set ImagodWeb Start SERVICE_AUTO_START
|
||||
/// nssm set ImagodWeb AppExit Default Restart
|
||||
/// nssm start ImagodWeb
|
||||
pub fn install() -> Result<()> {
|
||||
pub fn install() -> anyhow::Result<()> {
|
||||
if !cfg!(windows) {
|
||||
anyhow::bail!("L'installation en service Windows n'est disponible que sous Windows");
|
||||
anyhow::bail!("L'installation en service est disponible uniquement sous Windows.\nCompilation Windows: cargo build --release --target x86_64-pc-windows-gnu");
|
||||
}
|
||||
|
||||
tracing::info!("=== Installation du service Windows imagod-web ===");
|
||||
|
||||
// 1. Télécharger NSSM si nécessaire
|
||||
download_nssm().context("Échec du téléchargement de NSSM")?;
|
||||
let nssm = nssm_path()?;
|
||||
|
||||
// 2. Vérifier que l'exécutable existe
|
||||
let exe = imagod_exe()?;
|
||||
tracing::info!("Exécutable: {}", exe.display());
|
||||
|
||||
// 3. Désinstaller d'abord si déjà présent (évite les conflits)
|
||||
if nssm_installed()? {
|
||||
tracing::info!("Service déjà installé, suppression préalable...");
|
||||
let _ = uninstall();
|
||||
}
|
||||
|
||||
// 4. Installer le service
|
||||
let service_name = "ImagodWeb";
|
||||
tracing::info!("Installation du service '{}'...", service_name);
|
||||
|
||||
let status = Command::new(&nssm)
|
||||
.args([
|
||||
"install",
|
||||
service_name,
|
||||
&exe.to_string_lossy(),
|
||||
])
|
||||
.output()
|
||||
.context("Échec de la commande nssm install")?;
|
||||
|
||||
if !status.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&status.stderr);
|
||||
anyhow::bail!("nssm install a échoué: {}", stderr);
|
||||
}
|
||||
|
||||
// 5. Configurer les arguments de lancement: serve --host 127.0.0.1 --port 9199
|
||||
Command::new(&nssm)
|
||||
.args(["set", service_name, "AppParameters", "serve --host 127.0.0.1 --port 9199"])
|
||||
.output()
|
||||
.context("Échec configuration AppParameters")?;
|
||||
|
||||
// 6. Démarrage automatique
|
||||
Command::new(&nssm)
|
||||
.args(["set", service_name, "Start", "SERVICE_AUTO_START"])
|
||||
.output()
|
||||
.context("Échec configuration Start")?;
|
||||
|
||||
// 7. Redémarrage automatique sur crash
|
||||
Command::new(&nssm)
|
||||
.args(["set", service_name, "AppExit", "Default", "Restart"])
|
||||
.output()
|
||||
.context("Échec configuration AppExit")?;
|
||||
|
||||
// 8. Délai de redémarrage: 5 secondes
|
||||
Command::new(&nssm)
|
||||
.args(["set", service_name, "AppRestartDelay", "5000"])
|
||||
.output()
|
||||
.context("Échec configuration AppRestartDelay")?;
|
||||
|
||||
// 9. Répertoire de travail
|
||||
if let Some(parent) = exe.parent() {
|
||||
Command::new(&nssm)
|
||||
.args(["set", service_name, "AppDirectory", &parent.to_string_lossy()])
|
||||
.output()
|
||||
.context("Échec configuration AppDirectory")?;
|
||||
}
|
||||
|
||||
// 10. Démarrer le service
|
||||
tracing::info!("Démarrage du service...");
|
||||
let start = Command::new(&nssm)
|
||||
.args(["start", service_name])
|
||||
.output()
|
||||
.context("Échec de la commande nssm start")?;
|
||||
|
||||
if start.status.success() {
|
||||
tracing::info!("✅ Service '{}' installé et démarré avec succès!", service_name);
|
||||
tracing::info!(" Dashboard : http://127.0.0.1:9199");
|
||||
tracing::info!(" Le watcher clipboard est actif (capture automatique des screenshots)");
|
||||
tracing::info!(" Le service redémarre automatiquement en cas de crash");
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&start.stderr);
|
||||
tracing::warn!("⚠️ Service installé mais échec au démarrage: {}", stderr);
|
||||
tracing::info!(" Essayez 'sc start ImagodWeb' ou redémarrez Windows");
|
||||
}
|
||||
|
||||
println!("Installation du service Windows imagod-web...");
|
||||
println!("(Build Windows natif requis pour l'installation NSSM automatique)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Désinstalle le service Windows imagod-web
|
||||
///
|
||||
/// Équivalent à:
|
||||
/// nssm stop ImagodWeb
|
||||
/// nssm remove ImagodWeb confirm
|
||||
pub fn uninstall() -> Result<()> {
|
||||
pub fn uninstall() -> anyhow::Result<()> {
|
||||
if !cfg!(windows) {
|
||||
anyhow::bail!("La désinstallation de service Windows n'est disponible que sous Windows");
|
||||
anyhow::bail!("Désinstallation disponible uniquement sous Windows");
|
||||
}
|
||||
|
||||
let nssm = nssm_path()?;
|
||||
if !nssm.exists() {
|
||||
// NSSM pas présent, le service n'a probablement jamais été installé
|
||||
tracing::warn!("NSSM non trouvé — rien à désinstaller");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let service_name = "ImagodWeb";
|
||||
|
||||
if !nssm_installed()? {
|
||||
tracing::warn!("Service '{}' non trouvé — rien à désinstaller", service_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!("Arrêt du service '{}'...", service_name);
|
||||
let _ = Command::new(&nssm)
|
||||
.args(["stop", service_name])
|
||||
.output();
|
||||
|
||||
tracing::info!("Suppression du service '{}'...", service_name);
|
||||
let remove = Command::new(&nssm)
|
||||
.args(["remove", service_name, "confirm"])
|
||||
.output()
|
||||
.context("Échec de la commande nssm remove")?;
|
||||
|
||||
if remove.status.success() {
|
||||
tracing::info!("✅ Service '{}' désinstallé avec succès", service_name);
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&remove.stderr);
|
||||
anyhow::bail!("nssm remove a échoué: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Vérifie si le service est installé
|
||||
fn nssm_installed() -> Result<bool> {
|
||||
let nssm = nssm_path()?;
|
||||
if !nssm.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let output = Command::new(&nssm)
|
||||
.args(["status", "ImagodWeb"])
|
||||
.output()
|
||||
.context("Échec de nssm status")?;
|
||||
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
/// Affiche le statut du service
|
||||
pub fn status() -> Result<()> {
|
||||
if !cfg!(windows) {
|
||||
tracing::info!("Le statut de service n'est pertinent que sous Windows");
|
||||
tracing::info!("Utilisez 'imagod-web status' pour le statut général");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let nssm = nssm_path()?;
|
||||
if !nssm.exists() || !nssm_installed()? {
|
||||
tracing::warn!("Service 'ImagodWeb' non installé");
|
||||
tracing::info!("Exécutez 'imagod-web install' pour l'installer");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output = Command::new(&nssm)
|
||||
.args(["status", "ImagodWeb"])
|
||||
.output()
|
||||
.context("Échec de nssm status")?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
tracing::info!("Statut du service: {}", stdout.trim());
|
||||
|
||||
println!("Désinstallation du service...");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user