MC2: Live-Log-Blick - Worker-Log laufender Ideen im Auftragsbuch aufklappbar machen

Backend:
- backend/services/ideen.py: neue Funktion log_of(task_id)
  - ruft 'hermes kanban log <id>'
  - validiert task_id mit _TASK_ID_RX
  - strippt ANSI-Steuerzeichen (\x1b\[[0-9;]*m)
  - gibt letzte ~120 Zeilen zurück
  - kurzer Cache (~4s)
  - auf Windows/available=False leer zurück, nie crashen
- backend/routers/ideen.py: GET /api/ideen/{id}/log -> ideen.log_of(id)

Frontend:
- frontend/src/views/AuftragsbuchView.tsx: IdeenSection
  - Ideen mit status 'triage' oder 'running' per Klick aufklappbar
  - im aufgeklappten Zustand /api/ideen/{id}/log alle ~4s pollen
  - Log monospace/<pre> in scrollbaren, höhenbegrenztem Kasten
  - vorhandenes api()-Muster + react-query nutzen
- frontend/dist mitgebaut und committet

Branch: feature/live-log-blick
This commit is contained in:
werkstatt
2026-07-13 17:17:16 +02:00
parent 1427048f38
commit 9a41cce99b
26 changed files with 161 additions and 63 deletions
+7
View File
@@ -494,6 +494,10 @@ export interface IdeenItem {
fertig: number | null
von: string | null
}
export interface IdeenLogResp {
available: boolean
lines: string[]
}
export interface IdeenResp {
available: boolean
items: IdeenItem[]
@@ -588,3 +592,6 @@ export interface HermesBrainResp {
update_available: boolean
budget?: HermesBrainBudget | null
}
// ── Ideen-Log (Live Worker Log) ──────────────────────────────────────────────
export const getIdeenLog = (id: string) => api<IdeenLogResp>(`/api/ideen/${id}/log`)
+11
View File
@@ -15,6 +15,7 @@ import {
type GroupsResp,
type HermesBrainResp,
type Health,
type IdeenLogResp,
type IdeenResp,
type Job,
type Memory,
@@ -91,6 +92,16 @@ export const useIdeen = (refetchInterval: number = TAKT.gemuetlich) =>
refetchInterval,
})
// Ideen-Log (Live Worker Log): pollt nur solange Idee triage/running UND offen
export const useIdeenLog = (id: string, enabled: boolean = false) =>
useQuery({
queryKey: ["ideen-log", id],
queryFn: () => api<IdeenLogResp>(`/api/ideen/${id}/log`),
enabled,
refetchInterval: 4000, // alle ~4s
staleTime: 0,
})
export const useChronik = (limit = 150, refetchInterval: number = TAKT.gemuetlich) =>
useQuery({
queryKey: qk.chronik,
+51 -17
View File
@@ -4,7 +4,7 @@ import {
ChevronDown, ChevronRight, Clock, Hammer, RefreshCw, Lightbulb, Send,
} from "lucide-react"
import { api, type AuftragItem, type IdeenItem, type SkillKandidat } from "@/lib/api"
import { useAuftragsbuch, useIdeen, useQueryClient, qk } from "@/lib/queries"
import { useAuftragsbuch, useIdeen, useIdeenLog, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils"
@@ -153,10 +153,15 @@ function IdeenSection() {
const [text, setText] = useState("")
const [sending, setSending] = useState(false)
const [busy, setBusy] = useState<Record<string, boolean>>({})
const [openLog, setOpenLog] = useState<string | null>(null)
const items = data?.items ?? []
const reload = () => qc.invalidateQueries({ queryKey: qk.ideen })
// Polling-Log nur aktivieren, wenn Idee triage/running und geöffnet
const logEnabled = openLog !== null && (data?.items.find((i) => i.id === openLog)?.status === "triage" || data?.items.find((i) => i.id === openLog)?.status === "running")
const logData = useIdeenLog(openLog ?? "", logEnabled)
async function submit() {
const t = text.trim()
if (t.length < 3 || sending) return
@@ -213,7 +218,7 @@ function IdeenSection() {
</div>
<p className="text-[11px] text-muted-foreground">
Eine Queue, alle Türen: hier, per Telegram oder im Gespräch mit Lucy. Die Box arbeitet Ideen selbst
aus und baut Code-Ergebnisse erscheinen als Karte unter Fertige Patches", nichts davon geht ohne
aus und baut Code-Ergebnisse erscheinen als Karte unter Fertige Patches, nichts davon geht ohne
deinen Klick live. Nur reine Doku-Bagatellen zieht die Box nachts selbst ein (mit Fangnetz und Meldung).
</p>
@@ -228,22 +233,51 @@ function IdeenSection() {
<ul className="space-y-1.5">
{items.map((it: IdeenItem) => {
const ui = IDEEN_STATE[it.status] ?? { label: it.status, cls: "text-muted-foreground border-border/60 bg-background/30" }
const isOpen = openLog === it.id
const canOpen = it.status === "triage" || it.status === "running"
return (
<li key={it.id} className="flex items-center gap-2 rounded-xl border border-border/40 bg-background/30 px-3 py-2">
<span className={cn("flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)}>
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
{ui.label}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-foreground" title={it.body || it.titel}>{it.titel}</span>
<span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(it.erstellt)}</span>
{(it.status === "done" || it.status === "blocked") && (
<button
disabled={!!busy[it.id]}
onClick={() => archive(it.id)}
title="Aus der Liste räumen (wandert ins Kanban-Archiv)"
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
{busy[it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
</button>
<li key={it.id} className="flex flex-col gap-2 rounded-xl border border-border/40 bg-background/30 px-3 py-2">
<div className="flex items-center gap-2">
<span className={cn("flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)}>
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
{ui.label}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-foreground" title={it.body || it.titel}>{it.titel}</span>
<span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(it.erstellt)}</span>
{(it.status === "done" || it.status === "blocked") && (
<button
disabled={!!busy[it.id]}
onClick={() => archive(it.id)}
title="Aus der Liste räumen (wandert ins Kanban-Archiv)"
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
{busy[it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
</button>
)}
{canOpen && (
<button
onClick={() => setOpenLog(isOpen ? null : it.id)}
title="Worker-Log anzeigen"
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer">
{isOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
)}
</div>
{isOpen && canOpen && (
<div className="ml-2 -mt-1">
<div className="rounded-lg border border-border/50 bg-background/50 p-2">
{logEnabled && logData.isLoading ? (
<p className="text-[10px] text-muted-foreground animate-pulse">Log wird geladen </p>
) : logData.error ? (
<p className="text-[10px] text-red-400">Log nicht ladbar</p>
) : logData.data?.available && logData.data.lines.length > 0 ? (
<pre className="max-h-64 overflow-auto font-mono text-[10px] leading-relaxed text-foreground/80 whitespace-pre-wrap">
{logData.data.lines.join("\n")}
</pre>
) : (
<p className="text-[10px] text-muted-foreground">Kein Log verfügbar</p>
)}
</div>
</div>
)}
</li>
)