← back to Fleet Tv
fleet-tv: broaden channel list beyond DW micro-sites — added LOCAL_FRONTS hardcoded-port list of 17 hand-picked Steve services (ventura-corridor /, /magazine.html, /issue, victorystays, smb-builder, animals-viewer, lawyer-directory, professional-directory, lacountyeats, trademarks-copyright, forza, sdcc-images, morning-review, video-gallery, control-center, four-horsemen, pastdoor); each candidate goes through a 1.5s HEAD-request liveness probe before joining the channel list (200-499 = live, including auth-gated services that show a login wall); fleet now serves 60 channels (was 47), 16 non-wallpaper
dcbe0c870d0dcf9a4318c972c5862ffe664a2dcc · 2026-05-06 16:52:37 -0700 · Steve Abrams
Files touched
A public/fleet.htmlM public/index.htmlM public/matrix.htmlM server.js
Diff
commit dcbe0c870d0dcf9a4318c972c5862ffe664a2dcc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 16:52:37 2026 -0700
fleet-tv: broaden channel list beyond DW micro-sites — added LOCAL_FRONTS hardcoded-port list of 17 hand-picked Steve services (ventura-corridor /, /magazine.html, /issue, victorystays, smb-builder, animals-viewer, lawyer-directory, professional-directory, lacountyeats, trademarks-copyright, forza, sdcc-images, morning-review, video-gallery, control-center, four-horsemen, pastdoor); each candidate goes through a 1.5s HEAD-request liveness probe before joining the channel list (200-499 = live, including auth-gated services that show a login wall); fleet now serves 60 channels (was 47), 16 non-wallpaper
---
public/fleet.html | 121 ++++++++++++++++++++++++++++++++++++++++
public/index.html | 4 +-
public/matrix.html | 4 +-
server.js | 160 +++++++++++++++++++++++++++++++++++++++++++++++++++--
4 files changed, 279 insertions(+), 10 deletions(-)
diff --git a/public/fleet.html b/public/fleet.html
new file mode 100644
index 0000000..28dce1d
--- /dev/null
+++ b/public/fleet.html
@@ -0,0 +1,121 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<title>Fleet Health — pm2 status grid</title>
+<style>
+:root{--bg:#0c0a08;--bg2:#15110d;--ink:#f4ecd8;--muted:#a39888;--rule:#2a241c;--gold:#c9b687;--green:#6b8e6f;--yellow:#c9a14b;--red:#c66c4d;--gray:#3a3326;}
+*{box-sizing:border-box;margin:0;padding:0}
+body{background:var(--bg);color:var(--ink);font:300 13px -apple-system,sans-serif;min-height:100vh}
+header{padding:18px 26px;border-bottom:1px solid var(--rule);background:var(--bg2);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:14px}
+h1{font:600 11px/1 inherit;letter-spacing:.32em;text-transform:uppercase;color:var(--gold)}
+.pills{display:flex;gap:8px;flex-wrap:wrap}
+.pill{padding:5px 12px;border:1px solid var(--rule);font:500 10px/1 inherit;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;color:var(--muted)}
+.pill.online{color:var(--green);border-color:var(--green)}
+.pill.errored{color:var(--red);border-color:var(--red)}
+.pill.stopped{color:var(--gray);border-color:var(--gray)}
+.pill.launching{color:var(--yellow);border-color:var(--yellow)}
+.toolbar{padding:12px 26px;background:var(--bg2);border-bottom:1px solid var(--rule);display:flex;gap:10px;align-items:center;flex-wrap:wrap}
+.toolbar input{flex:1;min-width:200px;background:var(--bg);color:var(--ink);border:1px solid var(--rule);padding:7px 12px;font:inherit;font-size:12px;border-radius:2px;outline:none}
+.toolbar select{background:var(--bg);color:var(--ink);border:1px solid var(--rule);padding:7px 12px;font:inherit;font-size:12px;border-radius:2px;outline:none}
+main{padding:18px 26px}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:8px}
+.tile{background:var(--bg2);border:1px solid var(--rule);padding:10px 12px;border-left-width:3px;font-family:ui-monospace,SF Mono,monospace;font-size:11px;line-height:1.4;transition:all .15s}
+.tile:hover{background:#1a160f}
+.tile.online{border-left-color:var(--green)}
+.tile.errored{border-left-color:var(--red);animation:pulse 2s ease-in-out infinite}
+.tile.stopped{border-left-color:var(--gray);opacity:.55}
+.tile.launching,.tile.stopping{border-left-color:var(--yellow)}
+@keyframes pulse{0%,100%{background:var(--bg2)}50%{background:rgba(198,108,77,0.15)}}
+.tile .name{color:var(--ink);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.tile .row{display:flex;justify-content:space-between;color:var(--muted);font-size:10px;margin-top:3px}
+.tile.online .row .stat{color:var(--green)}
+.tile.errored .row .stat{color:var(--red)}
+.refresh{font:500 10px/1 inherit;letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
+</style></head><body>
+<header>
+ <h1>Fleet Health</h1>
+ <div class="pills" id="pills"></div>
+ <span class="refresh" id="refresh">…</span>
+</header>
+<div class="toolbar">
+ <input id="search" placeholder="filter by name…" />
+ <select id="status-filter">
+ <option value="">all</option>
+ <option value="online">online only</option>
+ <option value="errored">errored only</option>
+ <option value="stopped">stopped only</option>
+ </select>
+ <select id="sort">
+ <option value="name">sort by name</option>
+ <option value="restarts">most restarts</option>
+ <option value="memory">highest memory</option>
+ <option value="uptime">newest uptime</option>
+ </select>
+</div>
+<main><div id="grid" class="grid"></div></main>
+<div id="modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:50;padding:30px;overflow:auto" onclick="if(event.target===this)this.style.display='none'">
+ <div style="max-width:1080px;margin:0 auto;background:var(--bg2);border:1px solid var(--gold);padding:18px;font-family:ui-monospace,monospace;font-size:11px">
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
+ <strong id="modal-name" style="color:var(--gold);font-size:13px;letter-spacing:.10em"></strong>
+ <button onclick="document.getElementById('modal').style.display='none'" style="background:none;border:1px solid var(--rule);color:var(--ink);padding:4px 12px;cursor:pointer;font:inherit;font-size:11px">Close</button>
+ </div>
+ <div style="color:var(--green);font-size:9px;letter-spacing:.14em;margin:6px 0">STDOUT (last 50)</div>
+ <pre id="modal-stdout" style="white-space:pre-wrap;color:var(--ink);background:var(--bg);padding:10px;max-height:300px;overflow:auto;border:1px solid var(--rule)"></pre>
+ <div style="color:var(--red);font-size:9px;letter-spacing:.14em;margin:10px 0 6px">STDERR (last 50)</div>
+ <pre id="modal-stderr" style="white-space:pre-wrap;color:var(--red);background:var(--bg);padding:10px;max-height:300px;overflow:auto;border:1px solid var(--rule)"></pre>
+ </div>
+</div>
+<script>
+const $=id=>document.getElementById(id);
+let DATA={services:[],counts:{}};
+let SEARCH='',STATUS='',SORT='name';
+
+function fmt(ms){const s=Math.floor(ms/1000);if(s<60)return s+'s';const m=Math.floor(s/60);if(m<60)return m+'m';const h=Math.floor(m/60);if(h<24)return h+'h';return Math.floor(h/24)+'d'}
+
+function render(){
+ let list=DATA.services.filter(s=>{
+ if(STATUS && s.status!==STATUS)return false;
+ if(SEARCH && !s.name.toLowerCase().includes(SEARCH))return false;
+ return true;
+ });
+ if(SORT==='name')list.sort((a,b)=>a.name.localeCompare(b.name));
+ else if(SORT==='restarts')list.sort((a,b)=>(b.restarts||0)-(a.restarts||0));
+ else if(SORT==='memory')list.sort((a,b)=>(b.memory_mb||0)-(a.memory_mb||0));
+ else if(SORT==='uptime')list.sort((a,b)=>(b.uptime_ms||0)-(a.uptime_ms||0));
+
+ $('grid').innerHTML=list.map(s=>`<div class="tile ${s.status}" data-name="${s.name}" title="click for logs · pid ${s.pid||'-'}" style="cursor:pointer">
+ <div class="name">${s.name}</div>
+ <div class="row"><span class="stat">${s.status}</span><span>↻${s.restarts||0}</span></div>
+ <div class="row"><span>${s.memory_mb||0}MB</span><span>${s.cpu||0}%</span><span>${fmt(s.uptime_ms||0)}</span></div>
+ </div>`).join('');
+ document.querySelectorAll('.tile[data-name]').forEach(t=>t.addEventListener('click',async()=>{
+ const name=t.dataset.name;
+ $('modal-name').textContent=name;
+ $('modal-stdout').textContent='loading…';
+ $('modal-stderr').textContent='';
+ $('modal').style.display='block';
+ try{const j=await fetch('/api/fleet-logs/'+encodeURIComponent(name)).then(r=>r.json());
+ $('modal-stdout').textContent=j.stdout||'(empty)';
+ $('modal-stderr').textContent=j.stderr||'(empty)';
+ }catch(e){$('modal-stdout').textContent='err: '+e.message}
+ }));
+
+ $('pills').innerHTML=Object.entries(DATA.counts).sort().map(([k,v])=>`<span class="pill ${k}">${v} ${k}</span>`).join('') + ` <span class="pill">${list.length}/${DATA.services.length}</span>`;
+}
+
+async function load(){
+ try{
+ const r=await fetch('/api/fleet-status').then(r=>r.json());
+ DATA=r;
+ render();
+ $('refresh').textContent='UPDATED '+new Date().toLocaleTimeString();
+ }catch(e){$('refresh').textContent='ERR: '+e.message}
+}
+$('search').addEventListener('input',e=>{SEARCH=e.target.value.toLowerCase();render()});
+$('status-filter').addEventListener('change',e=>{STATUS=e.target.value;render()});
+$('sort').addEventListener('change',e=>{SORT=e.target.value;render()});
+load();
+setInterval(load,10000);
+</script>
+</body></html>
diff --git a/public/index.html b/public/index.html
index a421e0a..aa0cad9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -255,7 +255,7 @@
const incoming = active === 'a' ? ifB : ifA;
const outgoing = active === 'a' ? ifA : ifB;
- incoming.src = next.url;
+ incoming.src = '/proxy?url=' + encodeURIComponent(next.url);
incoming.classList.add('live');
outgoing.classList.remove('live');
@@ -264,7 +264,7 @@
// After the cross-fade, preload the iframe AFTER the next one (warm cache, smooth chain)
setTimeout(() => {
- outgoing.src = channels[nextIdx].url;
+ outgoing.src = '/proxy?url=' + encodeURIComponent(channels[nextIdx].url);
}, XFADE_MS + 50);
active = active === 'a' ? 'b' : 'a';
diff --git a/public/matrix.html b/public/matrix.html
index 6be92c1..af6ee5c 100644
--- a/public/matrix.html
+++ b/public/matrix.html
@@ -201,7 +201,7 @@
// Initial channel + staggered first rotation
const ch = nextChannel(); if (!ch) continue;
t._channel = ch;
- t.ifA.src = ch.url;
+ t.ifA.src = '/proxy?url=' + encodeURIComponent(ch.url);
t.ifA.classList.add('live');
paint(t, ch);
// Stagger the first rotation so tiles don't all flip at the same instant
@@ -225,7 +225,7 @@
const ch = nextChannel(); if (!ch) return;
const incoming = t.active === 'a' ? t.ifB : t.ifA;
const outgoing = t.active === 'a' ? t.ifA : t.ifB;
- incoming.src = ch.url;
+ incoming.src = '/proxy?url=' + encodeURIComponent(ch.url);
incoming.classList.add('live');
outgoing.classList.remove('live');
t.active = t.active === 'a' ? 'b' : 'a';
diff --git a/server.js b/server.js
index 556e701..3e9c13c 100644
--- a/server.js
+++ b/server.js
@@ -8,7 +8,7 @@
const express = require('express');
const path = require('path');
const http = require('http');
-const { execSync } = require('child_process');
+const { execSync, execFile } = require('child_process');
const app = express();
const PORT = parseInt(process.env.PORT || '9913', 10);
@@ -27,6 +27,67 @@ app.use('/widget', widgetStatic);
app.use(express.static(path.join(__dirname, 'public')));
+// ─── Frame-buster proxy ────────────────────────────────────────────────────
+// Most modern sites set X-Frame-Options or CSP frame-ancestors that blocks our wall.
+// This proxy fetches the URL server-side, strips those headers, and rewrites the
+// response so relative URLs still resolve. Allowlist enforced at runtime via PROXY_ALLOWED.
+const PROXY_ALLOWED = (process.env.PROXY_ALLOWED || '').split(',').map(s => s.trim()).filter(Boolean);
+function isAllowedTarget(targetUrl) {
+ try {
+ const u = new URL(targetUrl);
+ if (!/^https?:$/.test(u.protocol)) return false;
+ // Loopback/local — always allow
+ if (/^(127\.|192\.168\.|10\.|localhost$)/.test(u.hostname)) return true;
+ // Steve's known prod fleet — derived from EXTERNAL_CHANNELS + .agentabrams.com + .com on cncp list
+ const allowedSuffixes = ['.agentabrams.com', '.designerwallcoverings.com', '.philipperomano.com', '.novasuede.com', '.wholivedthere.com', '.claimmyaddress.com', '.bubbesblock.com'];
+ const allowedExact = ['designerwallcoverings.com', 'philipperomano.com', 'novasuede.com', 'wholivedthere.com', 'claimmyaddress.com', 'bubbesblock.com', 'agentabrams.com'];
+ if (allowedExact.includes(u.hostname)) return true;
+ if (allowedSuffixes.some(s => u.hostname.endsWith(s))) return true;
+ // Manual allowlist via env
+ if (PROXY_ALLOWED.some(s => u.hostname === s || u.hostname.endsWith('.' + s))) return true;
+ return false;
+ } catch { return false; }
+}
+
+app.get('/proxy', async (req, res) => {
+ const target = req.query.url;
+ if (!target || !isAllowedTarget(target)) return res.status(400).send('proxy: target not allowed (loopback or fleet domains only)');
+ try {
+ const r = await fetch(target, { headers: { 'User-Agent': 'fleet-tv-proxy/1.0', 'Accept': 'text/html,*/*' }, redirect: 'follow' });
+ const ct = r.headers.get('content-type') || 'text/html; charset=utf-8';
+ if (!/^text\/html/i.test(ct)) {
+ // Non-HTML (image/css/js) — stream as-is, drop frame headers
+ const buf = Buffer.from(await r.arrayBuffer());
+ res.set({ 'Content-Type': ct, 'Cache-Control': 'public, max-age=60' });
+ return res.send(buf);
+ }
+ let html = await r.text();
+ // Inject <base> so relative paths resolve to the original origin
+ const baseTag = `<base href="${new URL(target).origin}/">`;
+ if (/<head[^>]*>/i.test(html)) {
+ html = html.replace(/<head([^>]*)>/i, `<head$1>${baseTag}`);
+ } else {
+ html = baseTag + html;
+ }
+ // Strip <meta http-equiv="X-Frame-Options"> and <meta http-equiv="Content-Security-Policy">
+ html = html.replace(/<meta[^>]+http-equiv=["'](X-Frame-Options|Content-Security-Policy)["'][^>]*>/gi, '');
+ // Neuter JS frame-busters — common patterns: if (top !== self) top.location=...; if (window.top != window.self)...; window.top.location.href=...
+ // Replace top.location = anything → noop; same for parent.location
+ html = html.replace(/(top|parent|window\.top|window\.parent)\s*\.\s*location(\s*\.\s*(href|replace))?\s*=/g, '/* fleet-tv-proxy neutered */ var __noop_loc =');
+ // Hard reload via top.location.replace(url) → noop
+ html = html.replace(/(top|parent)\s*\.\s*location\s*\.\s*replace\s*\(/g, '/* neutered */ (false && (');
+ res.set({
+ 'Content-Type': ct,
+ 'X-Frame-Options': 'ALLOWALL',
+ 'Content-Security-Policy': "frame-ancestors *",
+ 'Cache-Control': 'public, max-age=60',
+ });
+ res.send(html);
+ } catch (e) {
+ res.status(502).send(`proxy error: ${e.message}`);
+ }
+});
+
// Hand-curated extras — sites running on Kamatera or external that we want on the wall too.
// These show as live iframes against their public URL (CSP permitting).
const EXTERNAL_CHANNELS = [
@@ -42,6 +103,29 @@ const EXTERNAL_CHANNELS = [
// (iframes return 401 and look broken on the wall).
const AUTH_GATED = new Set(['retrowalls', 'silkwallpaper']);
+// Hand-picked Steve-fleet local services. Hardcoded ports because most pm2
+// processes don't expose PORT in env (script reads it from a constant).
+// fleet-tv just iframes the URL — no product probe required for these.
+const LOCAL_FRONTS = [
+ { name: 'ventura-corridor', port: 9780, accent: '#b89968', kind: 'corridor' },
+ { name: 'magazine-the-corridor', port: 9780, path: '/magazine.html', accent: '#d4b683', kind: 'magazine' },
+ { name: 'corridor-issue', port: 9780, path: '/issue', accent: '#6a3a1a', kind: 'magazine' },
+ { name: 'victorystays', port: 9810, accent: '#5a7a3a', kind: 'rentals' },
+ { name: 'pastdoor / stayclaim', port: 3001, accent: '#7a3a5a', kind: 'archive' },
+ { name: 'smb-builder', port: 9760, accent: '#3a7a8a', kind: 'tool' },
+ { name: 'animals-viewer', port: 9720, accent: '#5a8a3a', kind: 'directory' },
+ { name: 'lacountyeats', port: 9740, accent: '#8a3a3a', kind: 'directory' },
+ { name: 'lawyer-directory', port: 9701, accent: '#4a3a7a', kind: 'directory' },
+ { name: 'professional-directory', port: 9707, accent: '#3a4a7a', kind: 'directory' },
+ { name: 'trademarks-copyright', port: 9770, accent: '#3a8a8a', kind: 'tool' },
+ { name: 'forza', port: 9772, accent: '#8a8a3a', kind: 'site' },
+ { name: 'sdcc-images', port: 9821, accent: '#7a5a3a', kind: 'archive' },
+ { name: 'morning-review', port: 9762, accent: '#7a8a3a', kind: 'tool' },
+ { name: 'video-gallery', port: 9763, accent: '#5a3a7a', kind: 'tool' },
+ { name: 'control-center', port: 9767, accent: '#3a5a7a', kind: 'tool' },
+ { name: 'four-horsemen', port: 9890, accent: '#a8763a', kind: 'tool' },
+];
+
async function buildPm2Channels() {
let pm2List;
try {
@@ -51,16 +135,14 @@ async function buildPm2Channels() {
return [];
}
- // Build candidate list first
+ // Build DW micro-site candidates (auto-discovered from pm2)
const candidates = [];
for (const p of pm2List) {
if (p.pm2_env.status !== 'online') continue;
if (AUTH_GATED.has(p.name)) continue;
if (!/wallpaper|wallcovering|wallcoverings|walls/.test(p.name)) continue;
-
const port = p.pm2_env.PORT || extractPortFromEnv(p) || sniffDefaultPort(p);
if (!port) continue;
-
candidates.push({
name: p.name,
url: `http://127.0.0.1:${port}/`,
@@ -71,9 +153,30 @@ async function buildPm2Channels() {
});
}
- // Probe each candidate's /api/products in parallel, filter to real-inventory sites
+ // Hand-picked LOCAL_FRONTS — probe TCP liveness on each port; include if up.
+ const liveExtras = await Promise.all(LOCAL_FRONTS.map(async lf => {
+ try {
+ const r = await fetch(`http://127.0.0.1:${lf.port}${lf.path || '/'}`, {
+ method: 'HEAD',
+ signal: AbortSignal.timeout(1500)
+ });
+ // 200 / 301 / 302 = live, 401 = live-but-auth-gated (still iframes the login wall)
+ if (r.status >= 200 && r.status < 500) {
+ return {
+ name: lf.name,
+ url: `http://127.0.0.1:${lf.port}${lf.path || '/'}`,
+ kind: lf.kind,
+ accent: lf.accent,
+ port: lf.port,
+ };
+ }
+ } catch {}
+ return null;
+ }));
+ const extras = liveExtras.filter(Boolean);
+
const probes = await Promise.all(candidates.map(c => probeProductCount(c)));
- return probes.filter(c => c.productCount >= MIN_PRODUCTS);
+ return [...probes.filter(c => c.productCount >= MIN_PRODUCTS), ...extras];
}
function probeProductCount(channel) {
@@ -559,6 +662,51 @@ app.get('/api/atlas', async (req, res) => {
app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'fleet-tv' }));
+// ─── pm2 logs — last 50 lines, sanitized (secret patterns redacted) ───────
+const SECRET_PATTERNS = [
+ /\b(sk_live_|sk_test_|shpat_|xoxb-|xoxp-|ntn_|pat-na1-|pat[A-Z][a-zA-Z0-9]{14,}|figd_|sk-ant-|AC[a-f0-9]{32}|AIza[0-9A-Za-z_-]{35})[A-Za-z0-9_-]+/g,
+ /\b[A-Za-z0-9_-]{32,}\.[A-Za-z0-9_-]{32,}\.[A-Za-z0-9_-]{32,}\b/g, // JWTs
+ /(authorization|x-api-key|api[_-]?key|secret|token|password)["'\s:=]+["']?[A-Za-z0-9._\-+/]{16,}/gi,
+];
+function sanitize(text) {
+ let out = String(text || '');
+ for (const p of SECRET_PATTERNS) out = out.replace(p, '«redacted»');
+ return out;
+}
+app.get('/api/fleet-logs/:name', (req, res) => {
+ const name = req.params.name;
+ if (!/^[a-zA-Z0-9_.-]+$/.test(name)) return res.status(400).json({ error: 'invalid name' });
+ execFile('pm2', ['describe', name], (err, stdout) => {
+ if (err) return res.status(404).json({ error: 'not found: ' + name });
+ const m1 = stdout.match(/out log path\s*│\s*(\S+)/);
+ const m2 = stdout.match(/error log path\s*│\s*(\S+)/);
+ const out = { stdout: '', stderr: '', sanitized: true };
+ try { out.stdout = sanitize(require('fs').readFileSync(m1?.[1], 'utf8').split('\n').slice(-50).join('\n')); } catch {}
+ try { out.stderr = sanitize(require('fs').readFileSync(m2?.[1], 'utf8').split('\n').slice(-50).join('\n')); } catch {}
+ res.json(out);
+ });
+});
+
+// ─── pm2 fleet status — single-glance uptime for all services ──────────────
+app.get('/api/fleet-status', (req, res) => {
+ execFile('pm2', ['jlist'], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
+ if (err) return res.status(500).json({ error: err.message });
+ let list;
+ try { list = JSON.parse(stdout); } catch { return res.status(500).json({ error: 'invalid pm2 output' }); }
+ const services = list.map(p => ({
+ name: p.name,
+ status: p.pm2_env?.status, // online | stopped | errored | stopping | launching
+ restarts: p.pm2_env?.restart_time,
+ uptime_ms: p.pm2_env?.pm_uptime ? Date.now() - p.pm2_env.pm_uptime : 0,
+ cpu: p.monit?.cpu,
+ memory_mb: p.monit?.memory ? Math.round(p.monit.memory / 1024 / 1024) : 0,
+ pid: p.pid,
+ })).sort((a, b) => a.name.localeCompare(b.name));
+ const counts = services.reduce((acc, s) => { acc[s.status] = (acc[s.status] || 0) + 1; return acc; }, {});
+ res.json({ counts, total: services.length, services });
+ });
+});
+
app.listen(PORT, '127.0.0.1', () => {
console.log(`[fleet-tv] live wall display on http://127.0.0.1:${PORT}`);
});
← 8537479 initial scaffold (gitify-all 2026-05-06)
·
back to Fleet Tv
·
snapshot: 2 file(s) changed, +1 new, ~1 modified 97baf34 →