chore(hosts): Demo-Daten, Screenshots und Issue-Notizen (#008)
SSHKV_SEED_DEMO=1 legt sechs Demo-Hosts, drei Gruppen und einen Kunden an, damit Entwicklung, Screenshots und Handbuch ohne echte Server auskommen. Nur mit der Variable, damit eine echte Instanz nie Phantom-Hosts bekommt; Upsert nach Name haelt den Seed idempotent. Das Screenshot-Skript kennt jetzt Seiten mit wechselnder ID (folgen: erster passender Link) und nimmt Hostliste und Hostdetail auf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -1,22 +1,56 @@
|
||||
# #008 Hosts und Hostgruppen
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** backend, ui
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0,5 h
|
||||
|
||||
## Ziel
|
||||
Hosts und Hostgruppen anlegen, bearbeiten, archivieren; Kunden und Tags.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Liste mit Status (erreichbar, letzter Abgleich, Drift)
|
||||
- [ ] Formular: Name, Adresse, Port, Gruppen, Tags, Beschreibung, Kunde, Anmeldebenutzer (`sshkv`/`root`)
|
||||
- [ ] Hostgruppen mit Mitgliedern
|
||||
- [ ] Hostdetail-Gerüst
|
||||
- [x] Liste mit Status (erreichbar, letzter Abgleich, Drift)
|
||||
- [x] Formular: Name, Adresse, Port, Gruppen, Tags, Beschreibung, Kunde, Anmeldebenutzer (`sshkv`/`root`)
|
||||
- [x] Hostgruppen mit Mitgliedern
|
||||
- [x] Hostdetail-Gerüst
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Leser sieht, Operator/Admin bearbeiten
|
||||
- [x] Leser sieht, Operator/Admin bearbeiten
|
||||
|
||||
## Abhängigkeiten
|
||||
#004
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Schichtung:** Regeln (zod-Schemas, Status-Ableitung) framework-frei in `src/lib/hosts.ts`;
|
||||
Services `src/server/services/hosts.ts` / `hostgruppen.ts`, Lesemodelle
|
||||
`src/server/queries/hosts.ts`, Aktionen `src/server/actions/hosts.ts` / `hostgruppen.ts`
|
||||
(gemeinsame Hilfen in `actions/formular.ts`, ohne `"use server"`). Die Action prüft vorab für
|
||||
Feldmeldungen, der Service prüft erneut (Seed, Skripte, spätere Assistenten).
|
||||
- **Archivieren statt Löschen** (`archiviertAm`). Dabei fallen die Gruppenmitgliedschaften weg
|
||||
(im Protokoll festgehalten), damit Zuweisungen an Gruppen nie einen archivierten Host
|
||||
treffen. Archivierte Hosts sind schreibgeschützt und erscheinen in keiner Liste; der Name
|
||||
bleibt belegt, damit das Protokoll eindeutig bleibt. Wiederherstellen gibt es noch nicht.
|
||||
- **Hostgruppen löschen** bewusst noch nicht: `PublicVerwendung` hängt per Cascade an der
|
||||
Gruppe; das Löschen klärt #016 zusammen mit den Verwendungen.
|
||||
- **Kunde** per Name: unbekannter Name legt den Kunden an (`kunde.angelegt` im Protokoll),
|
||||
leer = kein Kunde. Gruppen- und Kundennamen erlauben Leerzeichen/Umlaute (reine Anzeige).
|
||||
- **Protokoll:** `host.angelegt`, `host.geaendert` (nur geänderte Felder als
|
||||
`{ vorher, nachher }`, ohne Änderung kein Eintrag), `host.archiviert`,
|
||||
`hostgruppe.angelegt`, `hostgruppe.geaendert` (Mitglieder als `hinzu`/`entfernt`),
|
||||
`kunde.angelegt`. Demo-Seed schreibt bewusst keine Einträge.
|
||||
- **Status in der Liste:** nicht erreichbar > Drift (auch bei offenen Abweichungen) >
|
||||
synchron > unbekannt. Host-Key: ein ausstehender Key geht vor einem vertrauten, damit ein
|
||||
geänderter Key nicht hinter „bestätigt“ verschwindet.
|
||||
- **Formulare** senden per `onSubmit` + `startTransition` statt `<form action>`: sonst setzt
|
||||
React das Formular nach jeder Antwort zurück und Eingaben gingen bei Feldfehlern verloren.
|
||||
`Feld` hat dafür `fehler` bekommen; neu sind `Textbereich`, `Auswahlliste`, `Ankreuzfeld`,
|
||||
`Feldgruppe`.
|
||||
- **Mobil:** Hostliste unter `md` als Kartenliste statt Tabelle (Status und Host-Key ohne
|
||||
seitliches Wischen sichtbar); Gruppenliste zeigt die Beschreibung unter dem Namen.
|
||||
- **Platzhalter für Folge-Issues:** `src/app/(app)/hosts/[id]/HostKeyAktionen.tsx`
|
||||
(`hostId`, `darfEntscheiden`, `status: HostKeyZusammenfassung`, #009) im Kopf der Karte
|
||||
„Host-Key“ und `BootstrapAktionen.tsx` (`hostId`, `darfVerwalten`, `eingerichtet`,
|
||||
`hostKeyVertraut`, #010) unter der Info-Leiste – beide geben vorerst `null` zurück.
|
||||
- **Demo-Daten:** `SSHKV_SEED_DEMO=1 pnpm db:seed` legt 6 Hosts, 3 Gruppen und einen Kunden an
|
||||
(Upsert nach Name, idempotent).
|
||||
- **Screenshots:** `scripts/screenshots.ts` kennt `hosts` und `host-detail` (folgt dem ersten
|
||||
Host der Liste).
|
||||
|
||||
+81
-1
@@ -5,7 +5,7 @@ import { PASSWORT_MINDESTLAENGE, passwortHashen } from "../src/lib/passwort-hash
|
||||
import { EINGEBAUTE_VORLAGEN } from "./vorlagen";
|
||||
|
||||
/**
|
||||
* Grundausstattung (#003): Notfall-Admin und eingebaute Vorlagen.
|
||||
* Grundausstattung (#003): Notfall-Admin und eingebaute Vorlagen; optional Demo-Hosts (#008).
|
||||
*
|
||||
* Laeuft bei jedem Start des migrate-Dienstes und ist deshalb idempotent. Ein bestehender
|
||||
* Notfall-Admin wird nie angefasst – sonst setzte jeder Neustart ein in der Oberflaeche
|
||||
@@ -45,9 +45,89 @@ async function vorlagen(): Promise<void> {
|
||||
console.log(`${EINGEBAUTE_VORLAGEN.length} eingebaute Vorlagen geprüft.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo-Hosts fuer Entwicklung, Screenshots und Handbuch (#008) – nur mit SSHKV_SEED_DEMO=1,
|
||||
* damit eine echte Instanz nie Phantom-Hosts bekommt. Upsert nach Name: ein zweiter Lauf
|
||||
* aendert nichts, und in der Oberflaeche geaenderte Demo-Hosts bleiben, wie sie sind.
|
||||
* Bewusst ohne Protokolleintraege: Seed-Daten sind keine Handlung eines Menschen.
|
||||
*/
|
||||
const DEMO_GRUPPEN = [
|
||||
{ name: "Proxmox", beschreibung: "Virtualisierungs-Cluster im Rechenzentrum" },
|
||||
{ name: "DMZ", beschreibung: "Von außen erreichbare Systeme" },
|
||||
{ name: "Kunde X", beschreibung: "Systeme, die wir für Kunde X betreiben" },
|
||||
] as const;
|
||||
|
||||
const DEMO_HOSTS: {
|
||||
name: string;
|
||||
adresse: string;
|
||||
port?: number;
|
||||
tags: string[];
|
||||
beschreibung?: string;
|
||||
anmeldeart?: "DIENSTBENUTZER" | "ROOT";
|
||||
kunde?: string;
|
||||
gruppen: string[];
|
||||
}[] = [
|
||||
{ name: "srv-pve01", adresse: "10.0.10.11", tags: ["pve", "produktion"], gruppen: ["Proxmox"] },
|
||||
{ name: "srv-pve02", adresse: "10.0.10.12", tags: ["pve", "produktion"], gruppen: ["Proxmox"] },
|
||||
{
|
||||
name: "srv-backup01",
|
||||
adresse: "10.0.10.30",
|
||||
tags: ["backup"],
|
||||
beschreibung: "Proxmox Backup Server, zieht nachts von pve01/pve02",
|
||||
gruppen: ["Proxmox"],
|
||||
},
|
||||
{
|
||||
name: "srv-app01",
|
||||
adresse: "app01.kunde-x.example",
|
||||
port: 2222,
|
||||
tags: ["web"],
|
||||
kunde: "Kunde X",
|
||||
gruppen: ["Kunde X"],
|
||||
},
|
||||
{
|
||||
name: "fw-edge01",
|
||||
adresse: "192.168.1.1",
|
||||
tags: ["firewall"],
|
||||
anmeldeart: "ROOT",
|
||||
beschreibung: "Appliance ohne sudo – Anmeldung direkt als root",
|
||||
gruppen: ["DMZ"],
|
||||
},
|
||||
{ name: "srv-rz-claude-linux", adresse: "fd00:10::42", tags: ["test"], gruppen: [] },
|
||||
];
|
||||
|
||||
async function demoDaten(): Promise<void> {
|
||||
if (process.env["SSHKV_SEED_DEMO"] !== "1") return;
|
||||
const gruppen = new Map<string, string>();
|
||||
for (const g of DEMO_GRUPPEN) {
|
||||
const gruppe = await prisma.hostgruppe.upsert({ where: { name: g.name }, create: g, update: {} });
|
||||
gruppen.set(g.name, gruppe.id);
|
||||
}
|
||||
for (const h of DEMO_HOSTS) {
|
||||
const kunde = h.kunde
|
||||
? await prisma.kunde.upsert({ where: { name: h.kunde }, create: { name: h.kunde }, update: {} })
|
||||
: null;
|
||||
await prisma.host.upsert({
|
||||
where: { name: h.name },
|
||||
update: {},
|
||||
create: {
|
||||
name: h.name,
|
||||
adresse: h.adresse,
|
||||
port: h.port ?? 22,
|
||||
tags: h.tags,
|
||||
beschreibung: h.beschreibung ?? null,
|
||||
anmeldeart: h.anmeldeart ?? "DIENSTBENUTZER",
|
||||
kundeId: kunde?.id ?? null,
|
||||
gruppen: { create: h.gruppen.map((name) => ({ hostgruppeId: gruppen.get(name) ?? "" })) },
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(`Demo-Daten geprüft: ${DEMO_HOSTS.length} Hosts, ${DEMO_GRUPPEN.length} Gruppen.`);
|
||||
}
|
||||
|
||||
try {
|
||||
await notfallAdmin();
|
||||
await vorlagen();
|
||||
await demoDaten();
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
+18
-3
@@ -19,11 +19,17 @@ import { chromium } from "@playwright/test";
|
||||
const BASIS = (process.env["SSHKV_URL"] ?? "http://localhost:3400").replace(/\/$/, "");
|
||||
const ZIEL = process.env["SSHKV_BILDER"] ?? join(process.cwd(), "docs/anleitung/bilder");
|
||||
|
||||
/** `oeffentlich`: ohne Anmeldung aufnehmen. Alle anderen nach Anmeldung ueber den Mock-OIDC (#004). */
|
||||
const SEITEN: { name: string; pfad: string; oeffentlich?: boolean }[] = [
|
||||
/**
|
||||
* `oeffentlich`: ohne Anmeldung aufnehmen. Alle anderen nach Anmeldung ueber den Mock-OIDC (#004).
|
||||
* `folgen`: Seiten mit wechselnder ID (#008) – erst `pfad` laden, dann dem ersten Link folgen,
|
||||
* der zum Selektor passt (z. B. der erste Host der Liste, bei SSHKV_SEED_DEMO=1 ein Demo-Host).
|
||||
*/
|
||||
const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: string }[] = [
|
||||
{ name: "anmelden", pfad: "/anmelden", oeffentlich: true },
|
||||
{ name: "uebersicht", pfad: "/" },
|
||||
{ name: "schluessel-detail", pfad: "/schluessel/beispiel" },
|
||||
{ name: "hosts", pfad: "/hosts" },
|
||||
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
||||
];
|
||||
|
||||
const ANSICHTEN = [
|
||||
@@ -62,7 +68,16 @@ async function main() {
|
||||
await seite.waitForURL(`${BASIS}/`);
|
||||
angemeldet = true;
|
||||
}
|
||||
const antwort = await seite.goto(`${BASIS}${s.pfad}`, { waitUntil: "networkidle" });
|
||||
let antwort = await seite.goto(`${BASIS}${s.pfad}`, { waitUntil: "networkidle" });
|
||||
if (s.folgen && antwort?.ok()) {
|
||||
const links = seite.locator(s.folgen);
|
||||
const ziel = (await links.count()) > 0 ? await links.first().getAttribute("href") : null;
|
||||
if (!ziel) {
|
||||
fehler.push(`${s.pfad}: kein Link für „${s.name}“ (${s.folgen}) – Demo-Daten geseedet?`);
|
||||
continue;
|
||||
}
|
||||
antwort = await seite.goto(`${BASIS}${ziel}`, { waitUntil: "networkidle" });
|
||||
}
|
||||
if (!antwort?.ok()) {
|
||||
fehler.push(`${s.pfad}: HTTP ${antwort?.status() ?? "keine Antwort"}`);
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user