feat(patching/import): actions Reporter/Ajouter au patching + log + colonne Etat (etape A) + placeholder /patching/iexec affichant excludes effectifs (etape B a venir)
This commit is contained in:
parent
6eb7619efc
commit
a5f3a25198
@ -308,6 +308,7 @@ async def import_sheet_json(request: Request, import_id: int, sheet_name: str,
|
|||||||
r.mode_operatoire, r.impacts, r.commentaire, r.base_de_donnees,
|
r.mode_operatoire, r.impacts, r.commentaire, r.base_de_donnees,
|
||||||
r.duree_coupure, r.jour, r.jour_text, r.heure, r.heure_t,
|
r.duree_coupure, r.jour, r.jour_text, r.heure, r.heure_t,
|
||||||
r.pb_espace_disque, r.date_patch_realise,
|
r.pb_espace_disque, r.date_patch_realise,
|
||||||
|
r.is_eligible, r.reported_to_sheet, r.report_reason,
|
||||||
r.server_id, s.hostname as resolved_hostname,
|
r.server_id, s.hostname as resolved_hostname,
|
||||||
(r.jour + COALESCE(r.heure_t, TIME '00:00:00')) AS start_at
|
(r.jour + COALESCE(r.heure_t, TIME '00:00:00')) AS start_at
|
||||||
FROM patch_planning_import_rows r
|
FROM patch_planning_import_rows r
|
||||||
@ -345,12 +346,101 @@ async def import_sheet_json(request: Request, import_id: int, sheet_name: str,
|
|||||||
"start_iso": r.start_at.isoformat() if r.start_at else None,
|
"start_iso": r.start_at.isoformat() if r.start_at else None,
|
||||||
"pb_espace_disque": r.pb_espace_disque,
|
"pb_espace_disque": r.pb_espace_disque,
|
||||||
"date_patch_realise": r.date_patch_realise.isoformat() if r.date_patch_realise else None,
|
"date_patch_realise": r.date_patch_realise.isoformat() if r.date_patch_realise else None,
|
||||||
|
"is_eligible": r.is_eligible,
|
||||||
|
"reported_to_sheet": r.reported_to_sheet,
|
||||||
|
"report_reason": r.report_reason,
|
||||||
"server_id": r.server_id,
|
"server_id": r.server_id,
|
||||||
"resolved_hostname": r.resolved_hostname,
|
"resolved_hostname": r.resolved_hostname,
|
||||||
})
|
})
|
||||||
return JSONResponse({"ok": True, "rows": out, "count": len(out)})
|
return JSONResponse({"ok": True, "rows": out, "count": len(out)})
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Action sur les rows : éligible / report
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/patching/import/{import_id}/rows/action")
|
||||||
|
async def import_rows_action(request: Request, import_id: int, db=Depends(get_db)):
|
||||||
|
"""Pose une action sur N rows :
|
||||||
|
- action='eligible' : marque is_eligible=true (et clear report)
|
||||||
|
- action='unset_eligible' : remet is_eligible=false
|
||||||
|
- action='report' : reported_to_sheet=target_sheet, reason=... (clear is_eligible)
|
||||||
|
- action='unset_report' : clear report
|
||||||
|
"""
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not _can_import(perms):
|
||||||
|
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
row_ids = [int(x) for x in body.get("row_ids", []) if str(x).isdigit()]
|
||||||
|
action = (body.get("action") or "").strip()
|
||||||
|
target_sheet = (body.get("target_sheet") or "").strip()
|
||||||
|
reason = (body.get("reason") or "").strip()
|
||||||
|
|
||||||
|
if not row_ids:
|
||||||
|
return JSONResponse({"ok": False, "msg": "Aucune ligne sélectionnée"})
|
||||||
|
if action not in ("eligible", "unset_eligible", "report", "unset_report"):
|
||||||
|
return JSONResponse({"ok": False, "msg": f"Action inconnue: {action}"})
|
||||||
|
if action == "report" and not target_sheet:
|
||||||
|
return JSONResponse({"ok": False, "msg": "Semaine cible obligatoire pour reporter"})
|
||||||
|
|
||||||
|
placeholders = ",".join(str(i) for i in row_ids)
|
||||||
|
# Restreint aux rows de cet import (sécurité)
|
||||||
|
rows = db.execute(text(f"""
|
||||||
|
SELECT id FROM patch_planning_import_rows
|
||||||
|
WHERE id IN ({placeholders}) AND import_id=:imp
|
||||||
|
"""), {"imp": import_id}).fetchall()
|
||||||
|
valid_ids = [r.id for r in rows]
|
||||||
|
if not valid_ids:
|
||||||
|
return JSONResponse({"ok": False, "msg": "Aucune ligne valide pour cet import"})
|
||||||
|
valid_ph = ",".join(str(i) for i in valid_ids)
|
||||||
|
|
||||||
|
uid = user.get("uid")
|
||||||
|
details = {}
|
||||||
|
if action == "eligible":
|
||||||
|
db.execute(text(f"""
|
||||||
|
UPDATE patch_planning_import_rows
|
||||||
|
SET is_eligible=true, reported_to_sheet=NULL, report_reason=NULL,
|
||||||
|
last_action_at=NOW(), last_action_by=:uid
|
||||||
|
WHERE id IN ({valid_ph})
|
||||||
|
"""), {"uid": uid})
|
||||||
|
elif action == "unset_eligible":
|
||||||
|
db.execute(text(f"""
|
||||||
|
UPDATE patch_planning_import_rows
|
||||||
|
SET is_eligible=false, last_action_at=NOW(), last_action_by=:uid
|
||||||
|
WHERE id IN ({valid_ph})
|
||||||
|
"""), {"uid": uid})
|
||||||
|
elif action == "report":
|
||||||
|
details = {"target_sheet": target_sheet, "reason": reason}
|
||||||
|
db.execute(text(f"""
|
||||||
|
UPDATE patch_planning_import_rows
|
||||||
|
SET is_eligible=false, reported_to_sheet=:ts, report_reason=:rs,
|
||||||
|
last_action_at=NOW(), last_action_by=:uid
|
||||||
|
WHERE id IN ({valid_ph})
|
||||||
|
"""), {"ts": target_sheet, "rs": reason or None, "uid": uid})
|
||||||
|
elif action == "unset_report":
|
||||||
|
db.execute(text(f"""
|
||||||
|
UPDATE patch_planning_import_rows
|
||||||
|
SET reported_to_sheet=NULL, report_reason=NULL,
|
||||||
|
last_action_at=NOW(), last_action_by=:uid
|
||||||
|
WHERE id IN ({valid_ph})
|
||||||
|
"""), {"uid": uid})
|
||||||
|
|
||||||
|
# Log
|
||||||
|
for rid in valid_ids:
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
|
||||||
|
VALUES (:rid, :ac, :de, :uid)
|
||||||
|
"""), {"rid": rid, "ac": action,
|
||||||
|
"de": json.dumps(details, ensure_ascii=False) if details else None,
|
||||||
|
"uid": uid})
|
||||||
|
db.commit()
|
||||||
|
return JSONResponse({"ok": True, "updated": len(valid_ids), "action": action})
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
# Upload
|
# Upload
|
||||||
# ────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
@ -484,6 +574,43 @@ async def import_upload(request: Request, db=Depends(get_db),
|
|||||||
# Suppression
|
# Suppression
|
||||||
# ────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Workflow iexec — placeholder étape B (à compléter)
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/patching/iexec", response_class=HTMLResponse)
|
||||||
|
async def iexec_page(request: Request, db=Depends(get_db),
|
||||||
|
row_ids: str = Query("")):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/login")
|
||||||
|
perms = get_user_perms(db, user)
|
||||||
|
if not (can_view(perms, "planning") or can_view(perms, "campaigns")):
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
|
|
||||||
|
ids = [int(x) for x in row_ids.split(",") if x.strip().isdigit()]
|
||||||
|
rows = []
|
||||||
|
if ids:
|
||||||
|
placeholders = ",".join(str(i) for i in ids)
|
||||||
|
rows = db.execute(text(f"""
|
||||||
|
SELECT r.id, r.asset_name, r.environnement, r.os, r.os_version,
|
||||||
|
r.is_eligible, r.server_id,
|
||||||
|
s.hostname, vs.effective_excludes
|
||||||
|
FROM patch_planning_import_rows r
|
||||||
|
LEFT JOIN servers s ON s.id = r.server_id
|
||||||
|
LEFT JOIN v_servers_patching vs ON vs.id = r.server_id
|
||||||
|
WHERE r.id IN ({placeholders}) AND r.is_eligible = true
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
ctx = base_context(request, db, user)
|
||||||
|
ctx.update({
|
||||||
|
"app_name": APP_NAME,
|
||||||
|
"rows": rows,
|
||||||
|
"row_ids": ids,
|
||||||
|
})
|
||||||
|
return templates.TemplateResponse("patching_iexec.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/patching/import/{import_id}/delete")
|
@router.post("/patching/import/{import_id}/delete")
|
||||||
async def import_delete(request: Request, import_id: int, db=Depends(get_db)):
|
async def import_delete(request: Request, import_id: int, db=Depends(get_db)):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
|
|||||||
55
app/templates/patching_iexec.html
Normal file
55
app/templates/patching_iexec.html
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Pré-patching — iexec{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-cyber-accent">Pré-patching — workflow iexec</h2>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
{{ rows|length }} serveur(s) éligible(s) sélectionné(s) sur {{ row_ids|length }} demandés.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="javascript:history.back()" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">← Retour</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4 mb-4 border border-cyber-yellow/40">
|
||||||
|
<h3 class="text-sm font-bold text-cyber-yellow mb-2">⚠ Étape B — workflow à implémenter</h3>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Les 3 steps planifiés :
|
||||||
|
</p>
|
||||||
|
<ol class="text-xs text-gray-300 list-decimal pl-5 mt-2 space-y-1">
|
||||||
|
<li><strong>Step 1 — Pré-patching</strong> : vérif résolution DNS · vérif SSH · vérif Satellite (capsule)</li>
|
||||||
|
<li><strong>Step 2 — Snapshot</strong> : take snapshot vCenter (avant modif)</li>
|
||||||
|
<li><strong>Step 3 — Patch</strong> : <code>yum update -y --exclude=<effective_excludes></code></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-3 mb-4">
|
||||||
|
<h3 class="text-sm font-bold text-cyber-accent mb-2">Serveurs ciblés ({{ rows|length }})</h3>
|
||||||
|
{% if rows %}
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead class="text-cyber-accent border-b border-cyber-border">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left p-1">Asset</th>
|
||||||
|
<th class="text-left p-1">Hostname BDD</th>
|
||||||
|
<th class="text-left p-1">Env</th>
|
||||||
|
<th class="text-left p-1">OS</th>
|
||||||
|
<th class="text-left p-1">Excludes effectifs</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr class="border-b border-cyber-border/30">
|
||||||
|
<td class="p-1 font-mono">{{ r.asset_name }}</td>
|
||||||
|
<td class="p-1 font-mono">{{ r.hostname or '–' }}</td>
|
||||||
|
<td class="p-1">{{ r.environnement or '' }}</td>
|
||||||
|
<td class="p-1">{{ r.os or '' }}</td>
|
||||||
|
<td class="p-1 text-cyber-yellow">{{ r.effective_excludes or '(aucun)' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs text-gray-500">Aucune ligne éligible parmi les IDs demandés.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -123,17 +123,26 @@
|
|||||||
</label>
|
</label>
|
||||||
<span class="text-xs text-gray-400" id="selection-count">0 sélectionné(s)</span>
|
<span class="text-xs text-gray-400" id="selection-count">0 sélectionné(s)</span>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="btn-prepatch" class="btn-sm bg-cyber-yellow/20 text-cyber-yellow px-3 py-1 text-xs" disabled>
|
{% if can_import %}
|
||||||
Pré-patching <span class="text-[10px] opacity-60">(étape 2)</span>
|
<button id="btn-add-eligible" class="btn-sm bg-cyber-green/20 text-cyber-green px-3 py-1 text-xs" disabled title="Marque les lignes comme éligibles au patching">
|
||||||
|
+ Ajouter au patching
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-patch" class="btn-sm bg-cyber-green/20 text-cyber-green px-3 py-1 text-xs" disabled>
|
<button id="btn-report" class="btn-sm bg-cyber-blue/20 text-cyber-blue px-3 py-1 text-xs" disabled title="Reporter à une autre semaine">
|
||||||
Patcher <span class="text-[10px] opacity-60">(étape 3)</span>
|
⤳ Reporter
|
||||||
|
</button>
|
||||||
|
<button id="btn-unset" class="btn-sm bg-cyber-border text-gray-400 px-3 py-1 text-xs" disabled title="Annuler éligibilité / report">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button id="btn-prepatch" class="btn-sm bg-cyber-yellow/20 text-cyber-yellow px-3 py-1 text-xs" disabled title="Lancer le pré-patching (rows éligibles uniquement)">
|
||||||
|
Pré-patching →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full text-xs" id="sheet-table">
|
<table class="w-full text-xs" id="sheet-table">
|
||||||
<thead class="text-cyber-accent border-b border-cyber-border">
|
<thead class="text-cyber-accent border-b border-cyber-border">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-left p-1 w-6"><input type="checkbox" id="select-all-head"></th>
|
<th class="text-left p-1 w-6"><input type="checkbox" id="select-all-head"></th>
|
||||||
|
<th class="text-left p-1">État</th>
|
||||||
<th class="text-left p-1 cursor-pointer select-none hover:text-cyber-accent" id="th-asset" title="Cliquer pour trier">
|
<th class="text-left p-1 cursor-pointer select-none hover:text-cyber-accent" id="th-asset" title="Cliquer pour trier">
|
||||||
Asset <span id="th-asset-arrow" class="text-[10px] opacity-50">↕</span>
|
Asset <span id="th-asset-arrow" class="text-[10px] opacity-50">↕</span>
|
||||||
</th>
|
</th>
|
||||||
@ -170,7 +179,9 @@
|
|||||||
const selAllHead = document.getElementById('select-all-head');
|
const selAllHead = document.getElementById('select-all-head');
|
||||||
const selCount = document.getElementById('selection-count');
|
const selCount = document.getElementById('selection-count');
|
||||||
const btnPre = document.getElementById('btn-prepatch');
|
const btnPre = document.getElementById('btn-prepatch');
|
||||||
const btnPatch = document.getElementById('btn-patch');
|
const btnAddElig = document.getElementById('btn-add-eligible');
|
||||||
|
const btnReport = document.getElementById('btn-report');
|
||||||
|
const btnUnset = document.getElementById('btn-unset');
|
||||||
const fInter = document.getElementById('filter-intervenant');
|
const fInter = document.getElementById('filter-intervenant');
|
||||||
const fEnv = document.getElementById('filter-env');
|
const fEnv = document.getElementById('filter-env');
|
||||||
const fReset = document.getElementById('filter-reset');
|
const fReset = document.getElementById('filter-reset');
|
||||||
@ -221,14 +232,37 @@
|
|||||||
function refreshSelection(){
|
function refreshSelection(){
|
||||||
const visible = tbody.querySelectorAll('tr:not(.row-hidden)');
|
const visible = tbody.querySelectorAll('tr:not(.row-hidden)');
|
||||||
const visibleCb = tbody.querySelectorAll('tr:not(.row-hidden) input.row-cb');
|
const visibleCb = tbody.querySelectorAll('tr:not(.row-hidden) input.row-cb');
|
||||||
const checked = tbody.querySelectorAll('input.row-cb:checked').length;
|
const checkedCb = Array.from(tbody.querySelectorAll('input.row-cb:checked'));
|
||||||
selCount.textContent = checked + ' sélectionné(s) · ' + visible.length + ' visible(s)';
|
const checked = checkedCb.length;
|
||||||
|
const eligibleSelected = checkedCb.filter(cb => cb.dataset.eligible === '1').length;
|
||||||
|
selCount.textContent = checked + ' sélectionné(s) · ' + visible.length + ' visible(s)' + (eligibleSelected ? ' · ' + eligibleSelected + ' éligible(s)' : '');
|
||||||
const allVisibleChecked = visibleCb.length > 0 && Array.from(visibleCb).every(cb => cb.checked);
|
const allVisibleChecked = visibleCb.length > 0 && Array.from(visibleCb).every(cb => cb.checked);
|
||||||
selAll.checked = allVisibleChecked;
|
selAll.checked = allVisibleChecked;
|
||||||
selAllHead.checked = allVisibleChecked;
|
selAllHead.checked = allVisibleChecked;
|
||||||
const hasSel = checked > 0;
|
const hasSel = checked > 0;
|
||||||
btnPre.disabled = !hasSel;
|
if (btnAddElig) btnAddElig.disabled = !hasSel;
|
||||||
btnPatch.disabled = !hasSel;
|
if (btnReport) btnReport.disabled = !hasSel;
|
||||||
|
if (btnUnset) btnUnset.disabled = !hasSel;
|
||||||
|
// Pré-patching : actif uniquement si au moins 1 row éligible sélectionnée
|
||||||
|
btnPre.disabled = eligibleSelected === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedRowIds(){
|
||||||
|
return Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => parseInt(cb.dataset.id, 10)).filter(x => x);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postAction(payload){
|
||||||
|
const r = await fetch('/patching/import/' + importId + '/rows/action', {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.ok) { alert('Erreur : ' + (j.msg || 'inconnue')); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadCurrentSheet(){
|
||||||
|
if (sel.value) await loadSheet(sel.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilters(){
|
function applyFilters(){
|
||||||
@ -315,11 +349,19 @@
|
|||||||
const assetCell = r.server_id
|
const assetCell = r.server_id
|
||||||
? '<a href="/servers?search=' + encodeURIComponent(r.resolved_hostname || r.asset_name) + '" target="_blank" rel="noopener" class="text-cyber-blue hover:underline">' + display + '</a>'
|
? '<a href="/servers?search=' + encodeURIComponent(r.resolved_hostname || r.asset_name) + '" target="_blank" rel="noopener" class="text-cyber-blue hover:underline">' + display + '</a>'
|
||||||
: '<span class="text-cyber-yellow" title="Pas matché en base PatchCenter">' + escapeHTML(r.asset_name || '') + ' ⚠</span>';
|
: '<span class="text-cyber-yellow" title="Pas matché en base PatchCenter">' + escapeHTML(r.asset_name || '') + ' ⚠</span>';
|
||||||
|
let badge = '';
|
||||||
|
if (r.is_eligible) {
|
||||||
|
badge = '<span class="text-cyber-green" title="Éligible au patching">✓ ÉLIG.</span>';
|
||||||
|
} else if (r.reported_to_sheet) {
|
||||||
|
const t = r.report_reason ? ('Reporté → ' + r.reported_to_sheet + ' : ' + r.report_reason) : ('Reporté → ' + r.reported_to_sheet);
|
||||||
|
badge = '<span class="text-cyber-blue" title="' + escapeHTML(t) + '">⤳ ' + escapeHTML(r.reported_to_sheet) + '</span>';
|
||||||
|
}
|
||||||
return '<tr class="border-b border-cyber-border/20 hover:bg-cyber-border/10"'
|
return '<tr class="border-b border-cyber-border/20 hover:bg-cyber-border/10"'
|
||||||
+ ' data-asset="' + escapeHTML(r.asset_name||'') + '"'
|
+ ' data-asset="' + escapeHTML(r.asset_name||'') + '"'
|
||||||
+ ' data-intervenant="' + escapeHTML(r.intervenant||'') + '"'
|
+ ' data-intervenant="' + escapeHTML(r.intervenant||'') + '"'
|
||||||
+ ' data-env="' + escapeHTML(r.environnement||'') + '">'
|
+ ' data-env="' + escapeHTML(r.environnement||'') + '">'
|
||||||
+ '<td class="p-1"><input type="checkbox" class="row-cb" data-id="' + r.id + '" data-asset="' + escapeHTML(r.asset_name||'') + '" data-server-id="' + (r.server_id||'') + '"></td>'
|
+ '<td class="p-1"><input type="checkbox" class="row-cb" data-id="' + r.id + '" data-asset="' + escapeHTML(r.asset_name||'') + '" data-server-id="' + (r.server_id||'') + '" data-eligible="' + (r.is_eligible ? '1':'0') + '"></td>'
|
||||||
|
+ '<td class="p-1 text-[10px]">' + badge + '</td>'
|
||||||
+ '<td class="p-1 font-mono">' + assetCell + '</td>'
|
+ '<td class="p-1 font-mono">' + assetCell + '</td>'
|
||||||
+ '<td class="p-1">' + escapeHTML(r.environnement||'') + '</td>'
|
+ '<td class="p-1">' + escapeHTML(r.environnement||'') + '</td>'
|
||||||
+ '<td class="p-1">' + escapeHTML(r.domaine||'') + '</td>'
|
+ '<td class="p-1">' + escapeHTML(r.domaine||'') + '</td>'
|
||||||
@ -356,13 +398,37 @@
|
|||||||
thAsset.addEventListener('click', () => { cycleSort('asset'); updateSortArrow(); renderTable(); });
|
thAsset.addEventListener('click', () => { cycleSort('asset'); updateSortArrow(); renderTable(); });
|
||||||
thDate.addEventListener('click', () => { cycleSort('date'); updateSortArrow(); renderTable(); });
|
thDate.addEventListener('click', () => { cycleSort('date'); updateSortArrow(); renderTable(); });
|
||||||
|
|
||||||
btnPre.addEventListener('click', () => {
|
if (btnAddElig) btnAddElig.addEventListener('click', async () => {
|
||||||
const ids = Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => cb.dataset.serverId).filter(x => x);
|
const ids = getSelectedRowIds();
|
||||||
alert('Pré-patching à brancher (étape 2) — ' + ids.length + ' serveur(s) résolu(s) en base.');
|
if (!ids.length) return;
|
||||||
|
if (!confirm('Marquer ' + ids.length + ' ligne(s) comme éligibles au patching ?')) return;
|
||||||
|
if (await postAction({row_ids: ids, action: 'eligible'})) await reloadCurrentSheet();
|
||||||
});
|
});
|
||||||
btnPatch.addEventListener('click', () => {
|
if (btnReport) btnReport.addEventListener('click', async () => {
|
||||||
const ids = Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => cb.dataset.serverId).filter(x => x);
|
const ids = getSelectedRowIds();
|
||||||
alert('Patching by-step à brancher (étape 3) — ' + ids.length + ' serveur(s) résolu(s) en base.');
|
if (!ids.length) return;
|
||||||
|
const target = (prompt('Reporter vers quelle semaine ? (ex: S23)') || '').trim();
|
||||||
|
if (!target) return;
|
||||||
|
if (!/^S\d{1,2}$/i.test(target)) { alert('Format attendu : Sxx (ex S23)'); return; }
|
||||||
|
const reason = (prompt('Raison du report (optionnel) :') || '').trim();
|
||||||
|
if (await postAction({row_ids: ids, action: 'report', target_sheet: target.toUpperCase(), reason})) await reloadCurrentSheet();
|
||||||
|
});
|
||||||
|
if (btnUnset) btnUnset.addEventListener('click', async () => {
|
||||||
|
const ids = getSelectedRowIds();
|
||||||
|
if (!ids.length) return;
|
||||||
|
if (!confirm('Annuler éligibilité ET report sur ' + ids.length + ' ligne(s) ?')) return;
|
||||||
|
if (await postAction({row_ids: ids, action: 'unset_eligible'})) {
|
||||||
|
await postAction({row_ids: ids, action: 'unset_report'});
|
||||||
|
await reloadCurrentSheet();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
btnPre.addEventListener('click', () => {
|
||||||
|
const ids = Array.from(tbody.querySelectorAll('input.row-cb:checked'))
|
||||||
|
.filter(cb => cb.dataset.eligible === '1')
|
||||||
|
.map(cb => cb.dataset.id);
|
||||||
|
if (!ids.length) { alert('Aucune ligne éligible sélectionnée.'); return; }
|
||||||
|
// Étape B : workflow iexec à brancher
|
||||||
|
window.location.href = '/patching/iexec?row_ids=' + ids.join(',');
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
33
migrate_planning_imports_v4.sql
Normal file
33
migrate_planning_imports_v4.sql
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
-- Migration v4 : actions sur les rows du planning importé
|
||||||
|
-- - is_eligible : ligne marquée éligible au patching (passe au workflow iexec)
|
||||||
|
-- - reported_to_sheet : si reportée, semaine cible (ex 'S23')
|
||||||
|
-- - report_reason : raison du report
|
||||||
|
-- - last_action_* : tracking dernière action (qui/quand)
|
||||||
|
-- + table patch_planning_row_log : audit log de chaque action posée
|
||||||
|
-- Idempotent
|
||||||
|
|
||||||
|
ALTER TABLE public.patch_planning_import_rows
|
||||||
|
ADD COLUMN IF NOT EXISTS is_eligible boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS reported_to_sheet text,
|
||||||
|
ADD COLUMN IF NOT EXISTS report_reason text,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_action_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_action_by integer REFERENCES public.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pp_rows_eligible
|
||||||
|
ON public.patch_planning_import_rows(import_id, sheet_name)
|
||||||
|
WHERE is_eligible = true;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.patch_planning_row_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
row_id integer NOT NULL REFERENCES public.patch_planning_import_rows(id) ON DELETE CASCADE,
|
||||||
|
action text NOT NULL, -- 'eligible', 'unset_eligible', 'report', 'unset_report'
|
||||||
|
details jsonb, -- {target_sheet, reason, ...}
|
||||||
|
performed_by integer REFERENCES public.users(id) ON DELETE SET NULL,
|
||||||
|
performed_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pp_row_log_row ON public.patch_planning_row_log(row_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pp_row_log_at ON public.patch_planning_row_log(performed_at DESC);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.patch_planning_row_log TO patchcenter;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE public.patch_planning_row_log_id_seq TO patchcenter;
|
||||||
Loading…
Reference in New Issue
Block a user