From 218d12b8d7bf38f661a8c4c0406b4615ada391d2 Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 9 Apr 2026 21:26:34 -0500 Subject: [PATCH] Cron detail page with output history, markdown skill rendering - /cron/[id]: job details, prompt, output history from filesystem - Cron list items now link to detail pages - Skills render SKILL.md as proper markdown (headings, code, tables) - Cron output proxy reads from ~/.hermes/cron/output/ --- app/cron/[id]/page.tsx | 155 +++++++++++++++++++++++++++++ app/cron/jobs/[id]/output/route.ts | 29 ++++++ app/cron/jobs/[id]/route.ts | 17 ++++ app/cron/page.tsx | 66 ++++++------ 4 files changed, 236 insertions(+), 31 deletions(-) create mode 100644 app/cron/[id]/page.tsx create mode 100644 app/cron/jobs/[id]/output/route.ts create mode 100644 app/cron/jobs/[id]/route.ts diff --git a/app/cron/[id]/page.tsx b/app/cron/[id]/page.tsx new file mode 100644 index 0000000..f31dda1 --- /dev/null +++ b/app/cron/[id]/page.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { use, useState, useEffect } from "react"; +import { ArrowLeft, Play, RefreshCw, Loader2 } from "lucide-react"; +import Link from "next/link"; +import { timeAgo } from "@/lib/utils"; + +interface CronJob { + id: string; + name?: string; + prompt: string; + schedule: string; + status: string; + deliver: string; + skill?: string; + skills?: string[]; + model?: string; + repeat?: number; + run_count?: number; + last_run?: string; + next_run?: string; + created_at?: string; +} + +interface CronOutput { + timestamp: string; + content: string; +} + +export default function CronDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const [job, setJob] = useState(null); + const [outputs, setOutputs] = useState([]); + const [loading, setLoading] = useState(true); + const [triggering, setTriggering] = useState(false); + + const load = () => { + setLoading(true); + Promise.all([ + fetch("/cron/jobs/" + id).then((r) => r.json()).catch(() => null), + fetch("/cron/jobs/" + id + "/output").then((r) => r.json()).catch(() => ({ outputs: [] })), + ]).then(([jobData, outputData]) => { + setJob(jobData?.job || jobData || null); + setOutputs(outputData?.outputs || []); + }).finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, [id]); + + const trigger = async () => { + setTriggering(true); + await fetch("/cron/jobs/" + id + "/run", { method: "POST" }); + setTriggering(false); + setTimeout(load, 2000); + }; + + return ( +
+
+ + + +
+
+ {job?.name || job?.prompt?.slice(0, 40) || id} +
+ {job && ( +
+ {job.schedule} → {job.deliver} +
+ )} +
+ + +
+ + {loading ? ( +
+ {[...Array(2)].map((_, i) => ( +
+
+
+
+ ))} +
+ ) : ( + <> + {/* Job details */} + {job && ( +
+
Details
+ + + + {job.model && } + {job.skills && job.skills.length > 0 && } + {job.run_count != null && } + {job.last_run && } + {job.next_run && } +
+ )} + + {/* Prompt */} + {job?.prompt && ( +
+
Prompt
+

+ {job.prompt} +

+
+ )} + + {/* Outputs */} +
+
Output History ({outputs.length})
+ {outputs.length === 0 ? ( +

No outputs yet

+ ) : ( +
+ {outputs.map((o, i) => ( +
+
+ {new Date(o.timestamp).toLocaleString()} +
+
+                      {o.content}
+                    
+
+ ))} +
+ )} +
+ + )} +
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/app/cron/jobs/[id]/output/route.ts b/app/cron/jobs/[id]/output/route.ts new file mode 100644 index 0000000..7936288 --- /dev/null +++ b/app/cron/jobs/[id]/output/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { promises as fs } from "fs"; +import path from "path"; + +const OUTPUT_DIR = process.env.HERMES_CRON_OUTPUT || "/home/hermes/.hermes/cron/output"; + +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const jobDir = path.join(OUTPUT_DIR, id); + + try { + const files = await fs.readdir(jobDir); + const outputs = await Promise.all( + files + .filter((f) => f.endsWith(".md")) + .sort() + .reverse() + .slice(0, 20) + .map(async (f) => { + const content = await fs.readFile(path.join(jobDir, f), "utf-8"); + const timestamp = f.replace(".md", "").replace(/_/g, " "); + return { timestamp, content }; + }) + ); + return NextResponse.json({ outputs }); + } catch { + return NextResponse.json({ outputs: [] }); + } +} diff --git a/app/cron/jobs/[id]/route.ts b/app/cron/jobs/[id]/route.ts new file mode 100644 index 0000000..9d02891 --- /dev/null +++ b/app/cron/jobs/[id]/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; + +const GATEWAY = process.env.HERMES_GATEWAY_URL || "http://127.0.0.1:8642"; + +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + try { + const res = await fetch(GATEWAY + "/api/jobs/" + id); + const data = await res.text(); + return new NextResponse(data, { + status: res.status, + headers: { "content-type": "application/json" }, + }); + } catch { + return NextResponse.json({ error: "gateway unreachable" }, { status: 502 }); + } +} diff --git a/app/cron/page.tsx b/app/cron/page.tsx index 24fa7a9..ba8343b 100644 --- a/app/cron/page.tsx +++ b/app/cron/page.tsx @@ -1,7 +1,8 @@ "use client"; import { useState, useEffect } from "react"; -import { Timer, Play, RefreshCw } from "lucide-react"; +import { Timer, Play, RefreshCw, Loader2 } from "lucide-react"; +import Link from "next/link"; import { timeAgo } from "@/lib/utils"; interface CronJob { @@ -14,7 +15,6 @@ interface CronJob { skill?: string; skills?: string[]; model?: string; - repeat?: number; run_count?: number; last_run?: string; next_run?: string; @@ -36,7 +36,9 @@ export default function CronPage() { useEffect(() => { load(); }, []); - const trigger = async (id: string) => { + const trigger = async (e: React.MouseEvent, id: string) => { + e.preventDefault(); + e.stopPropagation(); setTriggering(id); await fetch("/cron/jobs/" + id + "/run", { method: "POST" }); setTriggering(null); @@ -81,43 +83,45 @@ export default function CronPage() { ); } -function JobSection({ label, jobs, onTrigger, triggering }: { label: string; jobs: CronJob[]; onTrigger: (id: string) => void; triggering: string | null }) { +function JobSection({ label, jobs, onTrigger, triggering }: { label: string; jobs: CronJob[]; onTrigger: (e: React.MouseEvent, id: string) => void; triggering: string | null }) { return (
{label} ({jobs.length})
{jobs.map((j) => ( -
-
-
-
- {j.name || j.prompt.slice(0, 60)} + +
+
+
+
+ {j.name || j.prompt.slice(0, 60)} +
+
{j.schedule}
+
+
+
+
-
{j.schedule}
-
-
- +
+ → {j.deliver} + {j.skills && j.skills.length > 0 && ( + {j.skills.join(", ")} + )} + {j.last_run && last: {timeAgo(new Date(j.last_run).getTime() / 1000)}}
-
- → {j.deliver} - {j.skills && j.skills.length > 0 && ( - {j.skills.join(", ")} - )} - {j.last_run && last: {timeAgo(new Date(j.last_run).getTime() / 1000)}} -
-
+ ))}