feat(projects): add decisions and result to project dialog
Add optional Decisions and Result blocks to the project dialog. Fill them for ToBePlayed, Tribble, Claude-in-a-Box and Playground. Remove Portainer and the Google Drive backup claims from Tribble.
This commit is contained in:
1 parent
1cff9e6eb2
commit
0857ce482b
6 files changed
+132
-21
No files matched your search
@@ -20,6 +20,20 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (project.decisions?.length) {
|
||||
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10 mb-4">
|
||||
<div class="flex items-center gap-3 mb-3 text-link">
|
||||
<mat-icon class="text-[24px]! w-6! h-6!">alt_route</mat-icon>
|
||||
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.DECISIONS' | translate }}</h3>
|
||||
</div>
|
||||
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
|
||||
@for(decision of project.decisions; track decision) {
|
||||
<li class="mb-1">{{ decision | translate }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] sm-dialog:grid-cols-1 gap-4 mb-4">
|
||||
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
|
||||
<div class="flex items-center gap-3 mb-3 text-link">
|
||||
@@ -45,6 +59,16 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (project.result) {
|
||||
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10 mb-4">
|
||||
<div class="flex items-center gap-3 mb-3 text-link">
|
||||
<mat-icon class="text-[24px]! w-6! h-6!">flag</mat-icon>
|
||||
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.RESULT' | translate }}</h3>
|
||||
</div>
|
||||
<p class="m-0 text-[0.95rem] leading-relaxed opacity-85">{{ project.result | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (project.images.length > 0)
|
||||
|
||||
@@ -69,4 +69,26 @@ describe('ProjectDialogComponent', () => {
|
||||
|
||||
expect(fixture.nativeElement.querySelector('img[alt="PROJECTS.DIAGRAM_ALT"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders decisions and result when the project has them', async () => {
|
||||
const fixture = await createDialog({
|
||||
title: 'T', technologies: [], images: [], decisions: ['D1', 'D2'], result: 'R',
|
||||
});
|
||||
|
||||
const text: string = fixture.nativeElement.textContent;
|
||||
for (const expected of ['PROJECTS.SECTION.DECISIONS', 'D1', 'D2', 'PROJECTS.SECTION.RESULT', 'R']) {
|
||||
expect(text).toContain(expected);
|
||||
}
|
||||
const decisions = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('h3'))
|
||||
.find(h => h.textContent?.includes('PROJECTS.SECTION.DECISIONS'));
|
||||
expect(decisions?.closest('div.p-5')?.querySelectorAll('li').length).toBe(2);
|
||||
});
|
||||
|
||||
it('renders no decisions or result blocks without them', async () => {
|
||||
const fixture = await createDialog({ title: 'T', technologies: [], images: [] });
|
||||
|
||||
const text: string = fixture.nativeElement.textContent;
|
||||
expect(text).not.toContain('PROJECTS.SECTION.DECISIONS');
|
||||
expect(text).not.toContain('PROJECTS.SECTION.RESULT');
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,13 @@ describe('ProjectsComponent', () => {
|
||||
...project.bulletPoints,
|
||||
...project.challenges,
|
||||
...project.learnings,
|
||||
...(project.decisions ?? []),
|
||||
...(project.result ? [project.result] : []),
|
||||
]).concat([
|
||||
'PROJECTS.SECTION.TECHNICAL',
|
||||
'PROJECTS.SECTION.LEARNINGS',
|
||||
'PROJECTS.SECTION.DECISIONS',
|
||||
'PROJECTS.SECTION.RESULT',
|
||||
]);
|
||||
|
||||
const missing = Object.entries({ en, de }).flatMap(([language, translations]) =>
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface Projects {
|
||||
bulletPoints: string[],
|
||||
challenges: string[],
|
||||
learnings: string[],
|
||||
decisions?: string[],
|
||||
result?: string,
|
||||
isFeatured: boolean,
|
||||
technologies: string[]
|
||||
}
|
||||
@@ -88,6 +90,12 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
||||
'PROJECTS.TBP.LEARNING_1',
|
||||
'PROJECTS.TBP.LEARNING_2',
|
||||
],
|
||||
decisions: [
|
||||
'PROJECTS.TBP.DECISION_1',
|
||||
'PROJECTS.TBP.DECISION_2',
|
||||
'PROJECTS.TBP.DECISION_3',
|
||||
],
|
||||
result: 'PROJECTS.TBP.RESULT',
|
||||
isFeatured: true,
|
||||
technologies: ['Java', 'Spring Boot', 'PostgreSQL', 'React', 'Tailwind CSS', 'Docker']
|
||||
},
|
||||
@@ -120,6 +128,12 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
||||
'PROJECTS.CLAUDE_BOX.LEARNING_2',
|
||||
'PROJECTS.CLAUDE_BOX.LEARNING_3',
|
||||
],
|
||||
decisions: [
|
||||
'PROJECTS.CLAUDE_BOX.DECISION_1',
|
||||
'PROJECTS.CLAUDE_BOX.DECISION_2',
|
||||
'PROJECTS.CLAUDE_BOX.DECISION_3',
|
||||
],
|
||||
result: 'PROJECTS.CLAUDE_BOX.RESULT',
|
||||
isFeatured: false,
|
||||
technologies: ['Claude Code', 'MCP', 'Docker', 'Bash', 'Node.js', 'Playwright']
|
||||
},
|
||||
@@ -151,6 +165,12 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
||||
'PROJECTS.PLAYGROUND.LEARNING_2',
|
||||
'PROJECTS.PLAYGROUND.LEARNING_3',
|
||||
],
|
||||
decisions: [
|
||||
'PROJECTS.PLAYGROUND.DECISION_1',
|
||||
'PROJECTS.PLAYGROUND.DECISION_2',
|
||||
'PROJECTS.PLAYGROUND.DECISION_3',
|
||||
],
|
||||
result: 'PROJECTS.PLAYGROUND.RESULT',
|
||||
isFeatured: false,
|
||||
technologies: ['Angular', 'TypeScript', 'Tailwind CSS', 'WebGPU', 'GLSL', 'Babylon.js', 'Gitea Actions', 'Docker', 'Traefik', 'nginx', 'Tailscale']
|
||||
},
|
||||
@@ -256,7 +276,6 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
||||
links: [
|
||||
{ name: 'Ubuntu Server', url: 'https://ubuntu.com/server' },
|
||||
{ name: 'Docker', url: 'https://www.docker.com/' },
|
||||
{ name: 'Portainer', url: 'https://www.portainer.io/' },
|
||||
{ name: 'Traefik', url: 'https://traefik.io/' },
|
||||
{ name: 'Gitea', url: 'https://gitea.io/' },
|
||||
{ name: 'Jellyfin', url: 'https://jellyfin.org/' },
|
||||
@@ -270,7 +289,6 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
||||
'PROJECTS.TRIBBLE.BULLET_2',
|
||||
'PROJECTS.TRIBBLE.BULLET_3',
|
||||
'PROJECTS.TRIBBLE.BULLET_4',
|
||||
'PROJECTS.TRIBBLE.BULLET_5',
|
||||
],
|
||||
challenges: [
|
||||
'PROJECTS.TRIBBLE.CHALLENGE_1',
|
||||
@@ -280,8 +298,14 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
||||
'PROJECTS.TRIBBLE.LEARNING_1',
|
||||
'PROJECTS.TRIBBLE.LEARNING_2',
|
||||
],
|
||||
decisions: [
|
||||
'PROJECTS.TRIBBLE.DECISION_1',
|
||||
'PROJECTS.TRIBBLE.DECISION_2',
|
||||
'PROJECTS.TRIBBLE.DECISION_3',
|
||||
],
|
||||
result: 'PROJECTS.TRIBBLE.RESULT',
|
||||
isFeatured: false,
|
||||
technologies: ['Ubuntu Server', 'Docker', 'Portainer', 'Traefik', 'Gitea', 'Jellyfin', 'AdGuard Home', 'Paperless-ngx', 'Tailscale', 'Watchtower']
|
||||
technologies: ['Ubuntu Server', 'Docker', 'Traefik', 'Gitea', 'Jellyfin', 'AdGuard Home', 'Paperless-ngx', 'Tailscale', 'Watchtower']
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
+26
-9
@@ -250,7 +250,7 @@
|
||||
},
|
||||
"TRIBBLE": {
|
||||
"TITLE": "Homeserver 'Tribble'",
|
||||
"DESCRIPTION": "Ein Mini-PC zu Hause betreibt Gitea mit Actions-Runner und Registry, dazu Jellyfin, Paperless-ngx und AdGuard Home. Ein VPS liefert die öffentlichen Seiten aus und holt neue Images mit Watchtower über Tailscale. Läuft seit Mai 2024, Backups auf Google Drive.",
|
||||
"DESCRIPTION": "Ein Mini-PC zu Hause betreibt Gitea mit Actions-Runner und Registry, dazu Jellyfin, Paperless-ngx und AdGuard Home. Ein VPS liefert die öffentlichen Seiten aus und holt neue Images mit Watchtower über Tailscale. Läuft seit Mai 2024.",
|
||||
"LINK_INTERNAL": "Projektdetails",
|
||||
"HIGHLIGHTS": {
|
||||
"P1": "Gitea-CI baut und testet diese Seite und To Be Played zu Hause; Watchtower spielt die Images auf dem VPS ein.",
|
||||
@@ -305,7 +305,9 @@
|
||||
"CLOSE": "Schließen",
|
||||
"SECTION": {
|
||||
"TECHNICAL": "Technische Herausforderungen",
|
||||
"LEARNINGS": "Learnings & Soft Skills"
|
||||
"LEARNINGS": "Learnings & Soft Skills",
|
||||
"DECISIONS": "Entscheidungen",
|
||||
"RESULT": "Ergebnis"
|
||||
},
|
||||
"PLAYGROUND": {
|
||||
"TITLE": "Playground",
|
||||
@@ -322,7 +324,11 @@
|
||||
"CHALLENGE_3": "Eine bestehende SPA vorrendern. Beim Build wird jede Seite in Node gerendert, und dort gibt es kein WebGPU, kein Canvas für Babylon.js und keinen localStorage. Browser-Code musste Komponente für Komponente abgesichert werden, bis der Build durchlief. Die Client-Shell index.csr.html bleibt, weil Service Worker und 404-Seite sie weiter brauchen.",
|
||||
"LEARNING_1": "Compute Shader verändern, wie man über einen Algorithmus denkt. Das Datenlayout und die Reihenfolge der Schreibzugriffe zählen mehr als die Mathematik.",
|
||||
"LEARNING_2": "Eine kleine CI mit einem Abgleich der Übersetzungsschlüssel und einem Lighthouse-Lauf fängt die langweiligen Fehler ab (eine fehlende Übersetzung, eine langsame Seite), bevor ich sie auf der Live-Seite sehe.",
|
||||
"LEARNING_3": "Eine Datenschutzseite ist eine Vorgabe für den Server. Sobald dort steht, dass es kein Access-Log und keine Verbindung zu Google gibt, darf nginx kein Log schreiben, und die Schriften müssen mit der Seite ausgeliefert werden. Es zuerst aufzuschreiben hat mir diese Entscheidungen abgenommen."
|
||||
"LEARNING_3": "Eine Datenschutzseite ist eine Vorgabe für den Server. Sobald dort steht, dass es kein Access-Log und keine Verbindung zu Google gibt, darf nginx kein Log schreiben, und die Schriften müssen mit der Seite ausgeliefert werden. Es zuerst aufzuschreiben hat mir diese Entscheidungen abgenommen.",
|
||||
"DECISION_1": "Vorrendern beim Build statt Client-Rendering oder einem SSR-Server. Jede Route wird beim Build zu einer eigenen HTML-Datei, die nginx als statische Datei ausliefert; auf dem VPS läuft kein Node-Prozess. Der Grund war die Link-Vorschau: Ein geteilter Link braucht Titel und Meta-Tags im HTML, nicht erst, nachdem die App geladen hat. Ein Node-Server wäre für eine Seite, deren Inhalt ohnehin im Browser läuft, zu aufwendig gewesen.",
|
||||
"DECISION_2": "Vitest mit jsdom statt Karma mit Chrome. Karma brauchte einen Headless-Chrome im CI-Container; Vitest läuft ohne Browser. Der Nachteil: jsdom wendet kein echtes CSS an, zwei Tests auf die geladenen Schriften sind dabei gestrichen worden.",
|
||||
"DECISION_3": "Tailwind ohne Preflight statt SCSS-first oder Tailwind komplett. Die Styles sind aus einer großen styles.scss in Utility-Klassen im Template umgezogen, damit ein Style neben dem Markup steht, das er betrifft; SCSS bleibt nur für das Material-Theme und seine Overrides. Tailwinds eigener Reset (Preflight) bleibt draußen, damit der Reset von Angular Material Vorrang behält.",
|
||||
"RESULT": "Die Seite läuft so, wie die Datenschutzseite es verspricht: ohne Cookies, ohne Tracking, ohne Google-Fonts-Request, und der Build schlägt fehl, sobald einer auftaucht. Ein Push auf main ist nach wenigen Minuten live, ohne manuellen Schritt. Geteilte Links zeigen jetzt Titel und Vorschau, und die CI fängt fehlende Übersetzungen und einen fehlenden Titel oder Canonical-Tag ab, bevor ich sie auf der Live-Seite sehe."
|
||||
},
|
||||
"TRIBBLE": {
|
||||
"TITLE": "Self-Hosted Infrastructure",
|
||||
@@ -330,13 +336,16 @@
|
||||
"INTRODUCTION": "Zwei Maschinen. Zu Hause läuft auf einem Mini-PC namens „Tribble“ Gitea mit Actions-Runner und Container-Registry, dazu die privaten Dienste. Ein Hostinger-VPS liefert die öffentlichen Seiten aus, auch diese hier. Ein Git-Push baut das Image zu Hause, der VPS holt es mit Watchtower über Tailscale, und Traefik übernimmt TLS und Routing. Das Ganze läuft seit Mai 2024.",
|
||||
"BULLET_1": "Hardware: ein KODLIX GD50 Mini-PC mit einem 8-Kern-Intel-Core-i5, 32 GB RAM und einer 512-GB-SSD.",
|
||||
"BULLET_2": "Gitea für Git, den Actions-Runner und die Container-Registry. Diese Seite und To Be Played werden dort gebaut und getestet.",
|
||||
"BULLET_3": "Private Dienste: Jellyfin für Medien, Paperless-ngx für Dokumente, AdGuard Home als DNS-Server und Werbeblocker, Portainer, um die Container im Blick zu haben, und Traefik davor.",
|
||||
"BULLET_3": "Private Dienste: Jellyfin für Medien, Paperless-ngx für Dokumente, AdGuard Home als DNS-Server und Werbeblocker, Traefik davor.",
|
||||
"BULLET_4": "Tailscale verbindet die beiden Maschinen. Darüber holt der VPS neue Images aus der Registry zu Hause, und Watchtower tauscht den laufenden Container aus.",
|
||||
"BULLET_5": "Die Backups gehen auf Google Drive.",
|
||||
"CHALLENGE_1": "DNS. Namen müssen von zu Hause aus, über Tailscale und aus dem öffentlichen Internet auf die richtige Adresse zeigen, und AdGuard Home sitzt mittendrin. Das richtig hinzubekommen hat länger gedauert als jeder einzelne Dienst.",
|
||||
"CHALLENGE_2": "Alles gleichzeitig zum Laufen zu bringen und es dann wiederholbar zu machen. Das erste Setup war eine lange Liste von Handgriffen. Ich habe es automatisiert, damit sich der Server neu aufsetzen lässt, ohne dass ich mir jeden Schritt merken muss.",
|
||||
"LEARNING_1": "Selbst hosten kostet wenig Geld und viel Zeit. Die meiste Zeit geht in die Teile, die niemand sieht: DNS, Zertifikate und Backups.",
|
||||
"LEARNING_2": "Ein Git-Push, der ohne Handgriff dazwischen in einem laufenden Container auf dem VPS endet, ist der Teil dieses Setups, den ich nicht mehr hergeben würde."
|
||||
"LEARNING_1": "Selbst hosten kostet wenig Geld und viel Zeit. Die meiste Zeit geht in die Teile, die niemand sieht: DNS, Zertifikate und Updates.",
|
||||
"LEARNING_2": "Ein Git-Push, der ohne Handgriff dazwischen in einem laufenden Container auf dem VPS endet, ist der Teil dieses Setups, den ich nicht mehr hergeben würde.",
|
||||
"DECISION_1": "Der VPS ist der einzige öffentliche Eingang. Traefik auf dem VPS leitet die Namen, die zu Hause gehostet werden, über Tailscale an den Heimserver weiter, mit ein paar YAML-Dateien im File-Provider. Die Alternative wäre eine Portweiterleitung im Router gewesen. So bleibt zu Hause kein Port offen, und die Adresse des Heimservers bleibt privat.",
|
||||
"DECISION_2": "Auf dem VPS zieht Watchtower, zu Hause bleiben die Tags gepinnt. Der VPS schaut jede Minute nach neuen Images und tauscht nur die markierten Container aus, einen nach dem anderen; die CI braucht dafür keinen Zugang zum VPS. Der Heimserver bekommt kein „latest“: Dort ist jedes Image-Tag festgeschrieben, und ein Update ist ein manueller Schritt pro Stack, weil mit „latest“ eine Datenbankmigration als Nebenwirkung passiert, und die lässt sich nicht zurücknehmen.",
|
||||
"DECISION_3": "Neu aufsetzen per Skript statt aus dem Gedächtnis. bootstrap.sh legt das Proxy-Netz und die Verzeichnisse an und warnt vor dem DNS-Port-Konflikt zwischen systemd-resolved und AdGuard; rebuild.sh startet die Stacks in fester Reihenfolge, Traefik zuerst, weil es alphabetisch zuletzt käme. Ansible hätte bei sechs Compose-Stacks mehr Aufwand gekostet, als es spart. Zwei Shell-Skripte reichen und sind leichter zu lesen. Die Skripte decken die Infrastruktur ab, nicht die Daten.",
|
||||
"RESULT": "Heute laufen zu Hause sechs Stacks: Traefik, AdGuard Home, Gitea mit Runner, Jellyfin, Paperless-ngx und Leantime; Nextcloud ist wieder abgeschaltet. Auf dem VPS laufen Traefik, Watchtower und die öffentlichen Seiten. Jeder Push dieser Seite und von To Be Played wird zu Hause gebaut, in die Registry zu Hause geschoben und kommt ohne manuellen Schritt auf den VPS. Im September habe ich alle Stacks in einem Durchgang aktualisiert, Stack für Stack mit den gepinnten Tags, ohne dass ein Dienst ausgefallen ist."
|
||||
},
|
||||
"EL_MUCHO": {
|
||||
"TITLE": "El Mucho (Steam Release)",
|
||||
@@ -397,7 +406,11 @@
|
||||
"CHALLENGE_4": "Zu wissen, ob eine Änderung an der Box etwas bringt. Jede angepasste Regel fühlte sich wie eine Verbesserung an, also habe ich die Eval-Suite gebaut: feste Aufgaben mit versteckten Prüfungen, vor und nach einer Änderung mit denselben Modellen ausgeführt. Die Suite prüft auch sich selbst: Die unveränderte Vorlage muss an jeder Aufgabe scheitern und die Referenzlösung muss bestehen, sonst sagt ein grüner Lauf nichts aus.",
|
||||
"LEARNING_1": "Die Formulierung der Prompts war deutlich weniger wichtig, als ich dachte. Fast der ganze Unterschied kam vom Drumherum.",
|
||||
"LEARNING_2": "Den Output des Agenten genauso zu reviewen und durch dieselben Gates zu schicken wie den Pull Request eines neuen Teammitglieds hat sich als die nützlichste Regel überhaupt herausgestellt.",
|
||||
"LEARNING_3": "Kleine Agenten mit jeweils frischem Kontext schlagen einen langen Chat. Nicht weil das Modell klüger wird, sondern weil niemand frühere Fehler mitschleppt und jede Übergabe eine Datei ist, die ich lesen und korrigieren kann."
|
||||
"LEARNING_3": "Kleine Agenten mit jeweils frischem Kontext schlagen einen langen Chat. Nicht weil das Modell klüger wird, sondern weil niemand frühere Fehler mitschleppt und jede Übergabe eine Datei ist, die ich lesen und korrigieren kann.",
|
||||
"DECISION_1": "Hooks statt Berechtigungsregeln. Claude Code kann Werkzeuge per Regel sperren, aber eine Regel gilt pro Werkzeug: Ein Leseverbot für .env-Dateien hält „cat .env“ in der Shell nicht auf. Deshalb sind der Secret-Guard, das Commit-Gate und der Type-Check Hooks, die jeden Aufruf sehen. Und seit ein Timeout im Hook einmal Commits ungeprüft durchgelassen hat, schließt das Gate bei jedem Fehler: Keine Antwort heißt rot.",
|
||||
"DECISION_2": "Der Docker-Socket des Hosts statt eines eigenen Docker-Daemons im Container. Damit laufen die Testcontainers-Tests der Java-Repos im Commit-Gate, statt übersprungen zu werden. Der Nachteil steht im README: Wer den Socket hat, ist auf dem Host root. Die Box ist also eine Arbeitsumgebung mit festen Werkzeugen, keine Sandbox.",
|
||||
"DECISION_3": "Ein zweites Modell statt nur eines zweiten Kontexts. Anfangs lief alles auf einem Modell, das zweite wurde nie benutzt. Jetzt ist das Modell, das plant und reviewt, nicht das, das implementiert, weil eine zweite Meinung mehr wert ist, wenn sie nicht aus demselben Modell kommt. Für einen Ein-Datei-Fix lohnt der Wechsel nicht, deshalb überspringen kleine Änderungen einzelne Schritte der Pipeline.",
|
||||
"RESULT": "Die Box ist seit Juli 2026 mein tägliches Werkzeug, in jedem Repo, an dem ich arbeite, im Job und zu Hause. Der Build mit echten Testcontainers läuft im Commit-Gate. Die Pipeline besteht jede Aufgabe der Eval-Suite, während der unveränderte Ausgangszustand an jeder scheitert. Das zeigt vor allem, dass die Aufgaben noch zu leicht sind; härtere sind der nächste Schritt."
|
||||
},
|
||||
"TBP": {
|
||||
"TITLE": "To Be Played",
|
||||
@@ -413,7 +426,11 @@
|
||||
"CHALLENGE_3": "Ein Spiel vorzuschlagen ist schwerer, als es klingt. Jede Vorschlagsreihe braucht eigene Regeln, und die Listen hinter jeder Zahl müssen dieselben Regeln nutzen wie die Zahl selbst, damit ein Spiel überall gleich zählt.",
|
||||
"CHALLENGE_4": "Missbrauch beim Login. Zu viele fehlgeschlagene Logins bekommen die Antwort 429 mit Retry-After. Auch bei einer unbekannten E-Mail läuft eine volle BCrypt-Prüfung, sodass die Antwortzeit nichts verrät. Ein Header-Schutz blockiert CSRF, CORS erlaubt nur exakte Origins, und das PlayStation-Token wird verschlüsselt gespeichert.",
|
||||
"LEARNING_1": "Die Aufteilung in Dienste lohnt sich nur mit einem klaren Vertrag. Die Token-Claims stehen in einer Vertragsdatei, gegen die beide Dienste testen, und ein Skript, das ich von Hand starte, prüft, dass beide Kopien gleich sind.",
|
||||
"LEARNING_2": "Auch ein Hobbyprojekt braucht die langweiligen Teile: tägliche Datenbank-Dumps, die sieben Tage, vier Wochen und sechs Monate aufbewahrt werden, Migrationen und eine schriftlich festgehaltene Deploy-Reihenfolge. Sie haben mehr darüber entschieden, wie stabil es läuft, als jedes Feature."
|
||||
"LEARNING_2": "Auch ein Hobbyprojekt braucht die langweiligen Teile: tägliche Datenbank-Dumps, die sieben Tage, vier Wochen und sechs Monate aufbewahrt werden, Migrationen und eine schriftlich festgehaltene Deploy-Reihenfolge. Sie haben mehr darüber entschieden, wie stabil es läuft, als jedes Feature.",
|
||||
"DECISION_1": "Ein Schlüsselpaar statt eines gemeinsamen Geheimnisses. Die erste Version hat die Tokens mit einem HMAC-Secret signiert, das Auth und Core beide kannten. Jetzt hält Auth den privaten Ed25519-Schlüssel und veröffentlicht den öffentlichen über einen JWKS-Endpunkt. Core kann einen Login prüfen, aber nie selbst ausstellen, und wenn ein Token eine unbekannte Key-ID trägt, holt Core sich den neuen Schlüssel selbst.",
|
||||
"DECISION_2": "Mails über eine Outbox-Tabelle statt direkt per SMTP. Jede Mail wird in der Transaktion ihres Ereignisses als Zeile in mail_outbox geschrieben, ein Job verschickt die Zeilen jede Minute. So scheitert eine Registrierung nie am Mailserver, und beim Neustart von Auth geht keine Mail verloren. Mit einer Auth-Instanz reicht „mindestens einmal“.",
|
||||
"DECISION_3": "Installierbar, aber ohne Service Worker. Die App hat ein Web-App-Manifest und lässt sich auf Handy und Desktop installieren, aber keinen Service Worker. So trifft ein Deploy nie auf ein altes, gecachtes Bundle. Der Nachteil ist ein fehlender Offline-Modus.",
|
||||
"RESULT": "Heute laufen Auth, Core und das Frontend als eigene Container neben dieser Seite, mit einer PostgreSQL-Instanz mit zwei Datenbanken, nächtlichem Sync und täglichen Dumps. Registrieren kann sich jeder mit bestätigter E-Mail, freigeschaltet wird von Hand. Ich nutze es täglich: Was ich als Nächstes spiele, kommt jetzt aus einer Liste, nicht mehr aus dem Scrollen durch die Steam-Bibliothek."
|
||||
}
|
||||
},
|
||||
"IMPRINT": {
|
||||
|
||||
+26
-9
@@ -250,7 +250,7 @@
|
||||
},
|
||||
"TRIBBLE": {
|
||||
"TITLE": "Homeserver 'Tribble'",
|
||||
"DESCRIPTION": "A mini PC at home runs Gitea with its Actions runner and registry, plus Jellyfin, Paperless-ngx and AdGuard Home. A VPS serves the public sites and pulls new images over Tailscale with Watchtower. Running since May 2024, backups to Google Drive.",
|
||||
"DESCRIPTION": "A mini PC at home runs Gitea with its Actions runner and registry, plus Jellyfin, Paperless-ngx and AdGuard Home. A VPS serves the public sites and pulls new images over Tailscale with Watchtower. Running since May 2024.",
|
||||
"LINK_INTERNAL": "Project details",
|
||||
"HIGHLIGHTS": {
|
||||
"P1": "Gitea CI builds and tests this site and To Be Played at home; Watchtower deploys the images on the VPS.",
|
||||
@@ -305,7 +305,9 @@
|
||||
"CLOSE": "Close",
|
||||
"SECTION": {
|
||||
"TECHNICAL": "Technical Challenges",
|
||||
"LEARNINGS": "Learnings & Soft Skills"
|
||||
"LEARNINGS": "Learnings & Soft Skills",
|
||||
"DECISIONS": "Decisions",
|
||||
"RESULT": "Result"
|
||||
},
|
||||
"PLAYGROUND": {
|
||||
"TITLE": "Playground",
|
||||
@@ -322,7 +324,11 @@
|
||||
"CHALLENGE_3": "Prerendering a SPA that already existed. At build time every page is rendered in Node, and there is no WebGPU, no canvas for Babylon.js and no localStorage there. Browser-only code had to be guarded component by component until the build ran through. The client-side shell index.csr.html stays, because the service worker and the 404 page still need it.",
|
||||
"LEARNING_1": "Compute shaders change how you think about an algorithm. The data layout and the order of writes matter more than the math.",
|
||||
"LEARNING_2": "A small CI with a key-parity check and a Lighthouse run catches the boring mistakes, a missing translation or a slow page, before I see them on the live site.",
|
||||
"LEARNING_3": "A privacy page is a spec for the server. Once it says there is no access log and no connection to Google, nginx must keep no log and the fonts must ship with the site. Writing it down first made those decisions for me."
|
||||
"LEARNING_3": "A privacy page is a spec for the server. Once it says there is no access log and no connection to Google, nginx must keep no log and the fonts must ship with the site. Writing it down first made those decisions for me.",
|
||||
"DECISION_1": "Prerender at build time instead of client rendering or an SSR server. Every route becomes its own HTML file at build time, and nginx serves it as a static file; no Node process runs on the VPS. The reason was the link preview: a shared link needs title and meta tags in the HTML, not only after the app has loaded. A Node server would have been too much effort for a site whose content runs in the browser anyway.",
|
||||
"DECISION_2": "Vitest with jsdom instead of Karma with Chrome. Karma needed a headless Chrome in the CI container; Vitest runs with no browser. The downside: jsdom applies no real CSS, so two tests on the loaded fonts were dropped.",
|
||||
"DECISION_3": "Tailwind without preflight instead of SCSS-first or all of Tailwind. The styles moved from one big styles.scss to utility classes in the templates, so a style sits next to the markup it affects; SCSS stays only for the Material theme and its overrides. Tailwind's own reset (preflight) stays out, so the reset of Angular Material keeps priority.",
|
||||
"RESULT": "The site runs the way the privacy page promises: no cookies, no tracking, no Google Fonts request, and the build fails as soon as one appears. A push to main is live within a few minutes, with no manual step. Shared links now show a title and a preview, and CI catches missing translations and a missing title or canonical tag before I see them on the live site."
|
||||
},
|
||||
"TRIBBLE": {
|
||||
"TITLE": "Self-Hosted Infrastructure",
|
||||
@@ -330,13 +336,16 @@
|
||||
"INTRODUCTION": "Two machines. At home a mini PC called \"Tribble\" runs Gitea with its Actions runner and container registry, plus the private services. A Hostinger VPS serves the public sites, this one included. A git push builds the image at home, the VPS pulls it over Tailscale with Watchtower, and Traefik does TLS and routing. It has been running since May 2024.",
|
||||
"BULLET_1": "Hardware: a KODLIX GD50 mini PC with an 8-core Intel Core i5, 32 GB RAM and a 512 GB SSD.",
|
||||
"BULLET_2": "Gitea for git, the Actions runner and the container registry. This site and To Be Played are built and tested there.",
|
||||
"BULLET_3": "Private services: Jellyfin for media, Paperless-ngx for documents, AdGuard Home for DNS and ad blocking, Portainer to look at the containers, Traefik in front.",
|
||||
"BULLET_3": "Private services: Jellyfin for media, Paperless-ngx for documents, AdGuard Home for DNS and ad blocking, Traefik in front.",
|
||||
"BULLET_4": "Tailscale links the two machines. The VPS fetches new images from the registry at home over it, and Watchtower swaps the running container.",
|
||||
"BULLET_5": "Backups go to Google Drive.",
|
||||
"CHALLENGE_1": "DNS. Names have to resolve to the right address from inside the house, over Tailscale and from the public internet, and AdGuard Home sits in the middle of it. Getting that right took longer than any single service.",
|
||||
"CHALLENGE_2": "Getting everything to run at the same time, and then making it repeatable. The first setup was a long list of manual steps. I automated it so the server can be rebuilt without me remembering each one.",
|
||||
"LEARNING_1": "Self-hosting is cheap in money and expensive in time. Most of the time goes into the parts nobody sees: DNS, certificates and backups.",
|
||||
"LEARNING_2": "A git push that ends in a running container on the VPS, with no manual step in between, is the one part of this setup I would not give up."
|
||||
"LEARNING_1": "Self-hosting is cheap in money and expensive in time. Most of the time goes into the parts nobody sees: DNS, certificates and updates.",
|
||||
"LEARNING_2": "A git push that ends in a running container on the VPS, with no manual step in between, is the one part of this setup I would not give up.",
|
||||
"DECISION_1": "The VPS is the only public entrance. Traefik on the VPS forwards the names hosted at home over Tailscale to the home server, with a few YAML files in its file provider. The alternative would have been a port forward on the home router. This way no port is open at home, and the home server's address stays private.",
|
||||
"DECISION_2": "Watchtower pulls on the VPS, tags stay pinned at home. The VPS checks for new images every minute and replaces only the labelled containers, one at a time; CI needs no access to the VPS for that. The home server gets no \"latest\": every image tag is pinned there, and an update is one manual step per stack, because with \"latest\" a database migration happens as a side effect, and that cannot be undone.",
|
||||
"DECISION_3": "Rebuild by script instead of from memory. bootstrap.sh creates the proxy network and the directories and warns about the DNS port clash between systemd-resolved and AdGuard; rebuild.sh starts the stacks in a fixed order, Traefik first, because alphabetically it would come last. For six Compose stacks, Ansible would cost more effort than it saves. Two shell scripts are enough and easier to read. The scripts cover the infrastructure, not the data.",
|
||||
"RESULT": "Today six stacks run at home: Traefik, AdGuard Home, Gitea with its runner, Jellyfin, Paperless-ngx and Leantime; Nextcloud is switched off again. The VPS runs Traefik, Watchtower and the public sites. Every push of this site and of To Be Played is built at home, pushed to my own registry on Tribble and reaches the VPS with no manual step. In September I updated all stacks in one pass, stack by stack with the pinned tags, and no service went down."
|
||||
},
|
||||
"EL_MUCHO": {
|
||||
"TITLE": "El Mucho (Steam Release)",
|
||||
@@ -397,7 +406,11 @@
|
||||
"CHALLENGE_4": "Knowing whether a change to the box helps. Every edit to a rule felt like an improvement, so I built the eval suite: fixed tasks with hidden checks, run before and after a change with the same models. The suite checks itself too: the untouched fixture must fail every task and the reference solution must pass, otherwise a green run means nothing.",
|
||||
"LEARNING_1": "Prompt wording mattered far less than I expected. Almost all of the difference came from the scaffolding.",
|
||||
"LEARNING_2": "Reviewing and gating the agent's output the same way you would a new colleague's pull request turned out to be the most useful rule of the lot.",
|
||||
"LEARNING_3": "Small agents with a fresh context each beat one long chat. Not because the model gets smarter, but because nobody carries earlier mistakes forward, and every hand-over is a file I can read and correct."
|
||||
"LEARNING_3": "Small agents with a fresh context each beat one long chat. Not because the model gets smarter, but because nobody carries earlier mistakes forward, and every hand-over is a file I can read and correct.",
|
||||
"DECISION_1": "Hooks instead of permission rules. Claude Code can block tools by rule, but a rule is per tool: a deny on reading .env files does not stop \"cat .env\" in the shell. So the secret guard, the commit gate and the type check are hooks that see every call. And since a hook timeout once let commits through unchecked, the gate fails closed: no answer means red.",
|
||||
"DECISION_2": "The host's Docker socket instead of a Docker daemon inside the container. That way the Testcontainers tests of the Java repos run in the commit gate instead of being skipped. The downside is in the README: whoever has the socket is root on the host. So the box is a work environment with pinned tools, not a sandbox.",
|
||||
"DECISION_3": "A second model, not only a second context. At first one model did everything and the second was never used. Now the model that plans and reviews is not the one that implements, because a second opinion is worth more when it does not come from the same model. For a one-file fix the switch is not worth it, so small changes skip steps of the pipeline.",
|
||||
"RESULT": "The box has been my daily tool since July 2026, in every repo I work in, at work and at home. The build with real Testcontainers runs in the commit gate. The pipeline passes every task of the eval suite, while the untouched starting point fails every one. Mostly that shows the tasks are still too easy; harder ones are the next step."
|
||||
},
|
||||
"TBP": {
|
||||
"TITLE": "To Be Played",
|
||||
@@ -413,7 +426,11 @@
|
||||
"CHALLENGE_3": "Suggesting a game is harder than it sounds. Each row of suggestions needs its own rules, and the lists behind every number must use the same rules as the number, so a game counts the same way everywhere.",
|
||||
"CHALLENGE_4": "Login abuse. Too many failed logins get a 429 with Retry-After. An unknown email still pays for a full BCrypt check, so the timing gives nothing away. A header guard blocks CSRF, CORS allows exact origins only, and the PlayStation token is encrypted at rest.",
|
||||
"LEARNING_1": "Splitting an app into services only pays off with a clear contract. The token claims are pinned in a contract file that both services test against, and a script I run by hand checks that both copies match.",
|
||||
"LEARNING_2": "A hobby project still needs the boring parts: daily database dumps kept for seven days, four weeks and six months, migrations, and a written deploy order. They decided more about how stable it runs than any feature did."
|
||||
"LEARNING_2": "A hobby project still needs the boring parts: daily database dumps kept for seven days, four weeks and six months, migrations, and a written deploy order. They decided more about how stable it runs than any feature did.",
|
||||
"DECISION_1": "A key pair instead of a shared secret. The first version signed the tokens with one HMAC secret that Auth and Core both knew. Now Auth holds the private Ed25519 key and publishes the public one on a JWKS endpoint. Core can check a login but never issue one, and when a token carries an unknown key id, Core fetches the new key itself.",
|
||||
"DECISION_2": "Mail through an outbox table instead of sending over SMTP inline. Every mail is written as a row in mail_outbox inside the transaction of its event, and a job sends the rows every minute. So a registration never fails because of the mail server, and no mail is lost when Auth restarts. With one Auth instance, at-least-once is enough.",
|
||||
"DECISION_3": "Installable, but no service worker. The app has a web app manifest and installs on phone and desktop, but no service worker, so a deploy never meets an old cached bundle. The downside is that there is no offline mode.",
|
||||
"RESULT": "Today Auth, Core and the frontend run as their own containers next to this site, with one PostgreSQL instance with two databases, a nightly sync and daily dumps. Anyone with a confirmed e-mail can register; accounts are approved by hand. I use it every day: what I play next now comes from a list, not from scrolling through the Steam library."
|
||||
}
|
||||
},
|
||||
"IMPRINT": {
|
||||
|
||||
Reference in new issue
Block a user