diff --git a/src/commands/help.ts b/src/commands/help.ts new file mode 100644 index 0000000..6641a89 --- /dev/null +++ b/src/commands/help.ts @@ -0,0 +1,33 @@ +import packageJson from "../../package.json"; + +export function showHelp() { + console.log(`cftop v${packageJson.version} +Terminal UI for viewing and monitoring Cloudflare Workers + +USAGE: + cftop [options] + +COMMANDS: + start Start the interactive terminal UI + init Initialize configuration + help Show this help message + version Show version number + config Show current configuration + test-observability Test observability API connection + +OPTIONS: + -h, --help Show this help message + -t, --apiToken Cloudflare API token + -a, --accountId Cloudflare account ID + --accessKey R2 access key ID (optional) + --secretKey R2 secret access key (optional) + +EXAMPLES: + cftop init --apiToken= --accountId= + cftop start + cftop help + cftop --help + +For more information, visit: https://github.com/NWBY/cftop +`); +} diff --git a/src/commands/start.tsx b/src/commands/start.tsx new file mode 100644 index 0000000..6a05393 --- /dev/null +++ b/src/commands/start.tsx @@ -0,0 +1,404 @@ +import { render, useKeyboard, useRenderer } from "@opentui/react"; +import type { DatabaseListResponse } from "cloudflare/resources/d1.mjs"; +import type { Namespace } from "cloudflare/resources/durable-objects.mjs"; +import type { DurableObject } from "cloudflare/resources/durable-objects/namespaces.mjs"; +import type { Domain } from "cloudflare/resources/intel.mjs"; +import type { Script } from "cloudflare/resources/page-shield.mjs"; +import type { Queue } from "cloudflare/resources/queues/queues.mjs"; +import type { Bucket } from "cloudflare/resources/r2.mjs"; +import { useState, useEffect, useRef } from "react"; +import { CloudflareAPI } from "../api"; +import { + getD1Databases, + getDomains, + getDurableObjects, + getKVNamespaces, + getQueues, + getR2Buckets, + getWorkers, + runObservabilityQuery, +} from "../cf"; +import Keybindings from "../components/keybindings"; +import { configExists, getConfig } from "../config"; +import type { WorkerSummary } from "../types"; +import HomeView from "../views/home"; +import { CheckForUpdate } from "../components/utils/check-for-update"; +import SingleD1DatabaseView from "../views/d1/single"; +import SingleQueueView from "../views/queues/single"; +import SingleR2BucketView from "../views/r2/single"; +import SingleWorkerView from "../views/workers/single"; +import packageJson from "../../package.json"; + +export async function start() { + const startConfigExistsResult = await configExists(); + + if (!startConfigExistsResult) { + console.error("Config not found, please run `cftop init`"); + process.exit(1); + } + + // user has called cftop start + function App() { + const views = [ + "home", + "single-worker", + "single-r2-bucket", + "single-d1-database", + "single-queue", + ]; + const panels = [ + "workers", + "durables", + "buckets", + "domains", + "queues", + "d1", + "kv", + ]; + const renderer = useRenderer(); + const [view, setView] = useState("home"); + const [workers, setWorkers] = useState([]); + const [durableObjects, setDurableObjects] = useState< + { + namespace: string; + objects: DurableObject[] | undefined; + }[] + >([]); + const [r2Buckets, setR2Buckets] = useState([]); + const [domains, setDomains] = useState([]); + const [queues, setQueues] = useState([]); + const [d1Databases, setD1Databases] = useState([]); + const [kvNamespaces, setKVNamespaces] = useState([]); + const [focussedSection, setFocussedSection] = useState("workers"); + const [focussedItem, setFocussedItem] = useState(""); + const [showFocussedItemLogs, setShowFocussedItemLogs] = + useState(false); + const [focussedItemLogs, setFocussedItemLogs] = useState([]); + const [metrics, setMetrics] = useState([]); + const [loading, setLoading] = useState(false); + + const { start, end } = CloudflareAPI.getTimeRange(24); + const nowTimestamp = Date.now(); + const startTimestamp = nowTimestamp - 24 * 60 * 60 * 1000; + const lastSuccessfulFocussedItemLogsTimestampRef = + useRef(startTimestamp); + const focussedItemRef = useRef(focussedItem); + + // Keep ref in sync with state + useEffect(() => { + focussedItemRef.current = focussedItem; + }, [focussedItem]); + + // Initial data fetch + useEffect(() => { + setLoading(true); + + const fetchAll = async () => { + await Promise.all([ + (async () => { + const workers = await getWorkers(); + setWorkers(workers); + })(), + (async () => { + const durableObjects = await getDurableObjects(); + setDurableObjects(durableObjects); + })(), + (async () => { + const config = await getConfig(); + const api = new CloudflareAPI({ + apiToken: config.apiToken, + accountTag: config.accountId, + }); + const metrics = await api.getWorkerSummary(start, end); + setMetrics(metrics); + })(), + (async () => { + const r2Buckets = await getR2Buckets(); + setR2Buckets(r2Buckets || []); + })(), + (async () => { + const domains = await getDomains(); + setDomains(domains || []); + })(), + (async () => { + const queues = await getQueues(); + setQueues(queues || []); + })(), + (async () => { + const d1Databases = await getD1Databases(); + setD1Databases(d1Databases || []); + })(), + (async () => { + const kvNamespaces = await getKVNamespaces(); + setKVNamespaces(kvNamespaces || []); + })(), + ]); + setLoading(false); + }; + + fetchAll(); + }, []); + + // Interval for fetching logs when in single-worker view + useEffect(() => { + if (view === "single-worker" && focussedItem) { + // Reset the timestamp when entering single-worker view to the current time + // This ensures we only fetch NEW logs from this point forward + lastSuccessfulFocussedItemLogsTimestampRef.current = Date.now(); + + const interval = setInterval(() => { + const nowTimestamp = Date.now(); + const fromTimestamp = + lastSuccessfulFocussedItemLogsTimestampRef.current; + const currentFocussedItem = focussedItemRef.current; + + runObservabilityQuery(currentFocussedItem, { + from: fromTimestamp, + to: nowTimestamp, + }) + .then((response) => { + if (response && Array.isArray(response) && response.length > 0) { + setFocussedItemLogs((prevLogs) => { + const updated = [...response, ...prevLogs]; + return updated; + }); + // Only update timestamp after successful fetch + lastSuccessfulFocussedItemLogsTimestampRef.current = + nowTimestamp; + } else { + // Still update timestamp to avoid fetching the same time range again + // lastFocussedItemLogsTimestampRef.current = nowTimestamp; + } + }) + .catch((error) => { + // Update timestamp even on error to avoid getting stuck + // lastFocussedItemLogsTimestampRef.current = nowTimestamp; + }); + }, 10000); // 10 seconds as requested + + return () => { + clearInterval(interval); + }; + } else { + // Reset logs when leaving single-worker view + setFocussedItemLogs([]); + } + }, [view, focussedItem]); + + useKeyboard((key) => { + if (key.name === "q") { + process.exit(0); + } + + if (key.name === "tab") { + setFocussedSection( + panels[ + (panels.indexOf(focussedSection) + 1) % panels.length + ] as string, + ); + } + + if (key.name === "h") { + setFocussedSection("workers"); + setFocussedItem(""); + setView("home"); + } + + if (key.name === "w") { + setFocussedSection("workers"); + setFocussedItem(workers[0]?.id || ""); + } + if (key.name === "d") { + setFocussedSection("durables"); + setFocussedItem(durableObjects[0]?.objects?.[0]?.id || ""); + } + if (key.name === "b") { + setFocussedSection("buckets"); + setFocussedItem(r2Buckets[0]?.name || ""); + } + if (key.name === "c") { + setFocussedSection("config"); + } + + if (key.ctrl && key.name === "k") { + renderer?.toggleDebugOverlay(); + renderer?.console.toggle(); + } + + if (key.name === "up") { + if (view === "home") { + if (focussedSection === "workers") { + const currentIndex = workers.findIndex( + (w) => w.id === focussedItem, + ); + const prevIndex = + currentIndex <= 0 ? workers.length - 1 : currentIndex - 1; + setFocussedItem(workers[prevIndex]?.id || ""); + } else if (focussedSection === "buckets") { + const currentIndex = r2Buckets.findIndex( + (b) => b.name === focussedItem, + ); + const prevIndex = + currentIndex <= 0 ? r2Buckets.length - 1 : currentIndex - 1; + setFocussedItem(r2Buckets[prevIndex]?.name || ""); + } else if (focussedSection === "d1") { + const currentIndex = d1Databases.findIndex( + (d) => d.uuid === focussedItem, + ); + console.log(currentIndex); + const prevIndex = + currentIndex <= 0 ? d1Databases.length - 1 : currentIndex - 1; + console.log(prevIndex); + console.log(d1Databases[prevIndex]?.uuid); + setFocussedItem(d1Databases[prevIndex]?.uuid || ""); + } else if (focussedSection === "queues") { + const currentIndex = queues.findIndex( + (q) => q.queue_id === focussedItem, + ); + const prevIndex = + currentIndex <= 0 ? queues.length - 1 : currentIndex - 1; + setFocussedItem(queues[prevIndex]?.queue_id || ""); + } else { + setFocussedItem(""); + } + } + } + if (key.name === "down") { + if (view === "home") { + if (focussedSection === "workers") { + const currentIndex = workers.findIndex( + (w) => w.id === focussedItem, + ); + const nextIndex = (currentIndex + 1) % workers.length; + setFocussedItem(workers[nextIndex]?.id || ""); + } else if (focussedSection === "buckets") { + const currentIndex = r2Buckets.findIndex( + (b) => b.name === focussedItem, + ); + const nextIndex = (currentIndex + 1) % r2Buckets.length; + setFocussedItem(r2Buckets[nextIndex]?.name || ""); + } else if (focussedSection === "d1") { + const currentIndex = d1Databases.findIndex( + (d) => d.uuid === focussedItem, + ); + const nextIndex = (currentIndex + 1) % d1Databases.length; + setFocussedItem(d1Databases[nextIndex]?.uuid || ""); + } else if (focussedSection === "queues") { + console.log("down in queues"); + const currentIndex = queues.findIndex( + (q) => q.queue_id === focussedItem, + ); + console.log(`queues current index: ${currentIndex}`); + const nextIndex = (currentIndex + 1) % queues.length; + console.log(`queues next index: ${nextIndex}`); + setFocussedItem(queues[nextIndex]?.queue_id || ""); + console.log(`queues focussed item: ${focussedItem}`); + } else { + setFocussedItem(""); + } + } + } + + if (key.name === "return") { + if (focussedSection === "workers") { + const worker = workers.find((w) => w.id === focussedItem); + if (worker) { + runObservabilityQuery(worker?.id || "", { + from: startTimestamp, + to: nowTimestamp, + }).then((response) => { + if (response && Array.isArray(response)) { + setFocussedItemLogs(response); + } else { + setFocussedItemLogs([]); + } + }); + setShowFocussedItemLogs(true); + setView("single-worker"); + } + } else if (focussedSection === "buckets") { + const bucket = r2Buckets.find((b) => b.name === focussedItem); + if (bucket) { + setView("single-r2-bucket"); + } + } else if (focussedSection === "d1") { + const database = d1Databases.find((d) => d.uuid === focussedItem); + if (database) { + setView("single-d1-database"); + } + } else if (focussedSection === "queues") { + const queue = queues.find((q) => q.queue_id === focussedItem); + if (queue) { + setView("single-queue"); + } + } + } + }); + + const dbName = d1Databases.find((d) => d.uuid === focussedItem)?.name || ""; + + let visibleView: React.ReactNode; + + if (view === "home") { + visibleView = ( + + ); + } else if (view === "single-worker") { + visibleView = ( + + ); + } else if (view === "single-r2-bucket") { + visibleView = ; + } else if (view === "single-d1-database") { + visibleView = ( + + ); + } else if (view === "single-queue") { + visibleView = ; + } + + return ( + + + + + + v{packageJson.version} + + + {loading ? ( + + Loading... + + ) : ( + visibleView + )} + + + + + ); + } + + render(); +} diff --git a/src/index.tsx b/src/index.tsx index 5dc9205..dcd0feb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,41 +1,17 @@ -import { TextAttributes } from "@opentui/core"; -import { render, useKeyboard, useRenderer } from "@opentui/react"; - +import Cloudflare from "cloudflare"; import { parseArgs } from "util"; -import { configExists, createConfig, getConfig } from "./config"; import packageJson from "../package.json"; -import { - getD1Databases, - getDomains, - getDurableObjects, - getKVNamespaces, - getQueues, - getR2Buckets, - getWorkers, - runObservabilityQuery, -} from "./cf"; -import { useEffect, useState, useRef } from "react"; -import type { Domain, Script } from "cloudflare/resources/workers.mjs"; -import { CloudflareAPI } from "./api"; -import type { WorkerSummary } from "./types"; -import type { DurableObject } from "cloudflare/resources/durable-objects/namespaces.mjs"; -import Cloudflare from "cloudflare"; -import WorkersBox from "./components/workers-box"; -import SingleWorkerView from "./views/workers/single"; -import HomeView from "./views/home"; -import Keybindings from "./components/keybindings"; -import type { Bucket } from "cloudflare/resources/r2.mjs"; -import SingleR2BucketView from "./views/r2/single"; -import type { Queue } from "cloudflare/resources/queues/queues.mjs"; -import type { DatabaseListResponse } from "cloudflare/resources/d1.mjs"; -import SingleD1DatabaseView from "./views/d1/single"; -import SingleQueueView from "./views/queues/single"; -import { CheckForUpdate } from "./components/utils/check-for-update"; -import type { Namespace } from "cloudflare/src/resources/kv.js"; +import { showHelp } from "./commands/help"; +import { start } from "./commands/start"; +import { configExists, createConfig, getConfig } from "./config"; const { values, positionals } = parseArgs({ args: Bun.argv, options: { + help: { + type: "boolean", + short: "h", + }, apiToken: { type: "string", short: "t", @@ -57,367 +33,27 @@ const { values, positionals } = parseArgs({ allowPositionals: true, }); -if (positionals.length == 2) { - const configExistsResult = await configExists(); - - if (!configExistsResult) { - console.error("Config not found, please run `cftop init`"); - process.exit(1); - } - - // user has just called cftop - function App() { - const views = [ - "home", - "single-worker", - "single-r2-bucket", - "single-d1-database", - "single-queue", - ]; - - const panels = [ - "workers", - "durables", - "buckets", - "domains", - "queues", - "d1", - "kv", - ]; - - const renderer = useRenderer(); - const [view, setView] = useState("home"); - const [workers, setWorkers] = useState([]); - const [durableObjects, setDurableObjects] = useState< - { - namespace: string; - objects: DurableObject[] | undefined; - }[] - >([]); - const [r2Buckets, setR2Buckets] = useState([]); - const [domains, setDomains] = useState([]); - const [queues, setQueues] = useState([]); - const [d1Databases, setD1Databases] = useState([]); - const [kvNamespaces, setKVNamespaces] = useState([]); - const [focussedSection, setFocussedSection] = useState("workers"); - const [focussedItem, setFocussedItem] = useState(""); - const [showFocussedItemLogs, setShowFocussedItemLogs] = useState(false); - const [focussedItemLogs, setFocussedItemLogs] = useState([]); - const [metrics, setMetrics] = useState([]); - const [loading, setLoading] = useState(false); - - const { start, end } = CloudflareAPI.getTimeRange(24); - const nowTimestamp = Date.now(); - const startTimestamp = nowTimestamp - 24 * 60 * 60 * 1000; - const lastSuccessfulFocussedItemLogsTimestampRef = useRef(startTimestamp); - const focussedItemRef = useRef(focussedItem); - - // Keep ref in sync with state - useEffect(() => { - focussedItemRef.current = focussedItem; - }, [focussedItem]); - - // Initial data fetch - useEffect(() => { - setLoading(true); - - const fetchAll = async () => { - await Promise.all([ - (async () => { - const workers = await getWorkers(); - setWorkers(workers); - })(), - (async () => { - const durableObjects = await getDurableObjects(); - setDurableObjects(durableObjects); - })(), - (async () => { - const config = await getConfig(); - const api = new CloudflareAPI({ - apiToken: config.apiToken, - accountTag: config.accountId, - }); - const metrics = await api.getWorkerSummary(start, end); - setMetrics(metrics); - })(), - (async () => { - const r2Buckets = await getR2Buckets(); - setR2Buckets(r2Buckets || []); - })(), - (async () => { - const domains = await getDomains(); - setDomains(domains || []); - })(), - (async () => { - const queues = await getQueues(); - setQueues(queues || []); - })(), - (async () => { - const d1Databases = await getD1Databases(); - setD1Databases(d1Databases || []); - })(), - (async () => { - const kvNamespaces = await getKVNamespaces(); - setKVNamespaces(kvNamespaces || []); - })(), - ]); - setLoading(false); - }; - - fetchAll(); - }, []); - - // Interval for fetching logs when in single-worker view - useEffect(() => { - if (view === "single-worker" && focussedItem) { - // Reset the timestamp when entering single-worker view to the current time - // This ensures we only fetch NEW logs from this point forward - lastSuccessfulFocussedItemLogsTimestampRef.current = Date.now(); - - const interval = setInterval(() => { - const nowTimestamp = Date.now(); - const fromTimestamp = lastSuccessfulFocussedItemLogsTimestampRef.current; - const currentFocussedItem = focussedItemRef.current; - - runObservabilityQuery(currentFocussedItem, { - from: fromTimestamp, - to: nowTimestamp, - }) - .then((response) => { - if (response && Array.isArray(response) && response.length > 0) { - setFocussedItemLogs((prevLogs) => { - const updated = [...response, ...prevLogs]; - return updated; - }); - // Only update timestamp after successful fetch - lastSuccessfulFocussedItemLogsTimestampRef.current = nowTimestamp; - } else { - // Still update timestamp to avoid fetching the same time range again - // lastFocussedItemLogsTimestampRef.current = nowTimestamp; - } - }) - .catch((error) => { - // Update timestamp even on error to avoid getting stuck - // lastFocussedItemLogsTimestampRef.current = nowTimestamp; - }); - }, 10000); // 10 seconds as requested - - return () => { - clearInterval(interval); - }; - } else { - // Reset logs when leaving single-worker view - setFocussedItemLogs([]); - } - }, [view, focussedItem]); - - useKeyboard((key) => { - if (key.name === "q") { - process.exit(0); - } - - if (key.name === "tab") { - setFocussedSection( - panels[(panels.indexOf(focussedSection) + 1) % panels.length] as string, - ); - } - - if (key.name === "escape" || key.name === "esc") { - setFocussedSection("workers"); - setFocussedItem(""); - setView("home"); - } - - if (key.name === "h") { - setFocussedSection("workers"); - setFocussedItem(""); - setView("home"); - } - - if (key.name === "w") { - setFocussedSection("workers"); - setFocussedItem(workers[0]?.id || ""); - } - if (key.name === "d") { - setFocussedSection("durables"); - setFocussedItem(durableObjects[0]?.objects?.[0]?.id || ""); - } - if (key.name === "b") { - setFocussedSection("buckets"); - setFocussedItem(r2Buckets[0]?.name || ""); - } - if (key.name === "c") { - setFocussedSection("config"); - } - - if (key.ctrl && key.name === "k") { - renderer?.toggleDebugOverlay(); - renderer?.console.toggle(); - } - - if (key.name === "up") { - if (view === "home") { - if (focussedSection === "workers") { - const currentIndex = workers.findIndex((w) => w.id === focussedItem); - const prevIndex = currentIndex <= 0 ? workers.length - 1 : currentIndex - 1; - setFocussedItem(workers[prevIndex]?.id || ""); - } else if (focussedSection === "buckets") { - const currentIndex = r2Buckets.findIndex((b) => b.name === focussedItem); - const prevIndex = - currentIndex <= 0 ? r2Buckets.length - 1 : currentIndex - 1; - setFocussedItem(r2Buckets[prevIndex]?.name || ""); - } else if (focussedSection === "d1") { - const currentIndex = d1Databases.findIndex((d) => d.uuid === focussedItem); - console.log(currentIndex); - const prevIndex = - currentIndex <= 0 ? d1Databases.length - 1 : currentIndex - 1; - console.log(prevIndex); - console.log(d1Databases[prevIndex]?.uuid); - setFocussedItem(d1Databases[prevIndex]?.uuid || ""); - } else if (focussedSection === "queues") { - const currentIndex = queues.findIndex((q) => q.queue_id === focussedItem); - const prevIndex = currentIndex <= 0 ? queues.length - 1 : currentIndex - 1; - setFocussedItem(queues[prevIndex]?.queue_id || ""); - } else { - setFocussedItem(""); - } - } - } - if (key.name === "down") { - if (view === "home") { - if (focussedSection === "workers") { - const currentIndex = workers.findIndex((w) => w.id === focussedItem); - const nextIndex = (currentIndex + 1) % workers.length; - setFocussedItem(workers[nextIndex]?.id || ""); - } else if (focussedSection === "buckets") { - const currentIndex = r2Buckets.findIndex((b) => b.name === focussedItem); - const nextIndex = (currentIndex + 1) % r2Buckets.length; - setFocussedItem(r2Buckets[nextIndex]?.name || ""); - } else if (focussedSection === "d1") { - const currentIndex = d1Databases.findIndex((d) => d.uuid === focussedItem); - const nextIndex = (currentIndex + 1) % d1Databases.length; - setFocussedItem(d1Databases[nextIndex]?.uuid || ""); - } else if (focussedSection === "queues") { - console.log("down in queues"); - const currentIndex = queues.findIndex((q) => q.queue_id === focussedItem); - console.log(`queues current index: ${currentIndex}`); - const nextIndex = (currentIndex + 1) % queues.length; - console.log(`queues next index: ${nextIndex}`); - setFocussedItem(queues[nextIndex]?.queue_id || ""); - console.log(`queues focussed item: ${focussedItem}`); - } else { - setFocussedItem(""); - } - } - } - - if (key.name === "return") { - if (focussedSection === "workers") { - const worker = workers.find((w) => w.id === focussedItem); - if (worker) { - runObservabilityQuery(worker?.id || "", { - from: startTimestamp, - to: nowTimestamp, - }).then((response) => { - if (response && Array.isArray(response)) { - setFocussedItemLogs(response); - } else { - setFocussedItemLogs([]); - } - }); - setShowFocussedItemLogs(true); - setView("single-worker"); - } - } else if (focussedSection === "buckets") { - const bucket = r2Buckets.find((b) => b.name === focussedItem); - if (bucket) { - setView("single-r2-bucket"); - } - } else if (focussedSection === "d1") { - const database = d1Databases.find((d) => d.uuid === focussedItem); - if (database) { - setView("single-d1-database"); - } - } else if (focussedSection === "queues") { - const queue = queues.find((q) => q.queue_id === focussedItem); - if (queue) { - setView("single-queue"); - } - } - } - }); - - const dbName = d1Databases.find((d) => d.uuid === focussedItem)?.name || ""; - - let visibleView: React.ReactNode; - - if (view === "home") { - visibleView = ( - - ); - } else if (view === "single-worker") { - visibleView = ( - - ); - } else if (view === "single-r2-bucket") { - visibleView = ; - } else if (view === "single-d1-database") { - visibleView = ; - } else if (view === "single-queue") { - visibleView = ; - } +if (values.help) { + // show help when using --help or -h + showHelp(); + process.exit(0); +} - return ( - - - - - - v{packageJson.version} - - - {loading ? ( - - Loading... - - ) : ( - visibleView - )} - - - - - ); - } +if (positionals.length == 2) { + await start(); +} - render(); -} else if (positionals.length >= 3) { - // user has called cftop with a command +if (positionals.length == 3) { const cmd = positionals[2]; switch (cmd) { + case "start": + start(); + break; + case "help": + showHelp(); + process.exit(0); case "init": - if (positionals.length > 3) { - console.error("Usage: cftop init --apiToken= --accountId="); - process.exit(1); - } // initialize the config const configExistsResult = await configExists(); if (configExistsResult) { @@ -462,10 +98,7 @@ if (positionals.length == 2) { const response = await client.workers.observability.telemetry.query({ account_id: config.accountId, queryId: "", // Empty string to satisfy SDK validation - API will use parameters instead - timeframe: { - from: yesterday, - to: now, - }, + timeframe: { from: yesterday, to: now }, parameters: { limit: 100, },