Files
esh-pfi-infrastructure/services/booth/booth/templates/index.html
T
vh 4be880f36c feat(booth): kept boards can be deleted from the UI; document the TTL-reset trap
Kept boards had no delete path in the UI at all. The kept lane deliberately
omits the wipe control -- a one-click wipe next to the durable stuff is a
footgun -- but "deliberate" had been implemented as "impossible": the only
routes out were ssh or a hand-written API call.

Now it is two deliberate acts. A `release` control on kept cards drops the
sentinel, the board moves to the ephemeral lane, and the existing x wipes it
from there. Release is reversible -- POST /b/<name>/keep pins it again.

  POST /b/<name>/unkeep   release the pin
  POST /b/<name>/keep     pin it (round-trip, so release is not a one-way door)

FOUND WHILE TESTING, and it invalidates the previously-documented workaround:
removing the sentinel BUMPS the booth directory's mtime, and booth age is the
newest mtime in the tree -- so a released board's clock RESETS from 10,000s to
0s and it survives another full TTL. The old comment said "remove the sentinel
first (it rejoins the sweep)", which is true but means the board lives another
24h, not that it gets reaped. Unkeep-and-wait is a delay, not a delete.

test_releasing_a_board_RESETS_its_ttl_clock pins that behaviour deliberately so
nobody re-derives the workaround. Release is what unlocks the x; the x is what
deletes.

CLI: `booth rm` already worked on kept boards but said nothing about it. It now
announces "(was KEPT -- durable board)" so wiping something durable can never
look identical to wiping run output. Not a block -- a CLI user naming a booth
is being explicit.

5 new tests (67 pass). Verified live on nh3-dev: release renders on all four
kept boards, the ephemeral lane keeps its x, and the links board is untouched
with its sentinel intact.
2026-08-23 10:42:32 -07:00

129 lines
5.6 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block content %}
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
<label class="drop" for="booth-files">
<span class="drop-icon">⬆</span>
<span class="drop-main">Upload files for pickup</span>
<span class="drop-sub" id="drop-sub">drop here, or click to choose · one pickup id, wiped in {{ ttl_hours }}h</span>
<input id="booth-files" name="files" type="file" multiple>
</label>
<button class="up-go" type="submit">Get pickup id →</button>
</form>
{% if kept %}
{# Kept boards render FIRST and look different on purpose: they are durable
operator-facing things (the agent link board, standing reports) and the
point of the lane is that they cannot be lost in a feed that turns over
every day. No countdown — they have no expiry to advertise. #}
<h2 class="lane-head">Kept <span class="lane-note">· no expiry · <code>{{ keep_marker }}</code></span></h2>
<div class="grid kept-grid">
{% for b in kept %}
<article class="card card-kept">
<a class="thumb" href="/b/{{ b.name_url }}/">
{% if b.thumb_url %}
<img loading="lazy" src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
{% elif b.has_index %}
<div class="ph">▦ page</div>
{% elif b.kinds.video %}
<div class="ph">▶ video</div>
{% elif b.kinds.audio %}
<div class="ph">♪ audio</div>
{% else %}
<div class="ph">◆ files</div>
{% endif %}
<span class="badge badge-kept">★ kept</span>
</a>
<div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
</div>
{# Still no × here — a one-click wipe next to the durable stuff is a
footgun. But "deliberate" must not mean "impossible from the UI",
which is what it meant before: the only routes out were ssh or a
hand-written API call. Release drops the sentinel and the board moves
to the ephemeral lane, where the × already lives. Two deliberate
acts, both reachable, and the first one is reversible.
The confirm says "wipe it from there" rather than "let it expire" on
purpose: releasing BUMPS the directory mtime, so the board's age
resets and it survives another full TTL. Unkeep-and-wait is a 24h
delay, not a delete. #}
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
onsubmit="return confirm('Release \u201c{{ b.name }}\u201d?\n\nIt moves to the ephemeral lane so you can wipe it from there. Nothing is deleted by this step.')">
<button title="release this board so it can be wiped">release</button>
</form>
</article>
{% endfor %}
</div>
{% if booths %}<h2 class="lane-head">Ephemeral <span class="lane-note">· wiped {{ ttl_hours }}h after last activity</span></h2>{% endif %}
{% endif %}
{% if not booths %}
{% if not kept %}
<div class="empty">
No booths yet. Upload files above, or drop a folder into <code>{{ data_dir }}</code>.
</div>
{% endif %}
{% else %}
<div class="grid">
{% for b in booths %}
<article class="card">
<a class="thumb" href="/b/{{ b.name_url }}/">
{% if b.thumb_url %}
<img loading="lazy" src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
{% elif b.has_index %}
<div class="ph">▦ page</div>
{% elif b.kinds.video %}
<div class="ph">▶ video</div>
{% elif b.kinds.audio %}
<div class="ph">♪ audio</div>
{% else %}
<div class="ph">◆ files</div>
{% endif %}
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
</a>
<div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
</div>
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
onsubmit="return confirm('Wipe booth “{{ b.name }}”?')">
<button title="wipe now" aria-label="wipe booth">×</button>
</form>
</article>
{% endfor %}
</div>
{% endif %}
<script>
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
With JS off, the native file input + submit still works. */
(function () {
var input = document.getElementById('booth-files');
var sub = document.getElementById('drop-sub');
var drop = document.querySelector('.drop');
if (!input) return;
function show() {
var n = input.files ? input.files.length : 0;
if (n) {
sub.textContent = n + ' file' + (n > 1 ? 's' : '') + ' ready — hit “Get pickup id”';
drop.classList.add('has');
}
}
input.addEventListener('change', show);
['dragover', 'dragenter'].forEach(function (e) {
drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.add('over'); });
});
['dragleave', 'drop'].forEach(function (e) {
drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.remove('over'); });
});
drop.addEventListener('drop', function (ev) {
if (ev.dataTransfer && ev.dataTransfer.files.length) {
try { input.files = ev.dataTransfer.files; } catch (_) {}
show();
}
});
})();
</script>
{% endblock %}