diff --git a/backend/.env b/backend/.env index e33764f..c1ed77b 100644 --- a/backend/.env +++ b/backend/.env @@ -1,2 +1,2 @@ -HTTPS_ENABLED=1 +HTTPS_ENABLED=0 AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173 \ No newline at end of file diff --git a/backend/data/pending-autostart/admin.json b/backend/data/pending-autostart/admin.json deleted file mode 100644 index fc69ce2..0000000 --- a/backend/data/pending-autostart/admin.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "items": [] -} \ No newline at end of file diff --git a/backend/log.go b/backend/log.go index dd888ba..91cca93 100644 --- a/backend/log.go +++ b/backend/log.go @@ -126,9 +126,22 @@ func appLogf(format string, args ...any) { appLogWriteLine(fmt.Sprintf(format, args...)) } +// appErrorLogSuppressed unterdrückt das automatische ❌-Logging für bekannte, +// erwartbare und sehr häufige Fehler (z.B. abgelaufene HLS-Edge-URLs während +// eines Stream-Reconnects). Der Fehler wird weiterhin zurückgegeben, damit die +// Retry-/Reconnect-Logik unverändert funktioniert – nur die Log-Zeile entfällt. +func appErrorLogSuppressed(msg string) bool { + m := strings.ToLower(msg) + + return strings.Contains(m, "leere hls url") || + strings.Contains(m, "http 403") +} + func appErrorf(format string, args ...any) error { err := fmt.Errorf(format, args...) - appLogln("❌", err) + if !appErrorLogSuppressed(err.Error()) { + appLogln("❌", err) + } return err } diff --git a/backend/ml/__pycache__/train_detector_model.cpython-314.pyc b/backend/ml/__pycache__/train_detector_model.cpython-314.pyc new file mode 100644 index 0000000..dc21c5d Binary files /dev/null and b/backend/ml/__pycache__/train_detector_model.cpython-314.pyc differ diff --git a/backend/ml/train_detector_model.py b/backend/ml/train_detector_model.py index a6bb1d5..b7ecbff 100644 --- a/backend/ml/train_detector_model.py +++ b/backend/ml/train_detector_model.py @@ -3,7 +3,7 @@ import argparse import json import shutil -import time +from datetime import datetime, timezone from pathlib import Path from ultralytics import YOLO @@ -53,23 +53,27 @@ def safe_int(value, fallback): return fallback -def batch_sample_id(batch): +def batch_sample_ids(batch): if not isinstance(batch, dict): - return "" + return [] image_paths = batch.get("im_file") if not image_paths: - return "" + return [] if isinstance(image_paths, (str, Path)): - image_path = image_paths - else: - try: - image_path = image_paths[0] - except (IndexError, KeyError, TypeError): - return "" + image_paths = [image_paths] - return Path(str(image_path)).stem.strip() + out = [] + seen = set() + + for image_path in image_paths: + stem = Path(str(image_path)).stem.strip() + if stem and stem not in seen: + seen.add(stem) + out.append(stem) + + return out def progress_detection_trainer(train_count, val_count, train_device, fallback_epochs): @@ -78,7 +82,6 @@ def progress_detection_trainer(train_count, val_count, train_device, fallback_ep super().__init__(*args, **kwargs) self._preview_epoch = 0 self._preview_batch = 0 - self._preview_emitted_at = 0.0 def preprocess_batch(self, batch): epoch = int(getattr(self, "epoch", 0)) + 1 @@ -90,27 +93,25 @@ def progress_detection_trainer(train_count, val_count, train_device, fallback_ep self._preview_batch += 1 - now = time.monotonic() - if self._preview_batch == 1 or now - self._preview_emitted_at >= 0.5: - sample_id = batch_sample_id(batch) + # Pro Batch das gerade trainierte Bild melden – ohne Drosselung, damit + # das Frontend live immer das aktuell trainierte Bild anzeigen kann. + sample_ids = batch_sample_ids(batch) + if sample_ids: + total_batches = max(1, len(self.train_loader)) + completed = (epoch - 1) + min(1.0, self._preview_batch / total_batches) + progress = 0.04 + 0.90 * (completed / max(1, total_epochs)) - if sample_id: - total_batches = max(1, len(self.train_loader)) - completed = (epoch - 1) + min(1.0, self._preview_batch / total_batches) - - emit_progress( - "detector", - 0.04 + 0.90 * (completed / max(1, total_epochs)), - "Object Detector trainiert…", - epoch=epoch, - epochs=total_epochs, - sampleId=sample_id, - trainSamples=train_count, - valSamples=val_count, - device=str(train_device), - ) - - self._preview_emitted_at = now + emit_progress( + "detector", + progress, + "Object Detector trainiert…", + epoch=epoch, + epochs=total_epochs, + sampleId=sample_ids[0], + trainSamples=train_count, + valSamples=val_count, + device=str(train_device), + ) return super().preprocess_batch(batch) @@ -181,6 +182,8 @@ def main(): best_epoch = 0 last_epoch = 0 + best_map50 = 0.0 + best_map5095 = 0.0 def on_train_epoch_start(trainer): epoch = int(getattr(trainer, "epoch", 0)) + 1 @@ -216,7 +219,7 @@ def main(): ) def on_fit_epoch_end(trainer): - nonlocal best_epoch + nonlocal best_epoch, best_map50, best_map5095 epoch = int(getattr(trainer, "epoch", 0)) + 1 total = int(getattr(trainer, "epochs", epochs) or epochs) @@ -237,6 +240,13 @@ def main(): if map50 is not None or map5095 is not None: best_epoch = epoch + # Bestes Modell merken (YOLO speichert best.pt nach Fitness ~ mAP). + m50 = float(map50) if map50 is not None else 0.0 + if m50 >= best_map50: + best_map50 = m50 + if map5095 is not None: + best_map5095 = float(map5095) + emit_progress( "detector", 0.04 + 0.90 * (epoch / max(1, total)), @@ -310,11 +320,15 @@ def main(): "model": str(final_model), "sourceModel": str(best), "runs": str(result_path), + "trainedAt": datetime.now(timezone.utc).isoformat(), "trainSamples": train_count, "valSamples": val_count, "epochs": epochs, "imgsz": imgsz, "device": str(train_device), + "mAP50": round(best_map50, 4), + "mAP5095": round(best_map5095, 4), + "bestEpoch": best_epoch, } with (out_dir / "status.json").open("w", encoding="utf-8") as f: diff --git a/backend/routes.go b/backend/routes.go index b81b336..4a7ca49 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -96,6 +96,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/training/cancel", trainingCancelHandler) api.HandleFunc("/api/training/status", trainingStatusHandler) api.HandleFunc("/api/training/stats", trainingStatsHandler) + api.HandleFunc("/api/training/history", trainingHistoryHandler) api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler) api.HandleFunc("/api/training/skip", trainingSkipHandler) api.HandleFunc("/api/training/import-video", trainingImportVideoHandler) diff --git a/backend/server.go b/backend/server.go index c5cce95..a05d5aa 100644 --- a/backend/server.go +++ b/backend/server.go @@ -102,9 +102,18 @@ func buildStartupNotificationMessage() string { } type aiServerProcess struct { - cmd *exec.Cmd + cmd *exec.Cmd + done chan struct{} } +// Globales Handle auf den laufenden AI-Server, damit er (z.B. nach einem neuen +// Modell) sauber neu gestartet werden kann. +var ( + aiServerMu sync.Mutex + aiServerCurrent *aiServerProcess + aiServerCtx context.Context +) + func aiServerAutostartEnabled() bool { raw := strings.ToLower(strings.TrimSpace(os.Getenv("AI_SERVER_AUTOSTART"))) @@ -572,7 +581,8 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { } proc := &aiServerProcess{ - cmd: cmd, + cmd: cmd, + done: make(chan struct{}), } go func() { @@ -583,6 +593,8 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { } else { appLogln("🛑 AI Server beendet.") } + + close(proc.done) }() waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second) @@ -606,6 +618,52 @@ func (p *aiServerProcess) Stop() { if p.cmd != nil && p.cmd.Process != nil { _ = p.cmd.Process.Kill() } + + // Auf das tatsächliche Prozessende warten, damit der Port frei ist, + // bevor ggf. ein neuer Server gestartet wird. + if p.done != nil { + select { + case <-p.done: + case <-time.After(10 * time.Second): + appLogln("⚠️ AI Server reagiert nicht auf Stop (Timeout).") + } + } +} + +// restartAIServer beendet den laufenden AI-Server und startet ihn frisch. +// Wird z.B. nach einem erfolgreichen Training aufgerufen, damit das neue Modell +// in einem sauberen Prozess geladen wird. +func restartAIServer() { + aiServerMu.Lock() + defer aiServerMu.Unlock() + + ctx := aiServerCtx + if ctx == nil { + ctx = context.Background() + } + + if ctx.Err() != nil { + appLogln("ℹ️ AI Server Neustart übersprungen (App wird beendet).") + return + } + + if aiServerCurrent != nil { + appLogln("🔄 AI Server wird neu gestartet…") + aiServerCurrent.Stop() + aiServerCurrent = nil + } + + proc, err := startAIServer(ctx) + if err != nil { + appLogln("⚠️ AI Server Neustart fehlgeschlagen:", err) + return + } + + aiServerCurrent = proc + + if proc != nil { + appLogln("✅ AI Server neu gestartet.") + } } func setEnvIfMissing(key, value string) { @@ -801,6 +859,13 @@ func main() { appLogln("⚠️ AI Server konnte nicht gestartet werden:", err) } + // Handle/Context global hinterlegen, damit der Server später neu gestartet + // werden kann (z.B. nach einem neuen Modell). + aiServerMu.Lock() + aiServerCtx = appCtx + aiServerCurrent = aiProc + aiServerMu.Unlock() + // ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen. resetAutostartPauseOnStartup() @@ -882,8 +947,13 @@ func main() { var shutdownOnce sync.Once shutdown := func() { shutdownOnce.Do(func() { - if aiProc != nil { - aiProc.Stop() + aiServerMu.Lock() + current := aiServerCurrent + aiServerCurrent = nil + aiServerMu.Unlock() + + if current != nil { + current.Stop() } appCancel() diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index 9d0d1d1..6966d92 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -527,16 +527,8 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin return } - updateCleanupJobState(jobID, func(st *CleanupTaskState) { - st.Queued = false - st.Running = true - st.Done = 0 - st.Total = 0 - st.CurrentFile = name - st.Text = "Räume Record-Reste auf…" - st.Error = "" - st.FinishedAt = nil - }) + // Fortschritt (Done/Total/CurrentFile) steuert der Aufrufer + // cleanupRecordDirOrphanAVFiles, damit die Progress-Bar sichtbar bleibt. if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) { resp.ErrorCount++ @@ -582,6 +574,34 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs return nil } + // Gesamtzahl der zu prüfenden Dateien für einen sichtbaren Fortschritt. + total := 0 + for _, e := range entries { + if e == nil || e.IsDir() { + continue + } + if strings.TrimSpace(e.Name()) == "" { + continue + } + total++ + } + + updateCleanupJobState(jobID, func(st *CleanupTaskState) { + st.Queued = false + st.Running = true + if st.StartedAt.IsZero() { + st.StartedAt = time.Now() + } + st.Done = 0 + st.Total = total + st.CurrentFile = "" + st.Text = "Räume Record-Reste auf…" + st.Error = "" + st.FinishedAt = nil + }) + + processed := 0 + for _, e := range entries { select { case <-ctx.Done(): @@ -598,6 +618,13 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs continue } + processed++ + updateCleanupJobState(jobID, func(st *CleanupTaskState) { + st.Done = processed + st.Total = total + st.CurrentFile = name + }) + full := filepath.Join(recordAbs, name) low := strings.ToLower(name) diff --git a/backend/training.go b/backend/training.go index 7d72334..0aff095 100644 --- a/backend/training.go +++ b/backend/training.go @@ -153,6 +153,9 @@ type TrainingJobStatus struct { Stage string `json:"stage,omitempty"` Epoch int `json:"epoch,omitempty"` Epochs int `json:"epochs,omitempty"` + + MAP50 float64 `json:"map50,omitempty"` + MAP5095 float64 `json:"map5095,omitempty"` } type TrainingConfidence struct { @@ -175,6 +178,18 @@ type TrainingStatsLabels struct { Clothing []TrainingLabelStat `json:"clothing"` } +type TrainingModelInfo struct { + TrainedAt string `json:"trainedAt,omitempty"` + TrainedAtMs int64 `json:"trainedAtMs,omitempty"` + Epochs int `json:"epochs,omitempty"` + TrainSamples int `json:"trainSamples,omitempty"` + ValSamples int `json:"valSamples,omitempty"` + Imgsz int `json:"imgsz,omitempty"` + Device string `json:"device,omitempty"` + MAP50 float64 `json:"map50,omitempty"` + MAP5095 float64 `json:"map5095,omitempty"` +} + type TrainingStatsResponse struct { OK bool `json:"ok"` FeedbackCount int `json:"feedbackCount"` @@ -184,18 +199,21 @@ type TrainingStatsResponse struct { SampleCount int `json:"sampleCount"` BoxCount int `json:"boxCount"` ModelAvailable bool `json:"modelAvailable"` + ModelInfo *TrainingModelInfo `json:"modelInfo,omitempty"` Confidence TrainingConfidence `json:"confidence"` Labels TrainingStatsLabels `json:"labels"` } type trainingProgressEvent struct { - Type string `json:"type"` - Stage string `json:"stage"` - Progress float64 `json:"progress"` // 0..1 - Message string `json:"message,omitempty"` - Epoch int `json:"epoch,omitempty"` - Epochs int `json:"epochs,omitempty"` - SampleID string `json:"sampleId,omitempty"` + Type string `json:"type"` + Stage string `json:"stage"` + Progress float64 `json:"progress"` // 0..1 + Message string `json:"message,omitempty"` + Epoch int `json:"epoch,omitempty"` + Epochs int `json:"epochs,omitempty"` + SampleID string `json:"sampleId,omitempty"` + MAP50 *float64 `json:"mAP50,omitempty"` + MAP5095 *float64 `json:"mAP5095,omitempty"` } type TrainingFeedbackListResponse struct { @@ -689,6 +707,13 @@ func trainingHandleProgressLine(line string, start int, end int, defaultStep str s.Epochs = ev.Epochs } + if ev.MAP50 != nil && *ev.MAP50 > 0 { + s.MAP50 = *ev.MAP50 + } + if ev.MAP5095 != nil && *ev.MAP5095 > 0 { + s.MAP5095 = *ev.MAP5095 + } + sampleID := strings.TrimSpace(ev.SampleID) if sampleID != "" && !strings.Contains(sampleID, "/") && @@ -2499,7 +2524,13 @@ func trainingRunJob(ctx context.Context, root string, count int) { } if detectorStatus == "trained" { - reloadAIServerModelAfterTraining() + // Verlaufseintrag schreiben (vor dem Neustart, solange die Job-Startzeit + // für die Dauer noch verfügbar ist). + trainingAppendRunHistory(root) + + // Neues Modell → AI-Server frisch neu starten (sauberer Zustand, + // statt nur das Modell im laufenden Prozess neu zu laden). + restartAIServer() } trainingSetJobStatus(func(s *TrainingJobStatus) { @@ -2591,6 +2622,7 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) { if os.IsNotExist(err) { stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples")) stats.ModelAvailable = trainingStatsModelAvailable(root) + stats.ModelInfo = trainingReadModelInfo(root) return stats, nil } @@ -2684,6 +2716,7 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) { stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples")) stats.ModelAvailable = trainingStatsModelAvailable(root) + stats.ModelInfo = trainingReadModelInfo(root) stats.Labels = TrainingStatsLabels{ // Personen/Box-Labels brauchen mehr Beispiele, weil der Detector Boxen lernen muss. @@ -2927,6 +2960,188 @@ func trainingStatsModelAvailable(root string) bool { return fileExistsNonEmpty(detectorModelPath) } +// trainingReadModelInfo liest Versions-/Datums-Infos zum aktuell trainierten +// Modell. Datum/Version stammen primär aus status.json (vom Trainingsskript), +// Fallback ist die Änderungszeit der best.pt-Datei. +func trainingReadModelInfo(root string) *TrainingModelInfo { + modelPath := filepath.Join(root, "detector", "model", "best.pt") + + fi, err := os.Stat(modelPath) + if err != nil || fi.IsDir() || fi.Size() <= 0 { + return nil + } + + info := &TrainingModelInfo{ + TrainedAt: fi.ModTime().UTC().Format(time.RFC3339), + TrainedAtMs: fi.ModTime().UnixMilli(), + } + + statusPath := filepath.Join(root, "detector", "model", "status.json") + if b, err := os.ReadFile(statusPath); err == nil { + var raw struct { + TrainedAt string `json:"trainedAt"` + Epochs int `json:"epochs"` + TrainSamples int `json:"trainSamples"` + ValSamples int `json:"valSamples"` + Imgsz int `json:"imgsz"` + Device string `json:"device"` + MAP50 float64 `json:"mAP50"` + MAP5095 float64 `json:"mAP5095"` + } + + if json.Unmarshal(b, &raw) == nil { + if trimmed := strings.TrimSpace(raw.TrainedAt); trimmed != "" { + if t, err := time.Parse(time.RFC3339, trimmed); err == nil { + info.TrainedAt = t.UTC().Format(time.RFC3339) + info.TrainedAtMs = t.UnixMilli() + } + } + + info.Epochs = raw.Epochs + info.TrainSamples = raw.TrainSamples + info.ValSamples = raw.ValSamples + info.Imgsz = raw.Imgsz + info.Device = strings.TrimSpace(raw.Device) + info.MAP50 = raw.MAP50 + info.MAP5095 = raw.MAP5095 + } + } + + return info +} + +type TrainingHistoryEntry struct { + TrainedAt string `json:"trainedAt,omitempty"` + TrainedAtMs int64 `json:"trainedAtMs,omitempty"` + DurationMs int64 `json:"durationMs,omitempty"` + Epochs int `json:"epochs,omitempty"` + TrainSamples int `json:"trainSamples,omitempty"` + ValSamples int `json:"valSamples,omitempty"` + Imgsz int `json:"imgsz,omitempty"` + Device string `json:"device,omitempty"` + MAP50 float64 `json:"map50,omitempty"` + MAP5095 float64 `json:"map5095,omitempty"` +} + +type TrainingHistoryResponse struct { + OK bool `json:"ok"` + Entries []TrainingHistoryEntry `json:"entries"` +} + +func trainingHistoryPath(root string) string { + return filepath.Join(root, "detector", "training_history.jsonl") +} + +// trainingAppendRunHistory hängt nach einem erfolgreichen Trainingslauf einen +// Verlaufseintrag an (Datum, mAP, Samples, Epochen, Dauer). +func trainingAppendRunHistory(root string) { + info := trainingReadModelInfo(root) + if info == nil { + return + } + + entry := TrainingHistoryEntry{ + TrainedAt: info.TrainedAt, + TrainedAtMs: info.TrainedAtMs, + Epochs: info.Epochs, + TrainSamples: info.TrainSamples, + ValSamples: info.ValSamples, + Imgsz: info.Imgsz, + Device: info.Device, + MAP50: info.MAP50, + MAP5095: info.MAP5095, + } + + // Dauer aus der Startzeit des aktuellen Jobs ableiten. + job := trainingGetJobStatus() + if startedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(job.StartedAt)); err == nil { + if ms := time.Now().UTC().Sub(startedAt).Milliseconds(); ms > 0 { + entry.DurationMs = ms + } + } + + b, err := json.Marshal(entry) + if err != nil { + return + } + + path := trainingHistoryPath(root) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + appLogln("⚠️ training history dir konnte nicht erstellt werden:", err) + return + } + + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + appLogln("⚠️ training history konnte nicht geöffnet werden:", err) + return + } + defer f.Close() + + if _, err := f.Write(append(b, '\n')); err != nil { + appLogln("⚠️ training history konnte nicht geschrieben werden:", err) + } +} + +// trainingReadHistory liest die Verlaufseinträge, neueste zuerst. +func trainingReadHistory(root string, limit int) []TrainingHistoryEntry { + b, err := os.ReadFile(trainingHistoryPath(root)) + if err != nil { + return nil + } + + lines := strings.Split(string(b), "\n") + entries := make([]TrainingHistoryEntry, 0, len(lines)) + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + var e TrainingHistoryEntry + if json.Unmarshal([]byte(line), &e) != nil { + continue + } + + entries = append(entries, e) + } + + // Datei ist chronologisch → umdrehen, damit der neueste Lauf oben steht. + for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { + entries[i], entries[j] = entries[j], entries[i] + } + + if limit > 0 && len(entries) > limit { + entries = entries[:limit] + } + + return entries +} + +func trainingHistoryHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + root, err := trainingRootDir() + if err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + entries := trainingReadHistory(root, 50) + if entries == nil { + entries = []TrainingHistoryEntry{} + } + + trainingWriteJSON(w, http.StatusOK, TrainingHistoryResponse{ + OK: true, + Entries: entries, + }) +} + func trainingConfidenceFromScore(score float64) TrainingConfidence { if math.IsNaN(score) || math.IsInf(score, 0) { score = 0 diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index baa4b27..1e44d94 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -68,6 +68,8 @@ type TrainingJobStatus = { epoch?: number epochs?: number previewUrl?: string + map50?: number + map5095?: number } type TrainingPrediction = { @@ -165,6 +167,31 @@ type TrainingLabelStat = { confidence?: TrainingConfidence } +type TrainingModelInfo = { + trainedAt?: string + trainedAtMs?: number + epochs?: number + trainSamples?: number + valSamples?: number + imgsz?: number + device?: string + map50?: number + map5095?: number +} + +type TrainingHistoryEntry = { + trainedAt?: string + trainedAtMs?: number + durationMs?: number + epochs?: number + trainSamples?: number + valSamples?: number + imgsz?: number + device?: string + map50?: number + map5095?: number +} + type TrainingStats = { feedbackCount: number acceptedCount: number @@ -173,6 +200,7 @@ type TrainingStats = { sampleCount: number boxCount: number modelAvailable: boolean + modelInfo?: TrainingModelInfo confidence?: TrainingConfidence labels: { people: TrainingLabelStat[] @@ -252,6 +280,54 @@ function confidenceLabel(confidence?: TrainingConfidence | null) { return confidence?.label || 'Keine' } +function formatModelTrainedAt(info?: TrainingModelInfo): string { + if (!info) return '' + + const ms = Number(info.trainedAtMs) + const date = + Number.isFinite(ms) && ms > 0 + ? new Date(ms) + : info.trainedAt + ? new Date(info.trainedAt) + : null + + if (!date || Number.isNaN(date.getTime())) return '' + + return date.toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +function formatMapPercent(value?: number | null): string { + const n = Number(value) + if (!Number.isFinite(n) || n <= 0) return '' + return `${(n * 100).toFixed(1)}%` +} + +function formatHistoryDate(entry: TrainingHistoryEntry): string { + const ms = Number(entry.trainedAtMs) + const date = + Number.isFinite(ms) && ms > 0 + ? new Date(ms) + : entry.trainedAt + ? new Date(entry.trainedAt) + : null + + if (!date || Number.isNaN(date.getTime())) return '—' + + return date.toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + function confidencePillClass(confidence?: TrainingConfidence | null) { switch (confidence?.level) { case 'high': @@ -780,6 +856,7 @@ function TrainingStageOverlay(props: { progress?: number backgroundUrl?: string visible?: boolean + instantBackground?: boolean }) { const progress = clampPercent(props.progress ?? 0) const isTraining = props.mode === 'training' @@ -819,15 +896,21 @@ function TrainingStageOverlay(props: {