feat(v5): Etappe W-5 — Oberflaeche: Tray, Toasts, Taskleiste, Einstellungen, Bibliothek
Ampel / ampel (push) Successful in 1m23s
Ampel / ampel (push) Successful in 1m23s
haupt/: tray.ts (Symbol mit Zustand, Kontextmenue, Fenster zu = weiter- laufen, Beenden nur noch gewollt), Windows-Toasts bei Disc erkannt / fertig / Fehler (Klick holt das Fenster), Taskleisten-Fortschritt (setProgressBar, Fehlermodus), echter Windows-Ordnerdialog per IPC. Eigenes Icon (bau/icon.png + tray.png, Disc-Optik). Kern: Einstellungen lesen/schreiben NUR ueber die eine Quelle (§ 6.7, Whitelist der Schluessel), Werkzeug-Auskunft (Katalog + gemessene HandBrake-Presets/Backends), Bibliothek in db.ts (Schema v2 — fingerprint, Titel, Groesse, Ort, Dauer), Duplikat-Erkennung beim Einlegen (§ 6.6: 'schon einmal gerippt am …' als disc-info + Toast), Pipeline traegt fertige Vorgaenge ein. Fenster: drei Bereiche (Uebersicht / Einstellungen / Bibliothek). Einstellungen: Ablage mit Durchsuchen-Dialog, Kompression je Disc-Typ (Preset-Liste VOM eigenen HandBrake gemessen, 'NICHT komprimieren' waehlbar), Sprachlisten, TMDb/OMDb-Keys, Medienserver, Werkzeug-Stand mit Klartext (gefunden/fehlt + Folgen). Bibliothek als Tabelle. Der Smoke-Screenshot zeigt nebenbei den Ernstfall in schoen: Das Laufwerk ist nach dem rc11-Vorfall inzwischen auch am Storage-Stack in 'unknown' gekippt — und die Kachel zeigt den ZUGRIFFS_GRUENDE-Klartext samt Abhilfe statt eines stummen Zustands. 165 Tests gruen, Smoke gruen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a9fa8fa2d8
commit
c78ff22ab7
+363
-100
@@ -1,11 +1,12 @@
|
||||
// Statusbild W-0/W-1: die drei Prozesse plus die echten Laufwerke aus der
|
||||
// Disc-Wache. Die große Oberfläche (Dashboard, Bibliothek, Einstellungen)
|
||||
// ist Etappe W-5 — hier wird das Gerüst SICHTBAR bewiesen.
|
||||
// Die Oberfläche (W-5): Übersicht (Laufwerke + Gerüst-Status),
|
||||
// Einstellungen (EINE Quelle — alles läuft über den Kern, § 6.7) und die
|
||||
// Bibliothek (§ 6.6). Design-Sprache wie das heutige Rippy: dunkel, Kacheln.
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
|
||||
import {
|
||||
aufVerbindung,
|
||||
auswerfen,
|
||||
einstellungSetzen,
|
||||
ripAbbrechen,
|
||||
ripStarten,
|
||||
verbindungStarten,
|
||||
@@ -32,6 +33,15 @@ const TYP_NAMEN: Record<GeraeteInfo['typ'], string> = {
|
||||
unknown: 'unbekannt',
|
||||
}
|
||||
|
||||
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
|
||||
|
||||
function sicherheitsWort(confidence: number): string {
|
||||
if (confidence >= 0.9) return 'sehr wahrscheinlich'
|
||||
if (confidence >= 0.8) return 'wahrscheinlich'
|
||||
if (confidence >= 0.6) return 'Vorschlag'
|
||||
return 'unsicher'
|
||||
}
|
||||
|
||||
interface KachelProps {
|
||||
titel: string
|
||||
zustand: 'ok' | 'fehler' | 'wartet'
|
||||
@@ -58,15 +68,6 @@ function Kachel({ titel, zustand, zeilen }: KachelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
|
||||
|
||||
function sicherheitsWort(confidence: number): string {
|
||||
if (confidence >= 0.9) return 'sehr wahrscheinlich'
|
||||
if (confidence >= 0.8) return 'wahrscheinlich'
|
||||
if (confidence >= 0.6) return 'Vorschlag'
|
||||
return 'unsicher'
|
||||
}
|
||||
|
||||
function LaufwerkKachel({
|
||||
geraet,
|
||||
rip,
|
||||
@@ -105,7 +106,7 @@ function LaufwerkKachel({
|
||||
onClick={() => ripStarten(geraet.id)}
|
||||
disabled={geraet.status !== 'ready'}
|
||||
className="rounded-lg border border-emerald-500/60 px-3 py-1 text-xs text-emerald-300 hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Alle Titel, verlustfrei (MakeMKV)"
|
||||
title="Alle Titel, verlustfrei (MakeMKV) — danach Kompression laut Einstellungen"
|
||||
>
|
||||
Rippen
|
||||
</button>
|
||||
@@ -135,6 +136,11 @@ function LaufwerkKachel({
|
||||
{info.quelle.length > 0 ? ` · ${info.quelle}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'ready' && info !== undefined && info.schonGerippt !== null && (
|
||||
<p className="text-amber-300">
|
||||
⚠ Diese Disc wurde schon einmal gerippt ({info.schonGerippt.slice(0, 10)}) — siehe Bibliothek.
|
||||
</p>
|
||||
)}
|
||||
{geraet.status === 'empty' && <p>keine Disc eingelegt</p>}
|
||||
{geraet.status === 'unknown' && <p className="text-red-300">{geraet.grund}</p>}
|
||||
{geraet.status !== 'unknown' && geraet.grund.length > 0 && (
|
||||
@@ -176,8 +182,240 @@ function LaufwerkKachel({
|
||||
)
|
||||
}
|
||||
|
||||
// ── Einstellungen ───────────────────────────────────────────────────────
|
||||
function Feld({
|
||||
name,
|
||||
wert,
|
||||
hinweis,
|
||||
geheim,
|
||||
aufAendern,
|
||||
}: {
|
||||
name: string
|
||||
wert: string
|
||||
hinweis?: string
|
||||
geheim?: boolean
|
||||
aufAendern: (wert: string) => void
|
||||
}) {
|
||||
const [entwurf, setEntwurf] = useState(wert)
|
||||
useEffect(() => setEntwurf(wert), [wert])
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-slate-300">{name}</span>
|
||||
{hinweis !== undefined && <span className="ml-2 text-xs text-slate-500">{hinweis}</span>}
|
||||
<input
|
||||
type={geheim === true ? 'password' : 'text'}
|
||||
value={entwurf}
|
||||
onChange={(e) => setEntwurf(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (entwurf !== wert) aufAendern(entwurf)
|
||||
}}
|
||||
className="mt-1 w-full rounded-lg border border-slate-600 bg-slate-900 px-3 py-1.5 font-mono text-xs text-slate-200 focus:border-sky-400 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function PresetWahl({
|
||||
name,
|
||||
schluessel,
|
||||
wert,
|
||||
presets,
|
||||
aufAendern,
|
||||
}: {
|
||||
name: string
|
||||
schluessel: string
|
||||
wert: string
|
||||
presets: string[]
|
||||
aufAendern: (schluessel: string, wert: string) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-slate-300">{name}</span>
|
||||
<select
|
||||
value={wert}
|
||||
onChange={(e) => aufAendern(schluessel, e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-slate-600 bg-slate-900 px-3 py-1.5 font-mono text-xs text-slate-200 focus:border-sky-400 focus:outline-none"
|
||||
>
|
||||
<option value="">(allgemeines Preset)</option>
|
||||
<option value="keine">NICHT komprimieren (verlustfrei lassen)</option>
|
||||
{presets.map((preset) => (
|
||||
<option key={preset} value={preset}>
|
||||
{preset}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Einstellungen({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const presets = verbindung.werkzeuge?.presets ?? []
|
||||
const setzen = einstellungSetzen
|
||||
|
||||
const ordnerWaehlen = async (): Promise<void> => {
|
||||
const ordner = await window.rippy.ordnerWaehlen('Ablage-Ordner wählen')
|
||||
if (ordner !== null) setzen('ablage', ordner)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500">Ablage</h2>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Feld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} aufAendern={(w) => setzen('ablage', w)} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void ordnerWaehlen()}
|
||||
className="rounded-lg border border-slate-600 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700"
|
||||
>
|
||||
Durchsuchen …
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
Kompression {verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 && (
|
||||
<span className="text-red-400">— HandBrakeCLI nicht gefunden</span>
|
||||
)}
|
||||
</h2>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={werte['transcodeEnabled'] !== 'false'}
|
||||
onChange={(e) => setzen('transcodeEnabled', e.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
Nach dem Rip komprimieren (der verlustfreie Rip bleibt bis zum Erfolg erhalten)
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} aufAendern={setzen} />
|
||||
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} aufAendern={setzen} />
|
||||
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} aufAendern={setzen} />
|
||||
</div>
|
||||
<Feld
|
||||
name="Allgemeines Preset"
|
||||
hinweis="gilt, wo oben nichts gewählt ist"
|
||||
wert={werte['transcodePreset'] ?? ''}
|
||||
aufAendern={(w) => setzen('transcodePreset', w)}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Feld
|
||||
name="Tonspuren (Sprachen)"
|
||||
hinweis="ISO-639-2, z. B. deu,eng — leer = alle"
|
||||
wert={werte['audioSprachen'] ?? ''}
|
||||
aufAendern={(w) => setzen('audioSprachen', w)}
|
||||
/>
|
||||
<Feld
|
||||
name="Untertitel (Sprachen)"
|
||||
hinweis="leer = alle"
|
||||
wert={werte['untertitelSprachen'] ?? ''}
|
||||
aufAendern={(w) => setzen('untertitelSprachen', w)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500">Metadaten</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Feld name="TMDb-API-Key" geheim wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
|
||||
<Feld name="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500">Medienserver</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-slate-300">System</span>
|
||||
<select
|
||||
value={werte['mediaServer'] ?? 'none'}
|
||||
onChange={(e) => setzen('mediaServer', e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-slate-600 bg-slate-900 px-3 py-1.5 font-mono text-xs text-slate-200 focus:border-sky-400 focus:outline-none"
|
||||
>
|
||||
<option value="none">keiner</option>
|
||||
<option value="jellyfin">Jellyfin</option>
|
||||
<option value="emby">Emby</option>
|
||||
<option value="kodi">Kodi</option>
|
||||
<option value="plex">Plex (nur Benennung)</option>
|
||||
</select>
|
||||
</label>
|
||||
<Feld name="Adresse" hinweis="http(s)://…" wert={werte['mediaServerUrl'] ?? ''} aufAendern={(w) => setzen('mediaServerUrl', w)} />
|
||||
<Feld name="API-Key" geheim hinweis="Kodi: benutzer:passwort" wert={werte['mediaServerApiKey'] ?? ''} aufAendern={(w) => setzen('mediaServerApiKey', w)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500">Werkzeuge</h2>
|
||||
<div className="space-y-1 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4 font-mono text-xs">
|
||||
<p className={verbindung.werkzeuge === null ? 'text-slate-400' : verbindung.werkzeuge.makemkvcon.length > 0 ? 'text-emerald-300' : 'text-red-300'}>
|
||||
makemkvcon: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.makemkvcon || 'NICHT GEFUNDEN — MakeMKV installieren'}
|
||||
</p>
|
||||
<p className={verbindung.werkzeuge === null ? 'text-slate-400' : verbindung.werkzeuge.handbrake.length > 0 ? 'text-emerald-300' : 'text-amber-300'}>
|
||||
HandBrakeCLI: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.handbrake || 'nicht gefunden — Rippen geht, Kompression nicht'}
|
||||
</p>
|
||||
<p className={verbindung.werkzeuge === null ? 'text-slate-400' : verbindung.werkzeuge.flac.length > 0 ? 'text-emerald-300' : 'text-amber-300'}>
|
||||
flac: {verbindung.werkzeuge === null ? 'wird gesucht …' : verbindung.werkzeuge.flac || 'nicht gefunden — nur für Audio-CDs nötig'}
|
||||
</p>
|
||||
{verbindung.werkzeuge !== null && verbindung.werkzeuge.backends.length > 0 && (
|
||||
<p className="text-slate-400">Encoder: {verbindung.werkzeuge.backends.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Bibliothek ──────────────────────────────────────────────────────────
|
||||
function Bibliothek({ verbindung }: { verbindung: Verbindung }) {
|
||||
if (verbindung.bibliothek.length === 0) {
|
||||
return (
|
||||
<p className="font-mono text-xs text-slate-500">
|
||||
Noch nichts gerippt — die Bibliothek füllt sich mit jedem fertigen Vorgang.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-700/60">
|
||||
<table className="w-full text-left font-mono text-xs">
|
||||
<thead className="bg-slate-800/60 text-slate-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Titel</th>
|
||||
<th className="px-3 py-2">Typ</th>
|
||||
<th className="px-3 py-2">Größe</th>
|
||||
<th className="px-3 py-2">Datum</th>
|
||||
<th className="px-3 py-2">Dauer</th>
|
||||
<th className="px-3 py-2">Ablageort</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-slate-300">
|
||||
{verbindung.bibliothek.map((eintrag) => (
|
||||
<tr key={eintrag.fingerprint} className="border-t border-slate-700/40">
|
||||
<td className="px-3 py-2">
|
||||
{eintrag.titel}
|
||||
{eintrag.jahr !== null ? ` (${eintrag.jahr})` : ''}
|
||||
</td>
|
||||
<td className="px-3 py-2">{eintrag.typ}</td>
|
||||
<td className="px-3 py-2">{gb(eintrag.groesseBytes)}</td>
|
||||
<td className="px-3 py-2">{eintrag.datum.slice(0, 16).replace('T', ' ')}</td>
|
||||
<td className="px-3 py-2">{Math.round(eintrag.dauerS / 60)} min</td>
|
||||
<td className="max-w-64 truncate px-3 py-2 text-slate-500" title={eintrag.ablageort}>
|
||||
{eintrag.ablageort}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Bereich = 'uebersicht' | 'einstellungen' | 'bibliothek'
|
||||
|
||||
export default function App() {
|
||||
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
|
||||
const [bereich, setBereich] = useState<Bereich>('uebersicht')
|
||||
const [verbindung, setVerbindung] = useState<Verbindung>({
|
||||
verbunden: false,
|
||||
kern: null,
|
||||
@@ -185,6 +423,9 @@ export default function App() {
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
bibliothek: [],
|
||||
meldungen: [],
|
||||
})
|
||||
|
||||
@@ -198,102 +439,124 @@ export default function App() {
|
||||
|
||||
const db = verbindung.kern?.datenbank ?? null
|
||||
|
||||
const knopf = (ziel: Bereich, text: string): React.JSX.Element => (
|
||||
<button
|
||||
onClick={() => setBereich(ziel)}
|
||||
className={`rounded-lg px-3 py-1 text-xs ${
|
||||
bereich === ziel
|
||||
? 'bg-sky-500/20 text-sky-300'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-slate-900 text-slate-100">
|
||||
<header className="border-b border-slate-700/60 px-6 py-4">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Rippy</h1>
|
||||
<span className="font-mono text-xs text-slate-400">
|
||||
v{haupt?.version ?? '…'} · Etappe W-1 — Laufwerk
|
||||
</span>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Rippy</h1>
|
||||
<span className="font-mono text-xs text-slate-400">v{haupt?.version ?? '…'}</span>
|
||||
</div>
|
||||
<nav className="flex gap-1">
|
||||
{knopf('uebersicht', 'Übersicht')}
|
||||
{knopf('einstellungen', 'Einstellungen')}
|
||||
{knopf('bibliothek', `Bibliothek${verbindung.bibliothek.length > 0 ? ` (${verbindung.bibliothek.length})` : ''}`)}
|
||||
</nav>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Drei Prozesse, eine Datenbank, eine Leine — und die Disc-Wache läuft.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 space-y-6 p-6">
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
Laufwerke
|
||||
</h2>
|
||||
{verbindung.laufwerke.length === 0 ? (
|
||||
<p className="font-mono text-xs text-slate-500">
|
||||
{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{verbindung.laufwerke.map((geraet) => (
|
||||
<LaufwerkKachel
|
||||
key={geraet.id}
|
||||
geraet={geraet}
|
||||
rip={verbindung.rips[geraet.id]}
|
||||
info={verbindung.discInfos[geraet.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
Gerüst
|
||||
</h2>
|
||||
<div className="grid content-start gap-4 sm:grid-cols-2">
|
||||
<Kachel
|
||||
titel="Kern (utilityProcess)"
|
||||
zustand={verbindung.kern !== null ? 'ok' : haupt?.kern.laeuft ? 'wartet' : 'fehler'}
|
||||
zeilen={
|
||||
verbindung.kern !== null
|
||||
? [
|
||||
`PID ${verbindung.kern.pid} · Node ${verbindung.kern.nodeVersion}`,
|
||||
`Neustarts: ${haupt?.kern.neustarts ?? 0}`,
|
||||
]
|
||||
: ['wartet auf Meldung …']
|
||||
}
|
||||
/>
|
||||
<Kachel
|
||||
titel="Datenbank (node:sqlite)"
|
||||
zustand={db === null ? 'wartet' : db.ok ? 'ok' : 'fehler'}
|
||||
zeilen={db === null ? ['wartet auf Kern …'] : db.ok ? [db.pfad] : [db.fehler]}
|
||||
/>
|
||||
<Kachel
|
||||
titel="Prozess-Leine (Job Object)"
|
||||
zustand={haupt === null ? 'wartet' : haupt.leine.ok ? 'ok' : 'fehler'}
|
||||
zeilen={
|
||||
haupt === null
|
||||
? ['wartet auf Haupt …']
|
||||
: haupt.leine.ok
|
||||
? ['gesetzt — Kern und Werkzeuge sterben mit Rippy']
|
||||
: [haupt.leine.fehler ?? 'nicht gesetzt']
|
||||
}
|
||||
/>
|
||||
<Kachel
|
||||
titel="Fenster ↔ Kern (MessagePort)"
|
||||
zustand={verbindung.pongLatenzMs !== null ? 'ok' : 'wartet'}
|
||||
zeilen={
|
||||
verbindung.pongLatenzMs !== null
|
||||
? [`Ping → Pong in ${verbindung.pongLatenzMs} ms`]
|
||||
: ['Ping unterwegs …']
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{verbindung.meldungen.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
Ereignisse
|
||||
</h2>
|
||||
<div className="space-y-1 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
|
||||
{verbindung.meldungen.map((zeile, i) => (
|
||||
<p key={i} className="font-mono text-xs text-slate-400">
|
||||
{zeile}
|
||||
{bereich === 'uebersicht' && (
|
||||
<>
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
Laufwerke
|
||||
</h2>
|
||||
{verbindung.laufwerke.length === 0 ? (
|
||||
<p className="font-mono text-xs text-slate-500">
|
||||
{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{verbindung.laufwerke.map((geraet) => (
|
||||
<LaufwerkKachel
|
||||
key={geraet.id}
|
||||
geraet={geraet}
|
||||
rip={verbindung.rips[geraet.id]}
|
||||
info={verbindung.discInfos[geraet.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
Status
|
||||
</h2>
|
||||
<div className="grid content-start gap-4 sm:grid-cols-2">
|
||||
<Kachel
|
||||
titel="Kern (utilityProcess)"
|
||||
zustand={verbindung.kern !== null ? 'ok' : haupt?.kern.laeuft ? 'wartet' : 'fehler'}
|
||||
zeilen={
|
||||
verbindung.kern !== null
|
||||
? [
|
||||
`PID ${verbindung.kern.pid} · Node ${verbindung.kern.nodeVersion}`,
|
||||
`Neustarts: ${haupt?.kern.neustarts ?? 0}`,
|
||||
]
|
||||
: ['wartet auf Meldung …']
|
||||
}
|
||||
/>
|
||||
<Kachel
|
||||
titel="Datenbank (node:sqlite)"
|
||||
zustand={db === null ? 'wartet' : db.ok ? 'ok' : 'fehler'}
|
||||
zeilen={db === null ? ['wartet auf Kern …'] : db.ok ? [db.pfad] : [db.fehler]}
|
||||
/>
|
||||
<Kachel
|
||||
titel="Prozess-Leine (Job Object)"
|
||||
zustand={haupt === null ? 'wartet' : haupt.leine.ok ? 'ok' : 'fehler'}
|
||||
zeilen={
|
||||
haupt === null
|
||||
? ['wartet auf Haupt …']
|
||||
: haupt.leine.ok
|
||||
? ['gesetzt — Kern und Werkzeuge sterben mit Rippy']
|
||||
: [haupt.leine.fehler ?? 'nicht gesetzt']
|
||||
}
|
||||
/>
|
||||
<Kachel
|
||||
titel="Fenster ↔ Kern (MessagePort)"
|
||||
zustand={verbindung.pongLatenzMs !== null ? 'ok' : 'wartet'}
|
||||
zeilen={
|
||||
verbindung.pongLatenzMs !== null
|
||||
? [`Ping → Pong in ${verbindung.pongLatenzMs} ms`]
|
||||
: ['Ping unterwegs …']
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{verbindung.meldungen.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">
|
||||
Ereignisse
|
||||
</h2>
|
||||
<div className="space-y-1 rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
|
||||
{verbindung.meldungen.map((zeile, i) => (
|
||||
<p key={i} className="break-all font-mono text-xs text-slate-400">
|
||||
{zeile}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{bereich === 'einstellungen' && <Einstellungen verbindung={verbindung} />}
|
||||
{bereich === 'bibliothek' && <Bibliothek verbindung={verbindung} />}
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-slate-700/60 px-6 py-3 text-center text-xs text-slate-500">
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ declare global {
|
||||
bereit(): void
|
||||
aufStatus(horcher: (status: unknown) => void): void
|
||||
melden(art: string): void
|
||||
ordnerWaehlen(titel: string): Promise<string | null>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
|
||||
import {
|
||||
istKernNachricht,
|
||||
type BibliothekEintrag,
|
||||
type DiscInfoStand,
|
||||
type GeraeteInfo,
|
||||
type KernNachricht,
|
||||
type RipStatus,
|
||||
type WerkzeugAuskunft,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
|
||||
export interface Verbindung {
|
||||
@@ -24,6 +26,9 @@ export interface Verbindung {
|
||||
rips: Record<string, RipStatus>
|
||||
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
|
||||
discInfos: Record<string, DiscInfoStand>
|
||||
einstellungen: Record<string, string>
|
||||
werkzeuge: WerkzeugAuskunft | null
|
||||
bibliothek: BibliothekEintrag[]
|
||||
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
|
||||
meldungen: string[]
|
||||
}
|
||||
@@ -39,6 +44,9 @@ const stand: Verbindung = {
|
||||
laufwerke: [],
|
||||
rips: {},
|
||||
discInfos: {},
|
||||
einstellungen: {},
|
||||
werkzeuge: null,
|
||||
bibliothek: [],
|
||||
meldungen: [],
|
||||
}
|
||||
const horcher = new Set<Horcher>()
|
||||
@@ -52,6 +60,8 @@ function melden(): void {
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
bibliothek: [...stand.bibliothek],
|
||||
meldungen: [...stand.meldungen],
|
||||
})
|
||||
}
|
||||
@@ -103,6 +113,15 @@ function kernNachricht(nachricht: KernNachricht): void {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'einstellungen':
|
||||
stand.einstellungen = nachricht.werte
|
||||
break
|
||||
case 'werkzeuge':
|
||||
stand.werkzeuge = nachricht.auskunft
|
||||
break
|
||||
case 'bibliothek':
|
||||
stand.bibliothek = nachricht.eintraege
|
||||
break
|
||||
case 'rip-status': {
|
||||
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
|
||||
if (nachricht.status.phase === 'fertig') {
|
||||
@@ -130,6 +149,9 @@ function portAnnehmen(neuerPort: MessagePort): void {
|
||||
}
|
||||
neuerPort.start()
|
||||
neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
|
||||
neuerPort.postMessage({ art: 'einstellungen-laden' })
|
||||
neuerPort.postMessage({ art: 'werkzeuge-laden' })
|
||||
neuerPort.postMessage({ art: 'bibliothek-laden' })
|
||||
melden()
|
||||
}
|
||||
|
||||
@@ -159,6 +181,8 @@ export function aufVerbindung(h: Horcher): () => void {
|
||||
laufwerke: [...stand.laufwerke],
|
||||
rips: { ...stand.rips },
|
||||
discInfos: { ...stand.discInfos },
|
||||
einstellungen: { ...stand.einstellungen },
|
||||
bibliothek: [...stand.bibliothek],
|
||||
meldungen: [...stand.meldungen],
|
||||
})
|
||||
return () => horcher.delete(h)
|
||||
@@ -184,3 +208,12 @@ export function ripStarten(id: string): void {
|
||||
export function ripAbbrechen(id: string): void {
|
||||
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
|
||||
}
|
||||
|
||||
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||||
if (port === null) {
|
||||
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
|
||||
melden()
|
||||
return
|
||||
}
|
||||
port.postMessage({ art: 'einstellung-setzen', schluessel, wert })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user