clawport-ui 0.6.7 → 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.
- package/app/activity/page.tsx +2 -16
- package/app/api/pipelines/route.ts +53 -0
- package/app/api/usage/stream/route.ts +75 -0
- package/app/crons/page.tsx +41 -41
- package/app/memory/page.tsx +1 -11
- package/app/providers.tsx +1 -7
- package/components/LiveStreamWidget.tsx +186 -112
- package/components/MobileSidebar.tsx +2 -1
- package/components/NavLinks.tsx +52 -48
- package/components/Sidebar.tsx +2 -1
- package/components/costs/ClaudeUsageRow.tsx +131 -0
- package/components/costs/CostsPage.tsx +553 -409
- package/components/costs/DailyCostChart.tsx +117 -0
- package/components/costs/OptimizationPanel.tsx +82 -0
- package/components/costs/RunDetailTable.tsx +108 -0
- package/components/costs/SummaryCard.tsx +18 -0
- package/components/costs/TokenDonut.tsx +87 -0
- package/components/costs/TopCrons.tsx +60 -0
- package/components/costs/formatters.ts +20 -0
- package/components/crons/PipelineDetailPanel.tsx +683 -0
- package/components/crons/PipelineGraph.tsx +689 -138
- package/components/crons/PipelineWizard.tsx +771 -0
- package/components/sidebar/SidebarUsageWidget.tsx +150 -0
- package/lib/claude-usage.test.ts +190 -0
- package/lib/claude-usage.ts +99 -0
- package/lib/costs.test.ts +203 -6
- package/lib/costs.ts +297 -5
- package/lib/cron-utils.test.ts +127 -2
- package/lib/cron-utils.ts +45 -0
- package/lib/pipeline-utils.test.ts +563 -0
- package/lib/pipeline-utils.ts +296 -0
- package/lib/themes.ts +1 -2
- package/lib/types.ts +26 -0
- package/package.json +1 -1
package/app/activity/page.tsx
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/app/crons/page.tsx
CHANGED
|
@@ -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
|
-
|
|
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" &&
|
|
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 {
|
package/app/memory/page.tsx
CHANGED
|
@@ -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.
|
|
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 (
|