9cc7ffcf3b
gates / gates (push) Failing after 44s
MEASURED FIRST (audits/app-page-2026-10-15b/P0/VERDICT.md). The tab bar rendered unstyled through
the public address for one reason, and it was not the box: exactly one guest runs 0.315.0 and it
runs the registry's current digest (8887343011d6, identical to the registry's). The floor is still
0.314.0, so nothing else could have taken it.
What it was, in four measured facts:
1. the stylesheet URL is /static/style.css?v={{.Version}} -> ?v=0.315.0 for all THREE different
0.315.0 builds (e66d466, fe0228c, db52f5a), the first of which had no .app-tabs rules at all;
2. the origin sends NO validator at all (ETag present = 0, Last-Modified present = 0), so a
cached copy cannot be revalidated - it can only expire;
3. Cloudflare caches that URL (cf-cache-status HIT, age climbing) and rewrites the browser TTL
from the origin's 3600 to 14400 - four hours;
4. the session's first request returned cf-cache-status EXPIRED, which only happens when the edge
holds an entry that has aged out.
Origin, edge and box now agree byte-for-byte. The stale object itself is gone - that first probe
forced its revalidation - and the REPORT says so rather than claiming a HIT nobody can reproduce.
THE FIX IS IN TWO PARTS, NEITHER OF WHICH IS PURGING A CACHE:
build.sh refuses to push a version that already exists, before building, for --push and
--multiarch. A registry that cannot be asked is a refusal too: proving the tag is new is the point,
and "I could not ask" is not that proof. Local builds are untouched - reusing a version while
iterating is normal. scripts/test_build_push_once.sh drives the real build.sh with a fake docker on
PATH over six cases; red-proved twice (check deleted -> a present tag builds and pushes; an
unreachable registry treated as absent -> two cases fail).
assetTag() now hashes version + GitCommit + BuildTime, and EVERY shell's stylesheet link uses it
(seven of the nine asked for ?v={{.Version}}; only the two stranger-facing share pages already had
it). It stays opaque and 8 hex, because the share pages are reached by a capability URL and naming
the build to whoever holds one is a gift to somebody hunting a known fault. Cache-Control is
unchanged: the new address is the fix, not a shorter cache.
The parity harness normalises the asset tag on BOTH sides of the comparison, as it already does for
the per-session CSRF token, so all 148 fixtures stay byte-identical on disk and stop depending on
which build rendered them. The same normalisation is applied at the second comparison site in
i18n_wiring_test.go, which had the same assumption.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
594 lines
38 KiB
HTML
594 lines
38 KiB
HTML
{{define "layout_start"}}
|
|
<!DOCTYPE html>
|
|
<html lang="{{T "layout.html_lang"}}" class="no-js">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{{.Title}} — Felhom.eu</title>
|
|
{{/* ?v= cache-bust: Cloudflare edge-caches /static/* for 4h — an unversioned URL keeps
|
|
serving the PREVIOUS release's asset after a deploy (0.126.1 live QA hit this on CSS;
|
|
the logo/favicon share the failure mode). The controller version busts it every release. */}}
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v={{.Version}}">
|
|
<link rel="stylesheet" href="/static/style.css?v={{.AssetTag}}">
|
|
<meta name="csrf-token" content="{{.CSRFToken}}">
|
|
<script>
|
|
// Progressive-enhancement hook (v0.166.0): flip no-js → js before any CSS-dependent paint so the
|
|
// mobile off-canvas drawer engages only when the drawer JS below can actually open it.
|
|
document.documentElement.className=document.documentElement.className.replace('no-js','js');
|
|
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
|
|
{{/* R-562 (decision 223): numbers and dates in the page's language. The browser twin of internal/i18n
|
|
format.go: Hungarian 2026. 10. 10. 15:04 and a decimal comma, English 2026-10-10 15:04 and a point.
|
|
sv-SE is used only because it prints the ISO-like order; the Hungarian form is built from it. */ -}}
|
|
var fhEN=document.documentElement.lang==='en';
|
|
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
|
|
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
|
|
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
|
|
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
|
|
// Inline two-step confirm (drill F-11): the trigger swaps IN PLACE to "<question> Igen/Mégse".
|
|
// Never native confirm() — it is an OS-modal that blocks browser automation — and no overlay:
|
|
// this is the LIGHT pattern for one-click consequential actions. Form buttons opt in with
|
|
// data-confirm="…" (submitted via requestSubmit so formaction/name-value are honored);
|
|
// JS flows call felhomConfirm(el, question, onYes) directly.
|
|
function felhomConfirm(el,question,onYes){
|
|
if(!el||el.dataset.fcOpen)return;
|
|
el.dataset.fcOpen='1';
|
|
var wrap=document.createElement('span');wrap.className='inline-confirm';
|
|
var q=document.createElement('span');q.className='inline-confirm-q';q.textContent=question;
|
|
var yes=document.createElement('button');yes.type='button';yes.className='btn btn-xs btn-danger';yes.textContent='{{T "layout.igen"}}';
|
|
var no=document.createElement('button');no.type='button';no.className='btn btn-xs btn-outline';no.textContent='{{T "layout.megse"}}';
|
|
wrap.appendChild(q);wrap.appendChild(yes);wrap.appendChild(no);
|
|
el.style.display='none';el.parentNode.insertBefore(wrap,el.nextSibling);
|
|
function close(){wrap.remove();el.style.display='';delete el.dataset.fcOpen;}
|
|
no.addEventListener('click',close);
|
|
yes.addEventListener('click',function(){close();onYes();});
|
|
}
|
|
document.addEventListener('click',function(e){
|
|
var btn=e.target.closest?e.target.closest('[data-confirm]'):null;
|
|
if(!btn)return;
|
|
e.preventDefault();
|
|
felhomConfirm(btn,btn.getAttribute('data-confirm'),function(){
|
|
var form=btn.closest('form');
|
|
if(form){if(form.requestSubmit)form.requestSubmit(btn);else form.submit();}
|
|
});
|
|
});
|
|
/* Collapsible sidebar groups (v0.146.0). Progressive enhancement: the server already
|
|
renders the group containing the active page with .is-open, so the correct group is
|
|
open before this runs and stays open if JS never does. Exactly one group open at a
|
|
time; clicking the open one closes it. */
|
|
document.addEventListener('click',function(e){
|
|
var btn=e.target.closest?e.target.closest('.nav-group-toggle'):null;
|
|
if(!btn)return;
|
|
var group=btn.closest('.nav-group');
|
|
if(!group)return;
|
|
var willOpen=!group.classList.contains('is-open');
|
|
document.querySelectorAll('.nav-group.is-open').forEach(function(g){
|
|
g.classList.remove('is-open');
|
|
var t=g.querySelector('.nav-group-toggle');
|
|
if(t)t.setAttribute('aria-expanded','false');
|
|
});
|
|
if(willOpen){group.classList.add('is-open');btn.setAttribute('aria-expanded','true');}
|
|
});
|
|
function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>{{T "layout.uzenet"}}</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
{{template "icon_sprite"}}
|
|
<header class="mobile-topbar">
|
|
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v={{.Version}}" alt="Felhom.eu"></a>
|
|
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="{{T "layout.menu"}}">
|
|
<svg class="ico"><use href="#i-menu"/></svg>
|
|
</button>
|
|
</header>
|
|
<div class="nav-backdrop" hidden></div>
|
|
<nav class="sidebar" id="sidebar">
|
|
<div class="sidebar-header">
|
|
<img src="/static/felhom-logo.svg?v={{.Version}}" alt="Felhom.eu" class="sidebar-logo">
|
|
</div>
|
|
<ul class="nav-links">
|
|
<li><a href="/launcher" class="{{if eq .Page "launcher"}}active{{end}}"><svg class="ico"><use href="#i-rocket"/></svg>{{T "layout.inditopult"}}</a></li>
|
|
<li><a href="/dashboard" class="{{if eq .Page "dashboard"}}active{{end}}"><svg class="ico"><use href="#i-layout-grid"/></svg>{{T "layout.vezerlopult"}}</a></li>
|
|
<li><a href="/stacks" class="{{if eq .Page "stacks"}}active{{end}}"><svg class="ico"><use href="#i-cloud"/></svg>{{T "layout.alkalmazasok"}}</a></li>
|
|
{{$storageOpen := or (eq .Page "storage") (eq .Page "storage-network") (eq .Page "storage_init") (eq .Page "storage_attach")}}
|
|
<li class="nav-group{{if $storageOpen}} is-open{{end}}">
|
|
<button type="button" class="nav-group-toggle{{if $storageOpen}} active{{end}}" aria-expanded="{{if $storageOpen}}true{{else}}false{{end}}" aria-controls="nav-group-storage"><svg class="ico"><use href="#i-hard-drive"/></svg>{{T "layout.tarhely"}}<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
|
<ul id="nav-group-storage" class="nav-links nav-links-sub nav-links-nested">
|
|
<li><a href="/storage" class="{{if or (eq .Page "storage") (eq .Page "storage_init") (eq .Page "storage_attach")}}active{{end}}">{{T "layout.meghajtok"}}</a></li>
|
|
<li><a href="/storage/network" class="{{if eq .Page "storage-network"}}active{{end}}">{{T "layout.halozati_tarhely"}}</a></li>
|
|
</ul>
|
|
</li>
|
|
{{$backupsOpen := or (eq .Page "backups") (eq .Page "backups-remote") (eq .Page "backups-apps") (eq .Page "backups-restore")}}
|
|
<li class="nav-group{{if $backupsOpen}} is-open{{end}}">
|
|
<button type="button" class="nav-group-toggle{{if $backupsOpen}} active{{end}}" aria-expanded="{{if $backupsOpen}}true{{else}}false{{end}}" aria-controls="nav-group-backups"><svg class="ico"><use href="#i-shield"/></svg>{{T "layout.biztonsagi_mentes"}}<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
|
<ul id="nav-group-backups" class="nav-links nav-links-sub nav-links-nested">
|
|
<li><a href="/backups" class="{{if eq .Page "backups"}}active{{end}}">{{T "layout.attekintes"}}</a></li>
|
|
<li><a href="/backups/remote" class="{{if eq .Page "backups-remote"}}active{{end}}">{{T "layout.tavoli_mentes"}}</a></li>
|
|
<li><a href="/backups/apps" class="{{if eq .Page "backups-apps"}}active{{end}}">{{T "layout.alkalmazasok"}}</a></li>
|
|
<li><a href="/backups/restore" class="{{if eq .Page "backups-restore"}}active{{end}}">{{T "layout.visszaallitas"}}</a></li>
|
|
</ul>
|
|
</li>
|
|
{{$sharingOpen := eq .Page "sharing"}}
|
|
<li class="nav-group{{if $sharingOpen}} is-open{{end}}">
|
|
<button type="button" class="nav-group-toggle{{if $sharingOpen}} active{{end}}" aria-expanded="{{if $sharingOpen}}true{{else}}false{{end}}" aria-controls="nav-group-sharing"><svg class="ico"><use href="#i-share"/></svg>{{T "layout.megosztas"}}<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
|
<ul id="nav-group-sharing" class="nav-links nav-links-sub nav-links-nested">
|
|
<li><a href="/sharing" class="{{if eq .Page "sharing"}}active{{end}}">{{T "layout.halozati_megosztas"}}</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a href="/monitoring" class="{{if eq .Page "monitoring"}}active{{end}}"><svg class="ico"><use href="#i-cpu"/></svg>{{T "layout.rendszermonitor"}}</a></li>
|
|
<li><a href="/debug" class="{{if eq .Page "debug"}}active{{end}}"><svg class="ico"><use href="#i-wrench"/></svg>{{T "layout.hibakereses"}}</a></li>
|
|
</ul>
|
|
<div class="sidebar-bottom">
|
|
<div class="nav-group-label">{{T "layout.beallitasok"}}</div>
|
|
<ul class="nav-links nav-links-sub">
|
|
<li><a href="/settings" class="{{if eq .Page "settings"}}active{{end}}"><svg class="ico"><use href="#i-settings"/></svg>{{T "layout.rendszer"}}</a></li>
|
|
<li><a href="/settings/notifications" class="{{if eq .Page "settings-notifications"}}active{{end}}"><svg class="ico"><use href="#i-bell"/></svg>{{T "layout.ertesitesek"}}</a></li>
|
|
<li><a href="/settings/security" class="{{if eq .Page "settings-security"}}active{{end}}"><svg class="ico"><use href="#i-lock"/></svg>{{T "layout.biztonsag_es_hozzaferes"}}</a></li>
|
|
</ul>
|
|
<div class="sidebar-footer">
|
|
<span class="version">{{.Version}}</span>{{if .LangSwitch}}{{template "lang_globe" .}}{{end}}
|
|
{{if .AuthEnabled}}<a href="/logout" class="logout-link">{{T "layout.kijelentkezes"}}</a>{{end}}
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
<main class="content">
|
|
{{if .ClaimLegacyOpen}}
|
|
<div class="alerts-container">
|
|
<div class="alert-banner alert-banner-error">
|
|
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
|
|
<span class="alert-message">{{T "layout.a_vezerlopult_meg_nincs_jelszoval"}}</span>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
{{/* R-241 (v0.206.0) — the recovery reminder bar. Rendered while the situation holds, the customer
|
|
has not opted out of reminders for THIS situation, and they have not clicked it away this visit.
|
|
It NEVER replaces the entry point on the Távoli mentés page — silencing a reminder is not the
|
|
same as removing the route. */}}
|
|
{{if .RecoveryBanner}}
|
|
<div class="alerts-container">
|
|
<div class="alert-banner alert-banner-warning">
|
|
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
|
|
<span class="alert-message">
|
|
{{if .RecoveryAbandonOverdue}}
|
|
{{/* R-365 follow-up: the due moment has passed; the deletion runs at the next daily sweep. */}}
|
|
{{T "layout.abandon_overdue"}}
|
|
{{if .RecoveryAbandonRetrievalOffered}}{{T "layout.addig_meg_visszaszerezheted_oket_a"}}{{else}}{{T "layout.hogy_ezek_meg_visszaszerezhetok_e"}}{{end}}
|
|
{{else if .RecoveryAbandonDays}}
|
|
{{/* R-302: the DELETION and its date are certain and always render. The RETRIEVAL clause is
|
|
conditional on the hub still holding the same sealed package it held when the customer
|
|
decided — pinned then, compared now. It rendered unconditionally, and was false on a
|
|
reachable state: a fresh escrow ceremony during the window replaces the package, which
|
|
is the exact act that cost both demo boxes their history on 2026-08-04. A countdown
|
|
started before this shipped carries no pin and takes the cautious branch. */}}
|
|
{{T "layout.a_korabbi_tavoli_menteseidet_nap"}}
|
|
{{if .RecoveryAbandonRetrievalOffered}}{{T "layout.addig_meg_visszaszerezheted_oket_a"}}{{else}}{{T "layout.hogy_ezek_meg_visszaszerezhetok_e"}}{{end}}
|
|
{{else if ge .RecoveryReminderTier 14}}
|
|
{{T "layout.ket_hete_varnak_rad_a"}}
|
|
{{else if ge .RecoveryReminderTier 7}}
|
|
{{T "layout.mar_egy_hete_megvannak_a"}}
|
|
{{else if ge .RecoveryReminderTier 3}}
|
|
{{T "layout.a_korabbi_tavoli_menteseid_megvannak"}}
|
|
{{else}}
|
|
{{T "layout.a_korabbi_tavoli_menteseid_megvannak_2"}}
|
|
{{end}}
|
|
<a href="/recovery">{{T "layout.megnezem"}}</a>
|
|
</span>
|
|
<span class="alert-actions">
|
|
<form method="POST" action="/recovery/banner/dismiss" style="display:inline">
|
|
{{.CSRFField}}<input type="hidden" name="back" value="{{.RecoveryBannerBack}}">
|
|
<button type="submit" class="btn btn-sm btn-outline">{{T "layout.most_nem"}}</button>
|
|
</form>
|
|
{{if not (or .RecoveryAbandonDays .RecoveryAbandonOverdue)}}
|
|
<form method="POST" action="/recovery/remind-optout" style="display:inline">
|
|
{{.CSRFField}}<input type="hidden" name="back" value="{{.RecoveryBannerBack}}">
|
|
<button type="submit" class="btn btn-sm btn-outline">{{T "layout.ne_emlekeztessen_ujra"}}</button>
|
|
</form>
|
|
{{end}}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
{{/* R-543 (v0.245.0) — the escrow reminder bar. The off-site tier is ON by default and does NOT RUN
|
|
until the household creates its recovery code (zero-knowledge escrow: their code is the only key).
|
|
The pause is the design; what was missing was ASKING. Same mechanism as the R-241 bar above —
|
|
dismissable for the visit, back at the next one, gone for good when the escrow completes. */}}
|
|
{{/* R-871 (v0.295.0, `09` decision 110) — the missed-backup banner: when the last daily backup is over 26 h old,
|
|
why (the box was off at the backup time, from the box's own record) and a suggested time. Closing it lasts until
|
|
the NEXT missed backup time (durable, the night ledger); a successful night removes it. Never changes the time. */ -}}
|
|
{{if .MissedBackupBanner}}
|
|
<div class="alerts-container">
|
|
<div class="alert-banner alert-banner-warning" id="missed-backup-banner">
|
|
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
|
|
<span class="alert-message">{{.MissedBackupMessage}} {{.MissedBackupHint}}</span>
|
|
<span class="alert-actions">
|
|
<a href="/backups#window_start" class="btn btn-sm btn-primary">{{T "layout.missed_backup_change"}}</a>
|
|
<form method="POST" action="/backups/missed-banner/dismiss" style="display:inline">
|
|
{{.CSRFField}}<input type="hidden" name="back" value="{{.MissedBackupBack}}">
|
|
<button type="submit" class="btn btn-sm btn-outline">{{T "layout.missed_backup_close"}}</button>
|
|
</form>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{{end -}}
|
|
{{if .EscrowBanner}}
|
|
<div class="alerts-container">
|
|
<div class="alert-banner alert-banner-warning">
|
|
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
|
|
<span class="alert-message">
|
|
{{T "layout.a_tavoli_mentes_szunetel_amig"}}
|
|
</span>
|
|
<span class="alert-actions">
|
|
<form method="POST" action="/backup/escrow/banner/dismiss" style="display:inline">
|
|
{{.CSRFField}}<input type="hidden" name="back" value="{{.EscrowBannerBack}}">
|
|
<button type="submit" class="btn btn-sm btn-outline">{{T "layout.most_nem"}}</button>
|
|
</form>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
{{if .Alerts}}
|
|
<div class="alerts-container">
|
|
{{range .Alerts}}
|
|
{{if and (not .Inline) (or (not .PageOnly) (pageMatch .PageOnly $.Page))}}
|
|
<div class="alert-banner alert-banner-{{.Level}}">
|
|
<span class="alert-icon"><svg class="ico">{{if eq .Level "error"}}<use href="#i-triangle-alert"/>{{else if eq .Level "warning"}}<use href="#i-triangle-alert"/>{{else}}<use href="#i-info"/>{{end}}</svg></span>
|
|
<span class="alert-message">{{.Message}}</span>
|
|
{{if .Link}}<a href="{{.Link}}" class="alert-link">{{.LinkText}} →</a>{{end}}
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
{{define "layout_end"}}
|
|
</main>
|
|
<script>
|
|
// Mobile off-canvas drawer (v0.166.0). Reuses the existing sidebar as the drawer; the accordion
|
|
// handler in the head script is untouched and keeps working inside it. Only wires up on ≤768px
|
|
// chrome — on desktop the burger/backdrop are display:none so these listeners never fire.
|
|
(function(){
|
|
var burger=document.querySelector('.nav-burger');
|
|
var sidebar=document.getElementById('sidebar');
|
|
var backdrop=document.querySelector('.nav-backdrop');
|
|
if(!burger||!sidebar||!backdrop)return;
|
|
function setOpen(open){
|
|
sidebar.classList.toggle('is-open',open);
|
|
document.body.classList.toggle('nav-open',open);
|
|
backdrop.hidden=!open;
|
|
burger.setAttribute('aria-expanded',open?'true':'false');
|
|
}
|
|
burger.addEventListener('click',function(){setOpen(!sidebar.classList.contains('is-open'));});
|
|
backdrop.addEventListener('click',function(){setOpen(false);});
|
|
document.addEventListener('keydown',function(e){
|
|
if(e.key==='Escape'&&sidebar.classList.contains('is-open'))setOpen(false);
|
|
});
|
|
})();
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
|
|
var card = e.target.closest('[data-href]');
|
|
if (card) window.location.href = card.dataset.href;
|
|
});
|
|
async function checkBeforeDeploy(e, name) {
|
|
try {
|
|
var resp = await fetch('/api/stacks/' + name);
|
|
var data = await resp.json();
|
|
if (data.ok && data.data && data.data.deployed) {
|
|
e.preventDefault();
|
|
showAlert('{{T "layout.ez_az_alkalmazas_mar_telepitve"}}');
|
|
window.location.reload();
|
|
return false;
|
|
}
|
|
} catch(err) {}
|
|
return true;
|
|
}
|
|
async function syncTemplates() {
|
|
const btn = document.getElementById('sync-btn');
|
|
const toast = document.getElementById('sync-toast');
|
|
if (!btn) return;
|
|
const origText = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '{{T "layout.frissites"}}';
|
|
btn.classList.add('loading');
|
|
try {
|
|
const resp = await fetch('/api/sync', {
|
|
method: 'POST',
|
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
|
});
|
|
const data = await resp.json();
|
|
if (toast) {
|
|
toast.textContent = data.ok ? (data.message || '{{T "layout.sablonok_frissitve"}}') : ('{{T "common.hiba_kettospont"}} ' + (data.error || '{{T "layout.ismeretlen_hiba"}}'));
|
|
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
|
|
toast.style.display = 'block';
|
|
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
|
}
|
|
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
|
|
setTimeout(function() { window.location.reload(); }, 1500);
|
|
}
|
|
} catch (err) {
|
|
if (toast) {
|
|
toast.textContent = '{{T "layout.halozati_hiba"}} ' + err.message;
|
|
toast.className = 'sync-toast sync-toast-err';
|
|
toast.style.display = 'block';
|
|
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
|
}
|
|
}
|
|
btn.innerHTML = origText;
|
|
btn.disabled = false;
|
|
btn.classList.remove('loading');
|
|
}
|
|
async function stackAction(event, name, action) {
|
|
const btn = event.currentTarget;
|
|
const origText = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = '{{T "layout.folyamatban"}}';
|
|
btn.classList.add('loading');
|
|
try {
|
|
const resp = await fetch('/api/stacks/' + name + '/' + action, {
|
|
method: 'POST',
|
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
|
});
|
|
const data = await resp.json();
|
|
if (!data.ok) {
|
|
showAlert('{{T "common.hiba_kettospont"}} ' + (data.error || '{{T "layout.ismeretlen_hiba"}}'));
|
|
btn.textContent = origText;
|
|
btn.disabled = false;
|
|
btn.classList.remove('loading');
|
|
return;
|
|
}
|
|
// Slice 4: an update is ACCEPTED, not finished (HTTP 202). Follow its phases on this
|
|
// button until the box says it is done or failed — never reload into a page that still
|
|
// shows the old state as if nothing were happening.
|
|
if (action === 'update') {
|
|
followUpdate(name, btn);
|
|
return;
|
|
}
|
|
window.location.reload();
|
|
} catch (err) {
|
|
showAlert('{{T "layout.halozati_hiba"}} ' + err.message);
|
|
btn.textContent = origText;
|
|
btn.disabled = false;
|
|
btn.classList.remove('loading');
|
|
}
|
|
}
|
|
function followUpdate(name, btn) {
|
|
var started = Date.now();
|
|
var timer = setInterval(async function() {
|
|
if (Date.now() - started > 30 * 60 * 1000) { clearInterval(timer); window.location.reload(); return; }
|
|
try {
|
|
var r = await fetch('/api/stacks/' + name);
|
|
var d = await r.json();
|
|
if (!d.ok || !d.data) return;
|
|
if (d.data.update_phase_label) btn.textContent = d.data.update_phase_label;
|
|
if (!d.data.updating) { clearInterval(timer); window.location.reload(); }
|
|
} catch (e) {}
|
|
}, 3000);
|
|
}
|
|
async function deleteOrphanStack(name) {
|
|
var modal = document.createElement('div');
|
|
modal.className = 'modal-overlay';
|
|
modal.id = 'delete-modal';
|
|
modal.innerHTML = '<div class="modal-card"><h3>{{T "layout.betoltes"}}</h3></div>';
|
|
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
|
|
document.body.appendChild(modal);
|
|
try {
|
|
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
|
|
var data = await resp.json();
|
|
var hddInfo = '';
|
|
var checkboxHTML = '';
|
|
if (data.ok && data.data && data.data.has_hdd_data) {
|
|
hddInfo = '<div class="modal-hdd-info"><strong>{{T "layout.felhasznaloi_adatok_a_merevlemezen"}}</strong>';
|
|
data.data.hdd_paths.forEach(function(p) {
|
|
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : '{{T "layout.nem_letezik"}}') + ')</div>';
|
|
});
|
|
hddInfo += '</div>';
|
|
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> {{T "layout.felhasznaloi_adatok_torlese_a_merevlemez"}}</label>';
|
|
}
|
|
// R-800 / decision 67: the household's own folders stay on every remove — said, by name.
|
|
if (data.ok && data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
|
var udPaths = '';
|
|
data.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
|
|
hddInfo += '<div class="modal-section" data-userdata-kept="true"><strong>{{T "layout.userdata_marad"}}</strong>' + udPaths + '</div>';
|
|
}
|
|
modal.querySelector('.modal-card').innerHTML =
|
|
'<h3>{{T "layout.alkalmazas_torlese"}} ' + name + '</h3>' +
|
|
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">{{T "layout.ez_a_muvelet_eltavolitja_a"}}</p>' +
|
|
'<div class="alert alert-warning" style="margin-bottom:.75rem">{{T "layout.ez_a_muvelet_nem_visszavonhato"}}</div>' +
|
|
hddInfo + checkboxHTML +
|
|
'<div class="modal-actions">' +
|
|
'<button class="btn btn-outline" onclick="closeDeleteModal()">{{T "common.megsem"}}</button>' +
|
|
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">{{T "common.torles"}}</button>' +
|
|
'</div>';
|
|
} catch (err) {
|
|
modal.querySelector('.modal-card').innerHTML =
|
|
'<h3>{{T "common.hiba"}}</h3><p style="color:var(--text-2)">{{T "layout.nem_sikerult_lekerni_az_adatokat"}} ' + err.message + '</p>' +
|
|
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">{{T "common.bezaras"}}</button></div>';
|
|
}
|
|
}
|
|
function closeDeleteModal() {
|
|
var modal = document.getElementById('delete-modal');
|
|
if (modal) modal.remove();
|
|
}
|
|
async function confirmDelete(name) {
|
|
var btn = document.getElementById('confirm-delete-btn');
|
|
var checkbox = document.getElementById('delete-hdd-check');
|
|
var removeHDD = checkbox ? checkbox.checked : false;
|
|
btn.disabled = true;
|
|
btn.textContent = '{{T "layout.torles_folyamatban"}}';
|
|
try {
|
|
var resp = await fetch('/api/stacks/' + name, {
|
|
method: 'DELETE',
|
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
|
body: JSON.stringify({remove_hdd_data: removeHDD})
|
|
});
|
|
var data = await resp.json();
|
|
if (data.ok) {
|
|
var modal = document.getElementById('delete-modal');
|
|
var removedInfo = '';
|
|
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
|
removedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">{{T "layout.torolt_adatok"}} ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
|
}
|
|
var preservedInfo = '';
|
|
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
|
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">{{T "layout.megorzott_adatok"}} ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
|
}
|
|
if (data.data && data.data.hdd_note) {
|
|
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
|
|
}
|
|
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
|
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">{{T "layout.userdata_marad"}} ' + data.data.userdata_kept.join(', ') + '</p>';
|
|
}
|
|
modal.querySelector('.modal-card').innerHTML =
|
|
'<h3>{{T "layout.sikeresen_torolve"}}</h3>' +
|
|
'<p style="color:var(--text-2)">{{T "layout.az_alkalmazas"}}' + name + '{{T "layout.torolve_lett"}}</p>' +
|
|
removedInfo + preservedInfo +
|
|
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">{{T "common.bezaras"}}</button></div>';
|
|
} else {
|
|
showAlert('{{T "common.hiba_kettospont"}} ' + (data.error || '{{T "layout.ismeretlen_hiba"}}'));
|
|
btn.disabled = false;
|
|
btn.textContent = '{{T "common.torles"}}';
|
|
}
|
|
} catch (err) {
|
|
showAlert('{{T "layout.halozati_hiba"}} ' + err.message);
|
|
btn.disabled = false;
|
|
btn.textContent = '{{T "common.torles"}}';
|
|
}
|
|
}
|
|
async function removeStack(name) {
|
|
var modal = document.createElement('div');
|
|
modal.className = 'modal-overlay';
|
|
modal.id = 'remove-modal';
|
|
modal.innerHTML = '<div class="modal-card"><h3>{{T "layout.betoltes"}}</h3></div>';
|
|
modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); });
|
|
document.body.appendChild(modal);
|
|
try {
|
|
var [hddResp, backupResp] = await Promise.all([
|
|
fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }),
|
|
fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); })
|
|
]);
|
|
var sections = '';
|
|
// v0.269.0 (decision 27): a held app whose page says support is informed — keep-data only.
|
|
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
|
if (keepOnly) {
|
|
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">{{T "layout.remove_keep_data_only"}}</div>';
|
|
}
|
|
// Section 1: Always removed
|
|
sections += '<div class="modal-section">' +
|
|
'<strong>{{T "layout.mindig_torlodik"}}</strong>' +
|
|
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
|
'<li>{{T "layout.docker_kotetek_adatbazis_alkalmazas_konf"}}</li>' +
|
|
'<li>{{T "layout.telepitesi_konfiguracio_app_yaml"}}</li>' +
|
|
'<li>{{T "layout.masodlagos_mentes_utemezese"}}</li>' +
|
|
'</ul></div>';
|
|
// Section 2: HDD data
|
|
var hddCheckbox = '';
|
|
if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) {
|
|
var hddPaths = '';
|
|
hddResp.data.hdd_paths.forEach(function(p) {
|
|
hddPaths += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : '{{T "layout.nem_letezik"}}') + ')</div>';
|
|
});
|
|
sections += '<div class="modal-section">' +
|
|
'<strong>{{T "layout.felhasznaloi_adatok_a_merevlemezen"}}</strong>' + hddPaths +
|
|
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> {{T "layout.felhasznaloi_adatok_torlese"}}</label>' +
|
|
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
|
|
'{{T "layout.ha_ujratelepiti_az_alkalmazast_az"}}' +
|
|
'</div></div>';
|
|
hddCheckbox = '<script>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
|
|
}
|
|
// Section 3: Backup data
|
|
var backupCheckbox = '';
|
|
if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) {
|
|
var bkPaths = '';
|
|
backupResp.data.backup_paths.forEach(function(p) {
|
|
if (p.exists) bkPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
|
});
|
|
if (bkPaths) {
|
|
sections += '<div class="modal-section">' +
|
|
'<strong>{{T "layout.mentesi_adatok"}}</strong>' + bkPaths +
|
|
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> {{T "layout.mentesi_adatok_torlese"}}</label>' +
|
|
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
|
|
'{{T "layout.az_ejszakai_restic_pillanatkepek_nem"}}' +
|
|
'</div></div>';
|
|
}
|
|
}
|
|
// R-800 / decision 67: the household's own folders stay on every remove — said, by name.
|
|
if (hddResp.ok && hddResp.data && hddResp.data.userdata_kept && hddResp.data.userdata_kept.length > 0) {
|
|
var udPaths = '';
|
|
hddResp.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
|
|
sections += '<div class="modal-section" data-userdata-kept="true"><strong>{{T "layout.userdata_marad"}}</strong>' + udPaths + '</div>';
|
|
}
|
|
modal.querySelector('.modal-card').innerHTML =
|
|
'<h3>{{T "layout.alkalmazas_eltavolitasa"}} ' + name + '</h3>' +
|
|
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">{{T "layout.az_alkalmazas_visszaall_nincs_telepitve"}}</p>' +
|
|
'<div class="alert alert-warning" style="margin-bottom:.75rem">{{T "layout.ez_a_muvelet_nem_visszavonhato"}}</div>' +
|
|
sections +
|
|
'<div class="modal-actions">' +
|
|
'<button class="btn btn-outline" onclick="closeRemoveModal()">{{T "common.megsem"}}</button>' +
|
|
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">{{T "common.eltavolitas"}}</button>' +
|
|
'</div>' + hddCheckbox;
|
|
} catch (err) {
|
|
modal.querySelector('.modal-card').innerHTML =
|
|
'<h3>{{T "common.hiba"}}</h3><p style="color:var(--text-2)">{{T "layout.nem_sikerult_lekerni_az_adatokat"}} ' + err.message + '</p>' +
|
|
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">{{T "common.bezaras"}}</button></div>';
|
|
}
|
|
}
|
|
function closeRemoveModal() {
|
|
var modal = document.getElementById('remove-modal');
|
|
if (modal) modal.remove();
|
|
}
|
|
async function confirmRemoveStack(name) {
|
|
var btn = document.getElementById('confirm-remove-btn');
|
|
var hddCheck = document.getElementById('remove-hdd-check');
|
|
var backupCheck = document.getElementById('remove-backup-check');
|
|
var removeHDD = hddCheck ? hddCheck.checked : false;
|
|
var removeBackups = backupCheck ? backupCheck.checked : false;
|
|
btn.disabled = true;
|
|
btn.textContent = '{{T "layout.eltavolitas_folyamatban"}}';
|
|
try {
|
|
var resp = await fetch('/api/stacks/' + name + '/remove', {
|
|
method: 'POST',
|
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
|
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
|
|
});
|
|
var data = await resp.json();
|
|
if (data.ok) {
|
|
var modal = document.getElementById('remove-modal');
|
|
var removedInfo = '';
|
|
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
|
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">{{T "layout.torolt_adatok"}} ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
|
}
|
|
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
|
|
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">{{T "layout.torolt_mentesek"}} ' + data.data.backup_paths_removed.join(', ') + '</p>';
|
|
}
|
|
var preservedInfo = '';
|
|
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
|
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">{{T "layout.megorzott_adatok"}} ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
|
}
|
|
if (data.data && data.data.hdd_note) {
|
|
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
|
|
}
|
|
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
|
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">{{T "layout.userdata_marad"}} ' + data.data.userdata_kept.join(', ') + '</p>';
|
|
}
|
|
if (data.data && data.data.backup_paths_refused && data.data.backup_paths_refused.length > 0) {
|
|
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">{{T "layout.nem_torolt_mentesek"}} ' + data.data.backup_paths_refused.join('; ') + '</p>';
|
|
}
|
|
modal.querySelector('.modal-card').innerHTML =
|
|
'<h3>{{T "layout.sikeresen_eltavolitva"}}</h3>' +
|
|
'<p style="color:var(--text-2)">{{T "layout.az_alkalmazas"}}' + name + '{{T "layout.eltavolitva_ujratelepitheto_a_telepites"}}</p>' +
|
|
removedInfo + preservedInfo +
|
|
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">{{T "common.bezaras"}}</button></div>';
|
|
} else {
|
|
showAlert('{{T "common.hiba_kettospont"}} ' + (data.error || '{{T "layout.ismeretlen_hiba"}}'));
|
|
btn.disabled = false;
|
|
btn.textContent = '{{T "common.eltavolitas"}}';
|
|
}
|
|
} catch (err) {
|
|
showAlert('{{T "layout.halozati_hiba"}} ' + err.message);
|
|
btn.disabled = false;
|
|
btn.textContent = '{{T "common.eltavolitas"}}';
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
{{end}}
|