clawport-ui 0.6.6 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,26 +2,12 @@
2
2
 
3
3
  import { useCallback, useEffect, useState } from 'react'
4
4
  import type { LogEntry, LogFilter, LogSummary } from '@/lib/types'
5
+ import { timeAgo } from '@/lib/cron-utils'
5
6
  import { Skeleton } from '@/components/ui/skeleton'
6
7
  import { RefreshCw, Radio } from 'lucide-react'
7
8
  import { ErrorState } from '@/components/ErrorState'
8
9
  import { LogBrowser } from '@/components/activity/LogBrowser'
9
10
 
10
- /* ── Time helpers ──────────────────────────────────────────────── */
11
-
12
- function timeAgo(dateStr: string): string {
13
- const d = new Date(dateStr)
14
- if (isNaN(d.getTime())) return '--'
15
- const diff = Date.now() - d.getTime()
16
- const mins = Math.floor(diff / 60000)
17
- const hrs = Math.floor(diff / 3600000)
18
- const days = Math.floor(diff / 86400000)
19
- if (mins < 1) return 'just now'
20
- if (mins < 60) return `${mins}m ago`
21
- if (hrs < 24) return `${hrs}h ago`
22
- return `${days}d ago`
23
- }
24
-
25
11
  /* ── Summary Cards ─────────────────────────────────────────────── */
26
12
 
27
13
  function TotalCard({ count }: { count: number }) {
@@ -201,7 +187,7 @@ export default function ActivityPage() {
201
187
  }}
202
188
  >
203
189
  <Radio size={14} />
204
- Open Live Stream
190
+ Open Live Logs
205
191
  </button>
206
192
 
207
193
  <span style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)' }}>
@@ -0,0 +1,53 @@
1
+ import { NextResponse } from "next/server"
2
+ import { existsSync, mkdirSync, writeFileSync } from "fs"
3
+ import { join } from "path"
4
+ import { loadPipelines } from "@/lib/cron-pipelines.server"
5
+
6
+ export async function GET() {
7
+ return NextResponse.json(loadPipelines())
8
+ }
9
+
10
+ export async function POST(req: Request) {
11
+ const workspacePath = process.env.WORKSPACE_PATH
12
+ if (!workspacePath) {
13
+ return NextResponse.json({ error: "WORKSPACE_PATH not set" }, { status: 500 })
14
+ }
15
+
16
+ const body = await req.json()
17
+
18
+ // Validate: must be array
19
+ if (!Array.isArray(body)) {
20
+ return NextResponse.json({ error: "Must be an array" }, { status: 400 })
21
+ }
22
+
23
+ // Validate each pipeline
24
+ for (let i = 0; i < body.length; i++) {
25
+ const p = body[i]
26
+ if (typeof p.name !== "string" || !p.name.trim()) {
27
+ return NextResponse.json({ error: `Pipeline ${i}: missing "name" string` }, { status: 400 })
28
+ }
29
+ if (!Array.isArray(p.edges)) {
30
+ return NextResponse.json({ error: `Pipeline "${p.name}": missing "edges" array` }, { status: 400 })
31
+ }
32
+ for (let j = 0; j < p.edges.length; j++) {
33
+ const e = p.edges[j]
34
+ if (typeof e.from !== "string" || typeof e.to !== "string" || typeof e.artifact !== "string") {
35
+ return NextResponse.json(
36
+ { error: `Pipeline "${p.name}", edge ${j}: requires "from", "to", and "artifact" strings` },
37
+ { status: 400 },
38
+ )
39
+ }
40
+ }
41
+ }
42
+
43
+ // Ensure clawport/ dir exists
44
+ const dir = join(workspacePath, "clawport")
45
+ if (!existsSync(dir)) {
46
+ mkdirSync(dir, { recursive: true })
47
+ }
48
+
49
+ // Write pipelines.json
50
+ writeFileSync(join(dir, "pipelines.json"), JSON.stringify(body, null, 2) + "\n", "utf-8")
51
+
52
+ return NextResponse.json({ ok: true })
53
+ }
@@ -0,0 +1,75 @@
1
+ import { fetchClaudeCodeUsage } from '@/lib/claude-usage'
2
+
3
+ const POLL_INTERVAL_MS = 60 * 1000 // 60 seconds
4
+ const HEARTBEAT_INTERVAL_MS = 15 * 1000
5
+ const MAX_LIFETIME_MS = 30 * 60 * 1000 // 30 minutes
6
+
7
+ export async function GET(request: Request) {
8
+ const encoder = new TextEncoder()
9
+
10
+ const stream = new ReadableStream({
11
+ start(controller) {
12
+ let poll: ReturnType<typeof setInterval> | null = null
13
+ let heartbeat: ReturnType<typeof setInterval> | null = null
14
+ let lifetime: ReturnType<typeof setTimeout> | null = null
15
+
16
+ function send(data: string) {
17
+ try {
18
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`))
19
+ } catch { /* controller closed */ }
20
+ }
21
+
22
+ let lastGood: Awaited<ReturnType<typeof fetchClaudeCodeUsage>> = null
23
+
24
+ async function tick() {
25
+ try {
26
+ const usage = await fetchClaudeCodeUsage()
27
+ if (usage) lastGood = usage
28
+ send(JSON.stringify({ type: 'usage', data: usage ?? lastGood }))
29
+ } catch {
30
+ send(JSON.stringify({ type: 'usage', data: lastGood }))
31
+ }
32
+ }
33
+
34
+ // Immediate first fetch
35
+ tick()
36
+
37
+ // Poll every 60s
38
+ poll = setInterval(tick, POLL_INTERVAL_MS)
39
+
40
+ // Heartbeat to prevent proxy timeouts
41
+ heartbeat = setInterval(() => {
42
+ try {
43
+ controller.enqueue(encoder.encode(`: heartbeat\n\n`))
44
+ } catch { /* closed */ }
45
+ }, HEARTBEAT_INTERVAL_MS)
46
+
47
+ // Max lifetime safety valve
48
+ lifetime = setTimeout(() => {
49
+ cleanup()
50
+ try { controller.close() } catch { /* already closed */ }
51
+ }, MAX_LIFETIME_MS)
52
+
53
+ // Cleanup on client disconnect
54
+ request.signal.addEventListener('abort', () => {
55
+ cleanup()
56
+ try { controller.close() } catch { /* already closed */ }
57
+ })
58
+
59
+ function cleanup() {
60
+ if (poll) { clearInterval(poll); poll = null }
61
+ if (heartbeat) { clearInterval(heartbeat); heartbeat = null }
62
+ if (lifetime) { clearTimeout(lifetime); lifetime = null }
63
+ }
64
+ },
65
+ })
66
+
67
+ return new Response(stream, {
68
+ headers: {
69
+ 'Content-Type': 'text/event-stream',
70
+ 'Cache-Control': 'no-cache, no-transform',
71
+ 'Connection': 'keep-alive',
72
+ 'X-Accel-Buffering': 'no',
73
+ },
74
+ })
75
+ }
@@ -4,51 +4,14 @@ import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import Link from "next/link";
5
5
  import type { Agent, CronJob, CronRun } from "@/lib/types";
6
6
  import type { Pipeline } from "@/lib/cron-pipelines";
7
- import { formatDuration } from "@/lib/cron-utils";
7
+ import { formatDuration, timeAgo, nextRunLabel } from "@/lib/cron-utils";
8
8
  import { Skeleton } from "@/components/ui/skeleton";
9
9
  import { RefreshCw, BarChart3, Calendar, GitBranch, Copy, Check } from "lucide-react";
10
10
  import { ErrorState } from "@/components/ErrorState";
11
11
  import { WeeklySchedule } from "@/components/crons/WeeklySchedule";
12
12
  import { PipelineGraph } from "@/components/crons/PipelineGraph";
13
-
14
- /* ─── Time helpers ──────────────────────────────────────────────── */
15
-
16
- function timeAgo(dateStr: string | null): string {
17
- if (!dateStr) return "never";
18
- const d = new Date(dateStr);
19
- if (isNaN(d.getTime())) return "\u2014";
20
- const diff = Date.now() - d.getTime();
21
- const mins = Math.floor(diff / 60000);
22
- const hrs = Math.floor(diff / 3600000);
23
- const days = Math.floor(diff / 86400000);
24
- if (diff < 0) {
25
- const absDiff = Math.abs(diff);
26
- const m = Math.floor(absDiff / 60000);
27
- const h = Math.floor(absDiff / 3600000);
28
- const dy = Math.floor(absDiff / 86400000);
29
- if (m < 60) return `in ${m}m`;
30
- if (h < 24) return `in ${h}h`;
31
- return `in ${dy}d`;
32
- }
33
- if (mins < 1) return "just now";
34
- if (mins < 60) return `${mins}m ago`;
35
- if (hrs < 24) return `${hrs}h ago`;
36
- return `${days}d ago`;
37
- }
38
-
39
- function nextRunLabel(dateStr: string | null): string {
40
- if (!dateStr) return "not scheduled";
41
- const d = new Date(dateStr);
42
- if (isNaN(d.getTime())) return "\u2014";
43
- const diff = d.getTime() - Date.now();
44
- if (diff < 0) return "overdue";
45
- const mins = Math.floor(diff / 60000);
46
- const hrs = Math.floor(diff / 3600000);
47
- const days = Math.floor(diff / 86400000);
48
- if (mins < 60) return `in ${mins}m`;
49
- if (hrs < 24) return `in ${hrs}h`;
50
- return `in ${days}d`;
51
- }
13
+ import { PipelineDetailPanel } from "@/components/crons/PipelineDetailPanel";
14
+ import { PipelineWizard } from "@/components/crons/PipelineWizard";
52
15
 
53
16
  /* ─── Types ─────────────────────────────────────────────────────── */
54
17
 
@@ -439,6 +402,8 @@ export default function CronsPage() {
439
402
  const [error, setError] = useState<string | null>(null);
440
403
  const [updatedAgo, setUpdatedAgo] = useState("just now");
441
404
  const [copiedId, setCopiedId] = useState<string | null>(null);
405
+ const [wizardOpen, setWizardOpen] = useState(false);
406
+ const [selectedPipelineJob, setSelectedPipelineJob] = useState<string | null>(null);
442
407
 
443
408
  const pillsRef = useRef<HTMLDivElement>(null);
444
409
 
@@ -862,11 +827,46 @@ export default function CronsPage() {
862
827
  {tab === "schedule" && <WeeklySchedule crons={crons} />}
863
828
 
864
829
  {/* ─── PIPELINES TAB ─────────────────────────────── */}
865
- {tab === "pipelines" && <PipelineGraph crons={crons} agents={agents} pipelines={pipelines} />}
830
+ {tab === "pipelines" && (
831
+ <PipelineGraph
832
+ crons={crons}
833
+ agents={agents}
834
+ pipelines={pipelines}
835
+ onSetupClick={() => setWizardOpen(true)}
836
+ onEditClick={() => setWizardOpen(true)}
837
+ onClearClick={() => {
838
+ fetch("/api/pipelines", {
839
+ method: "POST",
840
+ headers: { "Content-Type": "application/json" },
841
+ body: JSON.stringify([]),
842
+ }).then(() => refresh())
843
+ }}
844
+ onJobSelect={setSelectedPipelineJob}
845
+ selectedJob={selectedPipelineJob}
846
+ />
847
+ )}
866
848
  </>
867
849
  )}
868
850
  </div>
869
851
 
852
+ <PipelineWizard
853
+ open={wizardOpen}
854
+ onOpenChange={setWizardOpen}
855
+ agents={agents}
856
+ crons={crons}
857
+ onSaved={refresh}
858
+ />
859
+
860
+ {selectedPipelineJob && (
861
+ <PipelineDetailPanel
862
+ jobName={selectedPipelineJob}
863
+ crons={crons}
864
+ agents={agents}
865
+ pipelines={pipelines}
866
+ onClose={() => setSelectedPipelineJob(null)}
867
+ />
868
+ )}
869
+
870
870
  <style>{`
871
871
  @media (max-width: 640px) {
872
872
  .summary-cards-grid {
@@ -19,6 +19,7 @@ import {
19
19
  BookOpen,
20
20
  } from "lucide-react";
21
21
  import { renderMarkdown, colorizeJson } from "@/lib/sanitize";
22
+ import { timeAgo } from "@/lib/cron-utils";
22
23
  import { Skeleton } from "@/components/ui/skeleton";
23
24
  import { ErrorState } from "@/components/ErrorState";
24
25
 
@@ -35,17 +36,6 @@ const TABS: { key: Tab; label: string; Icon: typeof BarChart3 }[] = [
35
36
 
36
37
  /* ─── Helpers ────────────────────────────────────────────────── */
37
38
 
38
- function timeAgo(dateStr: string): string {
39
- const diff = Date.now() - new Date(dateStr).getTime();
40
- const mins = Math.floor(diff / 60000);
41
- const hrs = Math.floor(diff / 3600000);
42
- const days = Math.floor(diff / 86400000);
43
- if (mins < 1) return "just now";
44
- if (mins < 60) return `${mins}m ago`;
45
- if (hrs < 24) return `${hrs}h ago`;
46
- return `${days}d ago`;
47
- }
48
-
49
39
  function formatBytes(bytes: number): string {
50
40
  if (bytes < 1024) return `${bytes}B`;
51
41
  const kb = bytes / 1024;
package/app/providers.tsx CHANGED
@@ -18,13 +18,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
18
18
  setThemeState(t);
19
19
  localStorage.setItem('clawport-theme', t);
20
20
  const html = document.documentElement;
21
- html.removeAttribute('data-theme');
22
- if (t === 'system') {
23
- const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
24
- html.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
25
- } else {
26
- html.setAttribute('data-theme', t);
27
- }
21
+ html.setAttribute('data-theme', t);
28
22
  }
29
23
 
30
24
  return (