kept data: the choice at reinstall, the list, the read-only view, the load (09 decision 36); R-690 fixed
gates / gates (push) Successful in 26s

An install over an app's kept drive folder (appdata/<app> non-empty) asks the household:
"use my kept data" (a load from the newest copy of THIS drive's install, own unit or
second-drive mirror, then the template's after_load) or "start fresh" (the folder is
renamed into <drive>/kept/<app>/<date>/ with the removed app's unit; nothing deleted).
The install API answers 409 kept_data_choice until one is chosen; DeployStack refuses
too. New page Megorzott adatok / Kept data (/kept-data): Load / Look / Delete (typed
confirmation, the only deletion of kept data). FileBrowser gets a read-only source.
The drive-full warning names the kept folders. <drive>/kept is protected and outside
every backup leg.

R-690: the removed-app restore (R-487) never found a unit on a DATA drive — it asked
GetStackComposePath (true for every catalog app) and restored nextcloud with no env.
Now isStackDeployed; pinned with a production-shaped provider.

Red-proofs: audits/night-2026-09-26/E/redproofs/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-25 13:32:30 +02:00
parent 5a731b45b9
commit 43e99d160c
44 changed files with 3311 additions and 37 deletions
+36 -2
View File
@@ -449,6 +449,7 @@
{{end}}
{{end}}
<div id="kept-choice" class="kept-choice card" hidden></div>
<form id="deploy-form" class="deploy-form">
{{/* R-351 SCENARIO A — the values below came from this app's OWN backup, so a reinstall does
not ask the customer to remember what the backup already recorded. Stated, never silent:
@@ -680,6 +681,32 @@ var postDeployInfo = {
deployFields: {{json .Meta.DeployFields}}
};
// showKeptChoice renders the two choices of `09` §3 decision 36. Every sentence arrives rendered in the
// reader's language from the server (textContent only — nothing is parsed as HTML).
function showKeptChoice(d, form) {
var box = document.getElementById('kept-choice');
box.textContent = '';
function el(tag, cls, text) { var x = document.createElement(tag); if (cls) x.className = cls; if (text) x.textContent = text; return x; }
box.appendChild(el('h3', 'kept-choice-title', d.title));
box.appendChild(el('p', 'kept-choice-body', d.body));
function option(label, desc, choice, enabled, extra) {
var o = el('div', 'kept-choice-option');
var b = el('button', enabled ? 'btn btn-primary' : 'btn btn-outline', label);
b.type = 'button';
b.disabled = !enabled;
b.setAttribute('data-kept-choice', choice);
b.addEventListener('click', function () { window.keptChoice = choice; box.hidden = true; form.requestSubmit(); });
o.appendChild(b);
o.appendChild(el('p', 'kept-choice-desc', desc));
if (extra) o.appendChild(el('p', 'kept-choice-note', extra));
return o;
}
box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, ''));
box.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up));
box.hidden = false;
box.scrollIntoView({behavior: 'smooth'});
}
function buildPostDeployCard(stackName) {
var subdomain = '';
var sdField = document.getElementById('field-SUBDOMAIN');
@@ -992,9 +1019,16 @@ document.getElementById('deploy-form').addEventListener('submit', async function
var resp = await fetch('/api/stacks/' + stackName + '/deploy', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({values: values})
body: JSON.stringify({values: values, kept_data: window.keptChoice || ''})
});
var data = await resp.json();
// `09` §3 decision 36: the drive already holds this app's old data — the household chooses.
if (!data.ok && data.data && data.data.code === 'kept_data_choice') {
showKeptChoice(data.data, e.target);
btn.textContent = '{{T "deploy.telepites_inditasa"}}';
btn.disabled = false;
return;
}
if (!data.ok) {
showAlert('{{T "common.hiba_kettospont"}} ' + data.error);
btn.textContent = '{{T "deploy.telepites_inditasa"}}';
@@ -1015,7 +1049,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function
// Phase 2: Poll stack status
var startTime = Date.now();
var pollTimeout = 120000;
var pollTimeout = window.keptChoice === 'use' ? 900000 : 120000; // a load restores a backup first
var pollTimer = setInterval(async function() {
var elapsed = Math.round((Date.now() - startTime) / 1000);
elapsedEl.textContent = elapsed + ' {{T "deploy.masodperce"}}';
@@ -0,0 +1,58 @@
{{define "kept_data"}}
{{template "layout_start" .}}
<div class="page-header">
<div class="kept-header">
<a href="/storage" class="btn btn-sm btn-outline">{{T "kept.page.back"}}</a>
<h2>{{T "kept.page.title"}}</h2>
</div>
</div>
{{if .KeptError}}<div class="alert alert-error">{{.KeptError}}</div>{{end}}
{{if .KeptFlash}}<div class="alert alert-info">{{.KeptFlash}}</div>{{end}}
<div class="settings-card">
<p class="settings-card-desc">{{T "kept.page.intro"}}</p>
<p class="form-hint">{{T "kept.not_backed_up"}}</p>
{{if .KeptRows}}
<div class="kept-list">
{{range .KeptRows}}
<div class="kept-item" data-kept-path="{{.Path}}">
<div class="kept-item-head">
<span class="kept-item-name">{{.DisplayName}}</span>
<span class="kept-item-meta mono">{{.Date}} · {{.Size}}</span>
</div>
<div class="kept-item-backup">{{T "kept.list.backup"}} {{.Backup}}</div>
<div class="kept-item-actions">
{{if .CanLoad}}
<form method="POST" action="/kept-data/load" class="kept-inline-form">
{{$.CSRFField}}
<input type="hidden" name="path" value="{{.Path}}">
<button type="submit" class="btn btn-sm btn-primary">{{T "kept.action.load"}}</button>
</form>
{{else if .Installed}}
<span class="form-hint">{{T "kept.list.installed"}}</span>
{{end}}
<a href="{{.LookURL}}" target="_blank" rel="noopener" class="btn btn-sm btn-outline">{{T "kept.action.look"}}</a>
</div>
<details class="kept-delete">
<summary class="btn btn-sm btn-outline">{{T "kept.action.delete"}}</summary>
<form method="POST" action="/kept-data/delete" class="kept-delete-form">
{{$.CSRFField}}
<input type="hidden" name="path" value="{{.Path}}">
<p class="kept-delete-text">{{.DeleteText}}</p>
<label class="form-hint">{{.TypePrompt}}</label>
<input type="text" name="confirm" autocomplete="off" required>
<button type="submit" class="btn btn-sm btn-danger">{{T "kept.action.delete_final"}}</button>
</form>
</details>
</div>
{{end}}
</div>
{{else}}
<p class="form-hint">{{T "kept.list.empty"}}</p>
{{end}}
</div>
{{template "layout_end" .}}
{{end}}
@@ -12,6 +12,7 @@
{{if .StorageError}}<div class="alert alert-error">{{.StorageError}}</div>{{end}}
{{if .StorageSuccess}}<div class="alert alert-info">{{.StorageSuccess}}</div>{{end}}
<p class="storage-kept-link"><a href="/kept-data" class="btn btn-sm btn-outline">{{T "kept.page.title"}}</a> <span class="form-hint">{{T "kept.page.link_hint"}}</span></p>
{{if .StoragePaths}}
<div class="storage-paths-list">
@@ -3682,3 +3682,25 @@ html::-webkit-scrollbar-track { background: var(--bg-0); }
the shared .lang-globe-menu rule, so this block adds no positioning of its own. It floated in the
top-left of the viewport before, outside the card, which read as a stray browser control. */
.shell-lang { display: flex; justify-content: center; margin-top: 1.5rem; }
/* ── Kept data (`09` §3 decision 36): the install-time choice and the „Megőrzött adatok" list ── */
.kept-choice { margin-bottom: 1rem; }
.kept-choice-title { margin: 0 0 .5rem; }
.kept-choice-body { color: var(--text-1); margin: 0 0 1rem; }
.kept-choice-option { border-top: 1px solid var(--line-soft); padding: .75rem 0; }
.kept-choice-desc, .kept-choice-note { color: var(--text-2); margin: .4rem 0 0; font-size: .9rem; }
.kept-choice-note { color: var(--text-3); }
.kept-header { display: flex; align-items: center; gap: .5rem; }
.kept-list { display: flex; flex-direction: column; gap: .75rem; margin-top: .75rem; }
.kept-item { border: 1px solid var(--line); border-radius: var(--radius); padding: .75rem; background: var(--bg-1); }
.kept-item-head { display: flex; justify-content: space-between; gap: .75rem; flex-wrap: wrap; }
.kept-item-name { font-weight: 600; color: var(--text-1); }
.kept-item-meta { color: var(--text-2); }
.kept-item-backup { color: var(--text-2); margin: .35rem 0 .5rem; font-size: .9rem; }
.kept-item-actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.kept-inline-form { display: inline; margin: 0; }
.kept-delete { margin-top: .5rem; }
.kept-delete summary { list-style: none; display: inline-block; cursor: pointer; }
.kept-delete-form { display: flex; flex-direction: column; gap: .4rem; margin-top: .5rem; max-width: 32rem; }
.kept-delete-text { color: var(--warn); margin: 0; }
.storage-kept-link { margin-top: .75rem; }