Files
UmbrelApps/tools/relay-client/src/ui.js
T
HarmenandClaude Opus 5.5 85d85a1295 UmbrelApps: community app store whatsnext
Electrum Gate, Evolu Relay en Atreides Assets, met de image-recepten in tools/ en de tests in tests/. Begonnen zonder voorgeschiedenis op 09-10-2026.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:40:25 +02:00

313 lines
12 KiB
JavaScript

// ═══════════════════════════════════════════════════════════════════════════════
// Het bedieningsvlak: één pagina waarop je alle eigenaars ziet en aanstuurt.
//
// Wat dit scherm wél doet staat in het plan Testclient, PLAN.md §4b. Wat het
// NIET doet, en dat is geen tekortkoming maar de poortindeling van de app: de
// leerstand openzetten, iemand blokkeren en labelen. Die lopen via de agent-API
// van Evolu Relay, en die hangt achter de inlog van umbrelOS. Zet hier dus geen
// knoppen bij die dat proberen; ze kunnen niet werken.
//
// De server luistert op localhost en nergens anders. Hier zitten mnemonics
// achter, en dit is gereedschap op je eigen machine.
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { eigenaarUitMnemonic, nieuweEigenaar, openStore } from './client.js';
import { klopAan } from './probe.js';
const HIER = dirname(fileURLToPath(import.meta.url));
const MAX_BODY_BYTES = 4096;
const MAX_LOG = 200;
export const startUi = async ({ config, register, databasesIn }) => {
/** De open verbindingen, per eigenaar. Alleen deze module raakt hem aan. */
const open = new Map();
const log = [];
/**
* De laatste uitkomst van "Testen", per eigenaar.
*
* Hierop wordt de knop "Verbinden" uitgezet. Onbekend telt als toegestaan, en
* dat is met opzet: anders moet je eerst testen voordat je iets mag, en een
* test is zelf een poging die in de weigerlijst van de relay belandt.
*/
const laatsteTest = new Map();
/**
* Hoeveel sockets er achter elkaar mislukt zijn, per eigenaar.
*
* Evolu is local-first en blijft eindeloos opnieuw proberen. Dat is bij een
* relay die even weg is precies goed, maar bij een eigenaar die geweigerd
* wordt is elke poging een regel in de weigerlijst van de relay. Vandaar de
* teller: na een paar mislukkingen wordt er één keer getest, en bij een
* weigering gaat de verbinding eruit in plaats van door te blijven kloppen.
*/
const socketFouten = new Map();
const FOUTEN_VOOR_TEST = 3;
const meld = (tekst) => {
log.unshift({ tijd: new Date().toISOString(), tekst });
if (log.length > MAX_LOG) log.length = MAX_LOG;
console.log(`${new Date().toLocaleTimeString()} ${tekst}`);
};
const verbind = async (naam, metRelay) => {
if (open.has(naam)) return open.get(naam);
const regel = register.zoek(naam);
if (!regel) throw new Error(`Geen eigenaar "${naam}".`);
if (metRelay && !config.relayUrl) throw new Error('Geen relay-adres. Zet RELAY_URL in .env.');
const store = await openStore({
directory: databasesIn,
owner: eigenaarUitMnemonic(regel.mnemonic),
relayUrl: metRelay ? config.relayUrl : null,
onDefect: (defect) => {
meld(`${naam}: defect uit een worker, zie de terminal`);
console.error(defect);
},
onWebSocket: (bericht) => {
if (bericht.wat === 'open') {
socketFouten.set(naam, 0);
meld(`${naam}: socket open`);
return;
}
if (bericht.wat !== 'dicht') return;
const aantal = (socketFouten.get(naam) ?? 0) + 1;
socketFouten.set(naam, aantal);
if (aantal !== FOUTEN_VOOR_TEST) return;
// Precies één keer testen, bij de derde mislukking. Daarna weten we of
// dit een weigering is of een relay die even weg is, en dat scheelt de
// relay een weigerlijst vol pogingen.
meld(`${naam}: ${aantal} sockets mislukt (${bericht.detail}), wordt getest`);
void (async () => {
const uitkomst = await klopAan(config.relayUrl, regel.ownerId);
laatsteTest.set(naam, { ...uitkomst, tijd: new Date().toISOString() });
if (uitkomst.geaccepteerd) {
meld(`${naam}: de relay laat hem toe, dus dit is iets anders dan een weigering`);
return;
}
meld(`${naam}: geweigerd (${uitkomst.uitleg}), verbinding wordt verbroken`);
await verbreek(naam);
})();
},
});
const ingang = { store, sinds: new Date().toISOString(), metRelay };
open.set(naam, ingang);
meld(metRelay ? `${naam} verbonden met ${store.transportUrl}` : `${naam} lokaal geopend`);
return ingang;
};
const verbreek = async (naam) => {
const ingang = open.get(naam);
if (!ingang) return;
open.delete(naam);
socketFouten.delete(naam);
await ingang.store.sluit();
meld(`${naam} losgekoppeld`);
};
const status = async () => {
const eigenaars = [];
for (const regel of register.lijst()) {
const ingang = open.get(regel.naam);
let blobs = null;
if (ingang) {
try {
blobs = (await ingang.store.lees()).length;
} catch {
blobs = null;
}
}
const test = laatsteTest.get(regel.naam) ?? null;
eigenaars.push({
naam: regel.naam,
ownerId: regel.ownerId,
aangemaakt: regel.aangemaakt,
verbonden: Boolean(ingang),
metRelay: ingang?.metRelay ?? false,
sinds: ingang?.sinds ?? null,
transportUrl: ingang?.store.transportUrl ?? null,
blobs,
test,
// Onbekend telt als toegestaan: eerst moeten testen om te mogen
// verbinden zou betekenen dat je altijd een poging in de weigerlijst
// van de relay zet voordat je iets kunt doen.
magVerbinden: test === null || test.geaccepteerd === true,
});
}
return { relayUrl: config.relayUrl, eigenaars, log };
};
// ── De afhandeling ──────────────────────────────────────────────────────────
const acties = {
nieuw: async ({ naam }) => {
const owner = nieuweEigenaar();
const regel = register.voegToe({ naam, mnemonic: owner.mnemonic, ownerId: owner.id });
meld(`${regel.naam} aangemaakt, OwnerId ${regel.ownerId}`);
return { naam: regel.naam, ownerId: regel.ownerId };
},
verbind: async ({ naam }) => {
await verbind(naam, true);
return { verbonden: true };
},
verbreek: async ({ naam }) => {
await verbreek(naam);
return { verbonden: false };
},
schrijf: async ({ naam, label, bytes }) => {
const omvang = Number.parseInt(bytes ?? '64', 10);
if (!Number.isInteger(omvang) || omvang < 1 || omvang > 8 * 1024 * 1024) {
throw new Error('bytes moet tussen 1 en 8388608 liggen.');
}
// Schrijven opent zo nodig zelf een verbinding, dus dezelfde poort als bij
// de knop "Verbinden": een geweigerde eigenaar gaat lokaal verder in
// plaats van de weigerlijst van de relay te vullen.
const test = laatsteTest.get(naam);
const geweigerd = test != null && test.geaccepteerd === false;
const ingang = open.get(naam) ?? (await verbind(naam, Boolean(config.relayUrl) && !geweigerd));
const body = new Uint8Array(omvang);
for (let i = 0; i < omvang; i += 1) body[i] = i % 256;
const geschreven = await ingang.store.schrijf(label || 'blob', body);
meld(`${naam}: blob van ${omvang} bytes weggeschreven (${geschreven.id})`);
return { id: geschreven.id, bytes: omvang };
},
lees: async ({ naam }) => {
const ingang = open.get(naam) ?? (await verbind(naam, false));
const rijen = await ingang.store.lees();
return {
rijen: rijen.map((rij) => ({
id: rij.id,
label: rij.label,
bytes: rij.body?.length ?? 0,
})),
};
},
test: async ({ naam }) => {
const regel = register.zoek(naam);
if (!regel) throw new Error(`Geen eigenaar "${naam}".`);
if (!config.relayUrl) throw new Error('Geen relay-adres. Zet RELAY_URL in .env.');
const uitkomst = await klopAan(config.relayUrl, regel.ownerId);
laatsteTest.set(naam, { ...uitkomst, tijd: new Date().toISOString() });
meld(`${naam}: testen gaf ${uitkomst.status ?? '-'}, ${uitkomst.uitleg}`);
return uitkomst;
},
vergeet: async ({ naam }) => {
await verbreek(naam);
laatsteTest.delete(naam);
const weg = register.vergeet(naam);
meld(`${weg.naam} uit het register gehaald`);
return { naam: weg.naam };
},
};
const leesBody = (verzoek) =>
new Promise((klaar, faal) => {
let ruw = '';
verzoek.on('data', (stuk) => {
ruw += stuk;
if (ruw.length > MAX_BODY_BYTES) {
faal(new Error('Verzoek te groot.'));
verzoek.destroy();
}
});
verzoek.on('end', () => {
try {
klaar(ruw === '' ? {} : JSON.parse(ruw));
} catch (oorzaak) {
faal(new Error(`Onleesbare JSON: ${oorzaak.message}`));
}
});
verzoek.on('error', faal);
});
const stuur = (antwoord, code, waarde) => {
const inhoud = JSON.stringify(waarde);
antwoord.writeHead(code, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(inhoud),
});
antwoord.end(inhoud);
};
const server = createServer((verzoek, antwoord) => {
void (async () => {
const pad = (verzoek.url ?? '/').split('?')[0].replace(/\/+$/, '') || '/';
if (verzoek.method === 'GET' && (pad === '/' || pad === '/index.html')) {
// Elke keer van schijf. Dit is gereedschap op je eigen machine, dus een
// pagina bijwerken en verversen hoort te werken zonder herstart.
const pagina = readFileSync(join(HIER, 'index.html'));
antwoord.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Content-Length': pagina.length,
});
antwoord.end(pagina);
return;
}
if (verzoek.method === 'GET' && pad === '/api/status') {
stuur(antwoord, 200, await status());
return;
}
if (verzoek.method === 'POST' && pad.startsWith('/api/')) {
const actie = acties[pad.slice('/api/'.length)];
if (!actie) {
stuur(antwoord, 404, { fout: 'Onbekende actie.' });
return;
}
try {
stuur(antwoord, 200, await actie(await leesBody(verzoek)));
} catch (oorzaak) {
// 400 en niet 500: vrijwel alles wat hier misgaat is een naam die al
// bestaat of een relay die er niet is, en dat is geen serverfout.
stuur(antwoord, 400, { fout: oorzaak.message });
}
return;
}
stuur(antwoord, 404, { fout: 'Niet gevonden.' });
})();
});
await new Promise((klaar) => {
server.listen(config.uiPoort, '127.0.0.1', klaar);
});
const adres = `http://127.0.0.1:${config.uiPoort}`;
console.log('');
console.log(` Bedieningsvlak: ${adres}`);
console.log(` Relay: ${config.relayUrl ?? 'niet ingesteld (zie .env.sample)'}`);
console.log(` Register: ${register.pad}`);
console.log('');
console.log(' Ctrl-C om te stoppen.');
console.log('');
const stop = async () => {
console.log('');
console.log('Afsluiten...');
server.close();
for (const naam of [...open.keys()]) await verbreek(naam);
process.exit(0);
};
process.on('SIGINT', () => {
void stop();
});
await new Promise(() => {});
};