Persoonlijke site van Arjan Kapteijn: een zwevende MacBook in een baan om de
aarde, met een interactieve terminal die bezoekers zelf kunnen bedienen.
De HUD toont live telemetrie van het echte ISS en de site is tweetalig
(Nederlands op .nl, Engels op .com).
Personal site of Arjan Kapteijn - a floating MacBook in low Earth orbit with
an interactive terminal. Dutch on .nl, English on .com.
| Onderdeel | Technologie |
|---|---|
| Build & dev server | Vite + TypeScript |
| UI | React 19 |
| 3D | Three.js via @react-three/fiber + @react-three/drei |
| Terminal op het scherm | drei <Html transform occlude> + eigen React-component |
| Live ISS-data | wheretheiss.at API (satelliet 25544, geen key nodig) |
server-side via SMTP2GO (server/smtp.js, zero-dependency); fallback mailto: |
|
| Scheepslogboek | getypte commando's als Signal-push via signal-cli-rest-api (server/signal.js), met grove herkomst per IP via ip-api.com (server/geo.js) |
| IP-lookup | api64.ipify.org (client-side, eenmalig per sessie) - haalt het publieke IP van de bezoeker op voor de terminalprompt en het scheepslogboek |
| Zonnepanelen | live vermogen van de echte zonnepanelen via de SolarEdge Monitoring API V2 (server/solar.js → /api/solar, gecachet; al tijdens het 3D-laden opgehaald), getoond als accuicoontje in de menubalk; klikbaar voor uitgebreide dagoverzicht-modal |
| Serverstatus | live cpu/geheugengebruik van de host, rechtstreeks via /proc (server/host.js → /api/host, geen cloud-API/key; eerste meting al tijdens het 3D-laden), getoond als Activity Monitor-icoontje in de menubalk; klikbaar voor specs + live cijfers |
| Hosting | Docker-container op TrueNAS, beheerd via Arcane, achter Nginx Proxy Manager (Let's Encrypt) |
Alle assets (3D-model, textures, DRACO-decoder, HDR) worden lokaal geserveerd. Runtime-afhankelijkheden van externe API's: ISS-telemetrie (wheretheiss.at), IP-lookup (api64.ipify.org) en server-side geo-lookup (ip-api.com).
Klik op het MacBook-scherm en typ:
| Commando | Doet |
|---|---|
help |
lijst met commando's |
about / bio |
wie is Arjan? |
whoami |
naam + titel |
skills |
vaardigheden |
email |
stuur een e-mail vanuit de terminal (onderwerp → bericht → optioneel antwoordadres → bevestigen; verzonden via SMTP2GO) |
photo / open arjan.jpg |
opent de foto op het bureaublad |
iss |
live positie, hoogte en snelheid van het échte ISS |
lang en / lang nl |
wissel van taal (wordt onthouden) |
neofetch |
systeeminfo van deze MacBook (M1 Max 😉) |
date, pwd, ls, cat, echo |
doen wat je verwacht |
clear |
scherm leegmaken |
sudo …, exit |
probeer maar 😄 |
Pijltje omhoog/omlaag bladert door de commandogeschiedenis. De foto op het
bureaublad is ook direct aanklikbaar. Links in de terminaluitvoer (zoals
vavox.nl in about) zijn klikbaar; alleen http(s)- en mailto:-schema's
worden als link gerenderd, de rest blijft platte tekst (XSS-veilig).
npm install
npm run dev # http://localhost:5173
npm run build # productiebuild in dist/
npm run preview # test de productiebuild lokaal- Identiteit & links -
src/config.ts(naam, e-mail, LinkedIn, GitHub, /dev/null, foto-pad). - Teksten per taal -
src/i18n.ts(bio, titel, skills, alle UI- en terminalteksten; de TODO-markers wachten op echte content). - Foto -
public/photo.webp(vierkant, 800×800). - Terminal-commando's -
src/components/Terminal.tsx. - SEO & deelbaarheid -
index.html(meta-description, canonical, Open Graph + Twitter card, enPerson-structured-data voor Google).
.nl-domein → Nederlands, .com-domein → Engels. Daarbuiten (bijv. lokaal)
volgt de site de browsertaal. Overschrijven kan met ?lang=en|nl in de URL
of het lang-commando in de terminal (opgeslagen in localStorage).
?macbook=off verbergt het 3D-laptopmodel (en de bijbehorende hint-tekst),
zodat alleen de aarde met de live ISS-tracker in beeld blijft.
De site draait als kleine, gehardende Docker-container
(Dockerfile + docker-compose.yml):
multi-stage build (geen node_modules in het eindimage), niet-root (de
ingebouwde node-user, uid 1000), read_only rootfs (enige schrijfbare
plek: de bind mount /data/solaredge voor het SolarEdge-token), alle capabilities
gedropt, no-new-privileges,
geheugen- en pids-limiet, en een healthcheck op /healthz.
GitHub Actions bouwt het image en pusht het als privé-image naar
ghcr.io (ghcr.io/arjankapteijn/website, zie
docker-publish.yml) bij elke push
naar main (zie "Versies / releases").
Zelf draait de site op TrueNAS SCALE als gewone Docker-container, beheerd
via Arcane - géén TrueNAS
"custom app". Compose en env-vars staan in Arcane's eigen projectmap op de
host; docker-compose.yml hierboven is het portable
voorbeeld voor als je 'm zelf ergens anders (niet via Arcane) wil draaien.
Omdat het image privé is, moet de Docker-host één keer inloggen bij de
registry (PAT met scope read:packages):
echo <TOKEN> | docker login ghcr.io -u arjankapteijn --password-stdin.
Push naar main → nieuw :latest-image op ghcr.io. In Arcane pikt de
Auto Update-automation dat zelf op (Image Update Watcher pollt elk uur,
Auto Update past dagelijks toe) - of klik zelf op Update bij de
container. Lokaal de productieversie testen: npm run build && npm start
(→ http://localhost:8080).
- NPM → Hosts → Proxy Hosts → Add:
domains
arjankapteijn.nl, www.arjankapteijn.nl, schemehttp, forward host = IP van je TrueNAS, forward port8090(de host-poort uitdocker-compose.yml). Vink Block Common Exploits aan (websockets niet nodig). - Tab SSL: Request a new SSL certificate (Let's Encrypt), Force SSL + HTTP/2 aan.
- Herhaal voor
arjankapteijn.com, www.arjankapteijn.com(zelfde forward) - de site toont dan automatisch Engels. - DNS van beide domeinen → je publieke IP (A-record), en poort 80/443 geforward naar NPM.
NPM stuurt X-Forwarded-For standaard mee, zodat de prompt en de
Signal-melding het echte bezoekers-IP zien.
Releasen = gewoon pushen naar main. De docker-publish.yml-workflow
doet de rest automatisch:
- Leidt de volgende semver-tag af uit de commits sinds de laatste tag via
conventional commits:
feat:→ minor,!/BREAKING CHANGE→ major, anders patch. - Maakt de git-tag aan en pusht hem.
- Maakt automatisch een GitHub Release aan met een changelog uit de commit-onderwerpen.
- Bouwt en pusht het image naar ghcr.io met tags
:latest,:x.y.z,:x.yen:sha-<short>.
Schrijf dus altijd conventional commit-messages (feat:, fix:,
chore:, docs:, ci:) - de versie-bump hangt daarvan af.
package.json hoef je niet te bumpen; de git-tag is de single source
of truth.
Doc-only pushes (**.md, docs/**) slaan versie-bump én image-build over.
Een handmatige v*-tag-push werkt ook en bouwt altijd.
| Scenario | Image-tags |
|---|---|
Push naar main |
:latest, :x.y.z, :x.y, :sha-<short> |
Handmatige v1.2.3-tag |
:1.2.3, :1.2 |
De site draait bewust op :latest; Arcane's Update-knop/Auto Update pakt
vanzelf de nieuwste versie. Pinnen op een vaste tag (bijv. :1.1.0) is
mogelijk voor deterministisch rollback.
De site gebruikt https://api.wheretheiss.at/v1/satellites/25544 (gratis,
±1 request/seconde toegestaan; de site pollt elke 15 s):
- HUD - echte hoogte, snelheid, positie en of het ISS in het zonlicht of in de aardschaduw vliegt.
- ISS-marker op de globe - een pulserende groene reticle op de actuele positie, met een spoor van eerdere posities. De globe draait langzaam mee met het ISS, alsof station AK-01 ernaast meevliegt.
- Echte zonnestand - het zonlicht op de aarde volgt het sub-solaire punt
(
solar_lat/solar_lon), dus de dag/nacht-grens op de globe klopt met de werkelijkheid. De laptop heeft eigen sfeerverlichting (three.js layers), zodat die er altijd goed uitziet. iss-commando - live rapport in de terminal.
Valt de API weg, dan toont de HUD statische fallback-waarden en blijft de globe in de laatste stand staan.
Het email-commando POST naar /api/email; server/server.js verstuurt
de mail via SMTP2GO (impliciete TLS, poort 465) naar MAIL_TO. Het
optionele antwoordadres van de bezoeker komt in de Reply-To-header,
dus beantwoorden werkt gewoon vanuit je mailprogramma. Configuratie via
.env (zie .env.example); max. 4 mails per minuut per
IP. Zonder SMTP-configuratie (of op statische hosting) valt de terminal
automatisch terug op een mailto:-link.
Let op: het MAIL_FROM-domein moet in SMTP2GO als sender domain
geverifieerd zijn, en commit .env nooit (staat in .gitignore).
Alles wat bezoekers in de terminal typen wordt als los Signal-bericht
gepusht via een zelf-gehoste signal-cli-rest-api.
server/server.js handelt POST /api/log af en stuurt via server/signal.js
een POST /v2/send. Berichtformaat:
[2026-06-12 11:42:07 UTC] 86.82.123.45 (nl) % neofetch
Hoe het werkt:
- Config (
.env, zie .env.example):SIGNAL_API_URL,SIGNAL_NUMBER(het geregistreerde afzendernummer) en optioneelSIGNAL_RECIPIENTS(komma-gescheiden; leeg = note-to-self). Ontbreekt de config, dan staat het logboek vanzelf uit (501, de client faalt stil). - Lokaal én productie delen dezelfde
sendSignal()(een Vite-plugin spiegelt in dev het productiegedrag). Max. 30 posts/minuut per IP.
Privacy / AVG: het volledige bezoekers-IP gaat onverkort mee in de Signal-melding (bewuste keuze). E-mailinhoud (onderwerp/bericht) wordt nooit gelogd. IP's + tijdstippen zijn persoonsgegevens, en de interface meldt niet dát er gelogd wordt - vermeld dit dus zelf in een privacyverklaring. Houd er rekening mee dat je hiermee bezoekers-IP's naar een Signal-kanaal stuurt.
Het accuicoontje (🔋) in de menubalk toont het live vermogen van de echte zonnepanelen als percentage van het piekvermogen (2,7 kWp). Klikken opent een modal met vandaag-opbrengst, maandopbrengst en lifetime-totaal.
De data komt van de SolarEdge Monitoring API V2
(SolarEdge ONE Developer Platform) via /api/solar (server/solar.js). De
V1-API (?api_key=) stopt op 1 november 2026; V1-keys werken niet op V2.
| Veld | V2-bron | Credits |
|---|---|---|
power |
laatste kwartierwaarde van GET /sites/{id}/power |
1 per verversing |
today, month |
dagbuckets van GET /sites/{id}/energy?resolution=DAY (deze maand) |
1 per verversing |
lifetime |
GET /sites/{id}/lifetime-energy |
1 per 6 uur |
| tijdzone | GET /sites/{id} |
1 per herstart |
from/to gaan als UTC mét zone (2026-10-19T12:05:00Z); zonder zone
antwoordt V2 met 400.
De Free-tier geeft 2.000 credits/maand (~67/dag, max. 10/minuut). De
server cachet daarom standaard 60 minuten (≈ 53 calls/dag) en gebruikt bij
storingen een oplopende backoff (5 min → max. 6 uur, ook als er nog niets
gecachet is), zodat een kapotte koppeling niet per bezoeker credits verbrandt.
Bij een 429 wacht hij minstens retry-after, bij een op-gebruikt maandtegoed
6 uur. "Huidig vermogen" is dus het gemiddelde van het laatste kwartier, niet
live: écht live vermogen zit in de Advanced API (betaalde tier).
V2 kent voor huiseigenaren geen statische API-key meer, alleen OAuth "Site Access" (Homeowner and Self-Access Guide):
- Log in op developer.solaredge.com met je mySolarEdge-account en vul het developer-profiel in (Free-tier).
- Create Application → Service Monitoring API → Site Access.
Redirect URI bv.
http://localhost:8080/oauth/callback, scopeSITE_DATA. Kopieer Client ID en Client Secret (secret wordt maar één keer getoond). - Zet
SOLAREDGE_CLIENT_ID,SOLAREDGE_CLIENT_SECRET,SOLAREDGE_SITE_ID,SOLAREDGE_REDIRECT_URIenSOLAREDGE_TOKEN_FILEin.env. node server/solaredge-auth.js→ open de geprinte URL, keur goed, kopieer de URL waar je op belandt (een foutpagina is prima).node server/solaredge-auth.js '<die URL>'→ schrijft het refresh token naarSOLAREDGE_TOKEN_FILE. Draai dit bij voorkeur ín de container (docker exec -it arjankapteijn-website node server/solaredge-auth.js …), dan landt het meteen op het volume. Herstart daarna de container.
Het refresh token roteert: elke verversing (elke ~2 uur bij gebruik)
levert een nieuw token en maakt het oude ongeldig. Daarom staat het in een
bestand op een schrijfbaar volume (/data/solaredge, zie
docker-compose.yml); zonder volume werkt het tot de
volgende herstart. Een refresh token verloopt na 30 dagen ongebruikt; de
server ververst daarom zelf als het token ouder is dan 7 dagen. De grant
zelf (access_duration=24) is 24 maanden geldig, daarna stap 4-5 herhalen.
Gebruik voor npm run dev niet hetzelfde token-bestand als productie:
wie als eerste ververst maakt het token van de ander ongeldig.
Configuratie via .env (zie .env.example):
SOLAREDGE_CLIENT_ID=vul-hier-je-client-id-in
SOLAREDGE_CLIENT_SECRET=vul-hier-je-client-secret-in
SOLAREDGE_SITE_ID=vul-hier-je-site-id-in
SOLAREDGE_REDIRECT_URI=http://localhost:8080/oauth/callback
SOLAREDGE_TOKEN_FILE=/data/solaredge/token.jsonZonder configuratie (of op statische hosting) blijft het accu-icoontje wel klikbaar - de modal toont dan alleen de statische specs (omvormer, panelen), met een melding dat live-cijfers niet beschikbaar zijn. E-mailinhoud wordt nooit meegestuurd naar SolarEdge.
Het icoontje (📊) naast de accu in de menubalk toont live cpu-gebruik van de
machine die deze site host. Klikken opent een modal (🏠) met ook geheugen-
en schijfgebruik (incl. uptime) achter de statische specs van de
homelab-machine (hosting in src/config.ts - vul die zelf
in, incl. storageTotalGb voor het schijf-percentage).
Geen cloud-API, geen key: de server leest gewoon /proc/stat, /proc/meminfo
en /proc/uptime, zoals top/htop dat ook doen, en voor schijfruimte een
statfs op / in de container - geen volume nodig: de container-root is
een overlay in Docker's data-root (op TrueNAS /mnt/.ix-apps/docker, dataset
tank/ix-apps/docker) en geeft zo de vrije ruimte van de pool door
(geverifieerd 2026-09-30: exact gelijk aan zfs get available). ZFS geeft daarbij geen bruikbare totale pool-grootte terug (een bekende
eigenaardigheid: elke dataset toont zijn éígen "size"), dus alleen de
beschikbare ruimte komt live binnen; het totaal (storageTotalGb) is een
vaste spec, net als het cpu-model (server/host.js → /api/host, 15 sec.
gecachet). Geen bind-mount nodig op onze eigen
Arcane/Docker-setup: getest (2026-09) op de homelab-machine zelf - een gewone
container ziet daar zónder enige extra volume-config al de écht host-wide
cijfers (MemTotal/uptime in de container matchten exact free -h en
/proc/uptime op de host). Dat is geen garantie voor élke Docker-omgeving
(sommige zetten wel cgroup-limieten of lxcfs in), dus mocht het ergens anders
tóch nodig zijn: HOST_PROC_STAT/HOST_PROC_MEMINFO/HOST_PROC_UPTIME
laten een alternatief pad instellen (bv. na een read-only /proc:/host-proc:ro
bind-mount - niet rechtstreeks op /proc in de container mounten, dat
overschrijft 'm en kan de container breken).
Zonder werkende /proc (of lokaal op macOS, waar /proc niet bestaat) blijft
het icoontje wel klikbaar - de modal toont dan alleen de statische specs, met
een melding dat live-cijfers niet beschikbaar zijn.
- MacBook-model -
mac-draco.glbuit de officiële pmndrs/examples (floating-laptop demo), CC-BY-4.0. - Aarde-textures - uit de three.js-voorbeelden (gebaseerd op NASA Blue Marble-beeldmateriaal).
- HDR-omgeving -
potsdamer_platz_1k.hdrvia pmndrs/drei-assets (Poly Haven, CC0). - ISS-telemetrie - wheretheiss.at.
