From e48d8af2a8d2aae347b5b4eb3b0b295300457b41 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Tue, 1 Jul 2025 00:40:59 +0200 Subject: [PATCH] feat(studio): studio v2, inspector v3 --- frontend/apps/hub/package.json | 1 + .../components/actors/actors-provider.tsx | 663 ++++---- frontend/apps/studio/package.json | 4 + frontend/apps/studio/public/logo.svg | 12 +- frontend/apps/studio/src/app.tsx | 56 +- .../apps/studio/src/components/actors.tsx | 9 +- .../studio/src/components/connection-form.tsx | 68 + .../apps/studio/src/components/layout.tsx | 92 +- frontend/apps/studio/src/content/data.ts | 6 + frontend/apps/studio/src/index.css | 20 + frontend/apps/studio/src/queries/global.ts | 20 +- frontend/apps/studio/src/routes/__root.tsx | 90 +- frontend/apps/studio/src/routes/_layout.tsx | 328 +++- .../apps/studio/src/routes/_layout/index.tsx | 290 ++-- frontend/apps/studio/src/stores/manager.tsx | 414 ++--- frontend/apps/studio/vite.config.ts | 2 +- frontend/packages/components/package.json | 5 + .../components/src/actors/actor-build.tsx | 41 +- .../actors/actor-clear-events-log-button.tsx | 30 + .../src/actors/actor-config-tab.tsx | 3 +- .../src/actors/actor-connections-tab.tsx | 45 +- .../components/src/actors/actor-context.tsx | 441 +----- .../components/src/actors/actor-database.tsx | 149 ++ .../components/src/actors/actor-db-tab.tsx | 61 + .../actors/actor-details-settings-button.tsx | 11 +- .../src/actors/actor-download-logs-button.tsx | 105 +- .../src/actors/actor-editable-state.tsx | 40 +- .../src/actors/actor-events-list.tsx | 235 +++ .../src/actors/actor-events-tab.tsx | 46 + .../components/src/actors/actor-events.tsx | 214 +++ .../src/actors/actor-filters-context.tsx | 255 +++ .../components/src/actors/actor-general.tsx | 34 +- .../components/src/actors/actor-logs-tab.tsx | 25 +- .../components/src/actors/actor-logs.tsx | 83 +- .../src/actors/actor-metrics-tab.tsx | 4 +- .../components/src/actors/actor-metrics.tsx | 1373 +++++++++-------- .../components/src/actors/actor-network.tsx | 171 +- .../components/src/actors/actor-not-found.tsx | 48 +- .../src/actors/actor-queries-context.tsx | 207 +++ .../components/src/actors/actor-region.tsx | 27 +- .../components/src/actors/actor-runtime.tsx | 62 +- .../actors/actor-state-change-indicator.tsx | 42 +- .../components/src/actors/actor-state-tab.tsx | 81 +- .../src/actors/actor-status-indicator.tsx | 66 +- .../src/actors/actor-status-label.tsx | 25 +- .../components/src/actors/actor-status.tsx | 15 +- .../src/actors/actor-stop-button.tsx | 35 +- .../src/actors/actors-actor-details.tsx | 154 +- .../components/src/actors/actors-list-row.tsx | 90 +- .../components/src/actors/actors-list.tsx | 365 +---- .../actors/actors-view-context-provider.tsx | 8 +- .../components/src/actors/build-select.tsx | 57 +- .../actors/console/actor-console-input.tsx | 18 +- .../src/actors/console/actor-console.tsx | 28 +- .../src/actors/create-actor-button.tsx | 20 +- .../src/actors/database/database-table.tsx | 314 ++++ .../actors/dialogs/create-actor-dialog.tsx | 35 +- .../src/actors/form/actor-create-form.tsx | 190 ++- .../src/actors/form/go-to-actor-form.tsx | 5 +- .../src/actors/go-to-actor-button.tsx | 1 - .../src/actors/hooks/use-websocket.ts | 0 .../packages/components/src/actors/index.tsx | 5 +- .../src/actors/manager-queries-context.tsx | 315 ++++ .../components/src/actors/queries/actor.ts | 189 +++ .../components/src/actors/queries/index.ts | 54 + .../components/src/actors/region-select.tsx | 6 +- .../src/actors/worker/actor-repl.worker.ts | 221 +-- .../actors/worker/actor-worker-container.ts | 93 +- .../actors/worker/actor-worker-context.tsx | 98 +- .../src/actors/worker/actor-worker-schema.ts | 28 +- .../packages/components/src/copy-area.tsx | 12 +- .../packages/components/src/docs-sheet.tsx | 39 +- .../packages/components/src/json/index.tsx | 767 +++++++++ .../packages/components/src/live-badge.tsx | 12 + .../packages/components/src/tailwind-base.ts | 27 +- frontend/packages/components/src/ui/badge.tsx | 2 +- .../packages/components/src/ui/filters.tsx | 7 + .../packages/components/src/ui/select.tsx | 4 +- frontend/packages/components/src/ui/sheet.tsx | 5 +- frontend/packages/components/src/ui/table.tsx | 8 +- frontend/packages/rivetkit-actor.tgz | 3 + frontend/packages/rivetkit-core.tgz | 3 + package.json | 16 +- turbo.json | 1 + yarn.lock | 264 +++- 85 files changed, 6031 insertions(+), 3487 deletions(-) create mode 100644 frontend/apps/studio/src/components/connection-form.tsx create mode 100644 frontend/apps/studio/src/content/data.ts create mode 100644 frontend/packages/components/src/actors/actor-clear-events-log-button.tsx create mode 100644 frontend/packages/components/src/actors/actor-database.tsx create mode 100644 frontend/packages/components/src/actors/actor-db-tab.tsx create mode 100644 frontend/packages/components/src/actors/actor-events-list.tsx create mode 100644 frontend/packages/components/src/actors/actor-events-tab.tsx create mode 100644 frontend/packages/components/src/actors/actor-events.tsx create mode 100644 frontend/packages/components/src/actors/actor-filters-context.tsx create mode 100644 frontend/packages/components/src/actors/actor-queries-context.tsx create mode 100644 frontend/packages/components/src/actors/database/database-table.tsx create mode 100644 frontend/packages/components/src/actors/hooks/use-websocket.ts create mode 100644 frontend/packages/components/src/actors/manager-queries-context.tsx create mode 100644 frontend/packages/components/src/actors/queries/actor.ts create mode 100644 frontend/packages/components/src/actors/queries/index.ts create mode 100644 frontend/packages/components/src/json/index.tsx create mode 100644 frontend/packages/rivetkit-actor.tgz create mode 100644 frontend/packages/rivetkit-core.tgz diff --git a/frontend/apps/hub/package.json b/frontend/apps/hub/package.json index a01c192d13..4ca83a8026 100644 --- a/frontend/apps/hub/package.json +++ b/frontend/apps/hub/package.json @@ -49,6 +49,7 @@ "react-konami-code": "^2.3.0", "react-turnstile": "^1.1.3", "recharts": "^2.12.7", + "reconnecting-websocket": "^4.4.0", "strip-ansi": "^7.1.0", "superjson": "^2.2.1", "tailwind-merge": "^2.2.2", diff --git a/frontend/apps/hub/src/domains/project/components/actors/actors-provider.tsx b/frontend/apps/hub/src/domains/project/components/actors/actors-provider.tsx index 1049551360..2edfad4690 100644 --- a/frontend/apps/hub/src/domains/project/components/actors/actors-provider.tsx +++ b/frontend/apps/hub/src/domains/project/components/actors/actors-provider.tsx @@ -231,301 +231,374 @@ export function ActorsProvider({ status, devMode, }: ActorsProviderProps) { - const [store] = useState(() => createStore()); - - // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency - useEffect(() => { - store.set(currentActorIdAtom, actorId); - store.set(currentActorQueryAtom, { isLoading: false, error: null }); - if (!actorId) { - return; - } - - const actor = store.get(actorsAtom).find((a) => a.id === actorId); - if (actor) { - return; - } - - store.set(currentActorQueryAtom, { isLoading: true, error: null }); - const observer = new QueryObserver( - queryClient, - actorQueryOptions({ - actorId, - projectNameId, - environmentNameId, - }), - ); - - return observer.subscribe((query) => { - store.set(currentActorQueryAtom, { - isLoading: query.isLoading, - error: query.error, - }); - if (query.status === "success" && query.data) { - store.set(actorsAtom, (actors) => { - const existing = actors.find((a) => a.id === actorId); - - if (existing) { - return actors.map((a) => - a.id === actorId - ? { - ...existing, - ...query.data, - status: getActorStatus(query.data), - endpoint: createActorEndpoint( - query.data.network, - ), - tags: toRecord(existing.tags), - } - : a, - ); - } - - return [ - ...actors, - mountActor({ - actor: query.data, - projectNameId, - environmentNameId, - }), - ]; - }); - } - }); - }, [actorId]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency - useEffect(() => { - store.set(actorEnvironmentAtom, { projectNameId, environmentNameId }); - }, [projectNameId, environmentNameId]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency - useEffect(() => { - store.set(actorFiltersAtom, { - tags, - region, - createdAt, - destroyedAt, - status, - devMode, - }); - }, [tags, region, createdAt, destroyedAt, status, devMode]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency - useEffect(() => { - if (internalFilter) { - store.set(actorsInternalFilterAtom, { fn: internalFilter }); - } else { - store.set(actorsInternalFilterAtom, undefined); - } - }, [internalFilter]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency - useEffect(() => { - return store.sub(actorFiltersAtom, () => { - const value = store.get(actorFiltersAtom); - router.navigate({ - to: ".", - search: (old) => ({ - ...old, - ...value, - }), - }); - }); - }, [router]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency - useEffect(() => { - const actorsObserver = new InfiniteQueryObserver( - queryClient, - projectActorsQueryOptions({ - projectNameId, - environmentNameId, - includeDestroyed: true, - tags: fixedTags, - }), - ); - - const unsubFilters = store.sub(actorFiltersAtom, () => { - actorsObserver.setOptions( - projectActorsQueryOptions({ - projectNameId, - environmentNameId, - tags: fixedTags, - includeDestroyed: true, - }), - ); - actorsObserver.refetch(); - }); - - const unsub = actorsObserver.subscribe((query) => { - store.set(actorsQueryAtom, { - isLoading: query.isLoading, - error: query.error?.message ?? null, - }); - store.set(actorsPaginationAtom, { - hasNextPage: query.hasNextPage, - fetchNextPage: () => query.fetchNextPage(), - isFetchingNextPage: query.isFetchingNextPage, - }); - if ( - query.status === "success" && - query.data && - !query.isPlaceholderData - ) { - store.set(actorsAtom, (actors) => { - const additionalActors = upsertCurrentActor({ - store, - query, - actors, - projectNameId, - environmentNameId, - }); - - return [ - ...additionalActors, - ...query.data - .filter((actor) => filter?.(actor) ?? true) - .map((actor) => { - const existing = actors.find( - (a) => a.id === actor.id, - ); - if (existing) { - return { - ...existing, - ...actor, - status: getActorStatus(actor), - endpoint: createActorEndpoint( - actor.network, - ), - tags: toRecord(existing.tags), - }; - } - - return mountActor({ - actor, - projectNameId, - environmentNameId, - }); - }), - ]; - }); - } - }); - return () => { - actorsObserver.destroy(); - unsub(); - unsubFilters(); - }; - }, [projectNameId, environmentNameId]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency - useEffect(() => { - const regionsObserver = new QueryObserver( - queryClient, - actorRegionsQueryOptions({ projectNameId, environmentNameId }), - ); - - const unsub = regionsObserver.subscribe((query) => { - if (query.status === "success" && query.data) { - store.set(actorRegionsAtom, query.data); - } - }); - - return () => { - regionsObserver.destroy(); - unsub(); - }; - }, [projectNameId, environmentNameId]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency - useEffect(() => { - const buildsObserver = new QueryObserver( - queryClient, - actorBuildsQueryOptions({ - projectNameId, - environmentNameId, - }), - ); - const unsub = buildsObserver.subscribe((query) => { - if (query.status === "success" && query.data) { - store.set(actorBuildsAtom, (old) => { - if (equal(old, query.data)) { - return old; - } - return query.data; - }); - } - }); - return () => { - buildsObserver.destroy(); - unsub(); - }; - }, [projectNameId, environmentNameId]); - - useEffect(() => { - const mutationObserver = new MutationObserver(queryClient, { - mutationFn: (data: { - endpoint: string; - id: string; - tags: Record; - region?: string; - params?: Record; - }) => { - //const client = createClient(data.endpoint); - // - //const build = store - // .get(actorBuildsAtom) - // .find((build) => build.id === data.id); - // - //return client.create(build?.tags.name || "", { - // params: data.params, - // create: { - // tags: data.tags, - // region: data.region || undefined, - // }, - //}); - }, - }); - - const storeSub = store.sub(actorsAtom, () => { - const manager = store - .get(actorsAtom) - .find( - (a) => - toRecord(a.tags).name === "manager" && - a.status === "running", - ); - - store.set(createActorAtom, (old) => { - return { - ...old, - endpoint: manager?.network - ? createActorEndpoint(manager.network) || null - : null, - }; - }); - }); - - store.set(createActorAtom, (old) => ({ - ...old, - create: mutationObserver.mutate, - })); - - const unsub = mutationObserver.subscribe((mutation) => { - store.set(createActorAtom, (old) => ({ - ...old, - isCreating: mutation.isPending, - create: mutation.mutate, - })); - }); - return () => { - unsub(); - storeSub(); - }; - }); - - return {children}; + // const [store] = useState(() => createStore()); + + // // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency + // useEffect(() => { + // store.set(currentActorIdAtom, actorId); + // }, [actorId]); + + // // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency + // useEffect(() => { + // store.set(actorFiltersAtom, { + // tags, + // region, + // createdAt, + // destroyedAt, + // status, + // devMode, + // }); + // }, [tags, region, createdAt, destroyedAt, status, devMode]); + + // // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency + // useEffect(() => { + // if (internalFilter) { + // store.set(actorsInternalFilterAtom, { fn: internalFilter }); + // } else { + // store.set(actorsInternalFilterAtom, undefined); + // } + // }, [internalFilter]); + + // // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency + // useEffect(() => { + // return store.sub(actorFiltersAtom, () => { + // const value = store.get(actorFiltersAtom); + // router.navigate({ + // to: ".", + // search: (old) => ({ + // ...old, + // ...value, + // }), + // }); + // }); + // }, [router]); + + // // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency + // useEffect(() => { + // const actorsObserver = new InfiniteQueryObserver( + // queryClient, + // projectActorsQueryOptions({ + // projectNameId, + // environmentNameId, + // includeDestroyed: true, + // tags: fixedTags, + // }), + // ); + + // const unsubFilters = store.sub(actorFiltersAtom, () => { + // actorsObserver.setOptions( + // projectActorsQueryOptions({ + // projectNameId, + // environmentNameId, + // tags: fixedTags, + // includeDestroyed: true, + // }), + // ); + // actorsObserver.refetch(); + // }); + + // const unsub = actorsObserver.subscribe((query) => { + // store.set(actorsQueryAtom, { + // isLoading: query.isLoading, + // error: query.error?.message ?? null, + // }); + // store.set(actorsPaginationAtom, { + // hasNextPage: query.hasNextPage, + // fetchNextPage: () => query.fetchNextPage(), + // isFetchingNextPage: query.isFetchingNextPage, + // }); + // if (query.status === "success" && query.data) { + // store.set(actorsAtom, (actors) => { + // return query.data + // .filter((actor) => filter?.(actor) ?? true) + // .map((actor) => { + // const existing = actors.find( + // (a) => a.id === actor.id, + // ); + // if (existing) { + // return { + // ...existing, + // ...actor, + // status: getActorStatus(actor), + // endpoint: createActorEndpoint( + // actor.network, + // ), + // tags: toRecord(existing.tags), + // }; + // } + + // const destroy: PrimitiveAtom = atom({ + // isDestroying: false as boolean, + // destroy: async () => {}, + // }); + // destroy.onMount = (set) => { + // const mutObserver = new MutationObserver( + // queryClient, + // destroyActorMutationOptions(), + // ); + + // set({ + // destroy: async () => { + // await mutObserver.mutate({ + // projectNameId, + // environmentNameId, + // actorId: actor.id, + // }); + // }, + // isDestroying: false, + // }); + + // mutObserver.subscribe((mutation) => { + // set({ + // destroy: async () => { + // await mutation.mutate({ + // projectNameId, + // environmentNameId, + // actorId: actor.id, + // }); + // }, + // isDestroying: mutation.isPending, + // }); + // }); + + // return () => { + // mutObserver.reset(); + // }; + // }; + + // const logs = atom({ + // logs: [] as Logs, + // status: "pending", + // }); + // logs.onMount = (set) => { + // const logsObserver = new QueryObserver( + // queryClient, + // actorLogsQueryOptions({ + // projectNameId, + // environmentNameId, + // actorId: actor.id, + // }), + // ); + + // type LogQuery = { + // status: string; + // data?: Awaited< + // ReturnType< + // Exclude< + // ReturnType< + // typeof actorLogsQueryOptions + // >["queryFn"], + // undefined + // > + // > + // >; + // }; + + // function updateStdOut(query: LogQuery) { + // const data = query.data; + // set((prev) => ({ + // ...prev, + // ...data, + // status: query.status, + // })); + // } + + // const subOut = logsObserver.subscribe( + // (query) => { + // updateStdOut(query); + // }, + // ); + + // updateStdOut( + // logsObserver.getCurrentQuery().state, + // ); + + // return () => { + // logsObserver.destroy(); + // subOut(); + // }; + // }; + + // const metrics = atom({ + // metrics: { cpu: null, memory: null } as Metrics, + // status: "pending", + // }); + // metrics.onMount = (set) => { + // const metricsObserver = new QueryObserver( + // queryClient, + // actorMetricsQueryOptions({ + // projectNameId, + // environmentNameId, + // actorId: actor.id, + // }, { refetchInterval: 5000 }), + // ); + + // type MetricsQuery = { + // status: string; + // data?: Awaited< + // ReturnType< + // Exclude< + // ReturnType< + // typeof actorMetricsQueryOptions + // >["queryFn"], + // undefined + // > + // > + // >; + // }; + + // function updateMetrics(query: MetricsQuery) { + // const data = query.data; + // set((prev) => ({ + // ...prev, + // ...data, + // status: query.status, + // })); + // } + + // const subMetrics = metricsObserver.subscribe( + // (query) => { + // updateMetrics(query); + // }, + // ); + + // updateMetrics( + // metricsObserver.getCurrentQuery().state, + // ); + + // return () => { + // metricsObserver.destroy(); + // subMetrics(); + // }; + // }; + + // return { + // ...actor, + // logs, + // metrics, + // destroy, + // status: getActorStatus(actor), + // }; + // }); + // }); + // } + // }); + // return () => { + // actorsObserver.destroy(); + // unsub(); + // unsubFilters(); + // }; + // }, [projectNameId, environmentNameId]); + + // // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency + // useEffect(() => { + // const regionsObserver = new QueryObserver( + // queryClient, + // actorRegionsQueryOptions({ projectNameId, environmentNameId }), + // ); + + // const unsub = regionsObserver.subscribe((query) => { + // if (query.status === "success" && query.data) { + // store.set(actorRegionsAtom, query.data); + // } + // }); + + // return () => { + // regionsObserver.destroy(); + // unsub(); + // }; + // }, [projectNameId, environmentNameId]); + + // // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency + // useEffect(() => { + // const buildsObserver = new QueryObserver( + // queryClient, + // actorBuildsQueryOptions({ + // projectNameId, + // environmentNameId, + // }), + // ); + // const unsub = buildsObserver.subscribe((query) => { + // if (query.status === "success" && query.data) { + // store.set(actorBuildsAtom, (old) => { + // if (equal(old, query.data)) { + // return old; + // } + // return query.data; + // }); + // } + // }); + // return () => { + // buildsObserver.destroy(); + // unsub(); + // }; + // }, [projectNameId, environmentNameId]); + + // useEffect(() => { + // const mutationObserver = new MutationObserver(queryClient, { + // mutationFn: (data: { + // endpoint: string; + // id: string; + // tags: Record; + // region?: string; + // params?: Record; + // }) => { + // //const client = createClient(data.endpoint); + // // + // //const build = store + // // .get(actorBuildsAtom) + // // .find((build) => build.id === data.id); + // // + // //return client.create(build?.tags.name || "", { + // // params: data.params, + // // create: { + // // tags: data.tags, + // // region: data.region || undefined, + // // }, + // //}); + // }, + // }); + + // const storeSub = store.sub(actorsAtom, () => { + // const manager = store + // .get(actorsAtom) + // .find( + // (a) => + // toRecord(a.tags).name === "manager" && + // a.status === "running", + // ); + + // store.set(createActorAtom, (old) => { + // return { + // ...old, + // endpoint: manager?.network + // ? createActorEndpoint(manager.network) || null + // : null, + // }; + // }); + // }); + + // store.set(createActorAtom, (old) => ({ + // ...old, + // create: mutationObserver.mutate, + // })); + + // const unsub = mutationObserver.subscribe((mutation) => { + // store.set(createActorAtom, (old) => ({ + // ...old, + // isCreating: mutation.isPending, + // create: mutation.mutate, + // })); + // }); + // return () => { + // unsub(); + // storeSub(); + // }; + // }); + + // return {children}; + + return children; } function upsertCurrentActor({ diff --git a/frontend/apps/studio/package.json b/frontend/apps/studio/package.json index 4a9698a70a..e8a5b8a231 100644 --- a/frontend/apps/studio/package.json +++ b/frontend/apps/studio/package.json @@ -16,10 +16,13 @@ "@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/react-fontawesome": "^0.2.2", "@hookform/resolvers": "^3.3.4", + "@microsoft/fetch-event-source": "^2.0.1", "@rivet-gg/components": "workspace:*", "@rivet-gg/icons": "workspace:*", "@sentry/react": "^8.26.0", "@sentry/vite-plugin": "^2.22.2", + "@tanstack/react-query": "^5.81.5", + "@tanstack/react-query-devtools": "^5.81.5", "@tanstack/react-router": "^1.114.25", "@tanstack/react-table": "^8.20.6", "@tanstack/router-devtools": "^1.114.25", @@ -38,6 +41,7 @@ "actor-core": "^0.6.2", "autoprefixer": "^10.4.19", "bcryptjs": "^2.4.3", + "fast-json-patch": "^3.1.1", "file-saver": "^2.0.5", "framer-motion": "^11.2.11", "jotai": "^2.12.2", diff --git a/frontend/apps/studio/public/logo.svg b/frontend/apps/studio/public/logo.svg index 9086ec836c..81337354dd 100644 --- a/frontend/apps/studio/public/logo.svg +++ b/frontend/apps/studio/public/logo.svg @@ -1,3 +1,11 @@ - - + + + + + + + + + + diff --git a/frontend/apps/studio/src/app.tsx b/frontend/apps/studio/src/app.tsx index 29c6f48791..d45ec15d7d 100644 --- a/frontend/apps/studio/src/app.tsx +++ b/frontend/apps/studio/src/app.tsx @@ -6,18 +6,14 @@ import { TooltipProvider, getConfig, } from "@rivet-gg/components"; -import { - actorFiltersAtom, - currentActorIdAtom, - pickActorListFilters, -} from "@rivet-gg/components/actors"; import { PageLayout } from "@rivet-gg/components/layout"; import * as Sentry from "@sentry/react"; import { RouterProvider, createRouter } from "@tanstack/react-router"; -import { useAtom } from "jotai"; -import { withAtomEffect } from "jotai-effect"; import { Suspense } from "react"; import { routeTree } from "./routeTree.gen"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "./queries/global"; declare module "@tanstack/react-router" { interface Register { @@ -36,42 +32,26 @@ export const router = createRouter({ }, }); -const effect = withAtomEffect(actorFiltersAtom, (get, set) => { - // set initial values - const search = router.state.location.search; - - const filters = pickActorListFilters(search); - - set(actorFiltersAtom, filters); - set(currentActorIdAtom, router.state.location.search.actorId); -}); - -const effect2 = withAtomEffect(actorFiltersAtom, (get, set) => { - return router.subscribe("onResolved", (event) => { - set(actorFiltersAtom, pickActorListFilters(event.toLocation.search)); - set(currentActorIdAtom, event.toLocation.search.actorId); - }); -}); - function InnerApp() { - useAtom(effect); - useAtom(effect2); - return ; } export function App() { return ( - - - }> - - - - - - - - + + + + }> + + + + + + + + + + + ); } diff --git a/frontend/apps/studio/src/components/actors.tsx b/frontend/apps/studio/src/components/actors.tsx index 6a4c57a7a7..41fb815fbf 100644 --- a/frontend/apps/studio/src/components/actors.tsx +++ b/frontend/apps/studio/src/components/actors.tsx @@ -3,10 +3,8 @@ import { ActorsActorDetails, ActorsActorEmptyDetails, ActorsListPreview, - currentActorAtom, } from "@rivet-gg/components/actors"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { useAtomValue } from "jotai"; export function Actors({ actorId }: { actorId: string | undefined }) { return ( @@ -27,16 +25,15 @@ export function Actors({ actorId }: { actorId: string | undefined }) { } function Actor() { - const actor = useAtomValue(currentActorAtom); const navigate = useNavigate(); - const { tab } = useSearch({ from: "/_layout/" }); + const { tab, actorId } = useSearch({ from: "/_layout/" }); - if (!actor) { + if (!actorId) { return null; } return ( { navigate({ diff --git a/frontend/apps/studio/src/components/connection-form.tsx b/frontend/apps/studio/src/components/connection-form.tsx new file mode 100644 index 0000000000..4d5a9f02d9 --- /dev/null +++ b/frontend/apps/studio/src/components/connection-form.tsx @@ -0,0 +1,68 @@ +import { + Button, + createSchemaForm, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from "@rivet-gg/components"; +import type { ComponentProps } from "react"; +import z from "zod"; + +const connectionFormSchema = z.object({ + username: z + .string() + .url("Please enter a valid URL") + .min(1, "URL is required"), + token: z.string().min(1, "Token is required"), +}); + +const { Form, Submit: ConnectionSubmit } = + createSchemaForm(connectionFormSchema); + +export const ConnectionForm = ( + props: Omit, "children">, +) => { + return ( +
+
+ ( + + Endpoint + + + + )} + /> + ( + + Token + + + + )} + /> +
+ + + +
+
+ + ); +}; diff --git a/frontend/apps/studio/src/components/layout.tsx b/frontend/apps/studio/src/components/layout.tsx index 8fbf3fe559..20b8ee9450 100644 --- a/frontend/apps/studio/src/components/layout.tsx +++ b/frontend/apps/studio/src/components/layout.tsx @@ -1,10 +1,18 @@ -import { connectionStateAtom } from "@/stores/manager"; -import { DocsSheet, ShimmerLine, cn } from "@rivet-gg/components"; -import { NavItem, Header as RivetHeader } from "@rivet-gg/components/header"; -import { Icon, faGithub } from "@rivet-gg/icons"; +import { Button, cn, DocsSheet } from "@rivet-gg/components"; +import { Header as RivetHeader, NavItem } from "@rivet-gg/components/header"; +import { + faCheck, + faDiscord, + faGithub, + faLink, + faSpinnerThird, + faTriangleExclamation, + Icon, +} from "@rivet-gg/icons"; import { Link } from "@tanstack/react-router"; -import { useAtomValue } from "jotai"; +import { useQuery } from "@tanstack/react-query"; import type { PropsWithChildren, ReactNode } from "react"; +import { useManagerQueries } from "@rivet-gg/components/actors"; interface RootProps { children: ReactNode; @@ -16,7 +24,7 @@ const Root = ({ children }: RootProps) => { const Main = ({ children }: RootProps) => { return ( -
+
{children}
); @@ -30,18 +38,78 @@ const VisibleInFull = ({ children }: PropsWithChildren) => { ); }; +function ConnectionStatus() { + const { setToken, endpoint, ...queries } = useManagerQueries(); + const { isLoading, isError, isSuccess } = useQuery( + queries.managerStatusQueryOptions(), + ); + + if (!queries.managerStatusQueryOptions().enabled) { + return null; + } + + if (isLoading) { + return ( +

+ Connecting to{" "} + {endpoint} + +

+ ); + } + + if (isError) { + return ( +

+ Couldn't connect to{" "} + {endpoint} + + +

+ ); + } + + if (isSuccess) { + return ( +

+ Connected to{" "} + {endpoint} + +

+ ); + } +} + const Header = () => { - const connectionStatus = useAtomValue(connectionStateAtom); return ( } - addons={ - connectionStatus !== "connected" ? ( - - ) : null + className="bg-stripes border-b-2 border-b-primary/90" + logo={ + <> +
+ Rivet.gg{" "} + Studio +
+
+ +
+ } links={ <> + + + + + diff --git a/frontend/apps/studio/src/content/data.ts b/frontend/apps/studio/src/content/data.ts new file mode 100644 index 0000000000..356a38c6ef --- /dev/null +++ b/frontend/apps/studio/src/content/data.ts @@ -0,0 +1,6 @@ +export const docsLinks = { + gettingStarted: { + node: "https://www.rivet.gg/docs/actors/quickstart/backend/", + react: "https://www.rivet.gg/docs/actors/quickstart/react/", + }, +}; diff --git a/frontend/apps/studio/src/index.css b/frontend/apps/studio/src/index.css index 61131ed697..bc7a17a548 100644 --- a/frontend/apps/studio/src/index.css +++ b/frontend/apps/studio/src/index.css @@ -30,6 +30,26 @@ @apply ml-[-50px] mt-[-4px]; content: counter(step); } + + .bg-stripes { + background: repeating-linear-gradient( + 45deg, + /* biome-ignore lint/correctness/noUnknownFunction: tailwind functions */ + theme("colors.primary.DEFAULT" / 5%), + /* biome-ignore lint/correctness/noUnknownFunction: tailwind functions */ + theme("colors.primary.DEFAULT" / 5%) 20px, + transparent 20px, + transparent 40px + ); + } + + .selection-primary::selection { + /* biome-ignore lint/correctness/noUnknownFunction: tailwind functions */ + background-color: theme("colors.primary.DEFAULT"); + + /* biome-ignore lint/correctness/noUnknownFunction: tailwind functions */ + color: theme("colors.primary.foreground"); + } } :root { diff --git a/frontend/apps/studio/src/queries/global.ts b/frontend/apps/studio/src/queries/global.ts index 96ea5f93d5..1aa8ea0328 100644 --- a/frontend/apps/studio/src/queries/global.ts +++ b/frontend/apps/studio/src/queries/global.ts @@ -1,8 +1,5 @@ import { toast } from "@rivet-gg/components"; -import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental"; -import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; -import superjson from "superjson"; const queryCache = new QueryCache(); @@ -21,23 +18,12 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 1000, - gcTime: 1000 * 60 * 60 * 24, - retry: 2, - refetchOnWindowFocus: false, + gcTime: 60 * 1000, + retry: 3, + refetchOnWindowFocus: true, refetchOnReconnect: false, }, }, queryCache, mutationCache, }); - -export const queryClientPersister = createSyncStoragePersister({ - storage: window.localStorage, - serialize: superjson.stringify, - deserialize: superjson.parse, -}); - -broadcastQueryClient({ - queryClient, - broadcastChannel: "rivet-gg-hub", -}); diff --git a/frontend/apps/studio/src/routes/__root.tsx b/frontend/apps/studio/src/routes/__root.tsx index 8e36fa8ba6..bd87a7bd19 100644 --- a/frontend/apps/studio/src/routes/__root.tsx +++ b/frontend/apps/studio/src/routes/__root.tsx @@ -1,77 +1,10 @@ -import { FEEDBACK_FORM_ID, FullscreenLoading } from "@rivet-gg/components"; +import { FullscreenLoading } from "@rivet-gg/components"; -import * as Layout from "@/components/layout"; -import { useDialog } from "@rivet-gg/components/actors"; import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { zodValidator } from "@tanstack/zod-adapter"; -import { usePostHog } from "posthog-js/react"; -import { Suspense } from "react"; import { z } from "zod"; -function Modals() { - const search = Route.useSearch(); - const navigate = Route.useNavigate(); - - const posthog = usePostHog(); - - const FeedbackDialog = useDialog.Feedback.Dialog; - const GoToActorDialog = useDialog.GoToActor.Dialog; - const CreateActorDialog = useDialog.CreateActor.Dialog; - - const { modal, utm_source } = search; - - const handleOnOpenChange = (value: boolean) => { - if (!value) { - navigate({ search: (old) => ({ ...old, modal: undefined }) }); - } else { - posthog.capture("survey shown", { $survey_id: FEEDBACK_FORM_ID }); - } - }; - - return ( - <> - - { - navigate({ - to: ".", - search: (old) => ({ - ...old, - actorId, - modal: undefined, - }), - }); - }} - dialogProps={{ - open: modal === "go-to-actor", - onOpenChange: (value) => { - if (!value) { - navigate({ search: { modal: undefined } }); - } - }, - }} - /> - { - if (!value) { - navigate({ search: { modal: undefined } }); - } - }, - }} - /> - - ); -} - // function RootNotFoundComponent() { // return ( // @@ -91,29 +24,10 @@ function Modals() { // // ); // } - -function Root() { - return ( - - - - - {/* */} - - - - - - ); -} - function RootRoute() { return ( <> - - - - + {import.meta.env.DEV ? : null} ); diff --git a/frontend/apps/studio/src/routes/_layout.tsx b/frontend/apps/studio/src/routes/_layout.tsx index d40b51ba1e..f5779f5a9f 100644 --- a/frontend/apps/studio/src/routes/_layout.tsx +++ b/frontend/apps/studio/src/routes/_layout.tsx @@ -1,13 +1,333 @@ -import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, Outlet } from "@tanstack/react-router"; +import * as Layout from "@/components/layout"; +import z from "zod"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { + type ActorId, + ActorQueriesProvider, + createActorInspectorClient, + createManagerInspectorClient, + defaultActorQueries, + defaultManagerQueries, + getManagerToken, + ManagerQueriesProvider, + setManagerToken, + useDialog, +} from "@rivet-gg/components/actors"; +import { Suspense, useEffect, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { usePostHog } from "posthog-js/react"; +import { FEEDBACK_FORM_ID } from "@rivet-gg/components"; export const Route = createFileRoute("/_layout")({ component: RouteComponent, + validateSearch: zodValidator( + z.object({ + u: z.string().optional(), + t: z.string().optional(), + }), + ), }); +function ensureTrailingSlash(url: string): string { + if (url.endsWith("/")) { + return url; + } + return `${url}/`; +} + function RouteComponent() { + const { u: url } = Route.useSearch(); + + const [token, setToken] = useState(() => getManagerToken(url || "")); + + const queryClient = useQueryClient(); + useEffect(() => { + queryClient.invalidateQueries(); + }, [token, url]); + + const managerQueries = useMemo(() => { + const provideToken = (newUrl: string, newToken: string) => { + setToken(newToken); + setManagerToken(newUrl, newToken); + }; + + const createClient = (url: string, token: string) => { + const newUrl = new URL(url); + if (!newUrl.pathname.endsWith("registry/inspect")) { + if (!newUrl.pathname.endsWith("registry")) { + newUrl.pathname = `${ensureTrailingSlash(newUrl.pathname)}registry`; + } + if (!newUrl.pathname.endsWith("inspect")) { + newUrl.pathname = `${ensureTrailingSlash(newUrl.pathname)}inspect`; + } + } + + return createManagerInspectorClient(newUrl.href, { + headers: { Authorization: `Bearer ${token}` }, + }); + }; + + const getManagerStatus = async ({ + url, + token, + }: { + url: string; + token: string; + }) => { + const client = createClient(url, token); + const res = await client.ping.$get(); + if (!res.ok) { + throw new Error("Failed to fetch manager status"); + } + }; + + if (url) { + if (!token) { + return { + ...defaultManagerQueries, + queryClient, + endpoint: url, + token, + setToken: provideToken, + getManagerStatus, + }; + } + const client = createClient(url, token); + return { + ...defaultManagerQueries, + endpoint: url, + queryClient, + token, + getManagerStatus, + setToken: provideToken, + managerStatusQueryOptions() { + return { + ...defaultManagerQueries.managerStatusQueryOptions(), + enabled: true, + queryFn: async ({ signal }) => { + const status = await client.ping.$get({ signal }); + if (!status.ok) { + throw new Error( + "Failed to fetch manager status", + ); + } + return true; + }, + }; + }, + regionsQueryOptions() { + return { + ...defaultManagerQueries.regionsQueryOptions(), + enabled: true, + queryFn: async () => { + return [{ id: "local", name: "Local" }]; + }, + }; + }, + actorQueryOptions(actorId: ActorId) { + return { + ...defaultManagerQueries.actorQueryOptions(actorId), + enabled: true, + queryFn: async ({ signal }) => { + const actor = await client.actor[":id"].$get({ + param: { id: actorId }, + // @ts-expect-error + signal, + }); + if (!actor.ok) { + throw new Error( + `Failed to fetch actor with ID: ${actorId}`, + ); + } + return await actor.json(); + }, + }; + }, + actorsQueryOptions() { + return { + ...defaultManagerQueries.actorsQueryOptions(), + enabled: true, + queryFn: async ({ signal, pageParam }) => { + const actors = await client.actors.$get({ + query: { cursor: pageParam, limit: 10 }, + signal, + }); + if (!actors.ok) { + throw new Error("Failed to fetch actors"); + } + return await actors.json(); + }, + }; + }, + buildsQueryOptions() { + return { + ...defaultManagerQueries.buildsQueryOptions(), + enabled: true, + queryFn: async ({ signal }) => { + const builds = await client.builds.$get({ signal }); + if (!builds.ok) { + throw new Error("Failed to fetch builds"); + } + return await builds.json(); + }, + }; + }, + createActorMutationOptions() { + return { + ...defaultManagerQueries.createActorMutationOptions(), + mutationFn: async (data) => { + const response = await client.actors.$post({ + json: data, + }); + if (!response.ok) { + throw new Error("Failed to create actor"); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: this.actorsQueryOptions().queryKey, + }); + }, + }; + }, + }; + } + return { + ...defaultManagerQueries, + queryClient, + token, + setToken: provideToken, + getManagerStatus, + }; + }, [url, token]); + + const actorQueries = useMemo(() => { + if (!url || !token) { + return defaultActorQueries; + } + const newUrl = new URL(url); + if (!newUrl.pathname.endsWith("registry/inspect")) { + if (!newUrl.pathname.endsWith("registry")) { + newUrl.pathname = `${ensureTrailingSlash(newUrl.pathname)}registry`; + } + if (!newUrl.pathname.endsWith("inspect")) { + newUrl.pathname = `${ensureTrailingSlash(newUrl.pathname)}inspect`; + } + } + newUrl.pathname = newUrl.pathname.replace( + "/registry/inspect", + "/registry/actors/inspect", + ); + return { + ...defaultActorQueries, + createActorInspectorHeaders(actorId: ActorId | string) { + return { + "X-RivetKit-Query": JSON.stringify({ + getForId: { actorId }, + }), + Authorization: `Bearer ${token}`, + }; + }, + createActorInspector(actorId: ActorId | string) { + return createActorInspectorClient(newUrl.href, { + headers: this.createActorInspectorHeaders(actorId), + }); + }, + } satisfies typeof defaultActorQueries; + }, [url, token]); + + return ( + + + + + + + + + +
+ +
+
+
+ +
+
+
+ ); +} + +function Modals() { + const search = Route.useSearch(); + const navigate = Route.useNavigate(); + + const posthog = usePostHog(); + + const FeedbackDialog = useDialog.Feedback.Dialog; + const GoToActorDialog = useDialog.GoToActor.Dialog; + const CreateActorDialog = useDialog.CreateActor.Dialog; + + const { modal, utm_source } = search; + + const handleOnOpenChange = (value: boolean) => { + if (!value) { + navigate({ search: (old) => ({ ...old, modal: undefined }) }); + } else { + posthog.capture("survey shown", { $survey_id: FEEDBACK_FORM_ID }); + } + }; + return ( -
- -
+ <> + + { + navigate({ + to: ".", + search: (old) => ({ + ...old, + actorId, + modal: undefined, + }), + }); + }} + dialogProps={{ + open: modal === "go-to-actor", + onOpenChange: (value) => { + if (!value) { + navigate({ + search: (old) => ({ + ...old, + modal: undefined, + }), + }); + } + }, + }} + /> + { + if (!value) { + navigate({ + search: (old) => ({ + ...old, + modal: undefined, + }), + }); + } + }, + }} + /> + ); } diff --git a/frontend/apps/studio/src/routes/_layout/index.tsx b/frontend/apps/studio/src/routes/_layout/index.tsx index d606a91a8a..1be2bb35bb 100644 --- a/frontend/apps/studio/src/routes/_layout/index.tsx +++ b/frontend/apps/studio/src/routes/_layout/index.tsx @@ -1,57 +1,22 @@ import { Actors } from "@/components/actors"; -import { - connectionEffect, - connectionStateAtom, - initiallyConnectedAtom, -} from "@/stores/manager"; import { Button, Card, CardContent, - CardFooter, CardHeader, CardTitle, - CodeFrame, - CodeGroup, - CodeSource, DocsSheet, H1, - Link, - Strong, } from "@rivet-gg/components"; -import { - ActorsListFiltersSchema, - currentActorIdAtom, -} from "@rivet-gg/components/actors"; -import { - Icon, - faBrave, - faChrome, - faReact, - faSafari, - faTs, -} from "@rivet-gg/icons"; +import { Icon, faNodeJs, faReact } from "@rivet-gg/icons"; import { createFileRoute } from "@tanstack/react-router"; import { zodValidator } from "@tanstack/zod-adapter"; -import { AnimatePresence, motion } from "framer-motion"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { useEffect } from "react"; +import { useEffect, useLayoutEffect, useRef } from "react"; import { z } from "zod"; -// @ts-expect-error types are missing -import devNpm, { source as devNpmSource } from "../../content/dev-npm.sh?shiki"; -import devPnpm, { - source as devPnpmSource, - // @ts-expect-error types are missing -} from "../../content/dev-pnpm.sh?shiki"; -import devYarn, { - source as devYarnSource, - // @ts-expect-error types are missing -} from "../../content/dev-yarn.sh?shiki"; - -import devBun, { - source as devBunSource, - // @ts-expect-error types are missing -} from "../../content/dev-bun.sh?shiki"; +import { useManagerQueries } from "@rivet-gg/components/actors"; +import { useQuery } from "@tanstack/react-query"; +import { ConnectionForm } from "@/components/connection-form"; +import { docsLinks } from "@/content/data"; export const Route = createFileRoute("/_layout/")({ component: RouteComponent, @@ -60,163 +25,120 @@ export const Route = createFileRoute("/_layout/")({ .object({ actorId: z.string().optional(), tab: z.string().optional(), + url: z.string().optional(), }) - .merge(ActorsListFiltersSchema), + .and(z.record(z.string(), z.any())), ), }); function RouteComponent() { - useAtom(connectionEffect); + const { actorId, u = "http://localhost:8080", t } = Route.useSearch(); + + const navigate = Route.useNavigate(); + const { setToken, token, ...queries } = useManagerQueries(); - const isInitiallyConnected = useAtomValue(initiallyConnectedAtom); - const status = useAtomValue(connectionStateAtom); + const { isSuccess } = useQuery(queries.managerStatusQueryOptions()); + const previouslyConnected = useRef(isSuccess); - const { actorId } = Route.useSearch(); + const ref = useRef(null); - const setCurrentActorId = useSetAtom(currentActorIdAtom); + useLayoutEffect(() => { + if (u && t) { + ref.current?.requestSubmit(); + } + }, [u, t]); useEffect(() => { - setCurrentActorId(actorId); - }, [actorId, setCurrentActorId]); + if (isSuccess && !previouslyConnected.current) { + previouslyConnected.current = true; + } + }, [isSuccess]); - return ( - - {status === "disconnected" && !isInitiallyConnected ? ( - -

Rivet Studio

- - - Getting Started - - -

- Get started with one of our quick start guides: -

-
-
- - - - - - -
-
-
-
- - - Connect to Project - - -

- Connect Rivet Studio to your ActorCore project, - using the following command: -

+ if ((token && previouslyConnected.current) || isSuccess) { + return ; + } - - - {devNpm} - - - {devPnpm} - - +

Rivet Studio

+ + + Getting Started + + +

Get started with one of our quick start guides:

+
+
+ + + +
+
+
+
- - - Having trouble connecting? - - -

- Rivet Studio works best in{" "} - - Chrome - - . Some browsers like{" "} - - Safari - {" "} - and{" "} - - Brave - {" "} - block access to localhost by default. -

-
- -

- Having issues? Join the{" "} - - Rivet Discord - {" "} - or{" "} - - file a GitHub Issue - - . -

-
-
-
- ) : ( - - )} -
+ + + Connect to Project + + +

+ Connect to your RivetKit project by entering the URL and + access token. +

+ + { + try { + await queries.getManagerStatus({ + token: values.token, + url: values.username, + }); + setToken(values.username, values.token); + navigate({ + to: ".", + search: (old) => ({ + ...old, + u: values.username, + modal: undefined, + }), + }); + } catch (error) { + form.setError("token", { + message: + "Failed to connect. Please check your URL and token.", + }); + } + }} + /> +
+
+ ); } diff --git a/frontend/apps/studio/src/stores/manager.tsx b/frontend/apps/studio/src/stores/manager.tsx index 1a5d863b64..822b9fe9a3 100644 --- a/frontend/apps/studio/src/stores/manager.tsx +++ b/frontend/apps/studio/src/stores/manager.tsx @@ -1,207 +1,207 @@ -import { toast } from "@rivet-gg/components"; -import { - type Actor, - ActorFeature, - actorBuildsAtom, - actorsAtom, - createActorAtom, -} from "@rivet-gg/components/actors"; -import { createClient } from "actor-core/client"; -import { - type Actor as InspectorActor, - type ToClient, - ToClientSchema, - type ToServer, -} from "actor-core/inspector/protocol/manager"; -import { atom } from "jotai"; -import { atomEffect } from "jotai-effect"; - -const createConnection = ({ - onMessage, - onConnect, - onDisconnect, -}: { - onMessage?: (msg: ToClient) => void; - onConnect?: () => void; - onDisconnect?: () => void; -} = {}) => { - const ws = new WebSocket( - `ws://localhost:${ACTOR_CORE_MANAGER_PORT}/manager/inspect`, - ); - - const connectionTimeout = setTimeout(() => { - if (ws.readyState !== WebSocket.OPEN) { - ws.close(); - } - }, 1500); - - ws.addEventListener("open", () => { - onConnect?.(); - clearTimeout(connectionTimeout); - ws.send(JSON.stringify({ type: "info" })); - }); - - ws.addEventListener("message", (event) => { - const data = JSON.parse(event.data); - const result = ToClientSchema.safeParse(data); - if (!result.success) { - console.error("Invalid data", result.error); - return; - } - if (onMessage) { - onMessage(result.data); - } - }); - - ws.addEventListener("close", () => { - onDisconnect?.(); - }); - - ws.addEventListener("error", (event) => { - console.error("WebSocket error", event); - ws.close(); - }); - - return ws; -}; - -export const ACTOR_CORE_MANAGER_PORT = 6420; - -export const connectionStateAtom = atom<"disconnected" | "connected">( - "disconnected", -); - -export const websocketAtom = atom(null); -export const initiallyConnectedAtom = atom(false); - -export const connectionEffect = atomEffect((get, set) => { - if (get.peek(websocketAtom)) { - // effect already ran - return; - } - - let ws: WebSocket | null = null; - let reconnectTimeout: ReturnType | undefined = - undefined; - - function reconnect() { - ws = createConnection({ - onConnect: () => { - set(websocketAtom, ws); - set(initiallyConnectedAtom, true); - set(connectionStateAtom, "connected"); - - toast.success("Connected to Rivet Studio", { - id: "ws-reconnect", - }); - }, - onDisconnect: () => { - set(connectionStateAtom, "disconnected"); - - if (get.peek(initiallyConnectedAtom)) { - toast.loading("Reconnecting...", { id: "ws-reconnect" }); - } - reconnectTimeout = setTimeout(() => { - reconnect(); - }, 500); - }, - onMessage: (msg) => { - if (msg.type === "info") { - set(initiallyConnectedAtom, true); - set(actorsAtom, msg.actors.map(convertActor)); - set( - actorBuildsAtom, - msg.types.map((type) => ({ - id: type, - name: type, - tags: { current: "true" }, - contentLength: 0, - createdAt: new Date(), - })), - ); - - set(connectionStateAtom, "connected"); - - const managerEndpoint = `http://localhost:${ACTOR_CORE_MANAGER_PORT}`; - set(createActorAtom, { - endpoint: managerEndpoint, - isCreating: false, - async create(values) { - const client = createClient(managerEndpoint); - - return client.create(values.id, { - params: values.params, - create: { - tags: values.tags, - region: values.region, - }, - }); - }, - }); - } - if (msg.type === "actors") { - const existingActors = get.peek(actorsAtom); - set( - actorsAtom, - msg.actors.map((actor) => { - const existingActor = existingActors.find( - (a) => a.id === actor.id, - ); - if (existingActor) { - // remove logs from existing actor (bc logs are atom, and it will cause to recreate the atom) - const { logs, ...rest } = existingActor; - return { - ...existingActor, - ...rest, - }; - } - return convertActor(actor); - }), - ); - } - }, - }); - } - - reconnect(); - - return () => { - if (ws) { - clearTimeout(reconnectTimeout); - ws.close(); - ws = null; - } - }; -}); - -export const sendAtom = atom(null, (get, _set, msg: ToServer) => { - const ws = get(websocketAtom); - if (!ws) { - console.error("WebSocket not connected"); - return; - } - ws.send(JSON.stringify(msg)); -}); - -function convertActor(actor: InspectorActor): Actor { - return { - ...actor, - logs: atom({ - status: "pending", - logs: [], - }), - endpoint: `http://localhost:${ACTOR_CORE_MANAGER_PORT}/actors/${actor.id}`, - status: "running", - region: "local", - network: null, - runtime: null, - resources: null, - lifecycle: {}, - features: [ - ActorFeature.Console, - ActorFeature.State, - ActorFeature.Connections, - ActorFeature.Config, - ], - }; -} +// import { +// ToClientSchema, +// type ToClient, +// type ToServer, +// type Actor as InspectorActor, +// } from "actor-core/inspector/protocol/manager"; +// import { toast } from "@rivet-gg/components"; +// import { atom } from "jotai"; +// import { atomEffect } from "jotai-effect"; +// import { +// type Actor, +// actorBuildsAtom, +// ActorFeature, +// actorsAtom, +// createActorAtom, +// } from "@rivet-gg/components/actors"; +// import { createClient } from "actor-core/client"; + +// const createConnection = ({ +// onMessage, +// onConnect, +// onDisconnect, +// }: { +// onMessage?: (msg: ToClient) => void; +// onConnect?: () => void; +// onDisconnect?: () => void; +// } = {}) => { +// const ws = new WebSocket( +// `ws://localhost:${ACTOR_CORE_MANAGER_PORT}/manager/inspect`, +// ); + +// const connectionTimeout = setTimeout(() => { +// if (ws.readyState !== WebSocket.OPEN) { +// ws.close(); +// } +// }, 1500); + +// ws.addEventListener("open", () => { +// onConnect?.(); +// clearTimeout(connectionTimeout); +// ws.send(JSON.stringify({ type: "info" })); +// }); + +// ws.addEventListener("message", (event) => { +// const data = JSON.parse(event.data); +// const result = ToClientSchema.safeParse(data); +// if (!result.success) { +// console.error("Invalid data", result.error); +// return; +// } +// if (onMessage) { +// onMessage(result.data); +// } +// }); + +// ws.addEventListener("close", () => { +// onDisconnect?.(); +// }); + +// ws.addEventListener("error", (event) => { +// console.error("WebSocket error", event); +// ws.close(); +// }); + +// return ws; +// }; + +// export const ACTOR_CORE_MANAGER_PORT = 6420; + +// export const connectionStateAtom = atom<"disconnected" | "connected">( +// "disconnected", +// ); + +// export const websocketAtom = atom(null); +// export const initiallyConnectedAtom = atom(false); + +// export const connectionEffect = atomEffect((get, set) => { +// if (get.peek(websocketAtom)) { +// // effect already ran +// return; +// } + +// let ws: WebSocket | null = null; +// let reconnectTimeout: ReturnType | undefined = +// undefined; + +// function reconnect() { +// ws = createConnection({ +// onConnect: () => { +// set(websocketAtom, ws); +// set(initiallyConnectedAtom, true); +// set(connectionStateAtom, "connected"); + +// toast.success("Connected to Rivet Studio", { +// id: "ws-reconnect", +// }); +// }, +// onDisconnect: () => { +// set(connectionStateAtom, "disconnected"); + +// if (get.peek(initiallyConnectedAtom)) { +// toast.loading("Reconnecting...", { id: "ws-reconnect" }); +// } +// reconnectTimeout = setTimeout(() => { +// reconnect(); +// }, 500); +// }, +// onMessage: (msg) => { +// if (msg.type === "info") { +// set(initiallyConnectedAtom, true); +// set(actorsAtom, msg.actors.map(convertActor)); +// set( +// actorBuildsAtom, +// msg.types.map((type) => ({ +// id: type, +// name: type, +// tags: { current: "true" }, +// contentLength: 0, +// createdAt: new Date(), +// })), +// ); + +// set(connectionStateAtom, "connected"); + +// const managerEndpoint = `http://localhost:${ACTOR_CORE_MANAGER_PORT}`; +// set(createActorAtom, { +// endpoint: managerEndpoint, +// isCreating: false, +// async create(values) { +// const client = createClient(managerEndpoint); + +// return client.create(values.id, { +// params: values.params, +// create: { +// tags: values.tags, +// region: values.region, +// }, +// }); +// }, +// }); +// } +// if (msg.type === "actors") { +// const existingActors = get.peek(actorsAtom); +// set( +// actorsAtom, +// msg.actors.map((actor) => { +// const existingActor = existingActors.find( +// (a) => a.id === actor.id, +// ); +// if (existingActor) { +// // remove logs from existing actor (bc logs are atom, and it will cause to recreate the atom) +// const { logs, ...rest } = existingActor; +// return { +// ...existingActor, +// ...rest, +// }; +// } +// return convertActor(actor); +// }), +// ); +// } +// }, +// }); +// } + +// reconnect(); + +// return () => { +// if (ws) { +// clearTimeout(reconnectTimeout); +// ws.close(); +// ws = null; +// } +// }; +// }); + +// export const sendAtom = atom(null, (get, _set, msg: ToServer) => { +// const ws = get(websocketAtom); +// if (!ws) { +// console.error("WebSocket not connected"); +// return; +// } +// ws.send(JSON.stringify(msg)); +// }); + +// function convertActor(actor: InspectorActor): Actor { +// return { +// ...actor, +// logs: atom({ +// status: "pending", +// logs: [], +// }), +// endpoint: `http://localhost:${ACTOR_CORE_MANAGER_PORT}/actors/${actor.id}`, +// status: "running", +// region: "local", +// network: null, +// runtime: null, +// resources: null, +// lifecycle: {}, +// features: [ +// ActorFeature.Console, +// ActorFeature.State, +// ActorFeature.Connections, +// ActorFeature.Config, +// ], +// }; +// } diff --git a/frontend/apps/studio/vite.config.ts b/frontend/apps/studio/vite.config.ts index b6b701fd90..ff650fdf3a 100644 --- a/frontend/apps/studio/vite.config.ts +++ b/frontend/apps/studio/vite.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ vitePluginFaviconsInject( path.resolve(__dirname, "public", "favicon.svg"), { - appName: "Actor Core ⋅ Studio", + appName: "RivetKit ⋅ Studio", theme_color: "#ff4f00", }, ), diff --git a/frontend/packages/components/package.json b/frontend/packages/components/package.json index 5494e08b03..7555381d1d 100644 --- a/frontend/packages/components/package.json +++ b/frontend/packages/components/package.json @@ -32,6 +32,7 @@ "dependencies": { "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-json": "^6.0.1", + "@codemirror/merge": "^6.10.2", "@codemirror/view": "^6.28.4", "@fortawesome/fontawesome-svg-core": "^6.5.2", "@fortawesome/free-brands-svg-icons": "^6.5.2", @@ -59,9 +60,13 @@ "@radix-ui/react-tooltip": "^1.1.1", "@radix-ui/react-visually-hidden": "^1.0.3", "@rivet-gg/icons": "workspace:^", + "@rivetkit/actor": "^0.9.0", + "@rivetkit/core": "^0.9.0", "@sentry/react": "^8.26.0", "@shikijs/langs": "^3.2.1", "@tailwindcss/container-queries": "^0.1.1", + "@tanstack/query-core": "^5.81.5", + "@tanstack/react-query": "^5.81.5", "@tanstack/react-table": "^8.21.2", "@tanstack/react-virtual": "^3.10.8", "@uiw/codemirror-extensions-basic-setup": "^4.23.0", diff --git a/frontend/packages/components/src/actors/actor-build.tsx b/frontend/packages/components/src/actors/actor-build.tsx index 8416f0dd4e..45def57fc4 100644 --- a/frontend/packages/components/src/actors/actor-build.tsx +++ b/frontend/packages/components/src/actors/actor-build.tsx @@ -1,30 +1,16 @@ import { Dd, DiscreteCopyButton, Dl, Dt, Flex } from "@rivet-gg/components"; import { formatISO } from "date-fns"; -import { useAtomValue } from "jotai"; -import { selectAtom } from "jotai/utils"; -import { useCallback } from "react"; -import { type Actor, type ActorAtom, actorBuildsAtom } from "./actor-context"; -import { ActorTags } from "./actor-tags"; - -const buildIdSelector = (a: Actor) => a.runtime?.build; +import { useQuery } from "@tanstack/react-query"; +import { type ActorId } from "./queries"; +import { useManagerQueries } from "./manager-queries-context"; interface ActorBuildProps { - actor: ActorAtom; + actorId: ActorId; } -export function ActorBuild({ actor }: ActorBuildProps) { - const buildId = useAtomValue(selectAtom(actor, buildIdSelector)); - - const data = useAtomValue( - selectAtom( - actorBuildsAtom, - useCallback( - (builds) => { - return builds.find((build) => build.id === buildId); - }, - [buildId], - ), - ), +export function ActorBuild({ actorId }: ActorBuildProps) { + const { data } = useQuery( + useManagerQueries().actorBuildQueryOptions(actorId), ); if (!data) { @@ -40,11 +26,7 @@ export function ActorBuild({ actor }: ActorBuildProps) {
ID
- + {data.id}
@@ -60,12 +42,7 @@ export function ActorBuild({ actor }: ActorBuildProps) {
Tags
- + { + mutate(); + }} + > + + + } + /> + ); +} diff --git a/frontend/packages/components/src/actors/actor-config-tab.tsx b/frontend/packages/components/src/actors/actor-config-tab.tsx index 2a05406888..e1f6454212 100644 --- a/frontend/packages/components/src/actors/actor-config-tab.tsx +++ b/frontend/packages/components/src/actors/actor-config-tab.tsx @@ -4,9 +4,10 @@ import type { ActorAtom } from "./actor-context"; import { ActorGeneral } from "./actor-general"; import { ActorNetwork } from "./actor-network"; import { ActorRuntime } from "./actor-runtime"; +import type { ActorId } from "./queries"; interface ActorConfigTabProps { - actor: ActorAtom; + actorId: ActorId; } export function ActorConfigTab(props: ActorConfigTabProps) { diff --git a/frontend/packages/components/src/actors/actor-connections-tab.tsx b/frontend/packages/components/src/actors/actor-connections-tab.tsx index 42be046a7c..9adc5dc854 100644 --- a/frontend/packages/components/src/actors/actor-connections-tab.tsx +++ b/frontend/packages/components/src/actors/actor-connections-tab.tsx @@ -1,36 +1,39 @@ import { LiveBadge, ScrollArea } from "@rivet-gg/components"; -import { useAtomValue } from "jotai"; -import { selectAtom } from "jotai/utils"; -import type { Actor, ActorAtom } from "./actor-context"; import { ActorObjectInspector } from "./console/actor-inspector"; -import { - useActorConnections, - useActorWorkerStatus, -} from "./worker/actor-worker-context"; - -const selector = (a: Actor) => a.destroyedAt; +import { useQuery } from "@tanstack/react-query"; +import { useActorConnectionsStream, type ActorId } from "./queries"; +import { useManagerQueries } from "./manager-queries-context"; +import { useActorQueries } from "./actor-queries-context"; interface ActorConnectionsTabProps { - actor: ActorAtom; + actorId: ActorId; } -export function ActorConnectionsTab({ actor }: ActorConnectionsTabProps) { - const destroyedAt = useAtomValue(selectAtom(actor, selector)); - const status = useActorWorkerStatus(); +export function ActorConnectionsTab({ actorId }: ActorConnectionsTabProps) { + const { data: destroyedAt } = useQuery( + useManagerQueries().actorDestroyedAtQueryOptions(actorId), + ); + + const actorQueries = useActorQueries(); + const { + data: { connections } = {}, + isError, + isLoading, + } = useQuery(actorQueries.actorConnectionsQueryOptions(actorId)); - const connections = useActorConnections(); + useActorConnectionsStream(actorId); if (destroyedAt) { return ( -
+
Connections Preview is unavailable for inactive Actors.
); } - if (status.type === "error") { + if (isError) { return ( -
+
Connections Preview is currently unavailable.
See console/logs for more details. @@ -38,9 +41,9 @@ export function ActorConnectionsTab({ actor }: ActorConnectionsTabProps) { ); } - if (status.type !== "ready") { + if (isLoading) { return ( -
+
Loading connections...
); @@ -48,13 +51,13 @@ export function ActorConnectionsTab({ actor }: ActorConnectionsTabProps) { return ( -
+
[c.id, c]))} + data={connections} expandPaths={["$"]} />
diff --git a/frontend/packages/components/src/actors/actor-context.tsx b/frontend/packages/components/src/actors/actor-context.tsx index 9ad799b6a1..4fd0d20024 100644 --- a/frontend/packages/components/src/actors/actor-context.tsx +++ b/frontend/packages/components/src/actors/actor-context.tsx @@ -1,437 +1,4 @@ -import type { Rivet } from "@rivet-gg/api"; -import { isAfter, isBefore } from "date-fns"; -import { type Atom, atom } from "jotai"; -import { atomFamily, splitAtom } from "jotai/utils"; -import { toRecord } from "../lib/utils"; -import { FilterOp, type FilterValue } from "../ui/filters"; -import { ACTOR_FRAMEWORK_TAG_VALUE } from "./actor-tags"; - -export enum ActorFeature { - Logs = "logs", - Config = "config", - Connections = "connections", - State = "state", - Console = "console", - Runtime = "runtime", - Metrics = "metrics", - InspectReconnectNotification = "inspect_reconnect_notification", -} - -export type Actor = Omit< - Rivet.actor.Actor, - "createdAt" | "runtime" | "lifecycle" | "network" | "resources" -> & { - status: "unknown" | "starting" | "running" | "stopped" | "crashed"; - - lifecycle?: Rivet.actor.Lifecycle; - endpoint?: string; - logs: LogsAtom; - metrics: MetricsAtom; - network?: Rivet.actor.Network | null; - resources?: Rivet.actor.Resources | null; - runtime?: Rivet.actor.Runtime | null; - destroy?: DestroyActorAtom; - destroyTs?: Date; - createdAt?: Date; - features?: ActorFeature[]; -}; - -export type Logs = { - id: string; - level: "error" | "info"; - timestamp: Date; - line: string; - message: string; - properties: Record; -}[]; - -export type Metrics = Record; - -export type Build = Rivet.actor.Build; -export type DestroyActor = { - isDestroying: boolean; - destroy: () => Promise; -}; - -export type ActorAtom = Atom; -export type LogsAtom = Atom<{ - logs: Logs; - // query status - status: string; -}>; -export type MetricsAtom = Atom<{ - metrics: Metrics; - updatedAt: number; - // query status - status: string; -}>; -export type BuildAtom = Atom; -export type DestroyActorAtom = Atom; - -export type CreateActor = { - create: (values: { - endpoint: string; - id: string; - tags: Record; - region?: string; - params?: Record; - }) => Promise; - isCreating: boolean; - endpoint: string | null; -}; - -export type Region = Rivet.actor.Region; - -// global atoms -export const currentActorIdAtom = atom(undefined); - -export const currentActorQueryAtom = atom<{ - isLoading: boolean; - error: string | null; -}>({ - isLoading: false, - error: null, -}); -export const actorsQueryAtom = atom<{ - isLoading: boolean; - error: string | null; -}>({ - isLoading: false, - error: null, -}); -export const actorsAtom = atom([]); -export const actorFiltersAtom = atom<{ - tags: FilterValue; - region: FilterValue; - createdAt: FilterValue; - destroyedAt: FilterValue; - status: FilterValue; - devMode: FilterValue; -}>({ - tags: undefined, - region: undefined, - createdAt: undefined, - destroyedAt: undefined, - status: undefined, - devMode: undefined, -}); -export const actorsPaginationAtom = atom({ - hasNextPage: false, - isFetchingNextPage: false, - fetchNextPage: () => {}, -}); - -export const actorRegionsAtom = atom([ - { - id: "local", - name: "Local", - }, -]); - -export const actorBuildsAtom = atom([]); - -export const actorEnvironmentAtom = atom<{ - projectNameId: string; - environmentNameId: string; -} | null>(null); - -export const actorMetricsTimeWindowAtom = atom(15 * 60 * 1000); // Default to 15 minutes - -export const actorsInternalFilterAtom = atom<{ - fn: (actor: Actor) => boolean; -}>(); - -// derived atoms - -export const currentActorRegionAtom = atom((get) => { - const actorAtom = get(currentActorAtom); - if (!actorAtom) { - return undefined; - } - const regions = get(actorRegionsAtom); - const actor = get(actorAtom); - return regions.find((region) => region.id === actor.region); -}); -export const filteredActorsAtom = atom((get) => { - const filters = get(actorFiltersAtom); - const actors = get(actorsAtom); - - const isActorInternal = get(actorsInternalFilterAtom)?.fn; - - return actors.filter((actor) => { - const satisfiesFilters = Object.entries(filters).every( - ([key, filter]) => { - if (filter === undefined) { - return true; - } - if (key === "tags") { - const filterTags = filter.value.map((tag) => - tag.split("="), - ); - const tags = toRecord(actor.tags); - - if (filter.operator === FilterOp.NOT_EQUAL) { - return Object.entries(tags).every( - ([tagKey, tagValue]) => { - return filterTags.every( - ([filterKey, filterValue]) => { - if (filterKey === tagKey) { - if (filterValue === "*") { - return false; - } - return tagValue !== filterValue; - } - return true; - }, - ); - }, - ); - } - - return Object.entries(tags).some(([tagKey, tagValue]) => { - return filterTags.some(([filterKey, filterValue]) => { - if (filterKey === tagKey) { - if (filterValue === "*") { - return true; - } - return tagValue === filterValue; - } - return false; - }); - }); - } - - if (key === "region") { - if (filter.operator === FilterOp.NOT_EQUAL) { - return !filter.value.includes(actor.region); - } - - return filter.value.includes(actor.region); - } - - if (key === "createdAt") { - if (actor.createdAt === undefined) { - return false; - } - const createdAt = new Date(actor.createdAt); - - if (filter.operator === FilterOp.AFTER) { - return isAfter(createdAt, +filter.value[0]); - } - if (filter.operator === FilterOp.BEFORE) { - return isBefore(createdAt, +filter.value[0]); - } - if (filter.operator === FilterOp.BETWEEN) { - return ( - isAfter(createdAt, +filter.value[0]) && - isBefore(createdAt, +filter.value[1]) - ); - } - return false; - } - - if (key === "destroyedAt") { - if (actor.destroyTs === undefined) { - return false; - } - const destroyedAt = new Date(actor.destroyTs); - - if (filter.operator === FilterOp.AFTER) { - return isAfter(destroyedAt, +filter.value[0]); - } - if (filter.operator === FilterOp.BEFORE) { - return isBefore(destroyedAt, +filter.value[0]); - } - if (filter.operator === FilterOp.BETWEEN) { - return ( - isAfter(destroyedAt, +filter.value[0]) && - isBefore(destroyedAt, +filter.value[1]) - ); - } - return false; - } - - if (key === "status") { - if (filter.operator === FilterOp.NOT_EQUAL) { - return !filter.value.includes(actor.status); - } - - return filter.value.includes(actor.status); - } - - return true; - }, - ); - - const isInternal = - toRecord(actor.tags).owner === "rivet" || - (isActorInternal?.(actor) ?? false); - - return ( - satisfiesFilters && ((isInternal && filters.devMode) || !isInternal) - ); - }); -}); -export const actorsAtomsAtom = splitAtom( - filteredActorsAtom, - (actor) => actor.id, -); -export const actorsCountAtom = atom((get) => get(actorsAtom).length); -export const filteredActorsCountAtom = atom( - (get) => get(filteredActorsAtom).length, -); - -export const currentActorAtom = atom((get) => { - const actorId = get(currentActorIdAtom); - return get(actorsAtomsAtom).find((actor) => get(actor).id === actorId); -}); - -export const isCurrentActorAtom = atomFamily((actor: ActorAtom) => - atom((get) => { - const actorId = get(currentActorIdAtom); - return get(actor).id === actorId; - }), -); - -export const actorFiltersCountAtom = atom((get) => { - const filters = get(actorFiltersAtom); - return Object.values(filters).filter((value) => value !== undefined).length; -}); - -// tags created by the user, not from the server -export const actorCustomTagValues = atom([]); -export const actorCustomTagKeys = atom([]); - -const actorCustomTagsAtom = atom<{ keys: string[]; values: string[] }>( - (get) => { - const keys = get(actorCustomTagKeys); - const values = get(actorCustomTagValues); - - return { keys, values }; - }, - // @ts-expect-error - (get, set, value: { key: string; value: string }) => { - set(actorCustomTagKeys, (keys) => { - const newKeys = [...keys]; - const index = newKeys.indexOf(value.key); - if (index === -1) { - newKeys.push(value.key); - } - return newKeys; - }); - set(actorCustomTagValues, (values) => { - const newValues = [...values]; - const index = newValues.indexOf(value.value); - if (index === -1) { - newValues.push(value.value); - } - return newValues; - }); - }, -); - -export const createActorAtom = atom({ - endpoint: null, - isCreating: false, - create: async () => {}, -}); - -export const actorManagerEndpointAtom = atom((get) => { - return get(createActorAtom)?.endpoint ?? null; -}); - -export const actorTagsAtom = atom((get) => { - const actorTags = get(actorsAtom).flatMap((actor) => - Object.entries(toRecord(actor.tags)).map(([key, value]) => ({ - key, - value: value as string, - })), - ); - - const keys = new Set(); - const values = new Set(); - - for (const { key, value } of actorTags) { - keys.add(key); - values.add(value); - } - - const customTags = get(actorCustomTagsAtom); - - for (const key of customTags.keys) { - keys.add(key); - } - - for (const value of customTags.values) { - values.add(value); - } - - const allTags = []; - - for (const key of keys) { - for (const value of values) { - allTags.push({ key, value }); - } - } - - return allTags; -}); - -export const actorTagValuesAtom = atom((get) => { - const tags = get(actorTagsAtom); - const values = new Set(); - for (const tag of tags) { - values.add(tag.value); - } - return [...values]; -}); - -export const actorTagKeysAtom = atom((get) => { - const tags = get(actorTagsAtom); - const keys = new Set(); - for (const tag of tags) { - keys.add(tag.key); - } - return [...keys]; -}); - -export const actorBuildsCountAtom = atom((get) => { - return get(actorBuildsAtom).length; -}); - -const commonActorFeatures = [ - ActorFeature.Logs, - ActorFeature.Config, - ActorFeature.Runtime, - ActorFeature.Metrics, - ActorFeature.InspectReconnectNotification, -]; - -export const currentActorFeaturesAtom = atom((get) => { - const atom = get(currentActorAtom); - if (!atom) { - return []; - } - - const actor = get(atom); - - // actors from hub - if (!actor.features) { - const tags = toRecord(actor.tags); - if (tags.framework === ACTOR_FRAMEWORK_TAG_VALUE) { - if (tags.name === "manager") { - return commonActorFeatures; - } - return [ - ...commonActorFeatures, - ActorFeature.Connections, - ActorFeature.State, - ActorFeature.Console, - ActorFeature.InspectReconnectNotification, - ]; - } - return commonActorFeatures; - } - - return actor.features; -}); +export { + createManagerInspectorClient, + createActorInspectorClient, +} from "@rivetkit/core/inspector"; diff --git a/frontend/packages/components/src/actors/actor-database.tsx b/frontend/packages/components/src/actors/actor-database.tsx new file mode 100644 index 0000000000..544e306efd --- /dev/null +++ b/frontend/packages/components/src/actors/actor-database.tsx @@ -0,0 +1,149 @@ +import { useState } from "react"; +import type { ActorId } from "./queries"; +import { useQuery } from "@tanstack/react-query"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { Icon, faRefresh, faTable, faTableCells } from "@rivet-gg/icons"; +import { Flex } from "../ui/flex"; +import { DatabaseTable } from "./database/database-table"; +import { ScrollArea } from "../ui/scroll-area"; +import { Button } from "../ui/button"; +import { WithTooltip } from "../ui/tooltip"; +import { ShimmerLine } from "../shimmer-line"; +import { useActorQueries } from "./actor-queries-context"; + +interface ActorDatabaseProps { + actorId: ActorId; +} + +export function ActorDatabase({ actorId }: ActorDatabaseProps) { + const actorQueries = useActorQueries(); + const { data, refetch } = useQuery( + actorQueries.actorDatabaseQueryOptions(actorId), + ); + const [table, setTable] = useState( + () => data?.db[0]?.table.name, + ); + + const selectedTable = table || data?.db[0]?.table.name; + + const { + data: rows, + refetch: refetchData, + isLoading, + } = useQuery( + actorQueries.actorDatabaseRowsQueryOptions(actorId, selectedTable!, { + enabled: !!selectedTable, + }), + ); + + const currentTable = data?.db.find((db) => db.table.name === selectedTable); + + return ( + <> +
+
+ +
+
+ + + {currentTable ? ( + <> + {currentTable.table.schema}. + {currentTable.table.name} + + ({currentTable.columns.length} columns,{" "} + {currentTable.records} rows) + + + ) : ( + + No table selected + + )} + +
+
+ { + refetch(); + refetchData(); + }} + > + + + } + /> +
+
+
+ {isLoading ? : null} + + {currentTable ? ( + + ) : null} + +
+ + ); +} + +function TableSelect({ + actorId, + value, + onSelect, +}: { + actorId: ActorId; + onSelect: (table: string) => void; + value: string | undefined; +}) { + const actorQueries = useActorQueries(); + const { data: tables } = useQuery( + actorQueries.actorDatabaseTablesQueryOptions(actorId), + ); + + return ( + + ); +} diff --git a/frontend/packages/components/src/actors/actor-db-tab.tsx b/frontend/packages/components/src/actors/actor-db-tab.tsx new file mode 100644 index 0000000000..025e945463 --- /dev/null +++ b/frontend/packages/components/src/actors/actor-db-tab.tsx @@ -0,0 +1,61 @@ +import type { ActorId } from "./queries"; +import { useQuery } from "@tanstack/react-query"; +import { Info } from "./actor-state-tab"; +import { DocsSheet } from "../docs-sheet"; +import { Button } from "../ui/button"; +import { ActorDatabase } from "./actor-database"; +import { useManagerQueries } from "./manager-queries-context"; +import { useActorQueries } from "./actor-queries-context"; + +interface ActorDatabaseTabProps { + actorId: ActorId; +} + +export function ActorDatabaseTab({ actorId }: ActorDatabaseTabProps) { + const { data: destroyedAt } = useQuery( + useManagerQueries().actorDestroyedAtQueryOptions(actorId), + ); + + const actorQueries = useActorQueries(); + const { + data: isEnabled, + isLoading, + isError, + } = useQuery(actorQueries.actorDatabaseEnabledQueryOptions(actorId)); + + if (destroyedAt) { + return Database Studio is unavailable for inactive Actors.; + } + + if (isError) { + return ( + + Database Studio is currently unavailable. +
+ See console/logs for more details. +
+ ); + } + + if (isLoading) { + return Loading...; + } + + if (!isEnabled) { + return ( + +

+ Database Studio is not enabled for this Actor.
You + can enable it by providing a valid database connection + provider. +

+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/frontend/packages/components/src/actors/actor-details-settings-button.tsx b/frontend/packages/components/src/actors/actor-details-settings-button.tsx index 9e4684bd40..abecb8181c 100644 --- a/frontend/packages/components/src/actors/actor-details-settings-button.tsx +++ b/frontend/packages/components/src/actors/actor-details-settings-button.tsx @@ -1,5 +1,6 @@ import { Button, + cn, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, @@ -9,7 +10,13 @@ import { import { Icon, faCog } from "@rivet-gg/icons"; import { useActorDetailsSettings } from "./actor-details-settings"; -export function ActorDetailsSettingsButton() { +interface ActorDetailsSettingsButtonProps { + className?: string; +} + +export function ActorDetailsSettingsButton({ + className, +}: ActorDetailsSettingsButtonProps) { const [settings, setSettings] = useActorDetailsSettings(); return ( @@ -18,7 +25,7 @@ export function ActorDetailsSettingsButton() { trigger={ } diff --git a/frontend/packages/components/src/actors/actor-editable-state.tsx b/frontend/packages/components/src/actors/actor-editable-state.tsx index e54e474548..309f9d0928 100644 --- a/frontend/packages/components/src/actors/actor-editable-state.tsx +++ b/frontend/packages/components/src/actors/actor-editable-state.tsx @@ -1,4 +1,10 @@ -import { Badge, Button, WithTooltip } from "@rivet-gg/components"; +import { + Badge, + Button, + LiveBadge, + PauseBadge, + WithTooltip, +} from "@rivet-gg/components"; import { type CodeMirrorRef, EditorView, @@ -8,8 +14,12 @@ import { Icon, faRotateLeft, faSave } from "@rivet-gg/icons"; import { AnimatePresence, motion } from "framer-motion"; import { useMemo, useRef, useState } from "react"; import { ActorStateChangeIndicator } from "./actor-state-change-indicator"; -import type { ContainerState } from "./worker/actor-worker-container"; import { useActorWorker } from "./worker/actor-worker-context"; +import { + type ActorId, + useActorStatePatchMutation, + useActorStateStream, +} from "./queries"; const isValidJson = (json: string | null): json is string => { if (!json) return false; @@ -22,27 +32,36 @@ const isValidJson = (json: string | null): json is string => { }; interface ActorEditableStateProps { - state: ContainerState["state"]; + actorId: ActorId; + state: unknown; } -export function ActorEditableState({ state }: ActorEditableStateProps) { - const container = useActorWorker(); +export function ActorEditableState({ + state, + actorId, +}: ActorEditableStateProps) { const [isEditing, setIsEditing] = useState(false); const [value, setValue] = useState(null); const ref = useRef(null); const formatted = useMemo(() => { - return JSON.stringify(state.value || "{}", null, 2); - }, [state.value]); + return JSON.stringify(state || "{}", null, 2); + }, [state]); const isValid = isValidJson(value) ? JSON.parse(value) : false; + const { mutate, isPending } = useActorStatePatchMutation(actorId); + + useActorStateStream(actorId); + return ( <> -
+
- + {isEditing ? : } + +
@@ -68,9 +87,10 @@ export function ActorEditableState({ state }: ActorEditableStateProps) { + } + /> +
+ {isLive ? : } +
+
+
+
+
+ +
+
+
+ Timestamp +
+
Connection
+
Event
+
Name
+
Data
+
+ + +
+
+
+
+ ); +} + +ActorEvents.Skeleton = () => { + return ( +
+ +
+ ); +}; + +function useScrollToBottom( + ref: React.RefObject, + deps: unknown[], +) { + const [settings] = useActorDetailsSettings(); + const follow = useRef(true); + const shouldFollow = () => settings.autoFollowLogs && follow.current; + useResizeObserver({ + ref, + onResize: () => { + if (shouldFollow()) { + // https://github.com/TanStack/virtual/issues/537 + requestAnimationFrame(() => { + ref.current?.scrollTo({ + top: ref.current.scrollHeight, + behavior: "smooth", + }); + }); + } + }, + }); + + const onScroll = useCallback((e: React.UIEvent) => { + follow.current = + e.currentTarget.scrollHeight - e.currentTarget.scrollTop <= + e.currentTarget.clientHeight; + }, []); + + useEffect(() => { + if (!shouldFollow()) { + return () => {}; + } + // https://github.com/TanStack/virtual/issues/537 + const rafId = requestAnimationFrame(() => { + ref.current?.scrollTo({ + top: ref.current.scrollHeight, + behavior: "smooth", + }); + }); + + return () => { + cancelAnimationFrame(rafId); + }; + }, deps); + + return { onScroll }; +} diff --git a/frontend/packages/components/src/actors/actor-filters-context.tsx b/frontend/packages/components/src/actors/actor-filters-context.tsx new file mode 100644 index 0000000000..d0e58774a9 --- /dev/null +++ b/frontend/packages/components/src/actors/actor-filters-context.tsx @@ -0,0 +1,255 @@ +import { + faTag, + faCalendarCirclePlus, + faCalendarCircleMinus, + faSignalBars, + faGlobe, + faCode, +} from "@rivet-gg/icons"; +import { createContext, useContext } from "react"; +import { + FilterOp, + type FilterDefinitions, + createFiltersPicker, + createFiltersRemover, + createFiltersSchema, + type OptionsProviderProps, +} from "../ui/filters"; +import { ActorRegion } from "./actor-region"; +import { ActorStatus } from "./actor-status"; +import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; +import { CommandGroup, CommandItem } from "cmdk"; +import { cn } from "../lib/utils"; +import { ActorTag } from "./actor-tags"; +import { useManagerQueries } from "./manager-queries-context"; +import type { ActorStatus as ActorStatusType } from "./queries"; +import { Checkbox } from "../ui/checkbox"; + +export const ACTORS_FILTERS_DEFINITIONS = { + tags: { + type: "select", + label: "Tags", + icon: faTag, + options: TagsOptions, + operators: { + [FilterOp.EQUAL]: "is one of", + [FilterOp.NOT_EQUAL]: "is not one of", + }, + }, + createdAt: { + type: "date", + label: "Created", + icon: faCalendarCirclePlus, + }, + destroyedAt: { + type: "date", + label: "Destroyed", + icon: faCalendarCircleMinus, + }, + status: { + type: "select", + label: "Status", + icon: faSignalBars, + options: StatusOptions, + display: ({ value }) => { + if (value.length > 1) { + return {value.length} statuses; + } + return ( + + ); + }, + }, + region: { + type: "select", + label: "Region", + icon: faGlobe, + options: RegionOptions, + display: ({ value }) => { + if (value.length > 1) { + return {value.length} regions; + } + + return ; + }, + operators: { + [FilterOp.EQUAL]: "is one of", + [FilterOp.NOT_EQUAL]: "is not one of", + }, + }, + devMode: { + type: "boolean", + label: "Show hidden actors", + icon: faCode, + }, +} satisfies FilterDefinitions; + +const defaultActorFiltersContextValue = { + definitions: ACTORS_FILTERS_DEFINITIONS, + get pick() { + return createFiltersPicker(this.definitions); + }, + get schema() { + return createFiltersSchema(this.definitions); + }, + get remove() { + return createFiltersRemover(this.definitions); + }, +}; + +export const ActorsFilters = createContext(defaultActorFiltersContextValue); + +export const ActorsFiltersProvider = ActorsFilters.Provider; + +export const useActorsFilters = () => { + const context = useContext(ActorsFilters); + if (!context) { + throw new Error("useActorsFilters must be used within ActorsFilters"); + } + return context; +}; + +function StatusOptions({ onSelect, value: filterValue }: OptionsProviderProps) { + return ( + + {["running", "starting", "crashed", "stopped"].map((key) => { + const isSelected = filterValue.some((val) => val === key); + return ( + { + if (isSelected) { + onSelect( + filterValue.filter( + (filterKey) => filterKey !== key, + ), + { closeAfter: true }, + ); + return; + } + + onSelect([...filterValue, key], { + closeAfter: true, + }); + }} + > + + + + ); + })} + + ); +} + +function RegionOptions({ onSelect, value: filterValue }: OptionsProviderProps) { + const { data: regions = [] } = useQuery( + useManagerQueries().regionsQueryOptions(), + ); + return ( + + {regions.map(({ id, name }) => { + const isSelected = filterValue.some((val) => val === id); + return ( + { + if (isSelected) { + onSelect( + filterValue.filter( + (filterKey) => filterKey !== id, + ), + { closeAfter: true }, + ); + return; + } + + onSelect([...filterValue, id], { + closeAfter: true, + }); + }} + > + + + + ); + })} + + ); +} + +function TagsOptions({ onSelect, value: filterValue }: OptionsProviderProps) { + const { data: tags = [] } = useInfiniteQuery( + useManagerQueries().actorsTagsQueryOptions(), + ); + + const values = filterValue.map((filter) => filter.split("=")); + + return ( + + {tags.map(({ key, value }) => { + const isSelected = values.some( + ([filterKey, filterValue]) => + filterKey === key && filterValue === value, + ); + return ( + { + if (isSelected) { + onSelect( + values + .filter( + ([filterKey, filterValue]) => + filterKey !== key || + filterValue !== value, + ) + .map((pair) => pair.join("=")), + { closeAfter: true }, + ); + return; + } + onSelect([...filterValue, `${key}=${value}`], { + closeAfter: true, + }); + }} + > + + + + {key}={value} + + + + ); + })} + + ); +} diff --git a/frontend/packages/components/src/actors/actor-general.tsx b/frontend/packages/components/src/actors/actor-general.tsx index b9269fb39e..3c27a31682 100644 --- a/frontend/packages/components/src/actors/actor-general.tsx +++ b/frontend/packages/components/src/actors/actor-general.tsx @@ -1,27 +1,18 @@ import { Dd, DiscreteCopyButton, Dl, Dt, Flex, cn } from "@rivet-gg/components"; import { formatISO } from "date-fns"; -import equal from "fast-deep-equal"; -import { useAtomValue } from "jotai"; -import { selectAtom } from "jotai/utils"; -import type { Actor, ActorAtom } from "./actor-context"; import { ActorRegion } from "./actor-region"; import { ActorTags } from "./actor-tags"; - -const selector = (a: Actor) => ({ - id: a.id, - tags: a.tags, - createdAt: a.createdAt, - destroyedAt: a.destroyedAt, - region: a.region, -}); +import { useQuery } from "@tanstack/react-query"; +import { type ActorId } from "./queries"; +import { useManagerQueries } from "./manager-queries-context"; export interface ActorGeneralProps { - actor: ActorAtom; + actorId: ActorId; } -export function ActorGeneral({ actor }: ActorGeneralProps) { - const { id, tags, createdAt, destroyedAt, region } = useAtomValue( - selectAtom(actor, selector, equal), +export function ActorGeneral({ actorId }: ActorGeneralProps) { + const { data: { region, tags, createdAt, destroyedAt } = {} } = useQuery( + useManagerQueries().actorGeneralQueryOptions(actorId), ); return ( @@ -39,18 +30,13 @@ export function ActorGeneral({ actor }: ActorGeneralProps) {
ID
- - {id} + + {actorId}
Tags
- + Promise; - isExporting?: boolean; + actorId: ActorId; } -export function ActorLogsTab({ - actor, - onExportLogs, - isExporting, -}: ActorLogsTabProps) { +export function ActorLogsTab({ actorId }: ActorLogsTabProps) { const [search, setSearch] = useState(""); const [logsFilter, setLogsFilter] = useState("all"); @@ -71,18 +62,18 @@ export function ActorLogsTab({ - +
diff --git a/frontend/packages/components/src/actors/actor-logs.tsx b/frontend/packages/components/src/actors/actor-logs.tsx index 11427cab70..ec80217ce5 100644 --- a/frontend/packages/components/src/actors/actor-logs.tsx +++ b/frontend/packages/components/src/actors/actor-logs.tsx @@ -1,25 +1,23 @@ import { ShimmerLine, VirtualScrollArea } from "@rivet-gg/components"; import type { Virtualizer } from "@tanstack/react-virtual"; -import { useAtomValue } from "jotai"; -import { selectAtom } from "jotai/utils"; import { memo, useCallback, useEffect, useRef } from "react"; import { useResizeObserver } from "usehooks-ts"; -import type { Actor, ActorAtom, Logs } from "./actor-context"; import { useActorDetailsSettings } from "./actor-details-settings"; import { ActorConsoleMessage } from "./console/actor-console-message"; +import { useQuery } from "@tanstack/react-query"; +import { type ActorId, type ActorLogEntry } from "./queries"; +import { useManagerQueries } from "./manager-queries-context"; export type LogsTypeFilter = "all" | "output" | "errors"; -const selector = (a: Actor) => a.logs; - interface ActorLogsProps { - actor: ActorAtom; + actorId: ActorId; typeFilter?: LogsTypeFilter; filter?: string; } export const ActorLogs = memo( - ({ typeFilter, actor, filter }: ActorLogsProps) => { + ({ typeFilter, actorId, filter }: ActorLogsProps) => { const [settings] = useActorDetailsSettings(); const follow = useRef(true); const shouldFollow = () => settings.autoFollowLogs && follow.current; @@ -41,14 +39,19 @@ export const ActorLogs = memo( }, }); - const logsAtom = useAtomValue(selectAtom(actor, selector)); - - const { logs, status } = useAtomValue(logsAtom); + const { data: status } = useQuery( + useManagerQueries().actorStatusQueryOptions(actorId), + ); + const { + data: logs = [], + isFetching, + isError, + } = useQuery(useManagerQueries().actorLogsQueryOptions(actorId)); const combined = filterLogs({ typeFilter: typeFilter ?? "all", filter: filter ?? "", - logs, + logs: logs ?? [], }); // Scroll to the bottom when new logs are added @@ -86,18 +89,6 @@ export const ActorLogs = memo( [], ); - // if (isStdOutLoading || isStdErrLoading) { - // return ( - //
- // - // Loading logs... - // - //
- // ); - // } - - // const status = getActorStatus({ createdAt, startedAt, destroyedAt }); - if (status === "starting" && combined.length === 0) { return (
@@ -108,7 +99,7 @@ export const ActorLogs = memo( ); } - if (status === "pending") { + if (isFetching) { return ( <> @@ -122,16 +113,15 @@ export const ActorLogs = memo( } if (combined.length === 0) { - // if (!isStdOutSuccess || !isStdErrSuccess) { - // return ( - //
- // - // [SYSTEM]: Couldn't find the logs. Please try again - // later. - // - //
- // ); - // } + if (isError) { + return ( +
+ + [SYSTEM]: Couldn't find the logs. Please try again later. + +
+ ); + } return (
@@ -150,11 +140,12 @@ export const ActorLogs = memo( className="w-full flex-1 min-h-0" getRowData={(index) => ({ ...combined[index], - children: - combined[index].message || combined[index].line, - variant: combined[index].level, + children: combined[index].message, + variant: combined[index].level as "debug" | "error" | "info", timestamp: settings.showTimestamps ? combined[index].timestamp + ? new Date(combined[index].timestamp) + : undefined : undefined, })} onChange={handleChange} @@ -178,12 +169,9 @@ function Scroller({ virtualizer }: ScrollerProps) { // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on mount, no need to run this effect again useEffect(() => { // https://github.com/TanStack/virtual/issues/537 - virtualizer.current?.scrollToIndex( - virtualizer.current.options.count - 1, - { - align: "end", - }, - ); + virtualizer.current?.scrollToIndex(virtualizer.current.options.count - 1, { + align: "end", + }); }, []); return null; @@ -193,7 +181,11 @@ export function filterLogs({ typeFilter, filter, logs, -}: { typeFilter: LogsTypeFilter; filter: string; logs: Logs }) { +}: { + typeFilter: LogsTypeFilter; + filter: string; + logs: ActorLogEntry[]; +}) { const output = logs?.filter((log) => { if (typeFilter === "errors") { return log.level === "error"; @@ -211,8 +203,7 @@ export function filterLogs({ : output; const sorted = filtered.toSorted( - (a, b) => - new Date(a.timestamp).valueOf() - new Date(b.timestamp).valueOf(), + (a, b) => new Date(a.timestamp).valueOf() - new Date(b.timestamp).valueOf(), ); return sorted; diff --git a/frontend/packages/components/src/actors/actor-metrics-tab.tsx b/frontend/packages/components/src/actors/actor-metrics-tab.tsx index edc11a6618..fd8c377742 100644 --- a/frontend/packages/components/src/actors/actor-metrics-tab.tsx +++ b/frontend/packages/components/src/actors/actor-metrics-tab.tsx @@ -1,10 +1,10 @@ import { Button, ScrollArea } from "@rivet-gg/components"; import { Icon, faBooks } from "@rivet-gg/icons"; import { ActorMetrics } from "./actor-metrics"; -import type { ActorAtom } from "./actor-context"; +import type { ActorId } from "./queries"; interface ActorMetricsTabProps { - actor: ActorAtom; + actorId: ActorId; } export function ActorMetricsTab(props: ActorMetricsTabProps) { diff --git a/frontend/packages/components/src/actors/actor-metrics.tsx b/frontend/packages/components/src/actors/actor-metrics.tsx index 947d1166dc..ae21a567ea 100644 --- a/frontend/packages/components/src/actors/actor-metrics.tsx +++ b/frontend/packages/components/src/actors/actor-metrics.tsx @@ -1,24 +1,7 @@ -import { useAtomValue, useSetAtom } from "jotai"; -import { selectAtom } from "jotai/utils"; -import equal from "fast-deep-equal"; import { useState, useMemo } from "react"; -import type { Actor, ActorAtom } from "./actor-context"; -import { ActorCpuStats } from "./actor-cpu-stats"; -import { ActorMemoryStats } from "./actor-memory-stats"; -import { Dd, Dl, Dt } from "../ui/typography"; -import { Button } from "../ui/button"; -import { Flex } from "../ui/flex"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; -import { actorMetricsTimeWindowAtom, actorEnvironmentAtom } from "./actor-context"; +import { Dd, Dl, Dt, Flex, Button } from "@rivet-gg/components"; +import { type ActorId } from "./queries"; import { useQuery } from "@tanstack/react-query"; -import { actorMetricsQueryOptions } from "@/domains/project/queries/actors/query-options"; - -const selector = (a: Actor) => ({ - metrics: a.metrics, - status: a.status, - resources: a.resources, - id: a.id, -}); const timeWindowOptions = [ { label: "5 minutes", value: "5m", milliseconds: 5 * 60 * 1000 }, @@ -33,672 +16,690 @@ const timeWindowOptions = [ ]; export interface ActorMetricsProps { - actor: ActorAtom; + actorId: ActorId; } -export function ActorMetrics({ actor }: ActorMetricsProps) { - const { metrics, status, resources, id } = useAtomValue( - selectAtom(actor, selector, equal), - ); - const defaultMetricsData = useAtomValue(metrics); - const [showAdvanced, setShowAdvanced] = useState(false); - - const timeWindowMs = useAtomValue(actorMetricsTimeWindowAtom); - const setTimeWindowMs = useSetAtom(actorMetricsTimeWindowAtom); - const environment = useAtomValue(actorEnvironmentAtom); - - const currentTimeWindow = timeWindowOptions.find(option => option.milliseconds === timeWindowMs) || timeWindowOptions[1]; - const [timeWindow, setTimeWindow] = useState(currentTimeWindow.value); - - const isActorRunning = status === "running"; - - // Create a query for time window-specific metrics - const { data: customMetricsData, status: customMetricsStatus } = useQuery({ - ...actorMetricsQueryOptions( - { - projectNameId: environment?.projectNameId || "", - environmentNameId: environment?.environmentNameId || "", - actorId: id, - timeWindowMs: timeWindowMs, - }, - { refetchInterval: 5000 } - ), - enabled: !!environment && !!id, - }); - - // Use custom metrics if available, otherwise fall back to default - const metricsData = customMetricsData ? { - metrics: customMetricsData.metrics, - rawData: customMetricsData.rawData, - interval: customMetricsData.interval, - status: customMetricsStatus, - updatedAt: Date.now(), - } : defaultMetricsData; - - const handleTimeWindowChange = (value: string) => { - setTimeWindow(value); - const selectedOption = timeWindowOptions.find(option => option.value === value); - if (selectedOption) { - setTimeWindowMs(selectedOption.milliseconds); - } - }; - - const formatBytes = (bytes: number | null | undefined) => { - if (!isActorRunning || bytes === null || bytes === undefined) - return "n/a"; - const mb = bytes / 1024 / 1024; - if (mb < 1024) { - return `${mb.toFixed(1)} MB`; - } - return `${(mb / 1024).toFixed(1)} GB`; - }; - - const formatCpuUsage = (cpu: number | null | undefined) => { - if (!isActorRunning || cpu === null || cpu === undefined) return "n/a"; - return `${(cpu * 100).toFixed(2)}%`; - }; - - const formatNumber = (value: number | null | undefined) => { - if (!isActorRunning || value === null || value === undefined) - return "n/a"; - return value.toLocaleString(); - }; - - const formatTimestamp = (timestamp: number | null | undefined) => { - if (!isActorRunning || timestamp === null || timestamp === undefined) - return "n/a"; - return new Date(timestamp * 1000).toLocaleString(); - }; - - // Calculate CPU percentage using time series data points - const cpuPercentage = useMemo(() => { - if (!isActorRunning) { - return "Stopped"; - } - - const data = metricsData; - if (!data || !data.rawData || !data.interval) { - return "n/a"; - } - - const cpuValues = data.rawData.cpu_usage_seconds_total; - if (!cpuValues || cpuValues.length < 2) { - return "n/a"; - } - - // Find the last valid CPU rate from the most recent data points - let cpuRate = 0; - for (let i = cpuValues.length - 1; i > 0; i--) { - const currentCpu = cpuValues[i]; - const previousCpu = cpuValues[i - 1]; - - if ( - currentCpu !== 0 && - previousCpu !== 0 && - currentCpu >= previousCpu - ) { - const cpuDelta = currentCpu - previousCpu; - const timeDelta = data.interval / 1000; // Convert ms to seconds - - // Rate calculation: CPU seconds used per second of real time - // This gives the fraction of available CPU used (0-1) - cpuRate = (cpuDelta / timeDelta) * 100; - break; - } - } - - return `${Math.min(cpuRate, 100).toFixed(2)}%`; - }, [metricsData, isActorRunning]); - - const calculateMemoryPercentage = ( - usage: number | null | undefined, - ) => { - if ( - !isActorRunning || - usage === null || - usage === undefined || - !resources || - !resources.memory || - resources.memory === 0 - ) { - return null; - } - // Convert usage from bytes to MB and compare with resources.memory (which is in MB) - const usageMB = usage / (1024 * 1024); - return (usageMB / resources.memory) * 100; - }; - - const isLoading = metricsData.status === "pending"; - const hasError = metricsData.status === "error"; - const data = metricsData.metrics || {}; - - if (isLoading) { - return ( -
-

Metrics

-
Loading...
-
- ); - } - - if (hasError) { - return ( -
-

Metrics

-
- Error loading metrics -
-
- ); - } - - const memoryPercentage = calculateMemoryPercentage( - data.memory_usage_bytes, - ); - - return ( -
-
-

Container Metrics

- -
- - {/* Main Metrics */} -
-
-
-
CPU Usage
-
- {cpuPercentage} - {metricsData.rawData?.cpu_usage_seconds_total && - metricsData.rawData.cpu_usage_seconds_total.length > 0 ? ( - - ) : null} -
-
-
-
Memory Usage
-
- - {formatBytes(data.memory_usage_bytes)} - {memoryPercentage !== null && ( - - ({memoryPercentage.toFixed(1)}%) - - )} - - {metricsData.rawData?.memory_usage_bytes && - metricsData.rawData.memory_usage_bytes.length > 0 ? ( - - ) : null} -
-
-
-
- - {/* Advanced Metrics */} - {false && ( - - {/* CPU & Performance */} -
-

CPU & Performance

-
-
CPU Load Average (10s)
-
{formatCpuUsage(data.cpu_load_average_10s)}
-
CPU Usage Seconds Total
-
- {formatNumber(data.cpu_usage_seconds_total)} -
-
CPU User Seconds Total
-
{formatNumber(data.cpu_user_seconds_total)}
-
CPU System Seconds Total
-
- {formatNumber(data.cpu_system_seconds_total)} -
-
CPU Schedstat Run Periods
-
- {formatNumber( - data.cpu_schedstat_run_periods_total, - )} -
-
CPU Schedstat Run Seconds
-
- {formatNumber( - data.cpu_schedstat_run_seconds_total, - )} -
-
CPU Schedstat Runqueue Seconds
-
- {formatNumber( - data.cpu_schedstat_runqueue_seconds_total, - )} -
-
-
- - {/* Memory */} -
-

Memory

-
-
Memory Usage
-
{formatBytes(data.memory_usage_bytes)}
-
Memory Working Set
-
- {formatBytes(data.memory_working_set_bytes)} -
-
Memory RSS
-
{formatBytes(data.memory_rss)}
-
Memory Cache
-
{formatBytes(data.memory_cache)}
-
Memory Swap
-
{formatBytes(data.memory_swap)}
-
Memory Max Usage
-
{formatBytes(data.memory_max_usage_bytes)}
-
Memory Mapped File
-
{formatBytes(data.memory_mapped_file)}
-
Memory Failcnt
-
{formatNumber(data.memory_failcnt)}
-
-
- - {/* Memory Failures */} -
-

Memory Failures

-
-
Page Fault (Container)
-
- {formatNumber( - data.memory_failures_pgfault_container, - )} -
-
Page Fault (Hierarchy)
-
- {formatNumber( - data.memory_failures_pgfault_hierarchy, - )} -
-
Major Page Fault (Container)
-
- {formatNumber( - data.memory_failures_pgmajfault_container, - )} -
-
Major Page Fault (Hierarchy)
-
- {formatNumber( - data.memory_failures_pgmajfault_hierarchy, - )} -
-
-
- - {/* Resource Limits */} -
-

Resource Limits

-
-
Memory Limit
-
{resources?.memory ? `${resources.memory} MB` : "n/a"}
-
CPU Limit
-
{resources?.cpu ? `${resources.cpu / 1000} cores` : "n/a"}
-
-
- - {/* Processes & Threads */} -
-

- Processes & Threads -

-
-
Processes
-
{formatNumber(data.processes)}
-
Threads
-
{formatNumber(data.threads)}
-
Max Threads
-
{formatNumber(data.threads_max)}
-
Tasks Running
-
{formatNumber(data.tasks_state_running)}
-
Tasks Sleeping
-
{formatNumber(data.tasks_state_sleeping)}
-
Tasks Stopped
-
{formatNumber(data.tasks_state_stopped)}
-
Tasks IO Waiting
-
{formatNumber(data.tasks_state_iowaiting)}
-
Tasks Uninterruptible
-
- {formatNumber(data.tasks_state_uninterruptible)} -
-
-
- - {/* Filesystem */} -
-

Filesystem

-
-
Reads Bytes Total (sda)
-
- {formatBytes(data.fs_reads_bytes_total_sda)} -
-
Writes Bytes Total (sda)
-
- {formatBytes(data.fs_writes_bytes_total_sda)} -
-
-
- - {/* Network - Receive */} -
-

Network - Receive

-
-
Bytes Total (eth0)
-
- {formatBytes( - data.network_receive_bytes_total_eth0, - )} -
-
Bytes Total (eth1)
-
- {formatBytes( - data.network_receive_bytes_total_eth1, - )} -
-
Errors Total (eth0)
-
- {formatNumber( - data.network_receive_errors_total_eth0, - )} -
-
Errors Total (eth1)
-
- {formatNumber( - data.network_receive_errors_total_eth1, - )} -
-
Packets Dropped (eth0)
-
- {formatNumber( - data.network_receive_packets_dropped_total_eth0, - )} -
-
Packets Dropped (eth1)
-
- {formatNumber( - data.network_receive_packets_dropped_total_eth1, - )} -
-
Packets Total (eth0)
-
- {formatNumber( - data.network_receive_packets_total_eth0, - )} -
-
Packets Total (eth1)
-
- {formatNumber( - data.network_receive_packets_total_eth1, - )} -
-
-
- - {/* Network - Transmit */} -
-

Network - Transmit

-
-
Bytes Total (eth0)
-
- {formatBytes( - data.network_transmit_bytes_total_eth0, - )} -
-
Bytes Total (eth1)
-
- {formatBytes( - data.network_transmit_bytes_total_eth1, - )} -
-
Errors Total (eth0)
-
- {formatNumber( - data.network_transmit_errors_total_eth0, - )} -
-
Errors Total (eth1)
-
- {formatNumber( - data.network_transmit_errors_total_eth1, - )} -
-
Packets Dropped (eth0)
-
- {formatNumber( - data.network_transmit_packets_dropped_total_eth0, - )} -
-
Packets Dropped (eth1)
-
- {formatNumber( - data.network_transmit_packets_dropped_total_eth1, - )} -
-
Packets Total (eth0)
-
- {formatNumber( - data.network_transmit_packets_total_eth0, - )} -
-
Packets Total (eth1)
-
- {formatNumber( - data.network_transmit_packets_total_eth1, - )} -
-
-
- - {/* TCP Connections */} -
-

TCP Connections

-
-
Close
-
- {formatNumber(data.network_tcp_usage_close)} -
-
Close Wait
-
- {formatNumber( - data.network_tcp_usage_closewait, - )} -
-
Closing
-
- {formatNumber(data.network_tcp_usage_closing)} -
-
Established
-
- {formatNumber( - data.network_tcp_usage_established, - )} -
-
Fin Wait 1
-
- {formatNumber(data.network_tcp_usage_finwait1)} -
-
Fin Wait 2
-
- {formatNumber(data.network_tcp_usage_finwait2)} -
-
Last Ack
-
- {formatNumber(data.network_tcp_usage_lastack)} -
-
Listen
-
- {formatNumber(data.network_tcp_usage_listen)} -
-
Syn Recv
-
- {formatNumber(data.network_tcp_usage_synrecv)} -
-
Syn Sent
-
- {formatNumber(data.network_tcp_usage_synsent)} -
-
Time Wait
-
- {formatNumber(data.network_tcp_usage_timewait)} -
-
-
- - {/* TCP6 Connections */} -
-

TCP6 Connections

-
-
Close
-
- {formatNumber(data.network_tcp6_usage_close)} -
-
Close Wait
-
- {formatNumber( - data.network_tcp6_usage_closewait, - )} -
-
Closing
-
- {formatNumber(data.network_tcp6_usage_closing)} -
-
Established
-
- {formatNumber( - data.network_tcp6_usage_established, - )} -
-
Fin Wait 1
-
- {formatNumber(data.network_tcp6_usage_finwait1)} -
-
Fin Wait 2
-
- {formatNumber(data.network_tcp6_usage_finwait2)} -
-
Last Ack
-
- {formatNumber(data.network_tcp6_usage_lastack)} -
-
Listen
-
- {formatNumber(data.network_tcp6_usage_listen)} -
-
Syn Recv
-
- {formatNumber(data.network_tcp6_usage_synrecv)} -
-
Syn Sent
-
- {formatNumber(data.network_tcp6_usage_synsent)} -
-
Time Wait
-
- {formatNumber(data.network_tcp6_usage_timewait)} -
-
-
- - {/* UDP Connections */} -
-

UDP Connections

-
-
Dropped
-
- {formatNumber(data.network_udp_usage_dropped)} -
-
Listen
-
- {formatNumber(data.network_udp_usage_listen)} -
-
RX Queued
-
- {formatNumber(data.network_udp_usage_rxqueued)} -
-
TX Queued
-
- {formatNumber(data.network_udp_usage_txqueued)} -
-
-
- - {/* UDP6 Connections */} -
-

UDP6 Connections

-
-
Dropped
-
- {formatNumber(data.network_udp6_usage_dropped)} -
-
Listen
-
- {formatNumber(data.network_udp6_usage_listen)} -
-
RX Queued
-
- {formatNumber(data.network_udp6_usage_rxqueued)} -
-
TX Queued
-
- {formatNumber(data.network_udp6_usage_txqueued)} -
-
-
- - {/* System */} -
-

System

-
-
File Descriptors
-
{formatNumber(data.file_descriptors)}
-
Sockets
-
{formatNumber(data.sockets)}
-
Last Seen
-
{formatTimestamp(data.last_seen)}
-
Start Time
-
{formatTimestamp(data.start_time_seconds)}
-
-
-
- )} -
- ); -} \ No newline at end of file +export function ActorMetrics({ actorId }: ActorMetricsProps) { + // const { data: status } = useQuery(actorStatusQueryOptions(actorId)); + // const { + // data: metricsData, + // isLoading, + // isError, + // } = useQuery(actorMetricsQueryOptions(actorId)); + + // const [showAdvanced, setShowAdvanced] = useState(false); + + // const timeWindowMs = useAtomValue(actorMetricsTimeWindowAtom); + // const setTimeWindowMs = useSetAtom(actorMetricsTimeWindowAtom); + // const environment = useAtomValue(actorEnvironmentAtom); + + // const currentTimeWindow = + // timeWindowOptions.find( + // (option) => option.milliseconds === timeWindowMs, + // ) || timeWindowOptions[1]; + // const [timeWindow, setTimeWindow] = useState(currentTimeWindow.value); + + // const isActorRunning = status === "running"; + + // // Create a query for time window-specific metrics + // const { data: customMetricsData, status: customMetricsStatus } = useQuery({ + // ...actorMetricsQueryOptions( + // { + // projectNameId: environment?.projectNameId || "", + // environmentNameId: environment?.environmentNameId || "", + // actorId: id, + // timeWindowMs: timeWindowMs, + // }, + // { refetchInterval: 5000 }, + // ), + // enabled: !!environment && !!id, + // }); + + // // Use custom metrics if available, otherwise fall back to default + // const metricsData = customMetricsData + // ? { + // metrics: customMetricsData.metrics, + // rawData: customMetricsData.rawData, + // interval: customMetricsData.interval, + // status: customMetricsStatus, + // updatedAt: Date.now(), + // } + // : defaultMetricsData; + + // const handleTimeWindowChange = (value: string) => { + // setTimeWindow(value); + // const selectedOption = timeWindowOptions.find( + // (option) => option.value === value, + // ); + // if (selectedOption) { + // setTimeWindowMs(selectedOption.milliseconds); + // } + // }; + + // const formatBytes = (bytes: number | null | undefined) => { + // if (!isActorRunning || bytes === null || bytes === undefined) + // return "n/a"; + // const mb = bytes / 1024 / 1024; + // if (mb < 1024) { + // return `${mb.toFixed(1)} MB`; + // } + // return `${(mb / 1024).toFixed(1)} GB`; + // }; + + // const formatCpuUsage = (cpu: number | null | undefined) => { + // if (!isActorRunning || cpu === null || cpu === undefined) return "n/a"; + // return `${(cpu * 100).toFixed(2)}%`; + // }; + + // const formatNumber = (value: number | null | undefined) => { + // if (!isActorRunning || value === null || value === undefined) + // return "n/a"; + // return value.toLocaleString(); + // }; + + // const formatTimestamp = (timestamp: number | null | undefined) => { + // if (!isActorRunning || timestamp === null || timestamp === undefined) + // return "n/a"; + // return new Date(timestamp * 1000).toLocaleString(); + // }; + + // // Calculate CPU percentage using time series data points + // const cpuPercentage = useMemo(() => { + // if (!isActorRunning) { + // return "Stopped"; + // } + + // const data = metricsData; + // if (!data || !data.rawData || !data.interval) { + // return "n/a"; + // } + + // const cpuValues = data.rawData.cpu_usage_seconds_total; + // if (!cpuValues || cpuValues.length < 2) { + // return "n/a"; + // } + + // // Find the last valid CPU rate from the most recent data points + // let cpuRate = 0; + // for (let i = cpuValues.length - 1; i > 0; i--) { + // const currentCpu = cpuValues[i]; + // const previousCpu = cpuValues[i - 1]; + + // if ( + // currentCpu !== 0 && + // previousCpu !== 0 && + // currentCpu >= previousCpu + // ) { + // const cpuDelta = currentCpu - previousCpu; + // const timeDelta = data.interval / 1000; // Convert ms to seconds + + // // Rate calculation: CPU seconds used per second of real time + // // This gives the fraction of available CPU used (0-1) + // cpuRate = (cpuDelta / timeDelta) * 100; + // break; + // } + // } + + // return `${Math.min(cpuRate, 100).toFixed(2)}%`; + // }, [metricsData, isActorRunning]); + + // const calculateMemoryPercentage = (usage: number | null | undefined) => { + // if ( + // !isActorRunning || + // usage === null || + // usage === undefined || + // !resources || + // !resources.memory || + // resources.memory === 0 + // ) { + // return null; + // } + // // Convert usage from bytes to MB and compare with resources.memory (which is in MB) + // const usageMB = usage / (1024 * 1024); + // return (usageMB / resources.memory) * 100; + // }; + + // const data = metricsData?.metrics || {}; + + // if (isLoading) { + // return ( + //
+ //

Metrics

+ //
Loading...
+ //
+ // ); + // } + + // if (isError) { + // return ( + //
+ //

Metrics

+ //
+ // Error loading metrics + //
+ //
+ // ); + // } + + // const memoryPercentage = calculateMemoryPercentage(data.memory_usage_bytes); + + // return ( + //
+ //
+ //

Container Metrics

+ // + //
+ + // {/* Main Metrics */} + //
+ //
+ //
+ //
CPU Usage
+ //
+ // {cpuPercentage} + // {metricsData.rawData?.cpu_usage_seconds_total && + // metricsData.rawData.cpu_usage_seconds_total.length > + // 0 ? ( + // + // ) : null} + //
+ //
+ //
+ //
Memory Usage
+ //
+ // + // {formatBytes(data.memory_usage_bytes)} + // {memoryPercentage !== null && ( + // + // ({memoryPercentage.toFixed(1)}%) + // + // )} + // + // {metricsData.rawData?.memory_usage_bytes && + // metricsData.rawData.memory_usage_bytes.length > + // 0 ? ( + // + // ) : null} + //
+ //
+ //
+ //
+ + // {/* Advanced Metrics */} + // {false && ( + // + // {/* CPU & Performance */} + //
+ //

CPU & Performance

+ //
+ //
CPU Load Average (10s)
+ //
{formatCpuUsage(data.cpu_load_average_10s)}
+ //
CPU Usage Seconds Total
+ //
+ // {formatNumber(data.cpu_usage_seconds_total)} + //
+ //
CPU User Seconds Total
+ //
{formatNumber(data.cpu_user_seconds_total)}
+ //
CPU System Seconds Total
+ //
+ // {formatNumber(data.cpu_system_seconds_total)} + //
+ //
CPU Schedstat Run Periods
+ //
+ // {formatNumber( + // data.cpu_schedstat_run_periods_total, + // )} + //
+ //
CPU Schedstat Run Seconds
+ //
+ // {formatNumber( + // data.cpu_schedstat_run_seconds_total, + // )} + //
+ //
CPU Schedstat Runqueue Seconds
+ //
+ // {formatNumber( + // data.cpu_schedstat_runqueue_seconds_total, + // )} + //
+ //
+ //
+ + // {/* Memory */} + //
+ //

Memory

+ //
+ //
Memory Usage
+ //
{formatBytes(data.memory_usage_bytes)}
+ //
Memory Working Set
+ //
+ // {formatBytes(data.memory_working_set_bytes)} + //
+ //
Memory RSS
+ //
{formatBytes(data.memory_rss)}
+ //
Memory Cache
+ //
{formatBytes(data.memory_cache)}
+ //
Memory Swap
+ //
{formatBytes(data.memory_swap)}
+ //
Memory Max Usage
+ //
{formatBytes(data.memory_max_usage_bytes)}
+ //
Memory Mapped File
+ //
{formatBytes(data.memory_mapped_file)}
+ //
Memory Failcnt
+ //
{formatNumber(data.memory_failcnt)}
+ //
+ //
+ + // {/* Memory Failures */} + //
+ //

Memory Failures

+ //
+ //
Page Fault (Container)
+ //
+ // {formatNumber( + // data.memory_failures_pgfault_container, + // )} + //
+ //
Page Fault (Hierarchy)
+ //
+ // {formatNumber( + // data.memory_failures_pgfault_hierarchy, + // )} + //
+ //
Major Page Fault (Container)
+ //
+ // {formatNumber( + // data.memory_failures_pgmajfault_container, + // )} + //
+ //
Major Page Fault (Hierarchy)
+ //
+ // {formatNumber( + // data.memory_failures_pgmajfault_hierarchy, + // )} + //
+ //
+ //
+ + // {/* Resource Limits */} + //
+ //

Resource Limits

+ //
+ //
Memory Limit
+ //
+ // {resources?.memory + // ? `${resources.memory} MB` + // : "n/a"} + //
+ //
CPU Limit
+ //
+ // {resources?.cpu + // ? `${resources.cpu / 1000} cores` + // : "n/a"} + //
+ //
+ //
+ + // {/* Processes & Threads */} + //
+ //

+ // Processes & Threads + //

+ //
+ //
Processes
+ //
{formatNumber(data.processes)}
+ //
Threads
+ //
{formatNumber(data.threads)}
+ //
Max Threads
+ //
{formatNumber(data.threads_max)}
+ //
Tasks Running
+ //
{formatNumber(data.tasks_state_running)}
+ //
Tasks Sleeping
+ //
{formatNumber(data.tasks_state_sleeping)}
+ //
Tasks Stopped
+ //
{formatNumber(data.tasks_state_stopped)}
+ //
Tasks IO Waiting
+ //
{formatNumber(data.tasks_state_iowaiting)}
+ //
Tasks Uninterruptible
+ //
+ // {formatNumber(data.tasks_state_uninterruptible)} + //
+ //
+ //
+ + // {/* Filesystem */} + //
+ //

Filesystem

+ //
+ //
Reads Bytes Total (sda)
+ //
+ // {formatBytes(data.fs_reads_bytes_total_sda)} + //
+ //
Writes Bytes Total (sda)
+ //
+ // {formatBytes(data.fs_writes_bytes_total_sda)} + //
+ //
+ //
+ + // {/* Network - Receive */} + //
+ //

Network - Receive

+ //
+ //
Bytes Total (eth0)
+ //
+ // {formatBytes( + // data.network_receive_bytes_total_eth0, + // )} + //
+ //
Bytes Total (eth1)
+ //
+ // {formatBytes( + // data.network_receive_bytes_total_eth1, + // )} + //
+ //
Errors Total (eth0)
+ //
+ // {formatNumber( + // data.network_receive_errors_total_eth0, + // )} + //
+ //
Errors Total (eth1)
+ //
+ // {formatNumber( + // data.network_receive_errors_total_eth1, + // )} + //
+ //
Packets Dropped (eth0)
+ //
+ // {formatNumber( + // data.network_receive_packets_dropped_total_eth0, + // )} + //
+ //
Packets Dropped (eth1)
+ //
+ // {formatNumber( + // data.network_receive_packets_dropped_total_eth1, + // )} + //
+ //
Packets Total (eth0)
+ //
+ // {formatNumber( + // data.network_receive_packets_total_eth0, + // )} + //
+ //
Packets Total (eth1)
+ //
+ // {formatNumber( + // data.network_receive_packets_total_eth1, + // )} + //
+ //
+ //
+ + // {/* Network - Transmit */} + //
+ //

Network - Transmit

+ //
+ //
Bytes Total (eth0)
+ //
+ // {formatBytes( + // data.network_transmit_bytes_total_eth0, + // )} + //
+ //
Bytes Total (eth1)
+ //
+ // {formatBytes( + // data.network_transmit_bytes_total_eth1, + // )} + //
+ //
Errors Total (eth0)
+ //
+ // {formatNumber( + // data.network_transmit_errors_total_eth0, + // )} + //
+ //
Errors Total (eth1)
+ //
+ // {formatNumber( + // data.network_transmit_errors_total_eth1, + // )} + //
+ //
Packets Dropped (eth0)
+ //
+ // {formatNumber( + // data.network_transmit_packets_dropped_total_eth0, + // )} + //
+ //
Packets Dropped (eth1)
+ //
+ // {formatNumber( + // data.network_transmit_packets_dropped_total_eth1, + // )} + //
+ //
Packets Total (eth0)
+ //
+ // {formatNumber( + // data.network_transmit_packets_total_eth0, + // )} + //
+ //
Packets Total (eth1)
+ //
+ // {formatNumber( + // data.network_transmit_packets_total_eth1, + // )} + //
+ //
+ //
+ + // {/* TCP Connections */} + //
+ //

TCP Connections

+ //
+ //
Close
+ //
+ // {formatNumber(data.network_tcp_usage_close)} + //
+ //
Close Wait
+ //
+ // {formatNumber(data.network_tcp_usage_closewait)} + //
+ //
Closing
+ //
+ // {formatNumber(data.network_tcp_usage_closing)} + //
+ //
Established
+ //
+ // {formatNumber( + // data.network_tcp_usage_established, + // )} + //
+ //
Fin Wait 1
+ //
+ // {formatNumber(data.network_tcp_usage_finwait1)} + //
+ //
Fin Wait 2
+ //
+ // {formatNumber(data.network_tcp_usage_finwait2)} + //
+ //
Last Ack
+ //
+ // {formatNumber(data.network_tcp_usage_lastack)} + //
+ //
Listen
+ //
+ // {formatNumber(data.network_tcp_usage_listen)} + //
+ //
Syn Recv
+ //
+ // {formatNumber(data.network_tcp_usage_synrecv)} + //
+ //
Syn Sent
+ //
+ // {formatNumber(data.network_tcp_usage_synsent)} + //
+ //
Time Wait
+ //
+ // {formatNumber(data.network_tcp_usage_timewait)} + //
+ //
+ //
+ + // {/* TCP6 Connections */} + //
+ //

TCP6 Connections

+ //
+ //
Close
+ //
+ // {formatNumber(data.network_tcp6_usage_close)} + //
+ //
Close Wait
+ //
+ // {formatNumber( + // data.network_tcp6_usage_closewait, + // )} + //
+ //
Closing
+ //
+ // {formatNumber(data.network_tcp6_usage_closing)} + //
+ //
Established
+ //
+ // {formatNumber( + // data.network_tcp6_usage_established, + // )} + //
+ //
Fin Wait 1
+ //
+ // {formatNumber(data.network_tcp6_usage_finwait1)} + //
+ //
Fin Wait 2
+ //
+ // {formatNumber(data.network_tcp6_usage_finwait2)} + //
+ //
Last Ack
+ //
+ // {formatNumber(data.network_tcp6_usage_lastack)} + //
+ //
Listen
+ //
+ // {formatNumber(data.network_tcp6_usage_listen)} + //
+ //
Syn Recv
+ //
+ // {formatNumber(data.network_tcp6_usage_synrecv)} + //
+ //
Syn Sent
+ //
+ // {formatNumber(data.network_tcp6_usage_synsent)} + //
+ //
Time Wait
+ //
+ // {formatNumber(data.network_tcp6_usage_timewait)} + //
+ //
+ //
+ + // {/* UDP Connections */} + //
+ //

UDP Connections

+ //
+ //
Dropped
+ //
+ // {formatNumber(data.network_udp_usage_dropped)} + //
+ //
Listen
+ //
+ // {formatNumber(data.network_udp_usage_listen)} + //
+ //
RX Queued
+ //
+ // {formatNumber(data.network_udp_usage_rxqueued)} + //
+ //
TX Queued
+ //
+ // {formatNumber(data.network_udp_usage_txqueued)} + //
+ //
+ //
+ + // {/* UDP6 Connections */} + //
+ //

UDP6 Connections

+ //
+ //
Dropped
+ //
+ // {formatNumber(data.network_udp6_usage_dropped)} + //
+ //
Listen
+ //
+ // {formatNumber(data.network_udp6_usage_listen)} + //
+ //
RX Queued
+ //
+ // {formatNumber(data.network_udp6_usage_rxqueued)} + //
+ //
TX Queued
+ //
+ // {formatNumber(data.network_udp6_usage_txqueued)} + //
+ //
+ //
+ + // {/* System */} + //
+ //

System

+ //
+ //
File Descriptors
+ //
{formatNumber(data.file_descriptors)}
+ //
Sockets
+ //
{formatNumber(data.sockets)}
+ //
Last Seen
+ //
{formatTimestamp(data.last_seen)}
+ //
Start Time
+ //
{formatTimestamp(data.start_time_seconds)}
+ //
+ //
+ //
+ // )} + //
+ // ); + return null; +} diff --git a/frontend/packages/components/src/actors/actor-network.tsx b/frontend/packages/components/src/actors/actor-network.tsx index 3bb466ad99..c0211f9820 100644 --- a/frontend/packages/components/src/actors/actor-network.tsx +++ b/frontend/packages/components/src/actors/actor-network.tsx @@ -9,20 +9,20 @@ import { cn, } from "@rivet-gg/components"; import { Icon, faBooks } from "@rivet-gg/icons"; -import { useAtomValue } from "jotai"; -import { selectAtom } from "jotai/utils"; -import { Fragment } from "react"; -import type { Actor, ActorAtom } from "./actor-context"; import { ActorObjectInspector } from "./console/actor-inspector"; - -const selector = (a: Actor) => a.network?.ports; +import { Fragment } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { type ActorId } from "./queries"; +import { useManagerQueries } from "./manager-queries-context"; export interface ActorNetworkProps { - actor: ActorAtom; + actorId: ActorId; } -export function ActorNetwork({ actor }: ActorNetworkProps) { - const ports = useAtomValue(selectAtom(actor, selector)); +export function ActorNetwork({ actorId }: ActorNetworkProps) { + const { data: ports } = useQuery( + useManagerQueries().actorNetworkPortsQueryOptions(actorId), + ); if (!ports) { return null; } @@ -46,94 +46,73 @@ export function ActorNetwork({ actor }: ActorNetworkProps) {
Ports
- {Object.entries(ports).map( - ([name, port], index) => ( - - - {name} - -
-
Protocol
-
- - {port.protocol} - -
-
Port
-
- - {port.port} - -
-
Hostname
-
- - - {port.hostname} - - -
- {port.url ? ( - <> -
URL
-
- - - {port.url} - - -
- - ) : null} + {Object.entries(ports).map(([name, port], index) => ( + + + {name} + +
+
Protocol
+
+ + {port.protocol} + +
+
Port
+
+ + {port.port} + +
+
Hostname
+
+ + + {port.hostname} + + +
+ {port.url ? ( + <> +
URL
+
+ + + {port.url} + + +
+ + ) : null} - {port.routing?.host ? ( - <> -
Host Routing
-
- - - -
- - ) : null} -
-
- ), - )} + {port.routing?.host ? ( + <> +
Host Routing
+
+ + + +
+ + ) : null} +
+
+ ))}
diff --git a/frontend/packages/components/src/actors/actor-not-found.tsx b/frontend/packages/components/src/actors/actor-not-found.tsx index c1beda2464..fd86f89eef 100644 --- a/frontend/packages/components/src/actors/actor-not-found.tsx +++ b/frontend/packages/components/src/actors/actor-not-found.tsx @@ -1,32 +1,29 @@ import { Icon, faQuestionSquare } from "@rivet-gg/icons"; -import { useAtomValue, useSetAtom } from "jotai"; -import { selectAtom } from "jotai/utils"; -import { useCallback } from "react"; import { Button } from "../ui/button"; import { FilterOp } from "../ui/filters"; -import { - type ActorFeature, - actorFiltersAtom, - currentActorQueryAtom, -} from "./actor-context"; import { ActorTabs } from "./actors-actor-details"; import { useActorsView } from "./actors-view-context-provider"; import { ShimmerLine } from "../shimmer-line"; +import { useNavigate } from "@tanstack/react-router"; +import type { ActorFeature, ActorId } from "./queries"; +import { useQuery } from "@tanstack/react-query"; +import { useManagerQueries } from "./manager-queries-context"; export function ActorNotFound({ + actorId, features = [], -}: { features?: ActorFeature[] }) { +}: { features?: ActorFeature[]; actorId?: ActorId }) { const { copy } = useActorsView(); - const setFilters = useSetAtom(actorFiltersAtom); - const hasDevMode = useAtomValue( - selectAtom( - actorFiltersAtom, - useCallback((filters) => filters.devMode, []), - ), - ); + const navigate = useNavigate(); + + const hasDevMode = false; - const { isLoading } = useAtomValue(currentActorQueryAtom); + const { isLoading } = useQuery({ + // biome-ignore lint/style/noNonNullAssertion: enabled guarantees actorId is defined + ...useManagerQueries().actorQueryOptions(actorId!), + enabled: !!actorId, + }); return (
@@ -53,13 +50,16 @@ export function ActorNotFound({ variant="outline" size="sm" onClick={() => { - setFilters((prev) => ({ - ...prev, - devMode: { - value: ["true"], - operator: FilterOp.EQUAL, - }, - })); + navigate({ + to: ".", + search: (prev) => ({ + ...prev, + devMode: { + value: ["true"], + operator: FilterOp.EQUAL, + }, + }), + }); }} > {copy.showHiddenActors} diff --git a/frontend/packages/components/src/actors/actor-queries-context.tsx b/frontend/packages/components/src/actors/actor-queries-context.tsx new file mode 100644 index 0000000000..d539a09ddb --- /dev/null +++ b/frontend/packages/components/src/actors/actor-queries-context.tsx @@ -0,0 +1,207 @@ +import { queryOptions } from "@tanstack/react-query"; +import type { ActorId } from "./queries"; +import { useContext, createContext } from "react"; +import { createActorInspectorClient } from "@rivetkit/core/inspector"; + +export const defaultActorQueries = { + createActorInspectorHeaders: (actorId: ActorId | string) => ({ + "X-RivetKit-Query": JSON.stringify({ + getForId: { actorId }, + }), + }), + createActorInspector(actorId: ActorId | string) { + return createActorInspectorClient( + "http://localhost:6420/registry/actors/inspect", + { + headers: this.createActorInspectorHeaders(actorId), + }, + ); + }, + actorPingQueryOptions( + actorId: ActorId, + opts: { enabled?: boolean; refetchInterval?: number | false } = {}, + ) { + return queryOptions({ + enabled: false, + refetchInterval: 1000, + ...opts, + queryKey: ["actor", actorId, "ping"], + queryFn: async ({ queryKey: [, actorId] }) => { + const client = this.createActorInspector(actorId); + const response = await client.ping.$get(); + if (!response.ok) { + throw response; + } + return await response.json(); + }, + }); + }, + + actorStateQueryOptions( + actorId: ActorId, + { enabled }: { enabled: boolean } = { enabled: true }, + ) { + return queryOptions({ + enabled, + queryKey: ["actor", actorId, "state"], + queryFn: async ({ queryKey: [, actorId] }) => { + const client = this.createActorInspector(actorId); + const response = await client.state.$get(); + + if (!response.ok) { + throw response; + } + return (await response.json()) as { + enabled: boolean; + state: unknown; + }; + }, + }); + }, + + actorConnectionsQueryOptions( + actorId: ActorId, + { enabled }: { enabled: boolean } = { enabled: true }, + ) { + return queryOptions({ + enabled, + queryKey: ["actor", actorId, "connections"], + queryFn: async ({ queryKey: [, actorId] }) => { + const client = this.createActorInspector(actorId); + const response = await client.connections.$get(); + + if (!response.ok) { + throw response; + } + return await response.json(); + }, + }); + }, + + actorDatabaseQueryOptions( + actorId: ActorId, + { enabled }: { enabled: boolean } = { enabled: true }, + ) { + return queryOptions({ + enabled, + queryKey: ["actor", actorId, "database"], + queryFn: async ({ queryKey: [, actorId] }) => { + const client = this.createActorInspector(actorId); + const response = await client.db.$get(); + + if (!response.ok) { + throw response; + } + return await response.json(); + }, + }); + }, + + actorDatabaseEnabledQueryOptions( + actorId: ActorId, + { enabled }: { enabled: boolean } = { enabled: true }, + ) { + return queryOptions({ + ...this.actorDatabaseQueryOptions(actorId, { enabled }), + select: (data) => data.enabled, + notifyOnChangeProps: ["data", "isError", "isLoading"], + }); + }, + + actorDatabaseTablesQueryOptions( + actorId: ActorId, + { enabled }: { enabled: boolean } = { enabled: true }, + ) { + return queryOptions({ + ...this.actorDatabaseQueryOptions(actorId, { enabled }), + select: (data) => + data.db?.map((table) => ({ + name: table.table.name, + type: table.table.type, + records: table.records, + })) || [], + notifyOnChangeProps: ["data", "isError", "isLoading"], + }); + }, + + actorDatabaseRowsQueryOptions( + actorId: ActorId, + table: string, + { enabled }: { enabled: boolean } = { enabled: true }, + ) { + return queryOptions({ + enabled, + staleTime: 0, + gcTime: 5000, + queryKey: ["actor", actorId, "database", table], + queryFn: async ({ queryKey: [, actorId, , table] }) => { + const client = this.createActorInspector(actorId); + const response = await client.db.$post({ + json: { query: `SELECT * FROM ${table} LIMIT 500` }, + }); + if (!response.ok) { + throw response; + } + return await response.json(); + }, + }); + }, + + actorEventsQueryOptions( + actorId: ActorId, + { enabled }: { enabled: boolean } = { enabled: true }, + ) { + return queryOptions({ + enabled, + queryKey: ["actor", actorId, "events"], + queryFn: async ({ queryKey: [, actorId] }) => { + const client = this.createActorInspector(actorId); + const response = await client.events.$get(); + + if (!response.ok) { + throw response; + } + return await response.json(); + }, + }); + }, + + actorRpcsQueryOptions( + actorId: ActorId, + { enabled }: { enabled: boolean } = { enabled: true }, + ) { + return queryOptions({ + enabled, + queryKey: ["actor", actorId, "rpcs"], + queryFn: async ({ queryKey: [, actorId] }) => { + const client = this.createActorInspector(actorId); + const response = await client.rpcs.$get(); + + if (!response.ok) { + throw response; + } + return await response.json(); + }, + }); + }, + + actorClearEventsMutationOptions(actorId: ActorId) { + return { + mutationKey: ["actor", actorId, "clear-events"], + mutationFn: async () => { + const client = this.createActorInspector(actorId); + const response = await client.events.clear.$post(); + if (!response.ok) { + throw response; + } + return await response.json(); + }, + }; + }, +}; + +const ActorContext = createContext(defaultActorQueries); + +export const useActorQueries = () => useContext(ActorContext); + +export const ActorQueriesProvider = ActorContext.Provider; diff --git a/frontend/packages/components/src/actors/actor-region.tsx b/frontend/packages/components/src/actors/actor-region.tsx index 12b7eb3e2e..88ff4455a2 100644 --- a/frontend/packages/components/src/actors/actor-region.tsx +++ b/frontend/packages/components/src/actors/actor-region.tsx @@ -1,13 +1,11 @@ import { Flex, WithTooltip } from "@rivet-gg/components"; -import { useAtomValue } from "jotai"; -import { selectAtom } from "jotai/utils"; -import { useCallback } from "react"; import { REGION_LABEL, RegionIcon, getRegionKey, } from "../matchmaker/lobby-region"; -import { actorRegionsAtom } from "./actor-context"; +import { useQuery } from "@tanstack/react-query"; +import { useManagerQueries } from "./manager-queries-context"; interface ActorRegionProps { regionId?: string; @@ -20,16 +18,14 @@ export function ActorRegion({ regionId, className, }: ActorRegionProps) { - const region = useAtomValue( - selectAtom( - actorRegionsAtom, - useCallback( - (regions) => regions.find((region) => region.id === regionId), - [regionId], - ), - ), + const { data: region } = useQuery( + useManagerQueries().regionQueryOptions(regionId), ); + if (!regionId || !region) { + return null; + } + const regionKey = getRegionKey(region?.id); if (showLabel) { @@ -49,12 +45,7 @@ export function ActorRegion({ + } diff --git a/frontend/packages/components/src/actors/actor-runtime.tsx b/frontend/packages/components/src/actors/actor-runtime.tsx index e8c0f181a8..9533a777e2 100644 --- a/frontend/packages/components/src/actors/actor-runtime.tsx +++ b/frontend/packages/components/src/actors/actor-runtime.tsx @@ -1,39 +1,28 @@ -import equal from "fast-deep-equal"; -import { useAtomValue } from "jotai"; -import { selectAtom } from "jotai/utils"; import { Suspense } from "react"; +import { ActorBuild } from "./actor-build"; +import { ActorObjectInspector } from "./console/actor-inspector"; +import { ACTOR_FRAMEWORK_TAG_VALUE } from "./actor-tags"; +import { Dd, Dl, Dt } from "../ui/typography"; +import { Flex } from "../ui/flex"; import { formatDuration } from "../lib/formatter"; import { toRecord } from "../lib/utils"; -import { Flex } from "../ui/flex"; import { Skeleton } from "../ui/skeleton"; -import { Dd, Dl, Dt } from "../ui/typography"; -import { ActorBuild } from "./actor-build"; -import { - type Actor, - type ActorAtom, - ActorFeature, - currentActorFeaturesAtom, -} from "./actor-context"; -import { ACTOR_FRAMEWORK_TAG_VALUE } from "./actor-tags"; -import { ActorObjectInspector } from "./console/actor-inspector"; - -const selector = (a: Actor) => ({ - lifecycle: a.lifecycle, - resources: a.resources, - runtime: a.runtime, - tags: a.tags, -}); +import { useQuery } from "@tanstack/react-query"; +import { ActorFeature, type ActorId } from "./queries"; +import { useManagerQueries } from "./manager-queries-context"; export interface ActorRuntimeProps { - actor: ActorAtom; + actorId: ActorId; } -export function ActorRuntime({ actor }: ActorRuntimeProps) { - const { lifecycle, resources, runtime, tags } = useAtomValue( - selectAtom(actor, selector, equal), +export function ActorRuntime({ actorId }: ActorRuntimeProps) { + const { data: { lifecycle, resources, runtime, tags } = {} } = useQuery( + useManagerQueries().actorRuntimeQueryOptions(actorId), ); - const features = useAtomValue(currentActorFeaturesAtom); + const { data: features = [] } = useQuery( + useManagerQueries().actorFeaturesQueryOptions(actorId), + ); return ( <> @@ -50,27 +39,22 @@ export function ActorRuntime({ actor }: ActorRuntimeProps) { show0Min: true, })}
- {toRecord(tags).framework !== - ACTOR_FRAMEWORK_TAG_VALUE && resources ? ( + {toRecord(tags).framework !== ACTOR_FRAMEWORK_TAG_VALUE && + resources ? ( <>
Resources
- {resources.cpu / 1000} CPU cores,{" "} - {resources.memory} MB RAM + {resources.cpu / 1000} CPU cores, {resources.memory} MB RAM
) : null}
Arguments
- +
Environment
- +
Durable
@@ -80,10 +64,8 @@ export function ActorRuntime({ actor }: ActorRuntimeProps) { ) : null} - } - > - + }> + ); diff --git a/frontend/packages/components/src/actors/actor-state-change-indicator.tsx b/frontend/packages/components/src/actors/actor-state-change-indicator.tsx index b5d1400d85..5205bf7d0e 100644 --- a/frontend/packages/components/src/actors/actor-state-change-indicator.tsx +++ b/frontend/packages/components/src/actors/actor-state-change-indicator.tsx @@ -1,8 +1,14 @@ import { Badge } from "@rivet-gg/components"; -import { motion } from "framer-motion"; import { useEffect, useRef } from "react"; +import equal from "fast-deep-equal"; -const EMPTY_OBJECT = {}; +function usePreviousState(state: T) { + const ref = useRef(state); + useEffect(() => { + ref.current = state; + }, [state]); + return ref.current; +} interface ActorStateChangeIndicatorProps { state: unknown | undefined; @@ -11,29 +17,29 @@ interface ActorStateChangeIndicatorProps { export function ActorStateChangeIndicator({ state, }: ActorStateChangeIndicatorProps) { - const isMounted = useRef(false); - const oldState = useRef(); + const oldState = usePreviousState(state); + const hasChanged = !equal(state, oldState); - useEffect(() => { - isMounted.current = true; - }, []); + const ref = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: its okay, we only want to run this when state changes useEffect(() => { - oldState.current = state || EMPTY_OBJECT; - }, [state]); - - const hasChanged = state !== oldState.current; - const shouldUpdate = hasChanged && isMounted.current; + if (hasChanged && ref.current) { + ref.current?.animate( + [{ opacity: 1 }, { opacity: 1, offset: 0.7 }, { opacity: 0 }], + { + duration: 500, + easing: "ease-in", + }, + ); + } + }, [state, hasChanged]); return ( - +
State changed - +
); } diff --git a/frontend/packages/components/src/actors/actor-state-tab.tsx b/frontend/packages/components/src/actors/actor-state-tab.tsx index 634596a9b5..fd78a4fbf5 100644 --- a/frontend/packages/components/src/actors/actor-state-tab.tsx +++ b/frontend/packages/components/src/actors/actor-state-tab.tsx @@ -1,61 +1,76 @@ -import { useAtomValue } from "jotai"; -import { selectAtom } from "jotai/utils"; -import type { Actor, ActorAtom } from "./actor-context"; +import { useQuery } from "@tanstack/react-query"; +import type { ActorId } from "./queries"; +import { DocsSheet } from "../docs-sheet"; +import { Button } from "../ui/button"; +import type { PropsWithChildren } from "react"; import { ActorEditableState } from "./actor-editable-state"; -import { - useActorState, - useActorWorkerStatus, -} from "./worker/actor-worker-context"; - -const selector = (a: Actor) => a.destroyedAt; +import { useManagerQueries } from "./manager-queries-context"; +import { useActorQueries } from "./actor-queries-context"; interface ActorStateTabProps { - actor: ActorAtom; + actorId: ActorId; } -export function ActorStateTab({ actor }: ActorStateTabProps) { - const destroyedAt = useAtomValue(selectAtom(actor, selector)); - const status = useActorWorkerStatus(); +export function ActorStateTab({ actorId }: ActorStateTabProps) { + const { data: destroyedAt } = useQuery( + useManagerQueries().actorDestroyedAtQueryOptions(actorId), + ); - const state = useActorState(); + const actorQueries = useActorQueries(); + const { + data: state, + isError, + isLoading, + } = useQuery( + actorQueries.actorStateQueryOptions(actorId, { enabled: !destroyedAt }), + ); if (destroyedAt) { - return ( -
- State Preview is unavailable for inactive Actors. -
- ); + return State Preview is unavailable for inactive Actors.; } - if (status.type === "error") { + if (isError) { return ( -
+ State Preview is currently unavailable.
See console/logs for more details. -
+ ); } - if (status.type === "unsupported") { - return ( -
- State Preview is not supported for this Actor. -
- ); + if (isLoading) { + return Loading state...; } - if (status.type !== "ready") { + if (!state?.enabled) { return ( -
- Loading state... -
+ +

+ State Preview is not enabled for this Actor.
You can + enable it by providing a valid state constructor. +

+ + + +
); } return (
- + +
+ ); +} + +export function Info({ children }: PropsWithChildren) { + return ( +
+ {children}
); } diff --git a/frontend/packages/components/src/actors/actor-status-indicator.tsx b/frontend/packages/components/src/actors/actor-status-indicator.tsx index 81f9ff34dc..615762df7e 100644 --- a/frontend/packages/components/src/actors/actor-status-indicator.tsx +++ b/frontend/packages/components/src/actors/actor-status-indicator.tsx @@ -1,57 +1,29 @@ import { Ping, cn } from "@rivet-gg/components"; -import { useAtomValue } from "jotai"; -import { selectAtom } from "jotai/utils"; import type { ComponentPropsWithRef } from "react"; -import type { Actor, ActorAtom } from "./actor-context"; +import { useQuery } from "@tanstack/react-query"; +import type { ActorId, ActorStatus } from "./queries"; +import { useManagerQueries } from "./manager-queries-context"; -export type ActorStatus = - | "starting" - | "running" - | "stopped" - | "crashed" - | "unknown"; - -export function getActorStatus( - actor: Pick, -): ActorStatus { - const { createdAt, startedAt, destroyedAt } = actor; - - if (createdAt && !startedAt && !destroyedAt) { - return "starting"; - } - - if (createdAt && startedAt && !destroyedAt) { - return "running"; - } - - if (createdAt && startedAt && destroyedAt) { - return "stopped"; - } - - if (createdAt && !startedAt && destroyedAt) { - return "crashed"; - } - - return "unknown"; -} - -interface AtomizedActorStatusIndicatorProps - extends ComponentPropsWithRef<"span"> { - actor: ActorAtom; -} - -export const AtomizedActorStatusIndicator = ({ - actor, +export const QueriedActorStatusIndicator = ({ + actorId, ...props -}: AtomizedActorStatusIndicatorProps) => { - const status = useAtomValue(selectAtom(actor, selector)); - return ; -}; +}: { + actorId: ActorId; +} & ComponentPropsWithRef<"span">) => { + const { data: status, isError } = useQuery( + useManagerQueries().actorStatusQueryOptions(actorId), + ); -const selector = ({ status }: Actor) => status; + return ( + + ); +}; interface ActorStatusIndicatorProps extends ComponentPropsWithRef<"span"> { - status: ReturnType; + status: ActorStatus | undefined; } export const ActorStatusIndicator = ({ diff --git a/frontend/packages/components/src/actors/actor-status-label.tsx b/frontend/packages/components/src/actors/actor-status-label.tsx index c6a16ddb7d..3b4e659575 100644 --- a/frontend/packages/components/src/actors/actor-status-label.tsx +++ b/frontend/packages/components/src/actors/actor-status-label.tsx @@ -1,7 +1,6 @@ -import { useAtomValue } from "jotai"; -import { selectAtom } from "jotai/utils"; -import type { Actor, ActorAtom } from "./actor-context"; -import type { ActorStatus } from "./actor-status-indicator"; +import { useQuery } from "@tanstack/react-query"; +import type { ActorId, ActorStatus } from "./queries"; +import { useManagerQueries } from "./manager-queries-context"; export const ACTOR_STATUS_LABEL_MAP = { unknown: "Unknown", @@ -11,17 +10,13 @@ export const ACTOR_STATUS_LABEL_MAP = { crashed: "Crashed", } satisfies Record; -export const ActorStatusLabel = ({ status }: { status: ActorStatus }) => { - return {ACTOR_STATUS_LABEL_MAP[status]}; +export const ActorStatusLabel = ({ status }: { status?: ActorStatus }) => { + return {status ? ACTOR_STATUS_LABEL_MAP[status] : "Unknown"}; }; -const selector = (a: Actor) => a.status; - -export const AtomizedActorStatusLabel = ({ - actor, -}: { - actor: ActorAtom; -}) => { - const status = useAtomValue(selectAtom(actor, selector)); - return ; +export const QueriedActorStatusLabel = ({ actorId }: { actorId: ActorId }) => { + const { data: status, isError } = useQuery( + useManagerQueries().actorStatusQueryOptions(actorId), + ); + return ; }; diff --git a/frontend/packages/components/src/actors/actor-status.tsx b/frontend/packages/components/src/actors/actor-status.tsx index 0f587ed7cb..660d4b754f 100644 --- a/frontend/packages/components/src/actors/actor-status.tsx +++ b/frontend/packages/components/src/actors/actor-status.tsx @@ -1,21 +1,20 @@ import { cn } from "@rivet-gg/components"; -import type { ActorAtom } from "./actor-context"; import { ActorStatusIndicator, - type ActorStatus as ActorStatusType, - AtomizedActorStatusIndicator, + QueriedActorStatusIndicator, } from "./actor-status-indicator"; import { ActorStatusLabel, - AtomizedActorStatusLabel, + QueriedActorStatusLabel, } from "./actor-status-label"; +import type { ActorId, ActorStatus as ActorStatusType } from "./queries"; interface ActorStatusProps { className?: string; - actor: ActorAtom; + actorId: ActorId; } -export const AtomizedActorStatus = ({ +export const QueriedActorStatus = ({ className, ...props }: ActorStatusProps) => { @@ -26,8 +25,8 @@ export const AtomizedActorStatus = ({ className, )} > - - + + ); }; diff --git a/frontend/packages/components/src/actors/actor-stop-button.tsx b/frontend/packages/components/src/actors/actor-stop-button.tsx index cbc67ea89f..5c572790a2 100644 --- a/frontend/packages/components/src/actors/actor-stop-button.tsx +++ b/frontend/packages/components/src/actors/actor-stop-button.tsx @@ -1,42 +1,35 @@ import { Button, WithTooltip } from "@rivet-gg/components"; import { Icon, faXmark } from "@rivet-gg/icons"; -import equal from "fast-deep-equal"; -import { useAtomValue } from "jotai"; -import { selectAtom } from "jotai/utils"; -import type { Actor, ActorAtom, DestroyActorAtom } from "./actor-context"; - -const selector = (a: Actor) => ({ - destroyedAt: a.destroyedAt, - destroy: a.destroy, -}); +import { useMutation, useQuery } from "@tanstack/react-query"; +import { type ActorId } from "./queries"; +import { useManagerQueries } from "./manager-queries-context"; interface ActorStopButtonProps { - actor: ActorAtom; + actorId: ActorId; } -export function ActorStopButton({ actor }: ActorStopButtonProps) { - const { destroy: destroyAtom, destroyedAt } = useAtomValue( - selectAtom(actor, selector, equal), +export function ActorStopButton({ actorId }: ActorStopButtonProps) { + const { data: destroyedAt } = useQuery( + useManagerQueries().actorDestroyedAtQueryOptions(actorId), + ); + + const { mutate, isPending } = useMutation( + useManagerQueries().actorDestroyMutationOptions(actorId), ); - if (destroyedAt || !destroyAtom) { + if (destroyedAt) { return null; } - return ; -} - -function Content({ destroy: destroyAtom }: { destroy: DestroyActorAtom }) { - const { destroy, isDestroying } = useAtomValue(destroyAtom); return ( mutate()} > diff --git a/frontend/packages/components/src/actors/actors-actor-details.tsx b/frontend/packages/components/src/actors/actors-actor-details.tsx index e45a7bc48e..b5acf528cd 100644 --- a/frontend/packages/components/src/actors/actors-actor-details.tsx +++ b/frontend/packages/components/src/actors/actors-actor-details.tsx @@ -6,30 +6,29 @@ import { TabsTrigger, cn, } from "@rivet-gg/components"; -import { Icon, faQuestionSquare } from "@rivet-gg/icons"; -import { useAtomValue } from "jotai"; -import { type ReactNode, Suspense, memo } from "react"; -import { ActorConfigTab } from "./actor-config-tab"; -import { ActorConnectionsTab } from "./actor-connections-tab"; -import { - type ActorAtom, - ActorFeature, - currentActorFeaturesAtom, -} from "./actor-context"; +import { memo, type ReactNode, Suspense } from "react"; import { ActorDetailsSettingsProvider } from "./actor-details-settings"; import { ActorLogsTab } from "./actor-logs-tab"; -import { ActorMetricsTab } from "./actor-metrics-tab"; -import { ActorStateTab } from "./actor-state-tab"; -import { AtomizedActorStatus } from "./actor-status"; +import { QueriedActorStatus } from "./actor-status"; import { ActorStopButton } from "./actor-stop-button"; import { ActorsSidebarToggleButton } from "./actors-sidebar-toggle-button"; import { useActorsView } from "./actors-view-context-provider"; -import { ActorConsole } from "./console/actor-console"; +import { faQuestionSquare, Icon } from "@rivet-gg/icons"; +import { useQuery } from "@tanstack/react-query"; +import { ActorFeature, type ActorId } from "./queries"; +import { ActorConfigTab } from "./actor-config-tab"; +import { ActorMetricsTab } from "./actor-metrics-tab"; +import { ActorStateTab } from "./actor-state-tab"; import { ActorWorkerContextProvider } from "./worker/actor-worker-context"; +import { ActorConsole } from "./console/actor-console"; +import { ActorConnectionsTab } from "./actor-connections-tab"; +import { ActorEventsTab } from "./actor-events-tab"; +import { ActorDatabaseTab } from "./actor-db-tab"; +import { useManagerQueries } from "./manager-queries-context"; interface ActorsActorDetailsProps { tab?: string; - actor: ActorAtom; + actorId: ActorId; onTabChange?: (tab: string) => void; onExportLogs?: ( actorId: string, @@ -40,35 +39,31 @@ interface ActorsActorDetailsProps { } export const ActorsActorDetails = memo( - ({ - tab, - onTabChange, - actor, - onExportLogs, - isExportingLogs, - }: ActorsActorDetailsProps) => { - const actorFeatures = useAtomValue(currentActorFeaturesAtom); - const supportsConsole = actorFeatures?.includes(ActorFeature.Console); + ({ tab, onTabChange, actorId }: ActorsActorDetailsProps) => { + const { data: features = [] } = useQuery( + useManagerQueries().actorFeaturesQueryOptions(actorId), + ); + const supportsConsole = features?.includes(ActorFeature.Console); return (
- {supportsConsole ? : null} + {supportsConsole ? : null}
@@ -83,7 +78,7 @@ export const ActorsActorEmptyDetails = ({ }) => { const { copy } = useActorsView(); return ( -
+
@@ -98,32 +93,26 @@ export function ActorTabs({ tab, features, onTabChange, - actor, + actorId, className, disabled, children, - onExportLogs, - isExportingLogs, }: { disabled?: boolean; tab?: string; features: ActorFeature[]; onTabChange?: (tab: string) => void; - actor?: ActorAtom; + actorId?: ActorId; className?: string; children?: ReactNode; - onExportLogs?: ( - actorId: string, - typeFilter?: string, - filter?: string, - ) => Promise; - isExportingLogs?: boolean; }) { const supportsState = features?.includes(ActorFeature.State); const supportsLogs = features?.includes(ActorFeature.Logs); const supportsConnections = features?.includes(ActorFeature.Connections); const supportsConfig = features?.includes(ActorFeature.Config); const supportsMetrics = features?.includes(ActorFeature.Metrics); + const supportsEvents = features?.includes(ActorFeature.EventsMonitoring); + const supportsDatabase = features?.includes(ActorFeature.Database); const defaultTab = supportsState ? "state" : "logs"; const value = disabled ? undefined : tab || defaultTab; @@ -133,11 +122,11 @@ export function ActorTabs({ value={value} onValueChange={onTabChange} defaultValue={value} - className={cn(className, "flex-1 min-h-0 flex flex-col ")} + className={cn(className, "flex-1 min-h-0 min-w-0 flex flex-col ")} >
-
+
{supportsState ? ( @@ -145,13 +134,20 @@ export function ActorTabs({ ) : null} {supportsConnections ? ( - + Connections ) : null} + {supportsEvents ? ( + + Events + + ) : null} + {supportsDatabase ? ( + + Database + + ) : null} {supportsLogs ? ( Logs @@ -168,68 +164,62 @@ export function ActorTabs({ ) : null} - {actor ? ( + {actorId ? ( - - + ) : null}
- {actor ? ( + {actorId ? ( <> {supportsLogs ? ( - + }> - + ) : null} {supportsConfig ? ( - - + + ) : null} {supportsConnections ? ( + + + + ) : null} + {supportsEvents ? ( + + + + ) : null} + {supportsDatabase ? ( - + ) : null} {supportsState ? ( - - + + ) : null} {supportsMetrics ? ( - - + + ) : null} diff --git a/frontend/packages/components/src/actors/actors-list-row.tsx b/frontend/packages/components/src/actors/actors-list-row.tsx index 6108b4df0c..ac38fea0bf 100644 --- a/frontend/packages/components/src/actors/actors-list-row.tsx +++ b/frontend/packages/components/src/actors/actors-list-row.tsx @@ -4,35 +4,26 @@ import { SmallText, WithTooltip, cn, - toRecord, } from "@rivet-gg/components"; import { Icon, faTag, faTags } from "@rivet-gg/icons"; import { Link } from "@tanstack/react-router"; -import { useAtomValue } from "jotai"; -import { selectAtom } from "jotai/utils"; import { memo } from "react"; -import { - type Actor, - type ActorAtom, - isCurrentActorAtom, -} from "./actor-context"; import { ActorRegion } from "./actor-region"; -import { AtomizedActorStatusIndicator } from "./actor-status-indicator"; -import { AtomizedActorStatusLabel } from "./actor-status-label"; import { ActorTags } from "./actor-tags"; +import { type ActorId } from "./queries"; +import { useQuery } from "@tanstack/react-query"; +import { QueriedActorStatusLabel } from "./actor-status-label"; +import { QueriedActorStatusIndicator } from "./actor-status-indicator"; +import { useManagerQueries } from "./manager-queries-context"; interface ActorsListRowProps { className?: string; - actor: ActorAtom; + actorId: ActorId; + isCurrent?: boolean; } -const selector = (actor: Actor) => actor.id; - export const ActorsListRow = memo( - ({ className, actor }: ActorsListRowProps) => { - const id = useAtomValue(selectAtom(actor, selector)); - const isCurrent = useAtomValue(isCurrentActorAtom(actor)); - + ({ className, actorId, isCurrent }: ActorsListRowProps) => { return (
} - content={} + content={} /> - - {id.split("-")[0]} - + + + - - + + ); }, ); -const regionSelector = (actor: Actor) => actor.region; +function Id({ actorId }: { actorId: ActorId }) { + return ( + + {actorId.includes("-") ? actorId.split("-")[0] : actorId.substring(0, 8)} + + ); +} + +function Region({ actorId }: { actorId: ActorId }) { + const { data: regionId } = useQuery( + useManagerQueries().actorRegionQueryOptions(actorId), + ); -function Region({ actor }: { actor: ActorAtom }) { - const regionId = useAtomValue(selectAtom(actor, regionSelector)); + if (!regionId) { + return -; + } return ( toRecord(actor.tags); - -function Tags({ actor }: { actor: ActorAtom }) { - const tags = useAtomValue(selectAtom(actor, tagsSelector)); +function Tags({ actorId }: { actorId: ActorId }) { + const { data: tags = {} } = useQuery( + useManagerQueries().actorTagsQueryOptions(actorId), + ); const tagCount = Object.keys(tags).length; @@ -104,12 +107,8 @@ function Tags({ actor }: { actor: ActorAtom }) { excludeBuiltIn="actors" />
- - {Object.keys(tags).length}{" "} - {tagCount === 1 ? "tag" : "tags"} + + {Object.keys(tags).length} {tagCount === 1 ? "tag" : "tags"}
} @@ -130,10 +129,10 @@ function Tags({ actor }: { actor: ActorAtom }) { ); } -const createdAtSelector = (actor: Actor) => actor.createdAt; - -function CreatedAt({ actor }: { actor: ActorAtom }) { - const createdAt = useAtomValue(selectAtom(actor, createdAtSelector)); +function CreatedAt({ actorId }: { actorId: ActorId }) { + const { data: createdAt } = useQuery( + useManagerQueries().actorCreatedAtQueryOptions(actorId), + ); return ( @@ -149,9 +148,10 @@ function CreatedAt({ actor }: { actor: ActorAtom }) { ); } -const destroyedAtSelector = (actor: Actor) => actor.destroyedAt; -function DestroyedAt({ actor }: { actor: ActorAtom }) { - const destroyedAt = useAtomValue(selectAtom(actor, destroyedAtSelector)); +function DestroyedAt({ actorId }: { actorId: ActorId }) { + const { data: destroyedAt } = useQuery( + useManagerQueries().actorDestroyedAtQueryOptions(actorId), + ); return ( diff --git a/frontend/packages/components/src/actors/actors-list.tsx b/frontend/packages/components/src/actors/actors-list.tsx index 2ca0e8242d..3838885df8 100644 --- a/frontend/packages/components/src/actors/actors-list.tsx +++ b/frontend/packages/components/src/actors/actors-list.tsx @@ -1,55 +1,30 @@ import { Button, - Checkbox, - CommandGroup, - CommandItem, DocsSheet, FilterCreator, - type FilterDefinitions, - FilterOp, type OnFiltersChange, - type OptionsProviderProps, ScrollArea, ShimmerLine, SmallText, - cn, - createFiltersPicker, - createFiltersSchema, } from "@rivet-gg/components"; +import { ActorsListRow } from "./actors-list-row"; +import { CreateActorButton } from "./create-actor-button"; +import { GoToActorButton } from "./go-to-actor-button"; +import { useSearch, useNavigate } from "@tanstack/react-router"; import { Icon, faActors, - faCalendarCircleMinus, - faCalendarCirclePlus, faCalendarMinus, faCalendarPlus, - faCode, faGlobe, + faNodeJs, faReact, - faSignalBars, - faTag, - faTs, } from "@rivet-gg/icons"; -import { useNavigate, useSearch } from "@tanstack/react-router"; -import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useMemo } from "react"; -import { - actorFiltersAtom, - actorFiltersCountAtom, - actorRegionsAtom, - actorTagsAtom, - actorsAtomsAtom, - actorsPaginationAtom, - actorsQueryAtom, - filteredActorsCountAtom, -} from "./actor-context"; -import { ActorStatus } from "./actor-status"; -import type { ActorStatus as ActorStatusType } from "./actor-status-indicator"; -import { ActorTag } from "./actor-tags"; -import { ActorsListRow } from "./actors-list-row"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { useActorsView } from "./actors-view-context-provider"; -import { CreateActorButton } from "./create-actor-button"; -import { GoToActorButton } from "./go-to-actor-button"; +import { useManagerQueries } from "./manager-queries-context"; +import { useActorsFilters } from "./actor-filters-context"; export function ActorsList() { return ( @@ -58,7 +33,7 @@ export function ActorsList() {
- + {/* */}
@@ -104,27 +79,40 @@ export function ActorsList() { } function LoadingIndicator() { - const state = useAtomValue(actorsQueryAtom); - if (state.isLoading) { + const { isLoading } = useInfiniteQuery( + useManagerQueries().actorsListQueryOptions(), + ); + if (isLoading) { return ; } return null; } function List() { - const actors = useAtomValue(actorsAtomsAtom); + const { data: actorIds = [] } = useInfiniteQuery( + useManagerQueries().actorsListQueryOptions(), + ); + + const actorId = useSearch({ select: (state) => state.actorId }); + return ( <> - {actors.map((actor) => ( - + {actorIds.map((id) => ( + ))} ); } function Pagination() { - const { hasNextPage, isFetchingNextPage, fetchNextPage } = - useAtomValue(actorsPaginationAtom); + const { hasNextPage, isFetchingNextPage, fetchNextPage, data } = + useInfiniteQuery( + useManagerQueries().actorsListPaginationQueryOptions(), + ); if (hasNextPage) { return ( @@ -141,14 +129,25 @@ function Pagination() { ); } - return ; + return ; } -function EmptyState() { - const count = useAtomValue(filteredActorsCountAtom); - const filtersCount = useAtomValue(actorFiltersCountAtom); - const setFilters = useSetAtom(actorFiltersAtom); - const { copy } = useActorsView(); +function EmptyState({ count }: { count: number }) { + const navigate = useNavigate(); + const { copy, links } = useActorsView(); + const { remove, pick } = useActorsFilters(); + + const filtersCount = useSearch({ + select: (state) => Object.values(pick(state)).length, + }); + + const clearFilters = () => { + navigate({ + search: (prev) => ({ + ...remove(prev), + }), + }); + }; return (
@@ -167,24 +166,24 @@ function EmptyState() {
@@ -225,97 +211,34 @@ function EmptyState() { ); } -const FILTER_DEFINITIONS = { - tags: { - type: "select", - label: "Tags", - icon: faTag, - options: TagsOptions, - operators: { - [FilterOp.EQUAL]: "is one of", - [FilterOp.NOT_EQUAL]: "is not one of", - }, - }, - createdAt: { - type: "date", - label: "Created", - icon: faCalendarCirclePlus, - }, - destroyedAt: { - type: "date", - label: "Destroyed", - icon: faCalendarCircleMinus, - }, - status: { - type: "select", - label: "Status", - icon: faSignalBars, - options: StatusOptions, - display: ({ value }) => { - if (value.length > 1) { - return {value.length} statuses; - } - return ( - - ); - }, - }, - region: { - type: "select", - label: "Region", - icon: faGlobe, - options: RegionOptions, - display: ({ value }) => { - if (value.length > 1) { - return {value.length} regions; - } - const region = useAtomValue(actorRegionsAtom).find( - (region) => region.id === value[0], - ); - return {region?.name}; - }, - operators: { - [FilterOp.EQUAL]: "is one of", - [FilterOp.NOT_EQUAL]: "is not one of", - }, - }, - devMode: { - type: "boolean", - label: "Show hidden actors", - icon: faCode, - }, -} satisfies FilterDefinitions; - -export const ActorsListFiltersSchema = createFiltersSchema(FILTER_DEFINITIONS); - -export const pickActorListFilters = createFiltersPicker(FILTER_DEFINITIONS); - function Filters() { const navigate = useNavigate(); const filters = useSearch({ strict: false }); + const { pick, remove } = useActorsFilters(); + const onFiltersChange: OnFiltersChange = useCallback( (fnOrValue) => { if (typeof fnOrValue === "function") { navigate({ - search: ({ actorId, tab, ...filters }) => ({ - actorId, - tab, - ...Object.fromEntries( - Object.entries(fnOrValue(filters)).filter( - ([, filter]) => filter.value.length > 0, + search: (old) => { + const filters = pick(old); + const prev = remove(old); + + return { + ...prev, + ...Object.fromEntries( + Object.entries(fnOrValue(filters)).filter( + ([, filter]) => filter.value.length > 0, + ), ), - ), - }), + }; + }, }); } else { navigate({ search: (value) => ({ - actorId: value.actorId, - tab: value.tab, + ...remove(value), ...Object.fromEntries( Object.entries(fnOrValue).filter( ([, filter]) => filter.value.length > 0, @@ -325,21 +248,23 @@ function Filters() { }); } }, - [navigate], + [navigate, pick], ); const { copy } = useActorsView(); + const { definitions } = useActorsFilters(); + const filtersDefs = useMemo(() => { return { - ...FILTER_DEFINITIONS, + ...definitions, devMode: { - ...FILTER_DEFINITIONS.devMode, + ...definitions.devMode, hidden: true, label: copy.showHiddenActors, }, }; - }, [copy.showHiddenActors]); + }, [copy.showHiddenActors, definitions]); return ( ); } - -function TagsOptions({ onSelect, value: filterValue }: OptionsProviderProps) { - const tags = useAtomValue(actorTagsAtom); - - const values = filterValue.map((filter) => filter.split("=")); - - return ( - - {tags.map(({ key, value }) => { - const isSelected = values.some( - ([filterKey, filterValue]) => - filterKey === key && filterValue === value, - ); - return ( - { - if (isSelected) { - onSelect( - values - .filter( - ([filterKey, filterValue]) => - filterKey !== key || - filterValue !== value, - ) - .map((pair) => pair.join("=")), - { closeAfter: true }, - ); - return; - } - onSelect([...filterValue, `${key}=${value}`], { - closeAfter: true, - }); - }} - > - - - - {key}={value} - - - - ); - })} - - ); -} - -function StatusOptions({ onSelect, value: filterValue }: OptionsProviderProps) { - return ( - - {["running", "starting", "crashed", "stopped"].map((key) => { - const isSelected = filterValue.some((val) => val === key); - return ( - { - if (isSelected) { - onSelect( - filterValue.filter( - (filterKey) => filterKey !== key, - ), - { closeAfter: true }, - ); - return; - } - - onSelect([...filterValue, key], { - closeAfter: true, - }); - }} - > - - - - ); - })} - - ); -} - -function RegionOptions({ onSelect, value: filterValue }: OptionsProviderProps) { - const regions = useAtomValue(actorRegionsAtom); - return ( - - {regions.map(({ id, name }) => { - const isSelected = filterValue.some((val) => val === id); - return ( - { - if (isSelected) { - onSelect( - filterValue.filter( - (filterKey) => filterKey !== id, - ), - { closeAfter: true }, - ); - return; - } - - onSelect([...filterValue, id], { - closeAfter: true, - }); - }} - > - - {name} - - ); - })} - - ); -} diff --git a/frontend/packages/components/src/actors/actors-view-context-provider.tsx b/frontend/packages/components/src/actors/actors-view-context-provider.tsx index d7a063f1ae..4c8b6346d4 100644 --- a/frontend/packages/components/src/actors/actors-view-context-provider.tsx +++ b/frontend/packages/components/src/actors/actors-view-context-provider.tsx @@ -24,7 +24,7 @@ const defaultValue = { createActorModal: { title: "Create Actor", description: - "Choose a build to create an Actor from. Actor will be created using default settings.", + "Quickly create an Actor by providing the necessary details.", }, actorNotFound: "Actor not found", @@ -37,6 +37,12 @@ const defaultValue = { "Use a quick start guide to start deploying Actors to your environment.", }, }, + links: { + gettingStarted: { + node: "https://www.rivet.gg/docs/actors/quickstart/backend/", + react: "https://www.rivet.gg/docs/actors/quickstart/react/", + }, + }, canCreate: true, }; diff --git a/frontend/packages/components/src/actors/build-select.tsx b/frontend/packages/components/src/actors/build-select.tsx index ac5ed0eecf..53d257cc4c 100644 --- a/frontend/packages/components/src/actors/build-select.tsx +++ b/frontend/packages/components/src/actors/build-select.tsx @@ -1,70 +1,47 @@ -import { Badge, Combobox } from "@rivet-gg/components"; -import { useAtomValue } from "jotai"; +import { Combobox } from "@rivet-gg/components"; import { useMemo } from "react"; -import { actorBuildsAtom } from "./actor-context"; +import { useQuery } from "@tanstack/react-query"; +import { useManagerQueries } from "./manager-queries-context"; interface BuildSelectProps { onValueChange: (value: string) => void; value: string; - onlyCurrent?: boolean; } -export function BuildSelect({ - onValueChange, - value, - onlyCurrent, -}: BuildSelectProps) { - const data = useAtomValue(actorBuildsAtom); +export function BuildSelect({ onValueChange, value }: BuildSelectProps) { + const { data = [] } = useQuery(useManagerQueries().buildsQueryOptions()); const builds = useMemo(() => { - let sorted = data.toSorted( - (a, b) => b.createdAt.valueOf() - a.createdAt.valueOf(), - ); - - if (onlyCurrent) { - sorted = sorted.filter((build) => build.tags.current); - } - - const findLatest = (name: string) => - sorted.find((build) => build.tags.name === name); - return sorted.map((build, index, array) => { + return data.map((build, index, array) => { return { label: (
- {build.tags.name || build.id.split("-")[0]} - - {findLatest(build.tags.name)?.id === - build.id ? ( - - Latest - - ) : null} -
-
- Created: {build.createdAt.toLocaleString()} + {build.tags?.name || build.name}
+ {build.createdAt ? ( +
+ Created: {build.createdAt.toLocaleString()} +
+ ) : null}
), - value: build.id, + value: build.name, build, }; }); - }, [data, onlyCurrent]); + }, [data]); return ( - option.build.name.includes(search) || - option.build.tags.name.includes(search) - } - className="w-full h-14" + filter={(option, search) => option.build.name.includes(search)} + className="w-full" /> ); } diff --git a/frontend/packages/components/src/actors/console/actor-console-input.tsx b/frontend/packages/components/src/actors/console/actor-console-input.tsx index 58689575ef..6414906716 100644 --- a/frontend/packages/components/src/actors/console/actor-console-input.tsx +++ b/frontend/packages/components/src/actors/console/actor-console-input.tsx @@ -1,12 +1,24 @@ import { Button, ScrollArea } from "@rivet-gg/components"; import { useRef } from "react"; -import { useActorRpcs, useActorWorker } from "../worker/actor-worker-context"; +import { useActorWorker } from "../worker/actor-worker-context"; import { ActorConsoleMessage } from "./actor-console-message"; import { ReplInput, type ReplInputRef, replaceCode } from "./repl-input"; +import type { ActorId } from "../queries"; +import { useQuery } from "@tanstack/react-query"; +import { useActorQueries } from "../actor-queries-context"; -export function ActorConsoleInput() { +interface ActorConsoleInputProps { + actorId: ActorId; +} + +export function ActorConsoleInput({ actorId }: ActorConsoleInputProps) { const worker = useActorWorker(); - const rpcs = useActorRpcs(); + + const actorQueries = useActorQueries(); + const { + data: { rpcs = [] } = {}, + } = useQuery(actorQueries.actorRpcsQueryOptions(actorId)); + const ref = useRef(null); return ( diff --git a/frontend/packages/components/src/actors/console/actor-console.tsx b/frontend/packages/components/src/actors/console/actor-console.tsx index 681cbbf10d..f3a6c9e6c3 100644 --- a/frontend/packages/components/src/actors/console/actor-console.tsx +++ b/frontend/packages/components/src/actors/console/actor-console.tsx @@ -6,13 +6,33 @@ import { useActorWorkerStatus } from "../worker/actor-worker-context"; import { ActorWorkerStatus } from "../worker/actor-worker-status"; import { ActorConsoleInput } from "./actor-console-input"; import { ActorConsoleLogs } from "./actor-console-logs"; +import type { ActorId } from "../queries"; +import { useQuery } from "@tanstack/react-query"; +import { useActorQueries } from "../actor-queries-context"; -export function ActorConsole() { +interface ActorConsoleProps { + actorId: ActorId; +} + +export function ActorConsole({ actorId }: ActorConsoleProps) { const [isOpen, setOpen] = useState(false); const status = useActorWorkerStatus(); + const actorQueries = useActorQueries(); + const { isSuccess, isError, isLoading } = useQuery( + actorQueries.actorPingQueryOptions(actorId, { + enabled: true, + refetchInterval: false, + }), + ); + + const isBlocked = status.type !== "ready" || !isSuccess; - const isBlocked = status.type !== "ready"; + const combinedStatus = isError + ? "error" + : isLoading + ? "pending" + : status.type; return ( Console - + @@ -41,7 +61,7 @@ export function ActorConsole() { className="flex flex-col flex-1 max-h-full overflow-hidden" > - + ) : null} diff --git a/frontend/packages/components/src/actors/create-actor-button.tsx b/frontend/packages/components/src/actors/create-actor-button.tsx index 3748de59bb..27084273b2 100644 --- a/frontend/packages/components/src/actors/create-actor-button.tsx +++ b/frontend/packages/components/src/actors/create-actor-button.tsx @@ -1,21 +1,23 @@ import { Button, type ButtonProps, WithTooltip } from "@rivet-gg/components"; import { Icon, faPlus } from "@rivet-gg/icons"; import { useNavigate } from "@tanstack/react-router"; -import { useAtomValue } from "jotai"; -import { - actorBuildsCountAtom, - actorManagerEndpointAtom, -} from "./actor-context"; import { useActorsView } from "./actors-view-context-provider"; +import { useQuery } from "@tanstack/react-query"; +import { useManagerQueries } from "./manager-queries-context"; export function CreateActorButton(props: ButtonProps) { const navigate = useNavigate(); - const builds = useAtomValue(actorBuildsCountAtom); - const endpoint = useAtomValue(actorManagerEndpointAtom); + + const queries = useManagerQueries(); + const { data } = useQuery(useManagerQueries().buildsQueryOptions()); const { copy, canCreate: contextAllowActorsCreation } = useActorsView(); - const canCreate = builds > 0 && contextAllowActorsCreation && endpoint; + const canCreate = + data && + data.length > 0 && + contextAllowActorsCreation && + queries.endpoint; if (!contextAllowActorsCreation) { return null; @@ -52,7 +54,7 @@ export function CreateActorButton(props: ButtonProps) { { + return createColumns(dbCols, references, { enableRowSelection }); + }, [dbCols, references, enableRowSelection]); + + const [rowSelection, setRowSelection] = useState({}); + const [sorting, setSorting] = useState([]); + const [expanded, setExpanded] = useState({}); + + const table = useTable({ + columns, + data, + enableRowSelection, + enableSorting, + enableCellExpanding, + enableColumnResizing, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getSortedRowModel: getSortedRowModel(), + defaultColumn: {}, + columnResizeMode: "onChange", + onSortingChange: setSorting, + onRowSelectionChange: setRowSelection, + paginateExpandedRows: false, + state: { + sorting, + rowSelection, + expanded, + }, + }); + + function calculateColumnSizes() { + const headers = table.getFlatHeaders(); + const colSizes: { [key: string]: number } = {}; + for (let i = 0; i < headers.length; i++) { + const header = headers[i]!; + colSizes[`--header-${header.id}-size`] = header.getSize(); + colSizes[`--col-${header.column.id}-size`] = header.column.getSize(); + } + return colSizes; + } + + const columnSizeVars = useMemo(() => { + return calculateColumnSizes(); + }, [table.getState().columnSizingInfo, table.getState().columnSizing]); + + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : header.column.getCanSort() ? ( + + ) : ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ )} + {header.column.getCanResize() ? ( +
+
+
+ ) : null} + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + + {row.getVisibleCells().map((cell) => ( + +
+
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+
+
+ ))} +
+
+ ))} +
+
+ ); +} + +const ch = createColumnHelper(); + +function createColumns( + columns: Columns, + references?: ForeignKeys, + { enableRowSelection }: { enableRowSelection?: boolean } = {}, +) { + return [ + ...[ + enableRowSelection + ? ch.display({ + id: "select", + enableResizing: false, + header: ({ table }) => ( + { + if (value === "indeterminate") { + table.toggleAllRowsSelected(true); + return; + } + table.toggleAllRowsSelected(value); + }} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + { + if (value === "indeterminate") { + row.toggleSelected(true); + return; + } + row.toggleSelected(); + }} + /> + ), + }) + : null, + ].filter(Boolean), + ...columns.map((col) => + ch.accessor(col.name, { + header: (info) => ( + + {col.name}{" "} + + {col.type} + + + + ), + cell: (info) => { + if (col.type === "blob") { + return ( + + BINARY{" "} + + ); + } + const value = info.getValue(); + if (value === null) { + return ( + + NULL + + ); + } + + if (col) return <>{info.getValue()}; + }, + meta: { + type: col.type, + notNull: col.notnull, + default: col.dflt_value, + }, + }), + ), + ]; +} + +function ForeignKey({ + references, + column, +}: { + references?: ForeignKeys; + column: Column; +}) { + const ref = references?.find((ref) => ref.from === column.name); + if (!ref) return null; + return ( + + + {ref.table}.{ref.to} + + ); +} diff --git a/frontend/packages/components/src/actors/dialogs/create-actor-dialog.tsx b/frontend/packages/components/src/actors/dialogs/create-actor-dialog.tsx index 9dc8b0745f..9d42f7c602 100644 --- a/frontend/packages/components/src/actors/dialogs/create-actor-dialog.tsx +++ b/frontend/packages/components/src/actors/dialogs/create-actor-dialog.tsx @@ -1,4 +1,3 @@ -import { useAtomValue } from "jotai"; import { DialogDescription, DialogFooter, @@ -6,15 +5,18 @@ import { DialogTitle, } from "../../ui/dialog"; import { Flex } from "../../ui/flex"; -import { createActorAtom } from "../actor-context"; import { useActorsView } from "../actors-view-context-provider"; import * as ActorCreateForm from "../form/actor-create-form"; import type { DialogContentProps } from "../hooks"; +import { useMutation } from "@tanstack/react-query"; +import { useManagerQueries } from "../manager-queries-context"; interface ContentProps extends DialogContentProps {} export default function CreateActorDialog({ onClose }: ContentProps) { - const { endpoint, create } = useAtomValue(createActorAtom); + const { mutateAsync } = useMutation( + useManagerQueries().createActorMutationOptions(), + ); const { copy } = useActorsView(); @@ -22,23 +24,17 @@ export default function CreateActorDialog({ onClose }: ContentProps) { <> { - if (!endpoint) { - throw new Error("No endpoint"); - } - await create({ - endpoint, - id: values.buildId, - tags: Object.fromEntries( - values.tags.map((tag) => [tag.key, tag.value]), - ), - params: values.parameters - ? JSON.parse(values.parameters) + const key = JSON.parse(values.key); + await mutateAsync({ + name: values.name, + input: values.input + ? JSON.parse(values.input) : undefined, - region: values.regionId, + key: Array.isArray(key) ? key : [key], }); onClose?.(); }} - defaultValues={{ buildId: "", regionId: "" }} + defaultValues={{ name: "" }} > {copy.createActorModal.title} @@ -48,9 +44,10 @@ export default function CreateActorDialog({ onClose }: ContentProps) { - - - {/* */} + {/* */} + {/* */} + + diff --git a/frontend/packages/components/src/actors/form/actor-create-form.tsx b/frontend/packages/components/src/actors/form/actor-create-form.tsx index bf92b99b46..c2a72ab919 100644 --- a/frontend/packages/components/src/actors/form/actor-create-form.tsx +++ b/frontend/packages/components/src/actors/form/actor-create-form.tsx @@ -1,29 +1,17 @@ +import { type UseFormReturn, useFormContext } from "react-hook-form"; +import z from "zod"; + +import { BuildSelect } from "../build-select"; +import { createSchemaForm } from "../../lib/create-schema-form"; import { FormControl, + FormDescription, FormField, FormItem, FormLabel, FormMessage, - Label, - createSchemaForm, -} from "@rivet-gg/components"; -import { JsonCode } from "@rivet-gg/components/code-mirror"; -import { type UseFormReturn, useFormContext } from "react-hook-form"; -import z from "zod"; - -import { useAtomValue, useSetAtom } from "jotai"; -import { - actorCustomTagKeys, - actorCustomTagValues, - actorTagKeysAtom, - actorTagValuesAtom, -} from "../actor-context"; -import { BuildSelect } from "../build-select"; -import { RegionSelect } from "../region-select"; -import { - Tags as TagsInput, - formSchema as tagsFormSchema, -} from "./build-tags-form"; +} from "../../ui/form"; +import { JsonCode } from "../../code-mirror"; const jsonValid = z.custom((value) => { try { @@ -32,13 +20,30 @@ const jsonValid = z.custom((value) => { } catch { return false; } -}); +}, "Must be valid JSON"); export const formSchema = z.object({ - buildId: z.string().nonempty("Build is required"), - regionId: z.string(), - parameters: jsonValid.optional(), - tags: tagsFormSchema.shape.tags, + name: z.string().nonempty("Build is required"), + // regionId: z.string(), + key: jsonValid + .refine((val) => { + if (Array.isArray(JSON.parse(val))) { + return true; + } + if (typeof JSON.parse(val) === "string") { + return true; + } + return false; + }, "Must be a JSON array or a string") + .refine((val) => { + const parsed = JSON.parse(val); + if (Array.isArray(parsed)) { + return parsed.every((item) => typeof item === "string"); + } + return true; + }, "All items in the array must be strings"), + input: jsonValid.optional(), + // tags: tagsFormSchema.shape.tags, }); export type FormValues = z.infer; @@ -55,17 +60,20 @@ export const Build = () => { return ( ( - Build + Definition + + Used to differentiate between different actor types. + Corresponds to the bla bla bal bal. + )} @@ -73,22 +81,47 @@ export const Build = () => { ); }; -export const Region = () => { - const { control } = useFormContext(); +// export const Region = () => { +// const { control } = useFormContext(); + +// return ( +// ( +// +// Region +// +// +// +// +// +// )} +// /> +// ); +// }; +export const Keys = () => { + const { control } = useFormContext(); return ( ( - Region + Key - + + Either a JSON array or a string. + )} @@ -96,18 +129,25 @@ export const Region = () => { ); }; -export const Parameters = () => { +export const JsonInput = () => { const { control } = useFormContext(); return ( ( - Parameters + Input - + + + Optional JSON object that will be passed to the Actor as + input. + )} @@ -115,36 +155,48 @@ export const Parameters = () => { ); }; -export const Tags = () => { - const setValues = useSetAtom(actorCustomTagValues); - const setKeys = useSetAtom(actorCustomTagKeys); +// export const Tags = () => { +// // const setValues = useSetAtom(actorCustomTagValues); +// // const setKeys = useSetAtom(actorCustomTagKeys); - const keys = useAtomValue(actorTagKeysAtom); - const values = useAtomValue(actorTagValuesAtom); +// const { data: tags = [] } = useInfiniteQuery( +// useManagerQueries().actorsTagsQueryOptions(), +// ); - return ( -
- - ({ - label: key, - value: key, - }))} - values={values.map((value) => ({ - label: value, - value: value, - }))} - onCreateKeyOption={(value) => { - setKeys((old) => - Array.from(new Set([...old, value]).values()), - ); - }} - onCreateValueOption={(value) => { - setValues((old) => - Array.from(new Set([...old, value]).values()), - ); - }} - /> -
- ); -}; +// const keys = useMemo(() => { +// return Array.from( +// new Set(tags.flatMap((tag) => Object.keys(tag))), +// ).sort(); +// }, [tags]); +// const values = useMemo(() => { +// return Array.from( +// new Set(tags.flatMap((tag) => Object.values(tag))), +// ).sort(); +// }, [tags]); + +// return ( +//
+// +// ({ +// label: key, +// value: key, +// }))} +// values={values.map((value) => ({ +// label: value, +// value: value, +// }))} +// onCreateKeyOption={(value) => { +// // setKeys((old) => +// // Array.from(new Set([...old, value]).values()), +// // ); +// }} +// onCreateValueOption={(value) => { +// // setValues((old) => +// // Array.from(new Set([...old, value]).values()), +// // ); +// }} +// /> +//
+// ); +// }; diff --git a/frontend/packages/components/src/actors/form/go-to-actor-form.tsx b/frontend/packages/components/src/actors/form/go-to-actor-form.tsx index 4b7acdf0d2..a0b9595a2d 100644 --- a/frontend/packages/components/src/actors/form/go-to-actor-form.tsx +++ b/frontend/packages/components/src/actors/form/go-to-actor-form.tsx @@ -35,10 +35,7 @@ export const ActorId = () => { {copy.actorId} - + diff --git a/frontend/packages/components/src/actors/go-to-actor-button.tsx b/frontend/packages/components/src/actors/go-to-actor-button.tsx index b38d67f676..b702129f20 100644 --- a/frontend/packages/components/src/actors/go-to-actor-button.tsx +++ b/frontend/packages/components/src/actors/go-to-actor-button.tsx @@ -12,7 +12,6 @@ export function GoToActorButton(props: ButtonProps) { variant="ghost" onClick={() => { navigate({ - to: ".", search: (prev) => ({ ...prev, modal: "go-to-actor" }), }); }} diff --git a/frontend/packages/components/src/actors/hooks/use-websocket.ts b/frontend/packages/components/src/actors/hooks/use-websocket.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/packages/components/src/actors/index.tsx b/frontend/packages/components/src/actors/index.tsx index dd5baf45d2..0e88787840 100644 --- a/frontend/packages/components/src/actors/index.tsx +++ b/frontend/packages/components/src/actors/index.tsx @@ -4,7 +4,6 @@ export * from "./actor-tags"; export * from "./actor-context"; export * from "./actors-actor-details"; export * from "./hooks/index"; -export { getActorStatus } from "./actor-status-indicator"; export * from "./actors-layout"; export * from "./actors-layout-context"; export * from "./console/actor-console-message"; @@ -14,4 +13,6 @@ export * from "./actor-status-indicator"; export * from "./actor-status-label"; export * from "./actors-view-context-provider"; export * from "./actor-not-found"; -export { ActorsListFiltersSchema, pickActorListFilters } from "./actors-list"; +export * from "./queries"; +export * from "./manager-queries-context"; +export * from "./actor-queries-context"; diff --git a/frontend/packages/components/src/actors/manager-queries-context.tsx b/frontend/packages/components/src/actors/manager-queries-context.tsx new file mode 100644 index 0000000000..be30e78509 --- /dev/null +++ b/frontend/packages/components/src/actors/manager-queries-context.tsx @@ -0,0 +1,315 @@ +import type { Rivet } from "@rivet-gg/api"; +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import { + type Actor, + type ActorId, + getActorStatus, + type ActorLogEntry, + type ActorMetrics, +} from "./queries"; +import type { Builds, CreateActor } from "@rivetkit/core/inspector"; +import { useContext, createContext } from "react"; + +const ACTORS_PER_PAGE = 10; + +export const defaultManagerQueries = { + queryClient: null as unknown as import("@tanstack/react-query").QueryClient, + token: null as string | null, + endpoint: "http://localhost:3000", + async getManagerStatus(_: { url: string; token: string }): Promise { + throw new Error("Manager status query not implemented"); + }, + setToken(url: string, token: string) { + this.token = token; + sessionStorage.setItem( + `rivetkit-token:${JSON.stringify({ url })}`, + token, + ); + }, + actorsQueryOptions() { + return infiniteQueryOptions({ + queryKey: ["actors"], + initialPageParam: undefined as ActorId | undefined, + enabled: false, + refetchInterval: 5000, + queryFn: async () => { + throw new Error("Actors query not implemented"); + // biome-ignore lint/correctness/noUnreachable: this way we tell tanstack query the query type + return [] as Actor[]; + }, + getNextPageParam: (lastPage) => { + if ( + !lastPage || + lastPage.length === 0 || + lastPage.length < ACTORS_PER_PAGE + ) { + return undefined; + } + + return lastPage[lastPage.length - 1].id; + }, + }); + }, + + actorsTagsQueryOptions() { + return infiniteQueryOptions({ + ...this.actorsQueryOptions(), + select: (data) => { + const tagsMap = new Map>(); + for (const actors of data.pages) { + for (const actor of actors) { + if (actor.tags) { + for (const [key, value] of Object.entries( + actor.tags, + )) { + if (!tagsMap.has(key)) { + tagsMap.set(key, new Set()); + } + // biome-ignore lint/style/noNonNullAssertion: we checked for that above + tagsMap.get(key)!.add(value); + } + } + } + } + const result: { key: string; value: string }[] = []; + for (const [key, values] of tagsMap.entries()) { + for (const value of values) { + result.push({ key, value }); + } + } + return result; + }, + }); + }, + + buildsQueryOptions() { + return queryOptions({ + queryKey: ["actors", "builds"], + enabled: false, + queryFn: async () => { + throw new Error("Builds query not implemented"); + // biome-ignore lint/correctness/noUnreachable: this way we tell tanstack query the query type + return [] as Builds; + }, + }); + }, + + actorsListQueryOptions() { + return infiniteQueryOptions({ + ...this.actorsQueryOptions(), + refetchInterval: 5000, + select: (data) => { + return data.pages.flatMap((actors) => + actors.map((actor) => actor.id), + ); + }, + }); + }, + + actorsListPaginationQueryOptions() { + return infiniteQueryOptions({ + ...this.actorsQueryOptions(), + select: (data) => { + return data.pages.flatMap((actors) => + actors.map((actor) => actor.id), + ).length; + }, + }); + }, + + actorQueryOptions(actorId: ActorId) { + return queryOptions({ + queryFn: async () => { + throw new Error("Actor query not implemented"); + }, + queryKey: ["actor", actorId], + enabled: false, + }); + }, + + actorRegionQueryOptions(actorId: ActorId) { + return queryOptions({ + ...this.actorQueryOptions(actorId), + select: (data: Actor) => data.region, + }); + }, + + actorTagsQueryOptions(actorId: ActorId) { + return queryOptions({ + ...this.actorQueryOptions(actorId), + select: (data: Actor) => data.tags, + }); + }, + + actorCreatedAtQueryOptions(actorId: ActorId) { + return queryOptions({ + ...this.actorQueryOptions(actorId), + select: (data: Actor) => + data.createdAt ? new Date(data.createdAt) : null, + }); + }, + + actorDestroyedAtQueryOptions(actorId: ActorId) { + return queryOptions({ + ...this.actorQueryOptions(actorId), + select: (data: Actor) => + data.destroyedAt ? new Date(data.destroyedAt) : null, + }); + }, + + actorStatusQueryOptions(actorId: ActorId) { + return queryOptions({ + ...this.actorQueryOptions(actorId), + select: (data: Actor) => getActorStatus(data), + }); + }, + + actorFeaturesQueryOptions(actorId: ActorId) { + return queryOptions({ + ...this.actorQueryOptions(actorId), + select: (data: Actor) => data.features ?? [], + }); + }, + actorGeneralQueryOptions(actorId: ActorId) { + return queryOptions({ + ...this.actorQueryOptions(actorId), + select: (data) => ({ + tags: data.tags, + createdAt: data.createdAt ? new Date(data.createdAt) : null, + destroyedAt: data.destroyedAt + ? new Date(data.destroyedAt) + : null, + region: data.region, + }), + }); + }, + actorBuildQueryOptions(actorId: ActorId) { + return queryOptions({ + queryKey: ["actor", actorId, "build"], + queryFn: async () => { + throw new Error("Actor build query not implemented"); + }, + enabled: false, + }); + }, + actorMetricsQueryOptions( + actorId: ActorId, + opts: { refetchInterval?: number } = {}, + ) { + return queryOptions({ + queryKey: ["actor", actorId, "metrics"], + queryFn: async () => { + throw new Error("Actor metrics query not implemented"); + }, + enabled: false, + ...opts, + }); + }, + actorDestroyMutationOptions(actorId: ActorId) { + return { + mutationKey: ["actor", actorId, "destroy"], + mutationFn: async () => { + throw new Error("Actor destroy mutation not implemented"); + }, + }; + }, + + actorLogsQueryOptions(actorId: ActorId) { + return queryOptions({ + queryKey: ["actor", actorId, "logs"], + }); + }, + + regionsQueryOptions() { + return queryOptions({ + queryKey: ["actor", "regions"], + queryFn: async () => { + throw new Error("Regions query not implemented"); + }, + }); + }, + regionQueryOptions(regionId: string | undefined) { + return queryOptions({ + ...this.regionsQueryOptions(), + enabled: !!regionId, + select: (regions) => + regions.find((region) => region.id === regionId), + }); + }, + actorNetworkQueryOptions(actorId: ActorId) { + return queryOptions({ + ...this.actorQueryOptions(actorId), + select: (data) => data.network, + }); + }, + actorRuntimeQueryOptions(actorId: ActorId) { + return queryOptions({ + ...this.actorQueryOptions(actorId), + select: ({ runtime, lifecycle, resources, tags }) => ({ + runtime, + lifecycle, + resources, + tags, + }), + }); + }, + actorNetworkPortsQueryOptions(actorId: ActorId) { + return queryOptions({ + ...this.actorNetworkQueryOptions(actorId), + select: (data) => data.network?.ports ?? [], + }); + }, + actorWorkerQueryOptions(actorId: ActorId) { + return queryOptions({ + ...this.actorQueryOptions(actorId), + select: (data) => ({ + name: data.name, + endpoint: this.endpoint, + features: data.features ?? [], + id: data.id, + region: data.region, + destroyedAt: data.destroyedAt + ? new Date(data.destroyedAt) + : null, + startedAt: data.startedAt ? new Date(data.startedAt) : null, + }), + }); + }, + managerStatusQueryOptions() { + return queryOptions({ + queryKey: ["managerStatus"], + refetchInterval: 1000, + enabled: false, + retry: 0, + queryFn: async () => { + throw new Error("Manager status query not implemented"); + }, + }); + }, + createActorMutationOptions() { + return { + mutationKey: ["createActor"], + mutationFn: async (_: CreateActor): Promise => { + throw new Error("Create actor mutation not implemented"); + return; + }, + }; + }, +}; + +const ManagerContext = createContext(defaultManagerQueries); + +export const useManagerQueries = () => useContext(ManagerContext); + +export const ManagerQueriesProvider = ManagerContext.Provider; + +export const getManagerToken = (url: string) => { + const token = sessionStorage.getItem( + `rivetkit-token:${JSON.stringify({ url })}`, + ); + return token; +}; + +export const setManagerToken = (url: string, token: string) => { + sessionStorage.setItem(`rivetkit-token:${JSON.stringify({ url })}`, token); +}; diff --git a/frontend/packages/components/src/actors/queries/actor.ts b/frontend/packages/components/src/actors/queries/actor.ts new file mode 100644 index 0000000000..379ef36e9f --- /dev/null +++ b/frontend/packages/components/src/actors/queries/actor.ts @@ -0,0 +1,189 @@ +import type { + ActorId, + Patch, + RecordedRealtimeEvent, +} from "@rivetkit/core/inspector"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { applyPatch, compare } from "fast-json-patch"; +import { useCallback, useEffect, useMemo } from "react"; +import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { useActorQueries } from "../actor-queries-context"; + +export const useActorClearEventsMutation = ( + actorId: ActorId, + options?: Parameters[1], +) => { + const queryClient = useQueryClient(); + const queries = useActorQueries(); + return useMutation({ + ...queries.actorClearEventsMutationOptions(actorId), + onMutate: async () => { + queryClient.setQueryData( + queries.actorEventsQueryOptions(actorId).queryKey, + () => ({ events: [] }), + ); + }, + ...options, + }); +}; + +export const useActorStatePatchMutation = ( + actorId: ActorId, + options?: Parameters[1], +) => { + const queryClient = useQueryClient(); + const queries = useActorQueries(); + return useMutation({ + // biome-ignore lint/suspicious/noExplicitAny: its really any + mutationFn: async (data: any) => { + const client = queries.createActorInspector(actorId); + + const oldStateQuery = queryClient.getQueryData( + queries.actorStateQueryOptions(actorId).queryKey, + ); + + const oldState = oldStateQuery?.state || {}; + + const patches = compare(oldState, data); + + const response = await client.state.$patch({ + // its okay, we know the type + // @ts-expect-error + json: patches, + }); + + if (!response.ok) { + throw response; + } + return await response.json(); + }, + onSuccess: (data) => { + queryClient.setQueryData( + queries.actorStateQueryOptions(actorId).queryKey, + data, + ); + }, + ...options, + }); +}; + +function useStream( + actorId: ActorId, + onMessage: (data: T) => void, + url: string, + opts: { enabled: boolean } = { enabled: true }, +) { + const stableOnMessage = useCallback(onMessage, []); + const queries = useActorQueries(); + + useEffect(() => { + const controller = new AbortController(); + + if (!opts.enabled) { + controller.abort(); + return () => controller.abort(); + } + + function establishConnection() { + fetchEventSource(url, { + signal: controller.signal, + headers: queries.createActorInspectorHeaders(actorId), + onmessage: (event) => { + const msg = JSON.parse(event.data); + stableOnMessage(msg); + }, + onclose: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + controller.signal.throwIfAborted(); + establishConnection(); + }, + }).catch((error) => console.error(error)); + } + + establishConnection(); + return () => { + controller.abort(); + }; + }, [url, actorId, opts.enabled, stableOnMessage]); +} + +export const useActorStateStream = ( + actorId: ActorId, + opts: { enabled: boolean } = { enabled: true }, +) => { + const queryClient = useQueryClient(); + const queries = useActorQueries(); + + useStream( + actorId, + useCallback( + (data: unknown) => { + console.log("Received actor state update", data); + queryClient.setQueryData( + queries.actorStateQueryOptions(actorId).queryKey, + () => ({ enabled: true, state: data }), + ); + }, + [queryClient, actorId, queries], + ), + useMemo( + () => + queries.createActorInspector(actorId).state.stream.$url().href, + [actorId, queries], + ), + opts, + ); +}; + +export const useActorConnectionsStream = (actorId: ActorId) => { + const queryClient = useQueryClient(); + const queries = useActorQueries(); + + useStream( + actorId, + useCallback( + (data) => { + queryClient.setQueryData( + queries.actorConnectionsQueryOptions(actorId).queryKey, + () => ({ enabled: true, connections: data }), + ); + }, + [queryClient, actorId, queries], + ), + useMemo( + () => + queries.createActorInspector(actorId).connections.stream.$url() + .href, + [actorId, queries], + ), + ); +}; + +export const useActorEventsStream = ( + actorId: ActorId, + opts: { enabled: boolean }, +) => { + const queryClient = useQueryClient(); + const queries = useActorQueries(); + + useStream( + actorId, + useCallback( + (data: RecordedRealtimeEvent[]) => { + queryClient.setQueryData( + queries.actorEventsQueryOptions(actorId).queryKey, + () => { + return { events: data }; + }, + ); + }, + [queryClient, actorId, queries], + ), + useMemo( + () => + queries.createActorInspector(actorId).events.stream.$url().href, + [actorId, queries], + ), + opts, + ); +}; diff --git a/frontend/packages/components/src/actors/queries/index.ts b/frontend/packages/components/src/actors/queries/index.ts new file mode 100644 index 0000000000..5c6a863d62 --- /dev/null +++ b/frontend/packages/components/src/actors/queries/index.ts @@ -0,0 +1,54 @@ +import type { Actor as InspectorActor } from "@rivetkit/core/inspector"; +export { ActorFeature } from "@rivetkit/core/inspector"; +export type { ActorLogEntry } from "@rivetkit/core/inspector"; +import type { Rivet } from "@rivet-gg/api"; +import type { ActorId } from "@rivetkit/core/inspector"; + +export type { ActorId }; + +export type Actor = Omit & { + network?: Rivet.actors.Network; + runtime?: Rivet.actors.Runtime; + lifecycle?: Rivet.actors.Lifecycle; + resources?: Rivet.actors.Resources; + tags?: Record; +} & { id: ActorId }; + +export type ActorMetrics = { + metrics: Record; + rawData: Record; + interval: number; +}; + +export * from "./actor"; + +export type ActorStatus = + | "starting" + | "running" + | "stopped" + | "crashed" + | "unknown"; + +export function getActorStatus( + actor: Pick, +): ActorStatus { + const { createdAt, startedAt, destroyedAt } = actor; + + if (createdAt && !startedAt && !destroyedAt) { + return "starting"; + } + + if (createdAt && startedAt && !destroyedAt) { + return "running"; + } + + if (createdAt && startedAt && destroyedAt) { + return "stopped"; + } + + if (createdAt && !startedAt && destroyedAt) { + return "crashed"; + } + + return "unknown"; +} diff --git a/frontend/packages/components/src/actors/region-select.tsx b/frontend/packages/components/src/actors/region-select.tsx index d88d187105..114f49bcf1 100644 --- a/frontend/packages/components/src/actors/region-select.tsx +++ b/frontend/packages/components/src/actors/region-select.tsx @@ -1,7 +1,7 @@ import { Combobox } from "@rivet-gg/components"; -import { useAtomValue } from "jotai"; -import { actorRegionsAtom } from "./actor-context"; import { ActorRegion } from "./actor-region"; +import { useQuery } from "@tanstack/react-query"; +import { useManagerQueries } from "./manager-queries-context"; interface RegionSelectProps { onValueChange: (value: string) => void; @@ -9,7 +9,7 @@ interface RegionSelectProps { } export function RegionSelect({ onValueChange, value }: RegionSelectProps) { - const data = useAtomValue(actorRegionsAtom); + const { data = [] } = useQuery(useManagerQueries().regionsQueryOptions()); const regions = [ { diff --git a/frontend/packages/components/src/actors/worker/actor-repl.worker.ts b/frontend/packages/components/src/actors/worker/actor-repl.worker.ts index 9d879c989f..7f9c7f0755 100644 --- a/frontend/packages/components/src/actors/worker/actor-repl.worker.ts +++ b/frontend/packages/components/src/actors/worker/actor-repl.worker.ts @@ -1,25 +1,18 @@ -import { - type InspectData, - type ToClient, - ToClientSchema, - type ToServer, -} from "actor-core/inspector/protocol/actor"; -import { fromJs } from "esast-util-from-js"; -import { toJs } from "estree-util-to-js"; - -import type { Request, ResponseOk } from "actor-core/protocol/http"; import { type HighlighterCore, createHighlighterCore, createOnigurumaEngine, } from "shiki"; -import { endWithSlash } from "../../lib/utils"; import { MessageSchema, - type ReplErrorCode, - type Response, ResponseSchema, + type Response, + type ReplErrorCode, + type InitMessage, } from "./actor-worker-schema"; +import { fromJs } from "esast-util-from-js"; +import { toJs } from "estree-util-to-js"; +import { createClient } from "@rivetkit/actor/client"; class ReplError extends Error { constructor( @@ -109,68 +102,7 @@ const createConsole = (id: string) => { ); }; -let init: null | ({ ws: WebSocket; url: URL } & InspectData) = null; - -async function connect(endpoint: string, opts?: { token?: string }) { - const url = new URL("inspect", endWithSlash(endpoint)); - - if (opts?.token) { - url.searchParams.set("token", opts.token); - } - - const ws = new WebSocket(url); - - await waitForOpen(ws); - - ws.send( - JSON.stringify({ - type: "info", - } satisfies ToServer), - ); - - const { type: _, ...info } = await waitForMessage(ws, "info"); - init = { ...info, ws, url: new URL(endpoint) }; - - ws.addEventListener("message", (event) => { - try { - const data = ToClientSchema.parse(JSON.parse(event.data)); - - if (data.type === "info") { - return respond({ - type: "inspect", - data: { - ...data, - }, - }); - } - if (data.type === "error") { - return respond({ - type: "error", - data: data.message, - }); - } - } catch (error) { - console.warn("Malformed message", event.data, error); - return; - } - }); - - ws.addEventListener("close", () => { - respond({ - type: "lost-connection", - }); - setTimeout(() => { - connect(endpoint, opts); - }, 500); - }); - - respond({ - type: "ready", - data: { - ...info, - }, - }); -} +let init: null | Omit = null; addEventListener("message", async (event) => { const { success, error, data } = MessageSchema.safeParse(event.data); @@ -181,29 +113,16 @@ addEventListener("message", async (event) => { } if (data.type === "init") { - if (init) { - respond({ - type: "error", - data: new Error("Actor already initialized"), - }); - return; - } - - try { - await Promise.race([ - connect(data.endpoint, data.token ? { token: data.token } : {}), - wait(5000).then(() => { - throw new Error("Timeout"); - }), - ]); - - return; - } catch (e) { - return respond({ - type: "error", - data: e, - }); - } + init = { + rpcs: data.rpcs ?? [], + endpoint: data.endpoint, + name: data.name, + id: data.id, + }; + respond({ + type: "ready", + }); + return; } if (data.type === "code") { @@ -224,30 +143,20 @@ addEventListener("message", async (event) => { data: formatted, }); + const client = createClient(actor.endpoint).getForId( + actor.name, + actor.id, + ); + const createRpc = (rpc: string) => async (...args: unknown[]) => { - const url = new URL( - `rpc/${rpc}`, - endWithSlash(actor.url.href), - ); - const response = await fetch(url, { - method: "POST", - body: JSON.stringify({ - a: args, - } satisfies Request), - }); - - if (!response.ok) { - throw new Error("RPC failed"); - } - - const data = (await response.json()) as ResponseOk; - return data.o; + const response = await client.action({ name: rpc, args }); + return response; }; const exposedActor = Object.fromEntries( - init?.rpcs.map((rpc) => [rpc, createRpc(rpc)]) ?? [], + actor.rpcs?.map((rpc) => [rpc, createRpc(rpc)]) ?? [], ); const evaluated = await evaluateCode(data.data, { @@ -268,88 +177,8 @@ addEventListener("message", async (event) => { }); } } - - if (data.type === "set-state") { - const actor = init; - if (!actor) { - respond({ - type: "error", - data: new Error("Actor not initialized"), - }); - return; - } - - try { - const state = JSON.parse(data.data); - actor.ws.send( - JSON.stringify({ - type: "setState", - state, - } satisfies ToServer), - ); - } catch (e) { - return respond({ - type: "error", - data: e, - }); - } - } }); function respond(msg: Response) { return postMessage(ResponseSchema.parse(msg)); } - -function waitForOpen(ws: WebSocket) { - const { promise, resolve, reject } = Promise.withResolvers(); - ws.addEventListener("open", () => { - resolve(undefined); - }); - ws.addEventListener("error", (event) => { - reject(); - }); - ws.addEventListener("close", (event) => { - reject(); - }); - - return Promise.race([ - promise, - wait(5000).then(() => { - throw new Error("Timeout"); - }), - ]); -} - -function waitForMessage( - ws: WebSocket, - type: T, -): Promise> { - const { promise, resolve, reject } = - Promise.withResolvers>(); - - function onMessage(event: MessageEvent) { - try { - const data = ToClientSchema.parse(JSON.parse(event.data)); - - if (data.type === type) { - resolve(data as Extract); - ws.removeEventListener("message", onMessage); - } - } catch (e) { - console.error(e); - } - } - - ws.addEventListener("message", onMessage); - ws.addEventListener("error", (event) => { - ws.removeEventListener("message", onMessage); - reject(); - }); - - return Promise.race([ - promise, - wait(5000).then(() => { - throw new Error("Timeout"); - }), - ]); -} diff --git a/frontend/packages/components/src/actors/worker/actor-worker-container.ts b/frontend/packages/components/src/actors/worker/actor-worker-container.ts index 7352e8e6bc..c928583b44 100644 --- a/frontend/packages/components/src/actors/worker/actor-worker-container.ts +++ b/frontend/packages/components/src/actors/worker/actor-worker-container.ts @@ -1,15 +1,11 @@ import { CancelledError } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { ls } from "../../lib/utils"; import ActorWorker from "./actor-repl.worker?worker"; import { type CodeMessage, type FormattedCode, type InitMessage, - type InspectData, type Log, ResponseSchema, - type SetStateMessage, } from "./actor-worker-schema"; export type ReplCommand = { @@ -35,23 +31,19 @@ export type ContainerStatus = export type ContainerState = { status: ContainerStatus; commands: ReplCommand[]; -} & InspectData; +}; export class ActorWorkerContainer { #state: ContainerState = { status: { type: "unknown" }, commands: [], - rpcs: [], - state: { enabled: false, value: undefined }, - connections: [], }; #meta: { actorId: string; - } | null = null; - - #opts: { - notifyOnReconnect?: boolean; + rpcs: string[]; + endpoint?: string; + name?: string; } | null = null; #listeners: (() => void)[] = []; @@ -60,19 +52,20 @@ export class ActorWorkerContainer { // async init({ actorId, - endpoint, signal, - notifyOnReconnect, + rpcs = [], + endpoint, + name, }: { actorId: string; - endpoint: string; signal: AbortSignal; - notifyOnReconnect?: boolean; + rpcs?: string[]; + endpoint?: string; + name?: string; }) { this.terminate(); - this.#meta = { actorId }; - this.#opts = { notifyOnReconnect }; + this.#meta = { actorId, rpcs, endpoint, name }; this.#state.status = { type: "pending" }; this.#update(); try { @@ -93,7 +86,7 @@ export class ActorWorkerContainer { const worker = new ActorWorker({ name: `actor-${actorId}` }); signal.throwIfAborted(); // now worker needs to check if the actor is supported - this.#setupWorker(worker, { actorId, endpoint }); + this.#setupWorker(worker); signal.throwIfAborted(); return worker; } catch (e) { @@ -123,18 +116,11 @@ export class ActorWorkerContainer { this.#worker = undefined; this.#state.commands = []; this.#state.status = { type: "unknown" }; - this.#state.rpcs = []; - this.#state.state = { - enabled: false, - value: undefined, - }; this.#meta = null; - this.#opts = null; - this.#state.connections = []; this.#update(); } - #setupWorker(worker: Worker, data: Omit) { + #setupWorker(worker: Worker) { this.#worker = worker; this.#worker.addEventListener("message", (event) => { try { @@ -152,8 +138,10 @@ export class ActorWorkerContainer { this.#worker.postMessage({ type: "init", - ...data, - token: ls.get("rivet-token")?.token, + rpcs: this.#meta?.rpcs ?? [], + id: this.#meta?.actorId ?? "", + endpoint: this.#meta?.endpoint ?? "", + name: this.#meta?.name ?? "", } satisfies InitMessage); } @@ -172,18 +160,6 @@ export class ActorWorkerContainer { this.#update(); } - setState(data: string) { - this.#worker?.postMessage({ - type: "set-state", - data, - } satisfies SetStateMessage); - this.#state.state = { - ...this.#state.state, - value: JSON.parse(data || "{}"), - }; - this.#update(); - } - getCommands() { return this.#state.commands; } @@ -192,18 +168,6 @@ export class ActorWorkerContainer { return this.#state.status; } - getRpcs() { - return this.#state.rpcs; - } - - getState() { - return this.#state.state; - } - - getConnections() { - return this.#state.connections; - } - subscribe(cb: () => void) { this.#listeners.push(cb); return () => { @@ -294,30 +258,7 @@ export class ActorWorkerContainer { } if (msg.type === "ready") { - if (this.#opts?.notifyOnReconnect) { - toast.success("Connected to Actor", { - id: "ac-ws-reconnect", - }); - } this.#state.status = { type: "ready" }; - } - - if (msg.type === "inspect" || msg.type === "ready") { - this.#state.rpcs = [...msg.data.rpcs]; - this.#state.state = { - ...msg.data.state, - value: msg.data.state.value || {}, - }; - this.#state.connections = [...msg.data.connections]; - this.#update(); - } - - if (msg.type === "lost-connection") { - this.#state.status = { type: "pending" }; - - if (this.#opts?.notifyOnReconnect) { - toast.loading("Reconnecting...", { id: "ac-ws-reconnect" }); - } this.#update(); } } diff --git a/frontend/packages/components/src/actors/worker/actor-worker-context.tsx b/frontend/packages/components/src/actors/worker/actor-worker-context.tsx index 0d731caae6..9471da84fe 100644 --- a/frontend/packages/components/src/actors/worker/actor-worker-context.tsx +++ b/frontend/packages/components/src/actors/worker/actor-worker-context.tsx @@ -1,5 +1,3 @@ -import { useAtomValue } from "jotai"; -import { selectAtom } from "jotai/utils"; import { type ReactNode, createContext, @@ -9,10 +7,12 @@ import { useState, useSyncExternalStore, } from "react"; -import { toast } from "sonner"; -import { assertNonNullable } from "../../lib/utils"; -import { type Actor, type ActorAtom, ActorFeature } from "../actor-context"; import { ActorWorkerContainer } from "./actor-worker-container"; +import { assertNonNullable } from "../../lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { ActorFeature, type ActorId } from "../queries"; +import { useManagerQueries } from "../manager-queries-context"; +import { useActorQueries } from "../actor-queries-context"; export const ActorWorkerContext = createContext( null, @@ -24,31 +24,27 @@ export const useActorWorker = () => { return value; }; -const selector = (a: Actor) => ({ - actorId: a.id, - endpoint: a.endpoint, - enabled: - !a.destroyedAt && - a.endpoint !== null && - a.startedAt !== null && - a.features?.includes(ActorFeature.Console), -}); - interface ActorWorkerContextProviderProps { - actor: ActorAtom; + actorId: ActorId; children: ReactNode; - notifyOnReconnect?: boolean; } - -// FIXME: rewrite with jotai export const ActorWorkerContextProvider = ({ children, - actor, - notifyOnReconnect, + actorId, }: ActorWorkerContextProviderProps) => { - const { actorId, endpoint, enabled } = useAtomValue( - selectAtom(actor, selector), - ); + const { + data: { features, endpoint, name, destroyedAt, startedAt } = {}, + } = useQuery(useManagerQueries().actorWorkerQueryOptions(actorId)); + const enabled = + (features?.includes(ActorFeature.Console) && + !destroyedAt && + !!startedAt) ?? + false; + + const actorQueries = useActorQueries(); + const { + data: { rpcs } = {}, + } = useQuery(actorQueries.actorRpcsQueryOptions(actorId, { enabled })); const [container] = useState( () => new ActorWorkerContainer(), @@ -58,22 +54,21 @@ export const ActorWorkerContextProvider = ({ useEffect(() => { const ctrl = new AbortController(); - if (enabled && endpoint) { + if (enabled) { container.init({ actorId, endpoint, - notifyOnReconnect, + name, signal: ctrl.signal, + rpcs, }); - } else { - toast.dismiss("ac-ws-reconnect"); } return () => { ctrl.abort(); container.terminate(); }; - }, [actorId, endpoint, enabled]); + }, [actorId, enabled, rpcs, endpoint, name]); return ( @@ -111,48 +106,3 @@ export function useActorWorkerStatus() { }, [container]), ); } - -export function useActorRpcs() { - const container = useActorWorker(); - return useSyncExternalStore( - useCallback( - (cb) => { - return container.subscribe(cb); - }, - [container], - ), - useCallback(() => { - return container.getRpcs(); - }, [container]), - ); -} - -export function useActorState() { - const container = useActorWorker(); - return useSyncExternalStore( - useCallback( - (cb) => { - return container.subscribe(cb); - }, - [container], - ), - useCallback(() => { - return container.getState(); - }, [container]), - ); -} - -export function useActorConnections() { - const container = useActorWorker(); - return useSyncExternalStore( - useCallback( - (cb) => { - return container.subscribe(cb); - }, - [container], - ), - useCallback(() => { - return container.getConnections(); - }, [container]), - ); -} diff --git a/frontend/packages/components/src/actors/worker/actor-worker-schema.ts b/frontend/packages/components/src/actors/worker/actor-worker-schema.ts index 755bb57b59..ef1d16d504 100644 --- a/frontend/packages/components/src/actors/worker/actor-worker-schema.ts +++ b/frontend/packages/components/src/actors/worker/actor-worker-schema.ts @@ -1,11 +1,6 @@ -import { InspectDataSchema } from "actor-core/inspector/protocol/actor"; import { z } from "zod"; -export type ReplErrorCode = - | "unsupported" - | "runtime_error" - | "timeout" - | "syntax"; +export type ReplErrorCode = "unsupported" | "runtime_error" | "syntax"; const CodeMessageSchema = z.object({ type: z.literal("code"), @@ -14,20 +9,15 @@ const CodeMessageSchema = z.object({ }); const InitMessageSchema = z.object({ type: z.literal("init"), + rpcs: z.array(z.string()).optional(), endpoint: z.string(), - actorId: z.string(), - token: z.string().optional(), -}); - -const SetStateMessageSchema = z.object({ - type: z.literal("set-state"), - data: z.string(), + name: z.string(), + id: z.string(), }); export const MessageSchema = z.discriminatedUnion("type", [ CodeMessageSchema, InitMessageSchema, - SetStateMessageSchema, ]); export const FormattedCodeSchema = z @@ -73,14 +63,6 @@ export const ResponseSchema = z.discriminatedUnion("type", [ }), z.object({ type: z.literal("ready"), - data: InspectDataSchema, - }), - z.object({ - type: z.literal("inspect"), - data: InspectDataSchema, - }), - z.object({ - type: z.literal("lost-connection"), }), ]); @@ -90,5 +72,3 @@ export type FormattedCode = z.infer; export type Log = z.infer; export type InitMessage = z.infer; export type CodeMessage = z.infer; -export type InspectData = z.infer; -export type SetStateMessage = z.infer; diff --git a/frontend/packages/components/src/copy-area.tsx b/frontend/packages/components/src/copy-area.tsx index d9e34f7de6..6e72681f6e 100644 --- a/frontend/packages/components/src/copy-area.tsx +++ b/frontend/packages/components/src/copy-area.tsx @@ -101,13 +101,16 @@ export const CopyArea = forwardRef( interface CopyButtonProps extends ComponentProps { children: ReactNode; - value: string; + value: string | (() => string); } export const CopyButton = forwardRef( ({ children, value, ...props }, ref) => { const handleClick: MouseEventHandler = (event) => { - navigator.clipboard.writeText(value); + event.stopPropagation(); + navigator.clipboard.writeText( + typeof value === "function" ? value() : value, + ); toast.success("Copied to clipboard"); props.onClick?.(event); }; @@ -119,9 +122,12 @@ export const CopyButton = forwardRef( }, ); +export type DiscreteCopyButtonProps = CopyButtonProps & + ComponentProps; + export const DiscreteCopyButton = forwardRef< HTMLElement, - CopyButtonProps & ButtonProps + DiscreteCopyButtonProps >(({ children, value, ...props }, ref) => { return ( - {title} - - + {title}{" "} + + + +