From 7de6870b2d07309e3406af141938223a0311dbe5 Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Tue, 15 Apr 2025 00:27:09 +0200 Subject: [PATCH] feat(hub): functions --- .../system-test-route/src/shared/server.ts | 16 +- frontend/apps/hub/package.json | 3 +- frontend/apps/hub/src/app.tsx | 2 +- .../src/components/header/header-sub-nav.tsx | 12 +- .../components/actors/actors-provider.tsx | 416 + .../dialogs/create-route-dialog.tsx | 101 + .../components/dialogs/edit-route-dialog.tsx | 179 + .../domains/project/forms/route-edit-form.tsx | 338 + .../project/layouts/project-layout.tsx | 15 +- .../project/queries/actors/mutations.ts | 93 + .../project/queries/actors/query-options.ts | 207 +- frontend/apps/hub/src/hooks/use-dialog.tsx | 8 + frontend/apps/hub/src/layouts/root.tsx | 8 +- frontend/apps/hub/src/routeTree.gen.ts | 253 +- .../$environmentNameId._v2/actor-versions.tsx | 300 + .../$environmentNameId._v2/actors.tsx | 168 + .../$environmentNameId._v2/containers.tsx | 194 + .../$environmentNameId._v2/functions.tsx | 282 + .../$environmentNameId._v2/logs.tsx | 552 + .../$environmentNameId._v2/settings.tsx | 105 + .../environments/$environmentNameId.tsx | 12 + .../environments/$environmentNameId/_v2.tsx | 148 + .../$environmentNameId/actors.tsx | 484 - frontend/packages/components/package.json | 25 +- .../components/src/actors/actor-context.tsx | 1 + .../components/src/actors/actor-logs.tsx | 10 + .../src/actors/actor-status-indicator.tsx | 15 +- .../src/actors/actors-actor-missing.tsx | 7 +- .../components/src/actors/actors-layout.tsx | 34 + .../components/src/actors/actors-list.tsx | 17 +- .../actors/actors-view-context-provider.tsx | 35 + .../actors/console/actor-console-message.tsx | 86 +- .../src/actors/create-actor-button.tsx | 13 +- .../src/actors/dialogs/go-to-actor-dialog.tsx | 6 +- .../src/actors/form/go-to-actor-form.tsx | 4 +- .../src/actors/go-to-actor-button.tsx | 4 +- .../packages/components/src/actors/index.tsx | 7 + frontend/packages/components/src/index.ts | 5 + .../packages/components/src/layout/page.tsx | 4 +- .../packages/components/src/lib/logfmt.ts | 79 + .../packages/components/src/lib/safe-async.ts | 13 + frontend/packages/components/src/lib/table.ts | 84 + frontend/packages/components/src/page.tsx | 5 +- .../packages/components/src/ui/filters.tsx | 700 ++ frontend/packages/components/src/ui/form.tsx | 2 + .../packages/components/src/ui/popover.tsx | 4 +- frontend/packages/components/src/ui/table.tsx | 8 +- .../components/src/virtual-scroll-area.tsx | 8 +- frontend/packages/icons/manifest.json | 9084 +---------------- .../packages/icons/scripts/postinstall.js | 2 +- package.json | 20 +- sdks/api/full/typescript/archive.tgz | 4 +- yarn.lock | 241 +- 53 files changed, 4649 insertions(+), 9774 deletions(-) create mode 100644 frontend/apps/hub/src/domains/project/components/actors/actors-provider.tsx create mode 100644 frontend/apps/hub/src/domains/project/components/dialogs/create-route-dialog.tsx create mode 100644 frontend/apps/hub/src/domains/project/components/dialogs/edit-route-dialog.tsx create mode 100644 frontend/apps/hub/src/domains/project/forms/route-edit-form.tsx create mode 100644 frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/actor-versions.tsx create mode 100644 frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/actors.tsx create mode 100644 frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/containers.tsx create mode 100644 frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/functions.tsx create mode 100644 frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/logs.tsx create mode 100644 frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/settings.tsx create mode 100644 frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2.tsx delete mode 100644 frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/actors.tsx create mode 100644 frontend/packages/components/src/actors/actors-layout.tsx create mode 100644 frontend/packages/components/src/actors/actors-view-context-provider.tsx create mode 100644 frontend/packages/components/src/lib/logfmt.ts create mode 100644 frontend/packages/components/src/lib/table.ts create mode 100644 frontend/packages/components/src/ui/filters.tsx diff --git a/examples/system-test-route/src/shared/server.ts b/examples/system-test-route/src/shared/server.ts index e859ab5361..20227fc4ec 100644 --- a/examples/system-test-route/src/shared/server.ts +++ b/examples/system-test-route/src/shared/server.ts @@ -21,6 +21,10 @@ export function createAndStartServer( setInterval(() => { tickIndex++; console.log("Tick", tickIndex); + console.log( + JSON.stringify({ level: "info", message: "tick", tickIndex }), + ); + console.log(`level=info message=tick tickIndex=${tickIndex}`); }, 1000); // Get port from environment @@ -37,14 +41,16 @@ export function createAndStartServer( const app = new Hono(); app.get("/health", (c) => c.text("ok")); - + // Add a catch-all route to handle any other path (for testing routeSubpaths) app.all("*", (c) => { - console.log(`Received request to ${c.req.url} from ${c.req.header("x-forwarded-for") || "unknown"}`); - return c.json({ - actorId, + console.log( + `Received request to ${c.req.url} from ${c.req.header("x-forwarded-for") || "unknown"}`, + ); + return c.json({ + actorId, path: c.req.path, - query: c.req.query() + query: c.req.query(), }); }); diff --git a/frontend/apps/hub/package.json b/frontend/apps/hub/package.json index 7b7a0985ef..fa71c34a92 100644 --- a/frontend/apps/hub/package.json +++ b/frontend/apps/hub/package.json @@ -28,7 +28,7 @@ "@tanstack/react-query-devtools": "^5.58.0", "@tanstack/react-query-persist-client": "^5.56.2", "@tanstack/react-router": "^1.114.25", - "@tanstack/react-table": "^8.20.6", + "@tanstack/react-table": "^8.21.2", "@tanstack/router-devtools": "^1.114.25", "@tanstack/router-plugin": "^1.114.25", "@tanstack/zod-adapter": "^1.114.25", @@ -40,6 +40,7 @@ "framer-motion": "^11.2.11", "jotai": "^2.12.2", "lodash": "^4.17.21", + "nanoid": "^5.1.5", "posthog-js": "^1.144.2", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/frontend/apps/hub/src/app.tsx b/frontend/apps/hub/src/app.tsx index 70831e86aa..32f647d668 100644 --- a/frontend/apps/hub/src/app.tsx +++ b/frontend/apps/hub/src/app.tsx @@ -27,7 +27,7 @@ declare module "@tanstack/react-router" { router: typeof router; } interface StaticDataRouteOption { - layout?: "full" | "compact" | "onboarding" | "actors"; + layout?: "full" | "compact" | "onboarding" | "actors" | "v2"; } } diff --git a/frontend/apps/hub/src/components/header/header-sub-nav.tsx b/frontend/apps/hub/src/components/header/header-sub-nav.tsx index 71ef1a2757..d450695ad5 100644 --- a/frontend/apps/hub/src/components/header/header-sub-nav.tsx +++ b/frontend/apps/hub/src/components/header/header-sub-nav.tsx @@ -1,7 +1,11 @@ import { useAuth } from "@/domains/auth/contexts/auth"; import { Skeleton, cn } from "@rivet-gg/components"; import { ErrorBoundary } from "@sentry/react"; -import { useMatchRoute } from "@tanstack/react-router"; +import { + useMatches, + useMatchRoute, + useRouterState, +} from "@tanstack/react-router"; import { Suspense, useContext } from "react"; import { MobileBreadcrumbsContext } from "../breadcrumbs/mobile-breadcrumbs"; import { HeaderEnvironmentLinks } from "./links/header-environment-links"; @@ -9,11 +13,15 @@ import { HeaderGroupLinks } from "./links/header-group-links"; import { HeaderProjectLinks } from "./links/header-project-links"; function Content() { + const allMatches = useMatches(); + + const v2Envs = allMatches.find((match) => match.id.includes("/_v2/")); + const matchRoute = useMatchRoute(); const { profile } = useAuth(); - if (!profile?.identity.isRegistered) { + if (!profile?.identity.isRegistered || v2Envs) { return null; } 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 new file mode 100644 index 0000000000..408777500e --- /dev/null +++ b/frontend/apps/hub/src/domains/project/components/actors/actors-provider.tsx @@ -0,0 +1,416 @@ +import { router } from "@/app"; +import { queryClient } from "@/queries/global"; +import { toRecord } from "@rivet-gg/components"; +import { + currentActorIdAtom, + actorFiltersAtom, + actorsPaginationAtom, + actorsAtom, + getActorStatus, + type DestroyActor, + actorRegionsAtom, + actorBuildsAtom, + createActorAtom, +} from "@rivet-gg/components/actors"; +import { + InfiniteQueryObserver, + QueryObserver, + MutationObserver, +} from "@tanstack/react-query"; +import { createClient } from "actor-core/client"; +import { atom, createStore, Provider, type PrimitiveAtom } from "jotai"; +import equal from "fast-deep-equal"; +import { type ReactNode, useEffect, useState } from "react"; +import { + projectActorsQueryOptions, + createActorEndpoint, + destroyActorMutationOptions, + actorLogsQueryOptions, + actorRegionsQueryOptions, + actorBuildsQueryOptions, +} from "../../queries"; +import type { Rivet } from "@rivet-gg/api"; + +interface ActorsProviderProps { + actorId: string | undefined; + showDestroyed?: boolean; + tags?: [string, string][]; + projectNameId: string; + environmentNameId: string; + children?: ReactNode; + fixedTags?: Record; + filter?: (actor: Rivet.actors.Actor) => boolean; +} + +export function ActorsProvider({ + actorId, + showDestroyed, + tags, + projectNameId, + environmentNameId, + children, + fixedTags, + filter, +}: ActorsProviderProps) { + 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, { + showDestroyed: showDestroyed ?? true, + tags: Object.fromEntries( + tags?.map((tag) => [tag[0], tag[1]]) || [], + ), + }); + + store.set(currentActorIdAtom, actorId); + }, [tags, showDestroyed, actorId]); + + // 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, + tags: Object.entries(value.tags).map(([key, value]) => [ + key, + value, + ]), + showDestroyed: value.showDestroyed, + }), + }); + }); + }, [router]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: store is not a dependency + useEffect(() => { + const defaultFilters = store.get(actorFiltersAtom); + const actorsObserver = new InfiniteQueryObserver( + queryClient, + projectActorsQueryOptions({ + projectNameId, + environmentNameId, + includeDestroyed: defaultFilters.showDestroyed, + tags: { ...defaultFilters.tags, ...fixedTags }, + }), + ); + + const unsubFilters = store.sub(actorFiltersAtom, () => { + const filters = store.get(actorFiltersAtom); + actorsObserver.setOptions( + projectActorsQueryOptions({ + projectNameId, + environmentNameId, + tags: { ...filters.tags, ...fixedTags }, + includeDestroyed: filters.showDestroyed, + }), + ); + actorsObserver.refetch(); + }); + + const unsub = actorsObserver.subscribe((query) => { + 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: { + status: "loading", + lines: [] as string[], + timestamps: [] as string[], + ids: [] as string[], + }, + errors: { + status: "loading", + lines: [] as string[], + timestamps: [] as string[], + ids: [] as string[], + }, + }); + logs.onMount = (set) => { + const stdOutObserver = new QueryObserver( + queryClient, + actorLogsQueryOptions({ + projectNameId, + environmentNameId, + actorId: actor.id, + stream: "std_out", + }), + ); + const stdErrObserver = new QueryObserver( + queryClient, + actorLogsQueryOptions({ + projectNameId, + environmentNameId, + actorId: actor.id, + stream: "std_err", + }), + ); + + type LogQuery = { + status: string; + data?: Awaited< + ReturnType< + Exclude< + ReturnType< + typeof actorLogsQueryOptions + >["queryFn"], + undefined + > + > + >; + }; + + function updateStdOut(query: LogQuery) { + const data = query.data; + set((prev) => ({ + ...prev, + logs: { + lines: + data?.lines || prev.logs.lines, + timestamps: + data?.timestamps || + prev.logs.timestamps, + ids: data?.ids || prev.logs.ids, + status: query.status, + }, + })); + } + + function updateStdErr(query: LogQuery) { + const data = query.data; + set((prev) => ({ + ...prev, + errors: { + lines: + data?.lines || + prev.errors.lines, + timestamps: + data?.timestamps || + prev.errors.timestamps, + ids: data?.ids || prev.errors.ids, + status: query.status, + }, + })); + } + + const subOut = stdOutObserver.subscribe( + (query) => { + updateStdOut(query); + }, + ); + + const subErr = stdErrObserver.subscribe( + (query) => { + updateStdErr(query); + }, + ); + + updateStdOut( + stdOutObserver.getCurrentQuery().state, + ); + updateStdErr( + stdErrObserver.getCurrentQuery().state, + ); + + return () => { + stdOutObserver.destroy(); + stdErrObserver.destroy(); + subOut(); + subErr(); + }; + }; + + return { + ...actor, + logs, + 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" && + toRecord(a.tags).owner === "rivet" && + 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}; +} diff --git a/frontend/apps/hub/src/domains/project/components/dialogs/create-route-dialog.tsx b/frontend/apps/hub/src/domains/project/components/dialogs/create-route-dialog.tsx new file mode 100644 index 0000000000..c4bfd5328e --- /dev/null +++ b/frontend/apps/hub/src/domains/project/components/dialogs/create-route-dialog.tsx @@ -0,0 +1,101 @@ +import * as EditRouteForm from "@/domains/project/forms/route-edit-form"; +import type { DialogContentProps } from "@/hooks/use-dialog"; +import { + Button, + DialogFooter, + DialogHeader, + DialogTitle, + Label, +} from "@rivet-gg/components"; +import { useState } from "react"; +import { useCreateRouteMutation } from "../../queries"; + +interface ContentProps extends DialogContentProps { + projectNameId: string; + environmentNameId: string; +} + +export default function CreateRouteDialogContent(props: ContentProps) { + return ; +} + +function Content({ projectNameId, environmentNameId, onClose }: ContentProps) { + const { mutateAsync } = useCreateRouteMutation(); + + const [tagKeys, setTagKeys] = useState<{ label: string; value: string }[]>( + () => [], + ); + + const [tagValues, setTagValues] = useState< + { label: string; value: string }[] + >(() => []); + + return ( + { + const selectorTags = Object.fromEntries( + values.tags.map(({ key, value }) => [key, value]), + ); + + await mutateAsync({ + projectNameId, + environmentNameId, + hostname: values.hostname, + path: values.path, + stripPrefix: values.stripPrefix || false, + routeSubpaths: values.routeSubpaths || false, + target: { + actors: { + selectorTags, + }, + }, + }); + onClose?.(); + }} + > + + Add Route + +
+ + +
+ + + + + setTagKeys((opts) => [ + ...opts, + { label: option, value: option }, + ]) + } + onCreateValueOption={(option) => + setTagValues((opts) => [ + ...opts, + { label: option, value: option }, + ]) + } + /> + + + + + + Save + + +
+ ); +} diff --git a/frontend/apps/hub/src/domains/project/components/dialogs/edit-route-dialog.tsx b/frontend/apps/hub/src/domains/project/components/dialogs/edit-route-dialog.tsx new file mode 100644 index 0000000000..d1b42717ab --- /dev/null +++ b/frontend/apps/hub/src/domains/project/components/dialogs/edit-route-dialog.tsx @@ -0,0 +1,179 @@ +import * as EditRouteForm from "@/domains/project/forms/route-edit-form"; +import type { DialogContentProps } from "@/hooks/use-dialog"; +import { + Button, + DialogFooter, + DialogHeader, + DialogTitle, + Label, +} from "@rivet-gg/components"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { routeQueryOptions, usePatchRouteMutation } from "../../queries"; +import type { Rivet } from "@rivet-gg/api"; + +interface OptionalContentProps extends DialogContentProps { + projectNameId: string; + environmentNameId: string; + routeId?: string; +} + +export default function EditRouteDialogContent(props: OptionalContentProps) { + if (!props.routeId) { + return null; + } + + return ; +} + +function GuardedContent({ + routeId, + projectNameId, + environmentNameId, + onClose, +}: Omit & { routeId: string }) { + const { data } = useSuspenseQuery( + routeQueryOptions({ + routeId, + projectNameId, + environmentNameId, + }), + ); + + if (!data) { + return ( + <> +
+

Route not found

+

+ The route you are trying to edit does not exist. +

+
+ + + + + + ); + } + + return ( + + ); +} + +interface ContentProps extends DialogContentProps, Rivet.routes.Route { + projectNameId: string; + environmentNameId: string; +} + +function Content({ + projectNameId, + id, + target, + environmentNameId, + hostname, + routeSubpaths, + stripPrefix, + path, + onClose, +}: ContentProps) { + const { mutateAsync } = usePatchRouteMutation(); + + const tags = Object.entries(target?.actors?.selectorTags || {}).map( + ([key, value]) => ({ + key, + value, + }), + ); + + const [tagKeys, setTagKeys] = useState<{ label: string; value: string }[]>( + () => tags.map(({ key }) => ({ label: key, value: key })), + ); + + const [tagValues, setTagValues] = useState< + { label: string; value: string }[] + >(() => tags.map(({ value }) => ({ label: value, value }))); + + return ( + ({ + key, + value, + }), + ), + hostname, + path, + routeSubpaths, + stripPrefix, + }} + onSubmit={async (values) => { + const selectorTags = Object.fromEntries( + values.tags.map(({ key, value }) => [key, value]), + ); + + await mutateAsync({ + projectNameId, + environmentNameId, + id, + hostname: values.hostname, + path: values.path, + stripPrefix: values.stripPrefix || false, + routeSubpaths: values.routeSubpaths || false, + target: { + actors: { + selectorTags, + }, + }, + }); + onClose?.(); + }} + > + + Add Route + +
+ + +
+ + + + + setTagKeys((opts) => [ + ...opts, + { label: option, value: option }, + ]) + } + onCreateValueOption={(option) => + setTagValues((opts) => [ + ...opts, + { label: option, value: option }, + ]) + } + /> + + + + + + Save + + +
+ ); +} diff --git a/frontend/apps/hub/src/domains/project/forms/route-edit-form.tsx b/frontend/apps/hub/src/domains/project/forms/route-edit-form.tsx new file mode 100644 index 0000000000..19128b9537 --- /dev/null +++ b/frontend/apps/hub/src/domains/project/forms/route-edit-form.tsx @@ -0,0 +1,338 @@ +import { bootstrapQueryOptions } from "@/domains/auth/queries/bootstrap"; +import { queryClient } from "@/queries/global"; +import { + Button, + Combobox, + createSchemaForm, + FormControl, + FormField, + FormFieldContext, + FormItem, + FormLabel, + FormMessage, + Input, + type ComboboxOption, + FormDescription, + Checkbox, + Code, +} from "@rivet-gg/components"; +import { Icon, faTrash } from "@rivet-gg/icons"; +import { + type UseFormReturn, + useFieldArray, + useFormContext, +} from "react-hook-form"; +import z from "zod"; + +export const formSchema = z.object({ + stripPrefix: z.boolean().optional(), + routeSubpaths: z.boolean().optional(), + hostname: z + .string() + .min(1) + .refine((value) => { + const regex = /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/; + return regex.test(value); + }, "Hostname must be a valid domain name") + .refine( + async (value) => { + const bootstrap = queryClient.getQueryData( + bootstrapQueryOptions().queryKey, + ); + + const domain = bootstrap?.domains.job || "rivet-job.local"; + + return value.endsWith(`.${domain}`); + }, + () => { + const bootstrap = queryClient.getQueryData( + bootstrapQueryOptions().queryKey, + ); + + const domain = bootstrap?.domains.job || "rivet-job.local"; + return { + message: `Hostname must end with .${domain}`, + }; + }, + ), + path: z + .string() + .min(1) + .refine((value) => { + const regex = /^(\/[a-zA-Z0-9-_]+)+$/; + return regex.test(value); + }, "Path must start with a / and contain only alphanumeric characters, dashes, and underscores") + .refine((value) => { + const endsWithSlash = value.endsWith("/"); + return !endsWithSlash; + }, "Path must not end with a /"), + tags: z + .array( + z.object({ + key: z.string().min(1), + value: z.string(), + }), + ) + .min(1, "At least one selector is required") + .superRefine((tags, ctx) => { + const tagsSet = new Set(); + for (const [idx, tag] of [...tags].reverse().entries()) { + if (tagsSet.has(tag.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [idx, "key"], + message: "Tag keys must be unique", + }); + } + tagsSet.add(tag.key); + } + }), +}); + +export type FormValues = z.infer; +export type SubmitHandler = ( + values: FormValues, + form: UseFormReturn, +) => Promise; + +const { Form, Submit } = createSchemaForm(formSchema); +export { Form, Submit }; + +export const Hostname = () => { + const { control } = useFormContext(); + return ( + ( + + Hostname + + + + + + )} + /> + ); +}; + +export const Path = () => { + const { control } = useFormContext(); + + return ( + ( + + Path + + + + + + + + + )} + /> + ); +}; + +const PathMessage = () => { + const { watch } = useFormContext(); + + const path = watch("path"); + + return path.endsWith("/*") ? ( + Maximum of 8 path components when routing path prefixes. + ) : null; +}; + +export const Tags = ({ + onCreateKeyOption, + onCreateValueOption, + keys, + values, +}: { + onCreateKeyOption: (option: string) => void; + onCreateValueOption: (option: string) => void; + keys: ComboboxOption[]; + values: ComboboxOption[]; +}) => { + const { control, setValue, watch } = useFormContext(); + const { fields, append, remove } = useFieldArray({ + name: "tags", + control, + }); + + return ( + <> + {fields.length === 0 ? ( +

There's no selectors added.

+ ) : null} + {fields.map((field, index) => ( +
+ + + Key + + { + setValue(`tags.${index}.key`, value, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + }} + allowCreate + onCreateOption={onCreateKeyOption} + /> + + + + + + + + Value + + { + setValue(`tags.${index}.value`, value, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + }} + allowCreate + onCreateOption={onCreateValueOption} + /> + + + + + +
+ ))} + + + ); +}; + +export const StripPrefix = () => { + const { control } = useFormContext(); + + return ( + ( + + + + + + {/* biome-ignore lint/a11y/noLabelWithoutControl: injected by FromLabel */} + + + + )} + /> + ); +}; + +export const RouteSubpaths = () => { + const { control } = useFormContext(); + + return ( + ( + + + + + + {/* biome-ignore lint/a11y/noLabelWithoutControl: injected by FromLabel */} + + + + )} + /> + ); +}; diff --git a/frontend/apps/hub/src/domains/project/layouts/project-layout.tsx b/frontend/apps/hub/src/domains/project/layouts/project-layout.tsx index de6c0c2c15..43ae9949f4 100644 --- a/frontend/apps/hub/src/domains/project/layouts/project-layout.tsx +++ b/frontend/apps/hub/src/domains/project/layouts/project-layout.tsx @@ -1,4 +1,4 @@ -import { computePageLayout } from "@/lib/compute-page-layout"; +import { computePageLayout, usePageLayout } from "@/lib/compute-page-layout"; import { Page, Skeleton } from "@rivet-gg/components"; import { useMatches } from "@tanstack/react-router"; import type { PropsWithChildren, ReactNode } from "react"; @@ -12,7 +12,18 @@ function ProjectPage({ children }: ProjectPageProps) { return {children}; } -ProjectPage.Skeleton = Page.Skeleton; +ProjectPage.Skeleton = () => { + const layout = usePageLayout(); + if (layout === "v2") { + return ( +
+ +
+ ); + } + + return ; +}; function Content({ children }: PropsWithChildren) { return <>{children}; diff --git a/frontend/apps/hub/src/domains/project/queries/actors/mutations.ts b/frontend/apps/hub/src/domains/project/queries/actors/mutations.ts index 13fb39b0af..2ba974f894 100644 --- a/frontend/apps/hub/src/domains/project/queries/actors/mutations.ts +++ b/frontend/apps/hub/src/domains/project/queries/actors/mutations.ts @@ -6,7 +6,11 @@ import { actorBuildsQueryOptions, actorQueryOptions, projectActorsQueryOptions, + routesQueryOptions, } from "./query-options"; +import { customAlphabet } from "nanoid"; + +const nanoid = customAlphabet("0123456789abcdefghijklmnoprstwuxyz", 5); export function destroyActorMutationOptions() { return mutationOptions({ @@ -221,3 +225,92 @@ export function useCreateActorFromSdkMutation({ }, }); } + +export const usePatchRouteMutation = ({ + onSuccess, +}: { onSuccess?: () => void } = {}) => { + return useMutation({ + mutationFn: async ({ + projectNameId, + environmentNameId, + id, + ...request + }: { + projectNameId: string; + environmentNameId: string; + id: string; + } & Rivet.routes.UpdateRouteBody) => + rivetClient.routes.update(id, { + body: request, + project: projectNameId, + environment: environmentNameId, + }), + onSuccess: async (_, { projectNameId, environmentNameId }) => { + await queryClient.invalidateQueries( + routesQueryOptions({ + projectNameId, + environmentNameId, + }), + ); + onSuccess?.(); + }, + }); +}; + +export const useCreateRouteMutation = ({ + onSuccess, +}: { onSuccess?: () => void } = {}) => { + return useMutation({ + mutationFn: async ({ + projectNameId, + environmentNameId, + ...request + }: { + projectNameId: string; + environmentNameId: string; + } & Rivet.routes.UpdateRouteBody) => + rivetClient.routes.update(`route-${nanoid()}`, { + body: request, + project: projectNameId, + environment: environmentNameId, + }), + onSuccess: async (_, { projectNameId, environmentNameId }) => { + await queryClient.invalidateQueries( + routesQueryOptions({ + projectNameId, + environmentNameId, + }), + ); + onSuccess?.(); + }, + }); +}; + +export const useDeleteRouteMutation = ({ + onSuccess, +}: { onSuccess?: () => void } = {}) => { + return useMutation({ + mutationFn: async ({ + projectNameId, + environmentNameId, + routeId, + }: { + projectNameId: string; + environmentNameId: string; + routeId: string; + }) => + rivetClient.routes.delete(routeId, { + project: projectNameId, + environment: environmentNameId, + }), + onSuccess: async (_, { projectNameId, environmentNameId }) => { + await queryClient.invalidateQueries( + routesQueryOptions({ + projectNameId, + environmentNameId, + }), + ); + onSuccess?.(); + }, + }); +}; diff --git a/frontend/apps/hub/src/domains/project/queries/actors/query-options.ts b/frontend/apps/hub/src/domains/project/queries/actors/query-options.ts index df3dff7d4d..9236fe019e 100644 --- a/frontend/apps/hub/src/domains/project/queries/actors/query-options.ts +++ b/frontend/apps/hub/src/domains/project/queries/actors/query-options.ts @@ -1,10 +1,13 @@ import { mergeWatchStreams } from "@/lib/watch-utilities"; import { rivetClient } from "@/queries/global"; import { getMetaWatchIndex } from "@/queries/utils"; -import { Rivet } from "@rivet-gg/api"; +import type { Rivet } from "@rivet-gg/api"; +import { safe, logfmt, type LogFmtValue, toRecord } from "@rivet-gg/components"; +import { getActorStatus } from "@rivet-gg/components/actors"; import { type InfiniteData, infiniteQueryOptions, + keepPreviousData, queryOptions, } from "@tanstack/react-query"; import { uniqueId } from "lodash"; @@ -158,6 +161,21 @@ export const actorQueryOptions = ({ }); }; +export const actorStatusQueryOptions = ({ + projectNameId, + environmentNameId, + actorId, +}: { + projectNameId: string; + environmentNameId: string; + actorId: string; +}) => { + return queryOptions({ + ...actorQueryOptions({ projectNameId, environmentNameId, actorId }), + select: (data) => getActorStatus(data.actor), + }); +}; + export const actorDestroyedAtQueryOptions = ({ projectNameId, environmentNameId, @@ -185,7 +203,7 @@ export const actorLogsQueryOptions = ( projectNameId: string; environmentNameId: string; actorId: string; - stream: Rivet.actors.LogStream; + stream: Rivet.actors.QueryLogStream; }, opts: { refetchInterval?: number } = {}, ) => { @@ -200,18 +218,18 @@ export const actorLogsQueryOptions = ( actorId, "logs", stream, - ], + ] as const, queryFn: async ({ signal: abortSignal, meta, queryKey: [, project, , environment, , actorId, , stream], }) => { const response = await rivetClient.actors.logs.get( - actorId, { project, environment, - stream: stream as Rivet.actors.LogStream, + stream, + actorIdsJson: JSON.stringify([actorId]), watchIndex: getMetaWatchIndex(meta), }, { abortSignal }, @@ -243,7 +261,7 @@ export const actorErrorsQueryOptions = ({ projectNameId, environmentNameId, actorId, - stream: Rivet.actors.LogStream.StdErr, + stream: "std_err", }), select: (data) => data.lines.length > 0, }); @@ -269,15 +287,7 @@ export const actorBuildsQueryOptions = ({ ] as const, refetchInterval: 2000, queryFn: ({ - queryKey: [ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _, - project, - __, - environment, - ___, - tagsJson, - ], + queryKey: [, project, , environment, , tagsJson], signal: abortSignal, }) => rivetClient.builds.list( @@ -522,3 +532,170 @@ export const actorBuildsCountQueryOptions = ({ notifyOnChangeProps: ["data"], }); }; + +export interface FunctionInvoke { + id: string; + isFormatted: boolean; + actorId: string; + actorName: string; + actorTags: Record; + regionId: string; + timestamp: Date; + level: string; + line: string; + message: string; + properties: Record; +} + +export const logsAggregatedQueryOptions = ({ + projectNameId, + environmentNameId, + search, +}: { + projectNameId: string; + environmentNameId: string; + search?: { text?: string; caseSensitive?: boolean; enableRegex?: boolean }; +}) => { + return queryOptions({ + refetchInterval: 5000, + placeholderData: keepPreviousData, + queryKey: [ + "project", + projectNameId, + "environment", + environmentNameId, + "logs", + search, + ] as const, + queryFn: async ({ + signal: abortSignal, + client, + queryKey: [_, project, __, environment, ___, search], + }) => { + const actors = await client.fetchInfiniteQuery({ + ...projectActorsQueryOptions({ + projectNameId: project, + environmentNameId: environment, + includeDestroyed: true, + tags: {}, + }), + pages: 10, + }); + + const allActors = actors.pages.flatMap((page) => page.actors || []); + + const actorsMap = new Map(); + for (const actor of allActors) { + actorsMap.set(actor.id, actor); + } + + const logs = await rivetClient.actors.logs.get( + { + stream: "all", + project, + environment, + searchText: search?.text, + searchCaseSensitive: search?.caseSensitive, + searchEnableRegex: search?.enableRegex, + actorIdsJson: JSON.stringify(allActors.map((a) => a.id)), + }, + { abortSignal }, + ); + + const parsed = logs.lines.map((line, idx) => { + const actorIdx = logs.actorIndices[idx]; + const actorId = logs.actorIds[actorIdx]; + const timestamp = logs.timestamps[idx]; + const stream = logs.streams[idx]; + const raw = window.atob(line); + const fmt = safe(logfmt.parse)(raw)[0]; + const json = safe(JSON.parse)(raw)[0]; + const formatted = json || fmt; + const { + level = stream === 1 ? "error" : "info", + msg, + ...properties + } = formatted || {}; + const isFormatDefined = + (fmt?.level || json) && Object.keys(formatted).length > 0; + const actor = actorsMap.get(actorId); + return { + id: `${actorId}-${timestamp}-${idx}`, + level: level, + isFormatted: isFormatDefined, + actorId: actorId, + actorName: + (toRecord(actor?.tags).name as string) || + actorId.split("-")[0], + actorTags: toRecord(actor?.tags), + regionId: actor?.region || "local", + timestamp, + line: raw, + message: isFormatDefined ? (msg as string) : "", + properties: isFormatDefined ? properties : {}, + } satisfies FunctionInvoke; + }); + + return parsed.toReversed(); + }, + }); +}; + +export interface Route { + id: string; + hostname: string; + pathPrefix: string; + selector: Record; + createdAt: Date; +} + +export const routesQueryOptions = ({ + projectNameId, + environmentNameId, +}: { + projectNameId: string; + environmentNameId: string; +}) => { + return queryOptions({ + queryKey: [ + "project", + projectNameId, + "environment", + environmentNameId, + "routes", + ], + queryFn: async ({ + signal: abortSignal, + queryKey: [_, project, __, environment], + }) => { + return rivetClient.routes.list( + { + project, + environment, + }, + { abortSignal }, + ); + }, + select: (data) => data.routes, + }); +}; + +export const routeQueryOptions = ({ + projectNameId, + environmentNameId, + routeId, +}: { + projectNameId: string; + environmentNameId: string; + routeId: string; +}) => { + return queryOptions({ + ...routesQueryOptions({ + projectNameId, + environmentNameId, + }), + select: (data) => { + return data.routes.find((route) => route.id === routeId); + }, + }); +}; diff --git a/frontend/apps/hub/src/hooks/use-dialog.tsx b/frontend/apps/hub/src/hooks/use-dialog.tsx index c4f51c98d4..da912182d8 100644 --- a/frontend/apps/hub/src/hooks/use-dialog.tsx +++ b/frontend/apps/hub/src/hooks/use-dialog.tsx @@ -285,3 +285,11 @@ useDialog.CreateActor = createDialogHook( useDialog.GoToActor = createDialogHook( import("@rivet-gg/components/actors/dialogs/go-to-actor-dialog"), ); + +useDialog.EditRoute = createDialogHook( + import("@/domains/project/components/dialogs/edit-route-dialog"), +); + +useDialog.CreateRoute = createDialogHook( + import("@/domains/project/components/dialogs/create-route-dialog"), +); diff --git a/frontend/apps/hub/src/layouts/root.tsx b/frontend/apps/hub/src/layouts/root.tsx index e8b17bd21f..a4bd7a8375 100644 --- a/frontend/apps/hub/src/layouts/root.tsx +++ b/frontend/apps/hub/src/layouts/root.tsx @@ -23,7 +23,7 @@ const Root = ({ children }: RootProps) => { const Main = ({ children }: RootProps) => { return ( -
+
{children}
); @@ -37,8 +37,10 @@ const VisibleInFull = ({ children }: PropsWithChildren) => { "min-h-screen grid grid-rows-[auto,1fr]": layout === "full" || layout === "onboarding" || - layout === "actors", + layout === "actors" || + layout === "v2", contents: layout === "compact", + "max-h-screen": layout === "v2", })} > {children} @@ -56,7 +58,7 @@ const Header = () => { const Footer = () => { const layout = usePageLayout(); - if (layout === "actors") { + if (["actors", "v2"].includes(layout)) { return null; } return ( diff --git a/frontend/apps/hub/src/routeTree.gen.ts b/frontend/apps/hub/src/routeTree.gen.ts index d91c973602..b415826f6b 100644 --- a/frontend/apps/hub/src/routeTree.gen.ts +++ b/frontend/apps/hub/src/routeTree.gen.ts @@ -45,7 +45,7 @@ import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmen import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdCdnImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/cdn' import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBuildsImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/builds' import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend' -import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdActorsImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/actors' +import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Import } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2' import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdLobbiesIndexImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/lobbies/index' import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendIndexImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend/index' import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdServersSplatImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/servers/$' @@ -53,6 +53,12 @@ import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmen import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdLobbiesLogsImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/lobbies/logs' import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendVariablesImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend/variables' import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendLogsImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend/logs' +import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2SettingsImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/settings' +import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2LogsImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/logs' +import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2FunctionsImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/functions' +import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ContainersImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/containers' +import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorsImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/actors' +import { Route as AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorVersionsImport } from './routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/actor-versions' // Create/Update Routes @@ -316,11 +322,10 @@ const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBacke } as any, ) -const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdActorsRoute = - AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdActorsImport.update( +const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Route = + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Import.update( { - id: '/actors', - path: '/actors', + id: '/_v2', getParentRoute: () => AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdRoute, } as any, @@ -396,6 +401,66 @@ const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBacke } as any, ) +const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2SettingsRoute = + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2SettingsImport.update( + { + id: '/settings', + path: '/settings', + getParentRoute: () => + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Route, + } as any, + ) + +const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2LogsRoute = + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2LogsImport.update( + { + id: '/logs', + path: '/logs', + getParentRoute: () => + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Route, + } as any, + ) + +const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2FunctionsRoute = + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2FunctionsImport.update( + { + id: '/functions', + path: '/functions', + getParentRoute: () => + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Route, + } as any, + ) + +const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ContainersRoute = + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ContainersImport.update( + { + id: '/containers', + path: '/containers', + getParentRoute: () => + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Route, + } as any, + ) + +const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorsRoute = + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorsImport.update( + { + id: '/actors', + path: '/actors', + getParentRoute: () => + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Route, + } as any, + ) + +const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorVersionsRoute = + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorVersionsImport.update( + { + id: '/actor-versions', + path: '/actor-versions', + getParentRoute: () => + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Route, + } as any, + ) + // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { @@ -575,11 +640,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedLayoutTeamsGroupIdSettingsIndexImport parentRoute: typeof AuthenticatedLayoutTeamsGroupIdSettingsImport } - '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/actors': { - id: '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/actors' - path: '/actors' - fullPath: '/projects/$projectNameId/environments/$environmentNameId/actors' - preLoaderRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdActorsImport + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2': { + id: '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2' + path: '' + fullPath: '/projects/$projectNameId/environments/$environmentNameId' + preLoaderRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Import parentRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdImport } '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend': { @@ -645,6 +710,48 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdIndexImport parentRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdImport } + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actor-versions': { + id: '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actor-versions' + path: '/actor-versions' + fullPath: '/projects/$projectNameId/environments/$environmentNameId/actor-versions' + preLoaderRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorVersionsImport + parentRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Import + } + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actors': { + id: '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actors' + path: '/actors' + fullPath: '/projects/$projectNameId/environments/$environmentNameId/actors' + preLoaderRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorsImport + parentRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Import + } + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/containers': { + id: '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/containers' + path: '/containers' + fullPath: '/projects/$projectNameId/environments/$environmentNameId/containers' + preLoaderRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ContainersImport + parentRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Import + } + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/functions': { + id: '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/functions' + path: '/functions' + fullPath: '/projects/$projectNameId/environments/$environmentNameId/functions' + preLoaderRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2FunctionsImport + parentRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Import + } + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/logs': { + id: '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/logs' + path: '/logs' + fullPath: '/projects/$projectNameId/environments/$environmentNameId/logs' + preLoaderRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2LogsImport + parentRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Import + } + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/settings': { + id: '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/settings' + path: '/settings' + fullPath: '/projects/$projectNameId/environments/$environmentNameId/settings' + preLoaderRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2SettingsImport + parentRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Import + } '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend/logs': { id: '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend/logs' path: '/logs' @@ -732,6 +839,36 @@ const AuthenticatedLayoutProjectsProjectNameIdSettingsRouteWithChildren = AuthenticatedLayoutProjectsProjectNameIdSettingsRouteChildren, ) +interface AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2RouteChildren { + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorVersionsRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorVersionsRoute + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorsRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorsRoute + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ContainersRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ContainersRoute + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2FunctionsRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2FunctionsRoute + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2LogsRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2LogsRoute + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2SettingsRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2SettingsRoute +} + +const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2RouteChildren: AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2RouteChildren = + { + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorVersionsRoute: + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorVersionsRoute, + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorsRoute: + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorsRoute, + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ContainersRoute: + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ContainersRoute, + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2FunctionsRoute: + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2FunctionsRoute, + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2LogsRoute: + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2LogsRoute, + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2SettingsRoute: + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2SettingsRoute, + } + +const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2RouteWithChildren = + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Route._addFileChildren( + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2RouteChildren, + ) + interface AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendRouteChildren { AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendLogsRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendLogsRoute AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendVariablesRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendVariablesRoute @@ -790,7 +927,7 @@ const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdServe ) interface AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdRouteChildren { - AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdActorsRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdActorsRoute + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Route: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2RouteWithChildren AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendRouteWithChildren AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBuildsRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBuildsRoute AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdCdnRoute: typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdCdnRoute @@ -804,8 +941,8 @@ interface AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdR const AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdRouteChildren: AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdRouteChildren = { - AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdActorsRoute: - AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdActorsRoute, + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2Route: + AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2RouteWithChildren, AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendRoute: AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendRouteWithChildren, AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBuildsRoute: @@ -963,11 +1100,10 @@ export interface FileRoutesByFullPath { '/teams/$groupId/settings': typeof AuthenticatedLayoutTeamsGroupIdSettingsRouteWithChildren '/projects/$projectNameId/': typeof AuthenticatedLayoutProjectsProjectNameIdIndexRoute '/teams/$groupId/': typeof AuthenticatedLayoutTeamsGroupIdIndexRoute - '/projects/$projectNameId/environments/$environmentNameId': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdRouteWithChildren + '/projects/$projectNameId/environments/$environmentNameId': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2RouteWithChildren '/projects/$projectNameId/namespaces/$': typeof AuthenticatedLayoutProjectsProjectNameIdNamespacesSplatRoute '/projects/$projectNameId/settings/': typeof AuthenticatedLayoutProjectsProjectNameIdSettingsIndexRoute '/teams/$groupId/settings/': typeof AuthenticatedLayoutTeamsGroupIdSettingsIndexRoute - '/projects/$projectNameId/environments/$environmentNameId/actors': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdActorsRoute '/projects/$projectNameId/environments/$environmentNameId/backend': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendRouteWithChildren '/projects/$projectNameId/environments/$environmentNameId/builds': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBuildsRoute '/projects/$projectNameId/environments/$environmentNameId/cdn': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdCdnRoute @@ -977,6 +1113,12 @@ export interface FileRoutesByFullPath { '/projects/$projectNameId/environments/$environmentNameId/tokens': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdTokensRoute '/projects/$projectNameId/environments/$environmentNameId/versions': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdVersionsRoute '/projects/$projectNameId/environments/$environmentNameId/': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdIndexRoute + '/projects/$projectNameId/environments/$environmentNameId/actor-versions': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorVersionsRoute + '/projects/$projectNameId/environments/$environmentNameId/actors': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorsRoute + '/projects/$projectNameId/environments/$environmentNameId/containers': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ContainersRoute + '/projects/$projectNameId/environments/$environmentNameId/functions': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2FunctionsRoute + '/projects/$projectNameId/environments/$environmentNameId/logs': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2LogsRoute + '/projects/$projectNameId/environments/$environmentNameId/settings': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2SettingsRoute '/projects/$projectNameId/environments/$environmentNameId/backend/logs': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendLogsRoute '/projects/$projectNameId/environments/$environmentNameId/backend/variables': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendVariablesRoute '/projects/$projectNameId/environments/$environmentNameId/lobbies/logs': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdLobbiesLogsRoute @@ -1005,14 +1147,19 @@ export interface FileRoutesByTo { '/projects/$projectNameId/namespaces/$': typeof AuthenticatedLayoutProjectsProjectNameIdNamespacesSplatRoute '/projects/$projectNameId/settings': typeof AuthenticatedLayoutProjectsProjectNameIdSettingsIndexRoute '/teams/$groupId/settings': typeof AuthenticatedLayoutTeamsGroupIdSettingsIndexRoute - '/projects/$projectNameId/environments/$environmentNameId/actors': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdActorsRoute + '/projects/$projectNameId/environments/$environmentNameId': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdIndexRoute '/projects/$projectNameId/environments/$environmentNameId/builds': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBuildsRoute '/projects/$projectNameId/environments/$environmentNameId/cdn': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdCdnRoute '/projects/$projectNameId/environments/$environmentNameId/matchmaker': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdMatchmakerRoute '/projects/$projectNameId/environments/$environmentNameId/servers': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdServersRouteWithChildren '/projects/$projectNameId/environments/$environmentNameId/tokens': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdTokensRoute '/projects/$projectNameId/environments/$environmentNameId/versions': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdVersionsRoute - '/projects/$projectNameId/environments/$environmentNameId': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdIndexRoute + '/projects/$projectNameId/environments/$environmentNameId/actor-versions': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorVersionsRoute + '/projects/$projectNameId/environments/$environmentNameId/actors': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorsRoute + '/projects/$projectNameId/environments/$environmentNameId/containers': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ContainersRoute + '/projects/$projectNameId/environments/$environmentNameId/functions': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2FunctionsRoute + '/projects/$projectNameId/environments/$environmentNameId/logs': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2LogsRoute + '/projects/$projectNameId/environments/$environmentNameId/settings': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2SettingsRoute '/projects/$projectNameId/environments/$environmentNameId/backend/logs': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendLogsRoute '/projects/$projectNameId/environments/$environmentNameId/backend/variables': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendVariablesRoute '/projects/$projectNameId/environments/$environmentNameId/lobbies/logs': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdLobbiesLogsRoute @@ -1049,7 +1196,7 @@ export interface FileRoutesById { '/_authenticated/_layout/projects/$projectNameId/namespaces/$': typeof AuthenticatedLayoutProjectsProjectNameIdNamespacesSplatRoute '/_authenticated/_layout/projects/$projectNameId/settings/': typeof AuthenticatedLayoutProjectsProjectNameIdSettingsIndexRoute '/_authenticated/_layout/teams/$groupId/settings/': typeof AuthenticatedLayoutTeamsGroupIdSettingsIndexRoute - '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/actors': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdActorsRoute + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2RouteWithChildren '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendRouteWithChildren '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/builds': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBuildsRoute '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/cdn': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdCdnRoute @@ -1059,6 +1206,12 @@ export interface FileRoutesById { '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/tokens': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdTokensRoute '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/versions': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdVersionsRoute '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdIndexRoute + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actor-versions': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorVersionsRoute + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actors': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ActorsRoute + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/containers': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2ContainersRoute + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/functions': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2FunctionsRoute + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/logs': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2LogsRoute + '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/settings': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdV2SettingsRoute '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend/logs': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendLogsRoute '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend/variables': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdBackendVariablesRoute '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/lobbies/logs': typeof AuthenticatedLayoutProjectsProjectNameIdEnvironmentsEnvironmentNameIdLobbiesLogsRoute @@ -1095,7 +1248,6 @@ export interface FileRouteTypes { | '/projects/$projectNameId/namespaces/$' | '/projects/$projectNameId/settings/' | '/teams/$groupId/settings/' - | '/projects/$projectNameId/environments/$environmentNameId/actors' | '/projects/$projectNameId/environments/$environmentNameId/backend' | '/projects/$projectNameId/environments/$environmentNameId/builds' | '/projects/$projectNameId/environments/$environmentNameId/cdn' @@ -1105,6 +1257,12 @@ export interface FileRouteTypes { | '/projects/$projectNameId/environments/$environmentNameId/tokens' | '/projects/$projectNameId/environments/$environmentNameId/versions' | '/projects/$projectNameId/environments/$environmentNameId/' + | '/projects/$projectNameId/environments/$environmentNameId/actor-versions' + | '/projects/$projectNameId/environments/$environmentNameId/actors' + | '/projects/$projectNameId/environments/$environmentNameId/containers' + | '/projects/$projectNameId/environments/$environmentNameId/functions' + | '/projects/$projectNameId/environments/$environmentNameId/logs' + | '/projects/$projectNameId/environments/$environmentNameId/settings' | '/projects/$projectNameId/environments/$environmentNameId/backend/logs' | '/projects/$projectNameId/environments/$environmentNameId/backend/variables' | '/projects/$projectNameId/environments/$environmentNameId/lobbies/logs' @@ -1132,14 +1290,19 @@ export interface FileRouteTypes { | '/projects/$projectNameId/namespaces/$' | '/projects/$projectNameId/settings' | '/teams/$groupId/settings' - | '/projects/$projectNameId/environments/$environmentNameId/actors' + | '/projects/$projectNameId/environments/$environmentNameId' | '/projects/$projectNameId/environments/$environmentNameId/builds' | '/projects/$projectNameId/environments/$environmentNameId/cdn' | '/projects/$projectNameId/environments/$environmentNameId/matchmaker' | '/projects/$projectNameId/environments/$environmentNameId/servers' | '/projects/$projectNameId/environments/$environmentNameId/tokens' | '/projects/$projectNameId/environments/$environmentNameId/versions' - | '/projects/$projectNameId/environments/$environmentNameId' + | '/projects/$projectNameId/environments/$environmentNameId/actor-versions' + | '/projects/$projectNameId/environments/$environmentNameId/actors' + | '/projects/$projectNameId/environments/$environmentNameId/containers' + | '/projects/$projectNameId/environments/$environmentNameId/functions' + | '/projects/$projectNameId/environments/$environmentNameId/logs' + | '/projects/$projectNameId/environments/$environmentNameId/settings' | '/projects/$projectNameId/environments/$environmentNameId/backend/logs' | '/projects/$projectNameId/environments/$environmentNameId/backend/variables' | '/projects/$projectNameId/environments/$environmentNameId/lobbies/logs' @@ -1174,7 +1337,7 @@ export interface FileRouteTypes { | '/_authenticated/_layout/projects/$projectNameId/namespaces/$' | '/_authenticated/_layout/projects/$projectNameId/settings/' | '/_authenticated/_layout/teams/$groupId/settings/' - | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/actors' + | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2' | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend' | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/builds' | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/cdn' @@ -1184,6 +1347,12 @@ export interface FileRouteTypes { | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/tokens' | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/versions' | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/' + | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actor-versions' + | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actors' + | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/containers' + | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/functions' + | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/logs' + | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/settings' | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend/logs' | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend/variables' | '/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/lobbies/logs' @@ -1340,7 +1509,7 @@ export const routeTree = rootRoute "filePath": "_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId.tsx", "parent": "/_authenticated/_layout/projects/$projectNameId", "children": [ - "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/actors", + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2", "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend", "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/builds", "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/cdn", @@ -1364,9 +1533,17 @@ export const routeTree = rootRoute "filePath": "_authenticated/_layout/teams/$groupId/settings/index.tsx", "parent": "/_authenticated/_layout/teams/$groupId/settings" }, - "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/actors": { - "filePath": "_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/actors.tsx", - "parent": "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId" + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2": { + "filePath": "_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2.tsx", + "parent": "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId", + "children": [ + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actor-versions", + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actors", + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/containers", + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/functions", + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/logs", + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/settings" + ] }, "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend": { "filePath": "_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend.tsx", @@ -1417,6 +1594,30 @@ export const routeTree = rootRoute "filePath": "_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/index.tsx", "parent": "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId" }, + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actor-versions": { + "filePath": "_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/actor-versions.tsx", + "parent": "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2" + }, + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actors": { + "filePath": "_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/actors.tsx", + "parent": "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2" + }, + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/containers": { + "filePath": "_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/containers.tsx", + "parent": "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2" + }, + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/functions": { + "filePath": "_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/functions.tsx", + "parent": "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2" + }, + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/logs": { + "filePath": "_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/logs.tsx", + "parent": "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2" + }, + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/settings": { + "filePath": "_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/settings.tsx", + "parent": "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2" + }, "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend/logs": { "filePath": "_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend/logs.tsx", "parent": "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/backend" diff --git a/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/actor-versions.tsx b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/actor-versions.tsx new file mode 100644 index 0000000000..1054a5ab32 --- /dev/null +++ b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/actor-versions.tsx @@ -0,0 +1,300 @@ +import { ErrorComponent } from "@/components/error-component"; +import { ProjectBuildsTableActions } from "@/domains/project/components/project-builds-table-actions"; +import { TagsSelect } from "@/domains/project/components/tags-select"; +import { useEnvironment } from "@/domains/project/data/environment-context"; +import { useProject } from "@/domains/project/data/project-context"; +import * as Layout from "@/domains/project/layouts/servers-layout"; +import { + projectBuildsQueryOptions, + projectCurrentBuildsQueryOptions, + usePatchActorBuildTagsMutation, + useUpgradeAllActorsMutation, +} from "@/domains/project/queries"; +import type { Rivet } from "@rivet-gg/api"; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + DiscreteCopyButton, + Flex, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, + WithTooltip, +} from "@rivet-gg/components"; +import { ActorTags } from "@rivet-gg/components/actors"; +import { Icon, faCheckCircle, faInfoCircle, faRefresh } from "@rivet-gg/icons"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { + createFileRoute, + type ErrorComponentProps, +} from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; + +function ProjectBuildsRoute() { + const { gameId: projectId, nameId: projectNameId } = useProject(); + const { namespaceId: environmentId, nameId: environmentNameId } = + useEnvironment(); + + const search = Route.useSearch(); + const tags = "tags" in search ? Object.fromEntries(search.tags || []) : {}; + const { + data: builds, + isRefetching, + isLoading, + refetch, + } = useQuery(projectBuildsQueryOptions({ projectId, environmentId, tags })); + + const navigate = Route.useNavigate(); + + return ( +
+ + + + Versions +
+ { + navigate({ + search: { + tags: Object.entries(newTags).map( + ([key, value]) => + [key, value] as [ + string, + string, + ], + ), + }, + }); + }} + /> + +
+
+
+ + + + + ID + Name + Tags + + + Current{" "} + + + } + /> + + Created + + + + + {!isLoading && builds?.length === 0 ? ( + + + + There's no versions matching + criteria. + + + + ) : null} + {isLoading ? ( + <> + + + + + + + + + + ) : null} + {builds?.map((build) => ( + + + + {build.id.split("-")[0]} + + } + /> + + + + {build.tags.name} + + + + + + + + + + {build.createdAt.toLocaleString()} + + + + + + ))} + +
+
+
+
+ ); +} + +function RowSkeleton() { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} + +interface ProjectBuildLatestButtonProps extends Rivet.builds.Build { + projectNameId: string; + environmentNameId: string; + projectId: string; + environmentId: string; +} + +function ProjectBuildLatestButton({ + tags, + id, + projectId, + environmentId, + projectNameId, + environmentNameId, +}: ProjectBuildLatestButtonProps) { + const { mutateAsync: mutateBuildTagsAsync } = + usePatchActorBuildTagsMutation(); + const { mutate: mutateUpgradeActors, isPending } = + useUpgradeAllActorsMutation(); + const { data: latestBuilds } = useSuspenseQuery( + projectCurrentBuildsQueryOptions({ projectId, environmentId }), + ); + + if (tags.current !== "true") { + return ( + + ); + } + + return ; +} + +const searchSchema = z.object({ + tags: z.array(z.tuple([z.string(), z.string()])).optional(), +}); + +export const Route = createFileRoute( + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actor-versions", +)({ + validateSearch: zodValidator(searchSchema), + component: ProjectBuildsRoute, + staticData: { + layout: "v2", + }, + pendingComponent: () => ( +
+ +
+ ), + errorComponent(props: ErrorComponentProps) { + return ( +
+
+ +
+
+ ); + }, +}); diff --git a/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/actors.tsx b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/actors.tsx new file mode 100644 index 0000000000..d49d295f4a --- /dev/null +++ b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/actors.tsx @@ -0,0 +1,168 @@ +import { + ActorsActorDetails, + ActorsActorDetailsPanel, + ActorsListPreview, + currentActorAtom, +} from "@rivet-gg/components/actors"; +import { useEnvironment } from "@/domains/project/data/environment-context"; +import { useProject } from "@/domains/project/data/project-context"; +import * as Layout from "@/domains/project/layouts/servers-layout"; +import { actorBuildsCountQueryOptions } from "@/domains/project/queries"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { + createFileRoute, + type ErrorComponentProps, + useRouter, +} from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; +import { GettingStarted } from "@rivet-gg/components/actors"; +import { useAtomValue } from "jotai"; +import { useDialog } from "@/hooks/use-dialog"; +import { ErrorComponent } from "@/components/error-component"; +import { ActorsProvider } from "@/domains/project/components/actors/actors-provider"; + +function Actor() { + const navigate = Route.useNavigate(); + const { tab } = Route.useSearch(); + + const actor = useAtomValue(currentActorAtom); + + if (!actor) { + return null; + } + + return ( + { + navigate({ + to: ".", + search: (old) => ({ ...old, tab }), + }); + }} + /> + ); +} + +const FIXED_TAGS = { + framework: "actor-core", +}; +function Content() { + const { nameId: projectNameId } = useProject(); + const { nameId: environmentNameId } = useEnvironment(); + const { actorId, tags, showDestroyed, modal } = Route.useSearch(); + + const CreateActorDialog = useDialog.CreateActor.Dialog; + const GoToActorDialog = useDialog.GoToActor.Dialog; + const router = useRouter(); + const navigate = Route.useNavigate(); + + function handleOpenChange(open: boolean) { + router.navigate({ + to: ".", + search: (old) => ({ + ...old, + modal: !open ? undefined : modal, + }), + }); + } + + return ( + + + + {actorId ? : null} + + + + + { + navigate({ + to: ".", + search: (old) => ({ + ...old, + actorId, + modal: undefined, + }), + }); + }} + dialogProps={{ + open: modal === "go-to-actor", + onOpenChange: handleOpenChange, + }} + /> + + ); +} + +function ProjectActorsRoute() { + const { nameId: projectNameId } = useProject(); + const { nameId: environmentNameId } = useEnvironment(); + const { tags, showDestroyed } = Route.useSearch(); + + const { data } = useSuspenseQuery({ + ...actorBuildsCountQueryOptions({ + projectNameId, + environmentNameId, + }), + refetchInterval: (query) => + (query.state.data?.builds.length || 0) > 0 ? false : 2000, + }); + + if (data === 0 && !tags && showDestroyed === undefined) { + return ; + } + + return ( +
+ +
+ ); +} + +const searchSchema = z.object({ + actorId: z.string().optional(), + tab: z.string().optional(), + + tags: z.array(z.tuple([z.string(), z.string()])).optional(), + showDestroyed: z.boolean().optional().default(true), +}); + +export const Route = createFileRoute( + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/actors", +)({ + validateSearch: zodValidator(searchSchema), + staticData: { + layout: "v2", + }, + component: ProjectActorsRoute, + pendingComponent: () => ( +
+ +
+ ), + errorComponent(props: ErrorComponentProps) { + return ( +
+
+ +
+
+ ); + }, +}); diff --git a/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/containers.tsx b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/containers.tsx new file mode 100644 index 0000000000..bc9fdcd49e --- /dev/null +++ b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/containers.tsx @@ -0,0 +1,194 @@ +import { + ActorsActorDetails, + ActorsActorDetailsPanel, + ActorsListPreview, + ActorsViewContext, + currentActorAtom, +} from "@rivet-gg/components/actors"; +import { useEnvironment } from "@/domains/project/data/environment-context"; +import { useProject } from "@/domains/project/data/project-context"; +import * as Layout from "@/domains/project/layouts/servers-layout"; +import { actorBuildsCountQueryOptions } from "@/domains/project/queries"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { + createFileRoute, + type ErrorComponentProps, + useRouter, +} from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; +import { GettingStarted } from "@rivet-gg/components/actors"; +import { useAtomValue } from "jotai"; +import { useDialog } from "@/hooks/use-dialog"; +import { ErrorComponent } from "@/components/error-component"; +import { ActorsProvider } from "@/domains/project/components/actors/actors-provider"; +import type { Rivet } from "@rivet-gg/api"; +import { toRecord } from "@rivet-gg/components"; + +function Actor() { + const navigate = Route.useNavigate(); + const { tab } = Route.useSearch(); + + const actor = useAtomValue(currentActorAtom); + + if (!actor) { + return null; + } + + return ( + { + navigate({ + to: ".", + search: (old) => ({ ...old, tab }), + }); + }} + /> + ); +} + +const FIXED_TAGS = {}; + +//const ACTORS_FILTER = (actor: Rivet.actors.Actor) => +// toRecord(actor.tags).type !== "function" && +// toRecord(actor.tags).framework !== "actor-core"; + +const ACTORS_FILTER = (actor: Rivet.actors.Actor) => + //toRecord(actor.tags).type !== "function" && + toRecord(actor.tags).framework !== "actor-core"; + +const ACTORS_VIEW_CONTEXT = { + copy: { + goToActor: "Go to Container", + selectActor: "Select a Container from the list.", + showActorList: "Show Container list", + noActorsFound: "No Containers found", + createActor: "Create Container", + createActorUsingForm: "Create Container using simple form", + actorId: "Container ID", + }, + requiresManager: false, +}; + +function Content() { + const { nameId: projectNameId } = useProject(); + const { nameId: environmentNameId } = useEnvironment(); + const { actorId, tags, showDestroyed, modal } = Route.useSearch(); + + const CreateActorDialog = useDialog.CreateActor.Dialog; + const GoToActorDialog = useDialog.GoToActor.Dialog; + const router = useRouter(); + const navigate = Route.useNavigate(); + + function handleOpenChange(open: boolean) { + router.navigate({ + to: ".", + search: (old) => ({ + ...old, + modal: !open ? undefined : modal, + }), + }); + } + + return ( + + + + + {actorId ? : null} + + + + + { + navigate({ + to: ".", + search: (old) => ({ + ...old, + actorId, + modal: undefined, + }), + }); + }} + dialogProps={{ + open: modal === "go-to-actor", + onOpenChange: handleOpenChange, + }} + /> + + + ); +} + +function ProjectActorsRoute() { + const { nameId: projectNameId } = useProject(); + const { nameId: environmentNameId } = useEnvironment(); + const { tags, showDestroyed } = Route.useSearch(); + + const { data } = useSuspenseQuery({ + ...actorBuildsCountQueryOptions({ + projectNameId, + environmentNameId, + }), + refetchInterval: (query) => + (query.state.data?.builds.length || 0) > 0 ? false : 2000, + }); + + if (data === 0 && !tags && showDestroyed === undefined) { + return ; + } + + return ( +
+ +
+ ); +} + +const searchSchema = z.object({ + actorId: z.string().optional(), + tab: z.string().optional(), + + tags: z.array(z.tuple([z.string(), z.string()])).optional(), + showDestroyed: z.boolean().optional().default(true), +}); + +export const Route = createFileRoute( + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/containers", +)({ + validateSearch: zodValidator(searchSchema), + staticData: { + layout: "v2", + }, + component: ProjectActorsRoute, + pendingComponent: () => ( +
+ +
+ ), + errorComponent(props: ErrorComponentProps) { + return ( +
+
+ +
+
+ ); + }, +}); diff --git a/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/functions.tsx b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/functions.tsx new file mode 100644 index 0000000000..99fce145f8 --- /dev/null +++ b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/functions.tsx @@ -0,0 +1,282 @@ +import * as Layout from "@/domains/project/layouts/servers-layout"; +import { + projectActorsQueryOptions, + routesQueryOptions, + useDeleteRouteMutation, +} from "@/domains/project/queries"; +import { + useInfiniteQuery, + usePrefetchInfiniteQuery, + useSuspenseQuery, +} from "@tanstack/react-query"; +import { + createFileRoute, + type ErrorComponentProps, + Link, +} from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; +import { ErrorComponent } from "@/components/error-component"; +import { + Card, + CardHeader, + Flex, + CardTitle, + Button, + CardContent, + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, + DiscreteCopyButton, + toRecord, + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + Text, +} from "@rivet-gg/components"; +import { Icon, faPlus, faEllipsisH } from "@rivet-gg/icons"; +import { useEnvironment } from "@/domains/project/data/environment-context"; +import { useProject } from "@/domains/project/data/project-context"; +import { useDialog } from "@/hooks/use-dialog"; + +function ProjectFunctionsRoute() { + const { projectNameId, environmentNameId } = Route.useParams(); + const { data: routes } = useSuspenseQuery( + routesQueryOptions(Route.useParams()), + ); + + usePrefetchInfiniteQuery({ + ...projectActorsQueryOptions({ + projectNameId, + environmentNameId, + includeDestroyed: true, + tags: {}, + }), + pages: 10, + }); + + const { data: actors } = useInfiniteQuery( + projectActorsQueryOptions({ + projectNameId, + environmentNameId, + includeDestroyed: true, + tags: {}, + }), + ); + + const navigate = Route.useNavigate(); + + const { mutate: deleteRoute } = useDeleteRouteMutation(); + + return ( + <> + + +
+
+ + + + Routes +
+ +
+
+
+ + + + + Route + Instances + + + + + {routes.length === 0 ? ( + + + + There's no routes yet. + + + + ) : null} + {routes?.map((route) => ( + + + + {`${route.hostname}${route.path}${route.routeSubpaths ? "/*" : ""}`} + + + + {actors?.filter((actor) => + Object.entries( + route.target.actors + ?.selectorTags || + {}, + ).some(([key, value]) => { + return ( + toRecord( + actor.tags, + )[key] === value + ); + }), + ).length || 0} + + + + + + + + + navigate({ + to: ".", + search: { + modal: "edit-route", + route: route.id, + }, + params: { + projectNameId, + environmentNameId, + }, + }) + } + > + Edit + + { + deleteRoute({ + projectNameId, + environmentNameId, + routeId: + route.id, + }); + }} + > + Delete + + + + + + ))} + +
+
+
+
+
+ + ); +} + +function Modals() { + const navigate = Route.useNavigate(); + const { gameId: projectId, nameId: projectNameId } = useProject(); + const { namespaceId: environmentId, nameId: environmentNameId } = + useEnvironment(); + + const { modal, route } = Route.useSearch(); + + const EditRouteDialog = useDialog.EditRoute.Dialog; + const CreateRouteDialog = useDialog.CreateRoute.Dialog; + + const handleOnOpenChange = (value: boolean) => { + if (!value) { + navigate({ search: { modal: undefined } }); + } + }; + + return ( + <> + + + + ); +} + +const searchSchema = z.object({ + modal: z.enum(["add-route", "edit-route"]).or(z.string()).optional(), + route: z.string().optional(), +}); + +export const Route = createFileRoute( + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/functions", +)({ + validateSearch: zodValidator(searchSchema), + staticData: { + layout: "v2", + }, + component: ProjectFunctionsRoute, + pendingComponent: () => ( +
+ +
+ ), + errorComponent(props: ErrorComponentProps) { + return ( +
+
+ +
+
+ ); + }, +}); diff --git a/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/logs.tsx b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/logs.tsx new file mode 100644 index 0000000000..c3c50e4354 --- /dev/null +++ b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/logs.tsx @@ -0,0 +1,552 @@ +import * as Layout from "@/domains/project/layouts/servers-layout"; +import { + type FunctionInvoke, + logsAggregatedQueryOptions, + projectActorsQueryOptions, + routesQueryOptions, +} from "@/domains/project/queries"; +import { + cn, + VirtualScrollArea, + WithTooltip, + FilterCreator, + Button, + FilterOperator, + type FilterDefinition, + type Filter, + toRecord, + ToggleGroup, + ToggleGroupItem, + ShimmerLine, +} from "@rivet-gg/components"; +import { + useInfiniteQuery, + usePrefetchInfiniteQuery, + useQuery, +} from "@tanstack/react-query"; +import { + createFileRoute, + Link, + type ErrorComponentProps, +} from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { format } from "date-fns"; +import { + type Dispatch, + forwardRef, + type SetStateAction, + useCallback, + useMemo, + useRef, + useState, +} from "react"; +import { z } from "zod"; +import type { Virtualizer } from "@tanstack/react-virtual"; +import { + faAngleDown, + faAngleUp, + faFontCase, + faKey, + faRegex, + faSignal, + faSwap, + Icon, +} from "@rivet-gg/icons"; +import { + ActorObjectInspector, + ActorRegion, + ConsoleMessageVariantIcon, + getConsoleMessageVariant, + useActorsView, +} from "@rivet-gg/components/actors"; +import { ErrorComponent } from "@/components/error-component"; +import { useDebounceCallback } from "usehooks-ts"; + +const searchSchema = z.object({ + filters: z + .array( + z.object({ + id: z.string(), + defId: z.string(), + operator: z.custom(), + value: z.array(z.string()), + }), + ) + .optional(), + search: z.string().optional(), + flags: z.array(z.enum(["case-sensitive", "regex"])).optional(), +}); + +export const Route = createFileRoute( + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/logs", +)({ + validateSearch: zodValidator(searchSchema), + staticData: { + layout: "v2", + }, + component: ProjectFunctionsRoute, + pendingComponent: Layout.Content.Skeleton, + errorComponent(props: ErrorComponentProps) { + return ( +
+
+ +
+
+ ); + }, +}); + +function ProjectFunctionsRoute() { + const { environmentNameId, projectNameId } = Route.useParams(); + + const navigate = Route.useNavigate(); + const { filters = [], flags, search } = Route.useSearch(); + + usePrefetchInfiniteQuery({ + ...projectActorsQueryOptions({ + projectNameId, + environmentNameId, + includeDestroyed: true, + tags: {}, + }), + pages: 10, + }); + + const { data: actors } = useInfiniteQuery({ + ...projectActorsQueryOptions({ + projectNameId, + environmentNameId, + includeDestroyed: true, + tags: {}, + }), + }); + + const { data: rows, isLoading: isLoadingLogs } = useQuery( + logsAggregatedQueryOptions({ + projectNameId, + environmentNameId, + search: search + ? { + text: decodeURIComponent(search), + caseSensitive: + flags?.includes("case-sensitive") ?? false, + enableRegex: flags?.includes("regex") ?? false, + } + : undefined, + }), + ); + + const { data: routes } = useQuery( + routesQueryOptions({ + projectNameId, + environmentNameId, + }), + ); + + const searchInputRef = useRef(null); + + const setFilters: Dispatch> = useCallback( + (fnOrValue) => { + if (typeof fnOrValue === "function") { + navigate({ + search: (value) => ({ + ...value, + filters: fnOrValue(value.filters ?? []).filter( + (filter) => filter.value.length > 0, + ), + }), + }); + } else { + navigate({ + search: (value) => ({ + ...value, + filters: fnOrValue.filter( + (filter) => filter.value.length > 0, + ), + }), + }); + } + }, + [navigate], + ); + + const viewportRef = useRef(null); + const virtualizerRef = useRef>(null); + + const definitions = useMemo( + () => + [ + { + label: "Level", + icon: faSignal, + type: "select", + id: "level", + options: [ + { label: "Info", value: "info" }, + { label: "Warning", value: "warning" }, + { label: "Error", value: "error" }, + ], + }, + { + label: "Route", + type: "select", + icon: faSwap, + id: "routeId", + options: + routes?.map((route) => ({ + label: `${route.hostname}${route.path}${route.routeSubpaths ? "/*" : ""}`, + value: route.id, + })) ?? [], + }, + { + label: "Instance", + type: "select", + icon: faKey, + id: "actorId", + options: + actors?.map((actor) => { + const name = toRecord(actor.tags).name as string; + return { + label: ( +
+ + + {name ? ( + + {name}{" "} + + ({actor.id.split("-")[0]}) + + + ) : ( + actor.id.split("-")[0] + )} +
+ ), + value: actor.id, + }; + }) ?? [], + }, + ] satisfies FilterDefinition[], + [actors, routes], + ); + + // filter all rows by filters + const filteredRows = + rows?.filter((row) => { + const satisfiesFilters = filters.every((filter) => { + const { defId, operator, value } = filter; + const def = definitions.find((def) => def.id === defId); + if (!def || value.length === 0) return true; + + if (def.id === "level") { + if (operator === FilterOperator.IS) { + return row.level === value[0]; + } + if (operator === FilterOperator.IS_NOT) { + return value.length === 1 + ? row.level !== value[0] + : !value.includes(row.level as string); + } + if (operator === FilterOperator.IS_ANY_OF) { + return value.includes(row.level as string); + } + } + + if (def.id === "routeId") { + const route = routes + ?.filter((route) => value.includes(route.id)) + .filter((route) => !!route); + const actor = actors?.find( + (actor) => actor.id === row.actorId, + ); + + if (!route || !actor) return true; + + if ( + operator === FilterOperator.IS || + operator === FilterOperator.IS_ANY_OF + ) { + return route.some((r) => { + return Object.entries( + r.target.actors?.selectorTags || {}, + ).some(([key, value]) => { + return toRecord(actor.tags)[key] === value; + }); + }); + } + if (operator === FilterOperator.IS_NOT) { + return route.every((r) => { + return Object.entries( + r.target.actors?.selectorTags || {}, + ).every(([key, value]) => { + return toRecord(actor.tags)[key] !== value; + }); + }); + } + } + + if (def.id === "actorId") { + const actor = actors?.find( + (actor) => actor.id === row.actorId, + ); + if (!actor) return true; + + if (operator === FilterOperator.IS) { + return value.includes(actor.id); + } + if (operator === FilterOperator.IS_NOT) { + return value.length === 1 + ? actor.id !== value[0] + : !value.includes(actor.id); + } + if (operator === FilterOperator.IS_ANY_OF) { + return value.includes(actor.id); + } + } + }); + + return ( + satisfiesFilters && + row.line.toLowerCase().includes(search || "") + ); + }) ?? []; + + const setSearch = useDebounceCallback((search) => { + navigate({ + search: (value) => ({ + ...value, + search, + }), + }); + }, 500); + + const [expanded, setExpanded] = useState(() => [] as string[]); + + return ( +
+
+ setSearch(e.target.value)} + /> + { + navigate({ + search: (value) => ({ + ...value, + flags, + }), + }); + }} + > + + + + + + + +
+ +
+
+ ({ + ...filteredRows[index], + isExpanded: expanded.includes(filteredRows[index].id), + expand: () => + setExpanded((prev) => { + if (prev.includes(filteredRows[index].id)) { + return prev.filter( + (id) => id !== filteredRows[index].id, + ); + } + return [...prev, filteredRows[index].id]; + }), + })} + estimateSize={() => 28} + row={FunctionRow} + > + {isLoadingLogs ? ( + + ) : null} + {!isLoadingLogs && filteredRows?.length === 0 ? ( +
+

+ No logs found. +

+ + {filters.length > 0 || (search?.length || 0) > 0 ? ( + + ) : null} +
+ ) : null} +
+
+
+ ); +} + +const FunctionRow = forwardRef< + HTMLButtonElement, + FunctionInvoke & { + isExpanded: boolean; + expand: () => void; + className?: string; + } +>( + ( + { + id, + timestamp, + message, + line, + properties, + level, + actorId, + actorName, + regionId, + isFormatted, + isExpanded, + actorTags, + expand, + ...props + }, + ref, + ) => { + const { copy } = useActorsView(); + return ( + + ); + }, +); + +const ActorBadge = forwardRef< + HTMLButtonElement, + { + actorId: string; + actorName: string; + regionId: string; + actorTags: Record; + } +>(({ actorId, actorName, regionId, actorTags, ...props }, ref) => { + return ( + + ); +}); diff --git a/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/settings.tsx b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/settings.tsx new file mode 100644 index 0000000000..1cd788a728 --- /dev/null +++ b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId._v2/settings.tsx @@ -0,0 +1,105 @@ +import { ErrorComponent } from "@/components/error-component"; +import { useEnvironment } from "@/domains/project/data/environment-context"; +import { useProject } from "@/domains/project/data/project-context"; +import * as Layout from "@/domains/project/layouts/project-layout"; +import { useDialog } from "@/hooks/use-dialog"; +import { ActionCard, Button, Text } from "@rivet-gg/components"; +import { + type ErrorComponentProps, + Link, + createFileRoute, +} from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { z } from "zod"; + +function EnvironmentSettingsRoute() { + return ( +
+
+ + +
+
+ ); +} + +function ServiceTokenCard() { + return ( + <> + + + Generate + + + } + > + + Service tokens are used from private API servers. These + should never be shared. + + + + ); +} + +function Modals() { + const navigate = Route.useNavigate(); + const { gameId: projectId, nameId: projectNameId } = useProject(); + const { namespaceId: environmentId, nameId: environmentNameId } = + useEnvironment(); + + const { modal } = Route.useSearch(); + + const GenerateProjectEnvServiceTokenDialog = + useDialog.GenerateProjectEnvServiceToken.Dialog; + + const handleOnOpenChange = (value: boolean) => { + if (!value) { + navigate({ search: { modal: undefined } }); + } + }; + + return ( + <> + + + ); +} + +const searchSchema = z.object({ + modal: z.enum(["service-token"]).or(z.string()).optional(), +}); + +export const Route = createFileRoute( + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2/settings", +)({ + staticData: { + layout: "v2", + }, + validateSearch: zodValidator(searchSchema), + component: EnvironmentSettingsRoute, + pendingComponent: () => ( +
+ +
+ ), + errorComponent(props: ErrorComponentProps) { + return ( +
+
+ +
+
+ ); + }, +}); diff --git a/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId.tsx b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId.tsx index 4579911a5a..3f8ee82442 100644 --- a/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId.tsx +++ b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId.tsx @@ -6,6 +6,7 @@ import { import { useProject } from "@/domains/project/data/project-context"; import * as Layout from "@/domains/project/layouts/project-layout"; import { useDialog } from "@/hooks/use-dialog"; +import { usePageLayout } from "@/lib/compute-page-layout"; import { guardUuids } from "@/lib/guards"; import { type ErrorComponentProps, @@ -58,6 +59,17 @@ function Modals() { } function EnvironmentErrorComponent(props: ErrorComponentProps) { + const layout = usePageLayout(); + + if (layout === "v2") { + return ( +
+
+ +
+
+ ); + } return ; } diff --git a/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2.tsx b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2.tsx new file mode 100644 index 0000000000..753af9accd --- /dev/null +++ b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2.tsx @@ -0,0 +1,148 @@ +import { Button, cn, WithTooltip } from "@rivet-gg/components"; +import { ActorsLayout, useActorsLayout } from "@rivet-gg/components/actors"; +import { + faActorsBorderless, + faBarsStaggered, + faCodeBranch, + faCog, + faFunction, + faServer, + faSidebar, + Icon, + type IconProp, +} from "@rivet-gg/icons"; +import { createFileRoute, Link, Outlet } from "@tanstack/react-router"; +import { AnimatePresence, motion } from "framer-motion"; + +const SIDEBAR: ( + | { type: "separator" } + | { icon: IconProp; label: string; to: string; isDisabled?: boolean } +)[] = [ + { icon: faActorsBorderless, label: "Actors", to: "actors" }, + { icon: faServer, label: "Containers", to: "containers" }, + { icon: faFunction, label: "Functions", to: "functions" }, + { icon: faBarsStaggered, label: "Logs", to: "logs" }, + { icon: faCodeBranch, label: "Versions", to: "actor-versions" }, + { type: "separator" }, + { icon: faCog, label: "Settings", to: "settings" }, +]; + +export const Route = createFileRoute( + "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/_v2", +)({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( + } + right={ +
+ +
+ } + /> + ); +} + +function Sidebar() { + const { isFolded, setFolded } = useActorsLayout(); + return ( + +
    + {SIDEBAR.map((item, index) => { + if ("type" in item) { + return ( +
  • +
    +
  • + ); + } + + const button = ({ isActive }: { isActive: boolean }) => ( + + ); + + if (item.isDisabled) { + return ( +
  • + {button({ isActive: false })}
+ } + content="Coming soon" + /> + + ); + } + + return ( +
  • + + {({ isActive }) => { + return button({ isActive }); + }} + +
  • + ); + })} + + + + ); +} diff --git a/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/actors.tsx b/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/actors.tsx deleted file mode 100644 index c3d027f52b..0000000000 --- a/frontend/apps/hub/src/routes/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/actors.tsx +++ /dev/null @@ -1,484 +0,0 @@ -import { - actorBuildsAtom, - actorFiltersAtom, - actorRegionsAtom, - ActorsActorDetails, - ActorsActorDetailsPanel, - actorsAtom, - ActorsListPreview, - actorsPaginationAtom, - createActorAtom, - currentActorAtom, - currentActorIdAtom, - type DestroyActor, - getActorStatus, -} from "@rivet-gg/components/actors"; -import { createClient } from "actor-core/client"; -import { useEnvironment } from "@/domains/project/data/environment-context"; -import { useProject } from "@/domains/project/data/project-context"; -import * as Layout from "@/domains/project/layouts/servers-layout"; -import { - actorBuildsCountQueryOptions, - actorLogsQueryOptions, - actorRegionsQueryOptions, - createActorEndpoint, - projectActorsQueryOptions, - destroyActorMutationOptions, - actorBuildsQueryOptions, -} from "@/domains/project/queries"; -import { - InfiniteQueryObserver, - QueryObserver, - MutationObserver, - useSuspenseQuery, -} from "@tanstack/react-query"; -import { createFileRoute, useRouter } from "@tanstack/react-router"; -import { zodValidator } from "@tanstack/zod-adapter"; -import { z } from "zod"; -import { GettingStarted } from "@rivet-gg/components/actors"; -import { - atom, - createStore, - type PrimitiveAtom, - Provider, - useAtomValue, -} from "jotai"; -import { useEffect } from "react"; -import { queryClient } from "@/queries/global"; -import { toRecord } from "@rivet-gg/components"; -import { useDialog } from "@/hooks/use-dialog"; -import equal from "fast-deep-equal"; - -function Actor() { - const navigate = Route.useNavigate(); - const { tab } = Route.useSearch(); - - const actor = useAtomValue(currentActorAtom); - - if (!actor) { - return null; - } - - return ( - { - navigate({ - to: ".", - search: (old) => ({ ...old, tab }), - }); - }} - /> - ); -} - -const store = createStore(); - -function Content() { - const { nameId: projectNameId } = useProject(); - const { nameId: environmentNameId } = useEnvironment(); - const { actorId, tags, showDestroyed, modal } = Route.useSearch(); - - const CreateActorDialog = useDialog.CreateActor.Dialog; - const GoToActorDialog = useDialog.GoToActor.Dialog; - const router = useRouter(); - const navigate = Route.useNavigate(); - - useEffect(() => { - store.set(currentActorIdAtom, actorId); - }, [actorId]); - - useEffect(() => { - store.set(actorFiltersAtom, { - showDestroyed: showDestroyed ?? true, - tags: Object.fromEntries( - tags?.map((tag) => [tag[0], tag[1]]) || [], - ), - }); - - store.set(currentActorIdAtom, actorId); - }, [tags, showDestroyed, actorId]); - - useEffect(() => { - return store.sub(actorFiltersAtom, () => { - const value = store.get(actorFiltersAtom); - router.navigate({ - to: ".", - search: (old) => ({ - ...old, - tags: Object.entries(value.tags).map(([key, value]) => [ - key, - value, - ]), - showDestroyed: value.showDestroyed, - }), - }); - }); - }, [router]); - - useEffect(() => { - const defaultFilters = store.get(actorFiltersAtom); - const actorsObserver = new InfiniteQueryObserver( - queryClient, - projectActorsQueryOptions({ - projectNameId, - environmentNameId, - includeDestroyed: defaultFilters.showDestroyed, - tags: defaultFilters.tags, - }), - ); - - const unsubFilters = store.sub(actorFiltersAtom, () => { - const filters = store.get(actorFiltersAtom); - actorsObserver.setOptions( - projectActorsQueryOptions({ - projectNameId, - environmentNameId, - tags: filters.tags, - includeDestroyed: filters.showDestroyed, - }), - ); - actorsObserver.refetch(); - }); - - const unsub = actorsObserver.subscribe((query) => { - 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.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), - framework: "actor-core", - }, - }; - } - - 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: { lines: [], timestamps: [], ids: [] }, - errors: { lines: [], timestamps: [], ids: [] }, - }); - logs.onMount = (set) => { - const stdOutObserver = new QueryObserver( - queryClient, - actorLogsQueryOptions({ - projectNameId, - environmentNameId, - actorId: actor.id, - stream: "std_out", - }), - ); - const stdErrObserver = new QueryObserver( - queryClient, - actorLogsQueryOptions({ - projectNameId, - environmentNameId, - actorId: actor.id, - stream: "std_err", - }), - ); - - function updateStdOut(query: any) { - if (query.status === "success" && query.data) { - set((prev) => ({ - ...prev, - logs: { - lines: query.data.lines, - timestamps: query.data.timestamps, - ids: query.data.ids, - }, - })); - } - } - - function updateStdErr(query: any) { - if (query.status === "success" && query.data) { - set((prev) => ({ - ...prev, - errors: { - lines: query.data.lines, - timestamps: query.data.timestamps, - ids: query.data.ids, - }, - })); - } - } - - const subOut = stdOutObserver.subscribe((query) => { - updateStdOut(query); - }); - - const subErr = stdErrObserver.subscribe((query) => { - updateStdErr(query); - }); - - updateStdOut(stdOutObserver.getCurrentQuery()); - updateStdErr(stdErrObserver.getCurrentQuery()); - - return () => { - stdOutObserver.destroy(); - stdErrObserver.destroy(); - subOut(); - subErr(); - }; - }; - - return { - ...actor, - logs, - destroy, - status: getActorStatus(actor), - }; - }); - }); - } - }); - return () => { - actorsObserver.destroy(); - unsub(); - unsubFilters(); - }; - }, [projectNameId, environmentNameId]); - - 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]); - - 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" && - toRecord(a.tags).owner === "rivet" && - 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(); - }; - }); - - function handleOpenChange(open: boolean) { - router.navigate({ - to: ".", - search: (old) => ({ - ...old, - modal: !open ? undefined : modal, - }), - }); - } - - return ( - - - - {actorId ? : null} - - - - - { - navigate({ - to: ".", - search: (old) => ({ - ...old, - actorId, - modal: undefined, - }), - }); - }} - dialogProps={{ - open: modal === "go-to-actor", - onOpenChange: handleOpenChange, - }} - /> - - ); -} - -function ProjectActorsRoute() { - const { nameId: projectNameId } = useProject(); - const { nameId: environmentNameId } = useEnvironment(); - const { tags, showDestroyed } = Route.useSearch(); - - const { data } = useSuspenseQuery({ - ...actorBuildsCountQueryOptions({ - projectNameId, - environmentNameId, - }), - refetchInterval: (query) => - (query.state.data?.builds.length || 0) > 0 ? false : 2000, - }); - - if (data === 0 && !tags && showDestroyed === undefined) { - return ; - } - - return ( -
    - -
    - ); -} - -const searchSchema = z.object({ - actorId: z.string().optional(), - tab: z.string().optional(), - - tags: z.array(z.tuple([z.string(), z.string()])).optional(), - showDestroyed: z.boolean().optional().default(true), -}); - -export const Route = createFileRoute( - "/_authenticated/_layout/projects/$projectNameId/environments/$environmentNameId/actors", -)({ - validateSearch: zodValidator(searchSchema), - staticData: { - layout: "actors", - }, - component: ProjectActorsRoute, - pendingComponent: Layout.Content.Skeleton, -}); diff --git a/frontend/packages/components/package.json b/frontend/packages/components/package.json index 1348dcb810..e3324b3f66 100644 --- a/frontend/packages/components/package.json +++ b/frontend/packages/components/package.json @@ -40,20 +40,20 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-avatar": "^1.0.4", - "@radix-ui/react-checkbox": "^1.1.1", - "@radix-ui/react-dialog": "^1.1.1", - "@radix-ui/react-dropdown-menu": "^2.0.6", - "@radix-ui/react-label": "^2.0.2", - "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-checkbox": "^1.1.5", + "@radix-ui/react-dialog": "^1.1.7", + "@radix-ui/react-dropdown-menu": "^2.1.7", + "@radix-ui/react-label": "^2.1.3", + "@radix-ui/react-popover": "^1.1.7", "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-radio-group": "^1.0.3", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", - "@radix-ui/react-separator": "^1.0.3", - "@radix-ui/react-slider": "^1.1.2", - "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-separator": "^1.1.3", + "@radix-ui/react-slider": "^1.2.4", + "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-switch": "^1.0.3", - "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-tabs": "^1.1.4", "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle-group": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.1", @@ -62,14 +62,15 @@ "@sentry/react": "^8.26.0", "@shikijs/langs": "^3.2.1", "@tailwindcss/container-queries": "^0.1.1", + "@tanstack/react-table": "^8.21.2", "@tanstack/react-virtual": "^3.10.8", "@uiw/codemirror-extensions-basic-setup": "^4.23.0", "@uiw/codemirror-theme-github": "^4.23.0", "@uiw/react-codemirror": "^4.23.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", - "cmdk": "^1.0.0", - "date-fns": "^3.6.0", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "esast-util-from-js": "^2.0.1", "estree-util-to-js": "^2.0.0", "fast-deep-equal": "^3.1.3", @@ -79,7 +80,7 @@ "jotai-effect": "^2.0.2", "lucide-react": "^0.439.0", "react": "^19", - "react-day-picker": "^9.0.9", + "react-day-picker": "8.10.1", "react-dom": "^19", "react-hook-form": "^7.51.1", "react-resizable-panels": "^2.0.19", diff --git a/frontend/packages/components/src/actors/actor-context.tsx b/frontend/packages/components/src/actors/actor-context.tsx index 9fa176669a..2de23f55a9 100644 --- a/frontend/packages/components/src/actors/actor-context.tsx +++ b/frontend/packages/components/src/actors/actor-context.tsx @@ -34,6 +34,7 @@ export type Actor = Omit< }; export type Logs = { + status?: string; lines: string[]; timestamps: string[]; ids: string[]; diff --git a/frontend/packages/components/src/actors/actor-logs.tsx b/frontend/packages/components/src/actors/actor-logs.tsx index 8a4c57b181..17a20c763c 100644 --- a/frontend/packages/components/src/actors/actor-logs.tsx +++ b/frontend/packages/components/src/actors/actor-logs.tsx @@ -111,6 +111,16 @@ export const ActorLogs = memo( ); } + if (logs.status === "pending" || errors.status === "pending") { + return ( +
    + + Loading logs... + +
    + ); + } + if (combined.length === 0) { // if (!isStdOutSuccess || !isStdErrSuccess) { // return ( diff --git a/frontend/packages/components/src/actors/actor-status-indicator.tsx b/frontend/packages/components/src/actors/actor-status-indicator.tsx index fcf9148a84..5c14e84e49 100644 --- a/frontend/packages/components/src/actors/actor-status-indicator.tsx +++ b/frontend/packages/components/src/actors/actor-status-indicator.tsx @@ -29,15 +29,22 @@ export function getActorStatus( return "unknown"; } +export const AtomizedActorStatusIndicator = ({ + actor, +}: { + actor: ActorAtom; +}) => { + const status = useAtomValue(selectAtom(actor, selector)); + return ; +}; + const selector = ({ status }: Actor) => status; interface ActorStatusIndicatorProps { - actor: ActorAtom; + status: ReturnType; } -export const ActorStatusIndicator = ({ actor }: ActorStatusIndicatorProps) => { - const status = useAtomValue(selectAtom(actor, selector)); - +export const ActorStatusIndicator = ({ status }: ActorStatusIndicatorProps) => { if (status === "running") { return ; } diff --git a/frontend/packages/components/src/actors/actors-actor-missing.tsx b/frontend/packages/components/src/actors/actors-actor-missing.tsx index 0501619d97..3057eae0d2 100644 --- a/frontend/packages/components/src/actors/actors-actor-missing.tsx +++ b/frontend/packages/components/src/actors/actors-actor-missing.tsx @@ -1,23 +1,26 @@ import { Button } from "@rivet-gg/components"; import { useActorsLayout } from "./actors-layout-context"; import { ActorsSidebarToggleButton } from "./actors-sidebar-toggle-button"; +import { useActorsView } from "./actors-view-context-provider"; export function ActorsActorMissing() { const { setFolded, isFolded } = useActorsLayout(); + const { copy } = useActorsView(); + return (
    - Please select an Actor from the list. + {copy.selectActor} {isFolded ? ( ) : null}
    diff --git a/frontend/packages/components/src/actors/actors-layout.tsx b/frontend/packages/components/src/actors/actors-layout.tsx new file mode 100644 index 0000000000..396df15628 --- /dev/null +++ b/frontend/packages/components/src/actors/actors-layout.tsx @@ -0,0 +1,34 @@ +import { cn, ls } from "../lib/utils"; +import { type ReactNode, memo, useState } from "react"; +import { ActorsLayoutContextProvider } from "./actors-layout-context"; + +interface ActorsListPreviewProps { + left: ReactNode; + right: ReactNode; + className?: string; +} + +export const ActorsLayout = memo( + ({ left, right, className }: ActorsListPreviewProps) => { + const [folded, setFolded] = useState(() => ls.actorsList.getFolded()); + + return ( + +
    + {left} +
    + {right} +
    +
    +
    + ); + }, +); diff --git a/frontend/packages/components/src/actors/actors-list.tsx b/frontend/packages/components/src/actors/actors-list.tsx index 003247fcd1..3518ecf95c 100644 --- a/frontend/packages/components/src/actors/actors-list.tsx +++ b/frontend/packages/components/src/actors/actors-list.tsx @@ -12,6 +12,7 @@ import { filteredActorsCountAtom, } from "./actor-context"; import { faReact, faRust, faTs, Icon } from "@rivet-gg/icons"; +import { useActorsView } from "./actors-view-context-provider"; export function ActorsList() { return ( @@ -25,13 +26,13 @@ export function ActorsList() {
    -
    +
    -
    Region
    -
    ID
    -
    Tags
    -
    Created
    -
    Destroyed
    +
    Region
    +
    ID
    +
    Tags
    +
    Created
    +
    Destroyed
    @@ -78,13 +79,15 @@ function EmptyState() { const count = useAtomValue(filteredActorsCountAtom); const filtersCount = useAtomValue(actorFiltersCountAtom); const setFilters = useSetAtom(actorFiltersAtom); + const { copy } = useActorsView(); + return (
    {count === 0 ? ( filtersCount === 0 ? (
    - No actors found. + {copy.noActorsFound}
    {" "} diff --git a/frontend/packages/components/src/actors/actors-view-context-provider.tsx b/frontend/packages/components/src/actors/actors-view-context-provider.tsx new file mode 100644 index 0000000000..32627715b8 --- /dev/null +++ b/frontend/packages/components/src/actors/actors-view-context-provider.tsx @@ -0,0 +1,35 @@ +import { createContext, useContext } from "react"; + +export const ActorsViewContext = createContext<{ + copy: { + createActor: string; + createActorUsingForm: string; + noActorsFound: string; + selectActor: string; + goToActor: string; + showActorList: string; + actorId: string; + }; + requiresManager: boolean; +}>({ + copy: { + createActor: "Create Actor", + createActorUsingForm: "Create Actor using simple form", + noActorsFound: "No actors found", + selectActor: "Please select an Actor from the list.", + goToActor: "Go to Actor", + showActorList: "Show Actor List", + actorId: "Actor ID", + }, + requiresManager: true, +}); + +export const useActorsView = () => { + const context = useContext(ActorsViewContext); + if (!context) { + throw new Error( + "useActorsView must be used within a ActorsViewContextProvider", + ); + } + return context; +}; diff --git a/frontend/packages/components/src/actors/console/actor-console-message.tsx b/frontend/packages/components/src/actors/console/actor-console-message.tsx index 776a9c7660..e95c55eab6 100644 --- a/frontend/packages/components/src/actors/console/actor-console-message.tsx +++ b/frontend/packages/components/src/actors/console/actor-console-message.tsx @@ -3,6 +3,7 @@ import { Icon, faAngleLeft, faAngleRight, + faExclamationCircle, faSpinnerThird, faWarning, faXmark, @@ -33,44 +34,67 @@ export const ActorConsoleMessage = forwardRef< ref={ref} {...props} className={cn( - "whitespace-pre-wrap font-mono-console text-xs text-foreground/90 border-y pl-3 pr-5 flex py-1 -mt-[1px]", - { - "bg-red-950/30 border-red-800/40 text-red-400 z-10": - variant === "error", - "bg-yellow-500/10 border-yellow-800/40 text-yellow-200 z-10": - variant === "warn", - "bg-blue-950/30 border-blue-800/40 text-blue-400 z-10": - variant === "debug", - }, + "whitespace-pre-wrap font-mono-console text-xs text-foreground/90 border-y pl-3 pr-5 flex py-1 -mt-[1px] gap-2", + getConsoleMessageVariant(variant), className, )} > -
    - {variant === "input" ? ( - - ) : null} - {variant === "input-pending" ? ( - - ) : null} - {variant === "output" ? ( - - ) : null} - {variant === "error" ? ( - - ) : null} - {variant === "warn" ? ( - - ) : null} +
    +
    -
    - {timestamp ? format(timestamp, "LLL dd HH:mm:ss O") : null} +
    + {timestamp + ? format(timestamp, "LLL dd HH:mm:ss").toUpperCase() + : null}
    -
    +
    {children}
    ); }); + +export const ConsoleMessageVariantIcon = ({ + variant, + className, +}: { + variant: string; + className?: string; +}) => { + if (variant === "input") { + return ; + } + if (variant === "input-pending") { + return ( + + ); + } + if (variant === "output") { + return ; + } + if (variant === "error") { + return ( + + ); + } + if (variant === "warn") { + return ; + } + return ; +}; + +export const getConsoleMessageVariant = (variant: string) => + cn({ + "bg-red-950/30 border-red-800/40 text-red-400 z-10": + variant === "error", + "bg-yellow-500/10 border-yellow-800/40 text-yellow-200 z-10": + variant === "warn", + "bg-blue-950/30 border-blue-800/40 text-blue-400 z-10": + variant === "debug", + }); diff --git a/frontend/packages/components/src/actors/create-actor-button.tsx b/frontend/packages/components/src/actors/create-actor-button.tsx index e8ac1165c3..15e471b233 100644 --- a/frontend/packages/components/src/actors/create-actor-button.tsx +++ b/frontend/packages/components/src/actors/create-actor-button.tsx @@ -6,6 +6,7 @@ import { actorBuildsCountAtom, actorManagerEndpointAtom, } from "./actor-context"; +import { useActorsView } from "./actors-view-context-provider"; export function CreateActorButton(props: ButtonProps) { const navigate = useNavigate(); @@ -14,6 +15,8 @@ export function CreateActorButton(props: ButtonProps) { const canCreate = builds > 0 && endpoint; + const { copy, requiresManager } = useActorsView(); + return ( } {...props} > - Create Actor + {copy.createActor}
    } content={ builds <= 0 ? "No builds found, please deploy a build first." - : endpoint - ? "Create new Actor using simple form." - : "No Actor Manager found, please deploy a build first." + : !requiresManager + ? copy.createActorUsingForm + : endpoint + ? copy.createActorUsingForm + : "No Actor Manager found, please deploy a build first." } /> ); diff --git a/frontend/packages/components/src/actors/dialogs/go-to-actor-dialog.tsx b/frontend/packages/components/src/actors/dialogs/go-to-actor-dialog.tsx index 7933d0d42f..0bd117930a 100644 --- a/frontend/packages/components/src/actors/dialogs/go-to-actor-dialog.tsx +++ b/frontend/packages/components/src/actors/dialogs/go-to-actor-dialog.tsx @@ -1,7 +1,8 @@ import * as GoToActorForm from "../form/go-to-actor-form"; -import type { DialogContentProps } from "../../hooks"; +import type { DialogContentProps } from "../hooks"; import { DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog"; import { Button } from "../../ui/button"; +import { useActorsView } from "../actors-view-context-provider"; interface ContentProps extends DialogContentProps { onSubmit?: (actorId: string) => void; @@ -11,6 +12,7 @@ export default function GoToActorDialogContent({ onClose, onSubmit, }: ContentProps) { + const { copy } = useActorsView(); return ( - Go to Actor + {copy.goToActor} 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 b151a4f914..4b7acdf0d2 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 @@ -9,6 +9,7 @@ import { FormMessage, } from "../../ui/form"; import { Input } from "../../ui/input"; +import { useActorsView } from "../actors-view-context-provider"; export const formSchema = z.object({ actorId: z.string().nonempty("Actor ID is required").uuid(), @@ -25,13 +26,14 @@ export { Form, Submit }; export const ActorId = () => { const { control } = useFormContext(); + const { copy } = useActorsView(); return ( ( - Actor ID + {copy.actorId} } {...props} > - Go to Actor + {copy.goToActor} ); } diff --git a/frontend/packages/components/src/actors/index.tsx b/frontend/packages/components/src/actors/index.tsx index 8535a9f8e2..9fbc972caf 100644 --- a/frontend/packages/components/src/actors/index.tsx +++ b/frontend/packages/components/src/actors/index.tsx @@ -6,3 +6,10 @@ export * from "./actors-actor-details-panel"; 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"; +export * from "./actor-region"; +export * from "./console/actor-inspector"; +export * from "./actor-status-indicator"; +export * from "./actors-view-context-provider"; diff --git a/frontend/packages/components/src/index.ts b/frontend/packages/components/src/index.ts index c2aca4954a..1d8bf02ab5 100644 --- a/frontend/packages/components/src/index.ts +++ b/frontend/packages/components/src/index.ts @@ -68,6 +68,9 @@ export * from "./ui/combobox"; export * from "./ui/picture"; export * from "./ui/toggle-group"; export * from "./ui/kbd"; +export * from "./ui/checkbox"; +export { default as Filters } from "./ui/filters"; +export * from "./ui/filters"; export * from "./lib/utils"; export * from "./lib/filesize"; export * from "./lib/timing"; @@ -82,6 +85,8 @@ export * from "./hooks"; export * from "./lib/emoji"; export * from "./third-party-providers"; export * from "./lib/constants"; +export * from "./lib/table"; +export * from "./lib/logfmt"; export * as styleHelpers from "./ui/helpers/index"; export { toast } from "sonner"; diff --git a/frontend/packages/components/src/layout/page.tsx b/frontend/packages/components/src/layout/page.tsx index 81698c45ea..ecf7d2eac2 100644 --- a/frontend/packages/components/src/layout/page.tsx +++ b/frontend/packages/components/src/layout/page.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; interface PageLayoutProps { children: ReactNode; - layout?: "compact" | "full" | "onboarding" | "actors"; + layout?: "compact" | "full" | "onboarding" | "actors" | "v2"; } const PageLayout = ({ children, layout = "compact" }: PageLayoutProps) => ( @@ -14,6 +14,7 @@ const PageLayout = ({ children, layout = "compact" }: PageLayoutProps) => ( layout === "full" || layout === "onboarding" || layout === "actors", + "w-full h-full": layout === "v2", })} > {children} @@ -30,6 +31,7 @@ const PageLayoutSkeleton = ({ container: layout === "compact", "px-8 w-full h-full": layout === "full" || layout === "actors", + "px-4 w-full h-full": layout === "v2", }, "pt-4", )} diff --git a/frontend/packages/components/src/lib/logfmt.ts b/frontend/packages/components/src/lib/logfmt.ts new file mode 100644 index 0000000000..d8802210d1 --- /dev/null +++ b/frontend/packages/components/src/lib/logfmt.ts @@ -0,0 +1,79 @@ +export type LogFmtValue = boolean | string | null | object; + +export const logfmt = { + /** + * adapts the logfmt parser to the rivet logfmt format + * original: https://github.com/csquared/node-logfmt/blob/6c3c61fcf5b8fdea1bca2ddac60367f616979dfd/lib/logfmt_parser.js#L3 + */ + parse: (line: string): Record => { + let key = ""; + let value: boolean | string | null = ""; + let inKey = false; + let inValue = false; + let inQuote = false; + let inEscape = false; + let inJsonLike = false; + let hadQuote = false; + const result: Record = {}; + + for (let i = 0; i <= line.length; i++) { + const char = line[i]; + + if ((char === " " && !inQuote) || i === line.length) { + if (inKey && key) { + result[key] = true; + } else if (inValue) { + if (value === "true") value = true; + else if (value === "false") value = false; + else if (value === "" && !hadQuote) value = null; + else if ( + value[0] === "{" && + value[value.length - 1] === "}" + ) { + try { + value = JSON.parse(value); + } catch { + // do nothing + } + } + result[key] = value; + value = ""; + } + + if (i === line.length) break; + + inKey = false; + inValue = false; + inQuote = false; + inEscape = false; + hadQuote = false; + } else if (char === "=" && !inQuote) { + inKey = false; + inValue = true; + } else if (char === "\\") { + inEscape = true; + } else if (char === "{" && hadQuote && inValue && inQuote) { + inJsonLike = true; + value += char; + } else if (char === "}" && inJsonLike) { + inJsonLike = false; + value += char; + } else if (char === '"' && !inJsonLike) { + hadQuote = true; + inQuote = !inQuote; + } else if (char === "n" && inEscape && inValue && !inJsonLike) { + value += "\n"; + inEscape = false; + } else if (char !== " " && !inValue && !inKey) { + inKey = true; + key = char; + } else if (inKey) { + key += char; + } else if (inValue) { + value += char; + } + } + + return result; + }, +}; diff --git a/frontend/packages/components/src/lib/safe-async.ts b/frontend/packages/components/src/lib/safe-async.ts index 76331d13d1..4e59c1caa3 100644 --- a/frontend/packages/components/src/lib/safe-async.ts +++ b/frontend/packages/components/src/lib/safe-async.ts @@ -7,3 +7,16 @@ export async function safeAsync( return [undefined, e]; } } + +// biome-ignore lint/suspicious/noExplicitAny: we need to use any here +export function safe( + fn: (...args: Args) => T, +): (...args: Args) => [T, undefined] | [undefined, unknown] { + return (...args: Args) => { + try { + return [fn(...args), undefined]; + } catch (e) { + return [undefined, e]; + } + }; +} diff --git a/frontend/packages/components/src/lib/table.ts b/frontend/packages/components/src/lib/table.ts new file mode 100644 index 0000000000..88f286b8f6 --- /dev/null +++ b/frontend/packages/components/src/lib/table.ts @@ -0,0 +1,84 @@ +import type { Header } from "@tanstack/react-table"; + +declare module "@tanstack/react-table" { + interface ColumnMeta { + isGrow?: boolean; + widthPercentage?: number; + } +} + +function getSize(size = 100, max = Number.MAX_SAFE_INTEGER, min = 40) { + return Math.max(Math.min(size, max), min); +} + +/** + * Calculates the sizing of table columns and distributes available width proportionally. + * This function acts as an extension for TanStack Table, ensuring proper column sizing + * based on provided metadata, including `isGrow`, `widthPercentage`, and size constraints. + * + * @template DataType - The generic type of data used in the table rows. + * + * @param {Header[]} columns - An array of column headers. Each header contains + * metadata about the column, including size, constraints, and growth behavior. + * @param {number} totalWidth - The total width available for the table, including padding and margins. + * + * @returns {Record} An object mapping column IDs to their calculated sizes. + */ +export const calculateTableSizing = ( + columns: Header[], + totalWidth: number, +): Record => { + let totalAvailableWidth = totalWidth; + let totalIsGrow = 0; + + for (const header of columns) { + const column = header.column.columnDef; + if (!column.size) { + if (!column.meta?.isGrow) { + let calculatedSize = 100; + if (column?.meta?.widthPercentage) { + calculatedSize = + column.meta.widthPercentage * totalWidth * 0.01; + } else { + calculatedSize = totalWidth / columns.length; + } + + const size = getSize( + calculatedSize, + column.maxSize, + column.minSize, + ); + + column.size = size; + } + } + + if (column.meta?.isGrow) totalIsGrow += 1; + else + totalAvailableWidth -= getSize( + column.size, + column.maxSize, + column.minSize, + ); + } + + const sizing: Record = {}; + + for (const header of columns) { + const column = header.column.columnDef; + if (column.meta?.isGrow) { + let calculatedSize = 100; + calculatedSize = Math.floor(totalAvailableWidth / totalIsGrow); + const size = getSize( + calculatedSize, + column.maxSize, + column.minSize, + ); + column.size = size; + } + + sizing[`${column.id}`] = Number(column.size); + } + + return sizing; +}; diff --git a/frontend/packages/components/src/page.tsx b/frontend/packages/components/src/page.tsx index 3b9e497d30..ef3a21b40d 100644 --- a/frontend/packages/components/src/page.tsx +++ b/frontend/packages/components/src/page.tsx @@ -8,7 +8,7 @@ export interface PageProps { className?: string; title?: ReactNode; header?: ReactNode; - layout?: "compact" | "full" | "onboarding" | "actors"; + layout?: "compact" | "full" | "onboarding" | "actors" | "v2"; children: ReactNode; } @@ -27,7 +27,8 @@ export const Page = ({ "h-full": layout === "full" || layout === "onboarding" || - layout === "actors", + layout === "actors" || + layout === "v2", })} > {title ? ( diff --git a/frontend/packages/components/src/ui/filters.tsx b/frontend/packages/components/src/ui/filters.tsx new file mode 100644 index 0000000000..21fff8d694 --- /dev/null +++ b/frontend/packages/components/src/ui/filters.tsx @@ -0,0 +1,700 @@ +"use client"; +import { Checkbox } from "./checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "./command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; +import { nanoid } from "nanoid"; +import { cn } from "../lib/utils"; +import { + type IconProp, + faCheck, + faFilterList, + faTimes as faX, + Icon, +} from "@rivet-gg/icons"; +import { + type Dispatch, + type SetStateAction, + useRef, + useState, + useEffect, + type ReactNode, +} from "react"; +import { Button } from "./button"; +import { motion } from "framer-motion"; +import { Badge } from "./badge"; + +interface AnimateChangeInHeightProps { + children: React.ReactNode; + className?: string; +} + +export const AnimateChangeInHeight: React.FC = ({ + children, + className, +}) => { + const containerRef = useRef(null); + const [height, setHeight] = useState("auto"); + + useEffect(() => { + if (containerRef.current) { + const resizeObserver = new ResizeObserver((entries) => { + // We only have one entry, so we can use entries[0]. + const observedHeight = entries[0].contentRect.height; + setHeight(observedHeight); + }); + + resizeObserver.observe(containerRef.current); + + return () => { + // Cleanup the observer when the component is unmounted + resizeObserver.disconnect(); + }; + } + }, []); + + return ( + +
    {children}
    +
    + ); +}; + +export enum FilterOperator { + IS = "is", + IS_NOT = "is not", + IS_ANY_OF = "is any of", + INCLUDE = "include", + DO_NOT_INCLUDE = "do not include", + INCLUDE_ALL_OF = "include all of", + INCLUDE_ANY_OF = "include any of", + EXCLUDE_ALL_OF = "exclude all of", + EXCLUDE_IF_ANY_OF = "exclude if any of", + BEFORE = "before", + AFTER = "after", +} + +export type Filter = { + id: string; + defId: string; + operator: FilterOperator; + value: string[]; +}; + +function filterDefinitionToOptions(definition: FilterDefinition) { + if (definition.type === "select") { + return definition.options.map((option) => ({ + value: option.value, + label: option.label, + })); + } + + return []; +} + +function defaultFilterDefinitionOperator({ + definition, + filterValues, +}: { definition: FilterDefinition; filterValues: string[] }) { + if (definition.type === "select") { + if (filterValues.length > 1) { + return FilterOperator.IS_ANY_OF; + } + return FilterOperator.IS; + } + return FilterOperator.IS; +} + +const filterOperators = ({ + definition, + filterValues, +}: { + definition: FilterDefinition; + filterValues: string[]; +}) => { + switch (definition.type) { + case "select": + if (Array.isArray(filterValues) && filterValues.length > 1) { + return [FilterOperator.IS_ANY_OF, FilterOperator.IS_NOT]; + } + return [FilterOperator.IS, FilterOperator.IS_NOT]; + // case FilterType.STATUS: + // case FilterType.ASSIGNEE: + // case FilterType.PRIORITY: + // if (Array.isArray(filterValues) && filterValues.length > 1) { + // return [FilterOperator.IS_ANY_OF, FilterOperator.IS_NOT]; + // } else { + // return [FilterOperator.IS, FilterOperator.IS_NOT]; + // } + // case FilterType.LABELS: + // if (Array.isArray(filterValues) && filterValues.length > 1) { + // return [ + // FilterOperator.INCLUDE_ANY_OF, + // FilterOperator.INCLUDE_ALL_OF, + // FilterOperator.EXCLUDE_ALL_OF, + // FilterOperator.EXCLUDE_IF_ANY_OF, + // ]; + // } else { + // return [FilterOperator.INCLUDE, FilterOperator.DO_NOT_INCLUDE]; + // } + // case FilterType.DUE_DATE: + // case FilterType.CREATED_DATE: + // case FilterType.UPDATED_DATE: + // if (filterValues?.includes(DueDate.IN_THE_PAST)) { + // return [FilterOperator.IS, FilterOperator.IS_NOT]; + // } else { + // return [FilterOperator.BEFORE, FilterOperator.AFTER]; + // } + default: + return []; + } +}; + +const FilterOperatorDropdown = ({ + definition, + operator, + filterValues, + setOperator, +}: { + definition: FilterDefinition; + operator: FilterOperator; + filterValues: string[]; + setOperator: (operator: FilterOperator) => void; +}) => { + const operators = filterOperators({ definition, filterValues }); + return ( + + + + + + {operators.map((operator) => ( + setOperator(operator)} + > + {operator} + + ))} + + + ); +}; + +const FilterValueCombobox = ({ + definition, + filterValues, + setFilterValues, +}: { + definition: FilterDefinition; + filterValues: string[]; + setFilterValues: (filterValues: string[]) => void; +}) => { + const [open, setOpen] = useState(false); + const [commandInput, setCommandInput] = useState(""); + const commandInputRef = useRef(null); + + const options = filterDefinitionToOptions(definition); + + const nonSelectedOptions = options.filter( + (option) => !filterValues.includes(option.value), + ); + const selectedOptions = options.filter((option) => + filterValues.includes(option.value), + ); + + return ( + { + setOpen(open); + if (!open) { + setTimeout(() => { + setCommandInput(""); + }, 200); + } + }} + > + + + + + + + { + setCommandInput(e.currentTarget.value); + }} + ref={commandInputRef} + /> + + No results found. + + {selectedOptions.map((option) => ( + { + setFilterValues( + filterValues.filter( + (v) => v !== option.value, + ), + ); + setTimeout(() => { + setCommandInput(""); + }, 200); + setOpen(false); + }} + > + + {/* */} + {option.label} + + ))} + + {nonSelectedOptions?.length > 0 && ( + <> + + + {nonSelectedOptions.map((filter) => ( + { + setFilterValues([ + ...filterValues, + currentValue, + ]); + setTimeout(() => { + setCommandInput(""); + }, 200); + setOpen(false); + }} + > + + {/* {filter.icon} */} + + {filter.label} + + + ))} + + + )} + + + + + + ); +}; + +const FilterValueDateCombobox = ({ + filterType, + filterValues, + setFilterValues, +}: { + filterType: FilterType; + filterValues: string[]; + setFilterValues: (filterValues: string[]) => void; +}) => { + const [open, setOpen] = useState(false); + const [commandInput, setCommandInput] = useState(""); + const commandInputRef = useRef(null); + return ( + { + setOpen(open); + if (!open) { + setTimeout(() => { + setCommandInput(""); + }, 200); + } + }} + > + + {filterValues?.[0]} + + + + + { + setCommandInput(e.currentTarget.value); + }} + ref={commandInputRef} + /> + + No results found. + + {filterDefinitionToOptions.map( + (filter: FilterOption) => ( + { + setFilterValues([currentValue]); + setTimeout(() => { + setCommandInput(""); + }, 200); + setOpen(false); + }} + > + + {filter.name} + + + + ), + )} + + + + + + + ); +}; + +function FilterValue({ + definition, + filterValues, + setFilterValues, +}: { + definition: FilterDefinition; + filterValues: string[]; + setFilterValues: (filterValues: string[]) => void; +}) { + if (definition.type === "select") { + return ( + + ); + } +} + +export default function Filters({ + filters, + setFilters, + definitions, +}: { + filters: Filter[]; + setFilters: Dispatch>; + definitions: FilterDefinition[]; +}) { + return ( +
    + {filters + .filter((filter) => filter.value?.length > 0) + .map((filter) => { + const definition = definitions.find( + (def) => def.id === filter.defId, + ); + + if (!definition) return null; + return ( + + + {definition.label} + { + setFilters((prev) => + prev.map((f) => + f.id === filter.id + ? { ...f, operator } + : f, + ), + ); + }} + /> + { + setFilters((prev) => + prev.map((f) => { + if (f.id === filter.id) { + const allowedOperators = + filterOperators({ + definition, + filterValues, + }); + + if ( + allowedOperators.includes( + f.operator, + ) + ) { + return { + ...f, + value: filterValues, + }; + } + + // If the operator is not allowed, set it to the first allowed operator + const newOperator = + allowedOperators[0]; + return { + ...f, + operator: newOperator, + value: filterValues, + }; + } + return f; + }), + ); + }} + /> + + + ); + })} +
    + ); +} + +export type FilterDefinition = { + type: "date" | "string" | "number" | "boolean" | "select"; + label: string; + icon: IconProp; + id: string; + options: { value: string; label: ReactNode }[]; +}; + +export const FilterCreator = ({ + definitions, + filters, + setFilters, +}: { + definitions: FilterDefinition[]; + filters: Filter[]; + setFilters: Dispatch>; +}) => { + const [open, setOpen] = useState(false); + const [selectedDefId, setSelectedDefId] = useState(null); + const [commandInput, setCommandInput] = useState(""); + const commandInputRef = useRef(null); + + const selectedDefinition = definitions.find( + (definition) => definition.id === selectedDefId, + ); + return ( +
    + + {filters.filter((filter) => filter.value?.length > 0).length > + 0 && ( + + )} + { + setOpen(open); + if (!open) { + setTimeout(() => { + setSelectedDefId(null); + setCommandInput(""); + }, 200); + } + }} + > + + + + + + + { + setCommandInput(e.currentTarget.value); + }} + ref={commandInputRef} + /> + + No results found. + {selectedDefinition ? ( + + {filterDefinitionToOptions( + selectedDefinition, + ).map((filter) => ( + { + setFilters((prev) => [ + ...prev, + { + id: nanoid(), + defId: selectedDefinition.id, + operator: + defaultFilterDefinitionOperator( + { + definition: + selectedDefinition, + filterValues: + [ + currentValue, + ], + }, + ), + value: [ + currentValue, + ], + }, + ]); + setTimeout(() => { + setSelectedDefId(null); + setCommandInput(""); + }, 200); + setOpen(false); + }} + > + {/* {filter.icon} */} + + {filter.label} + + + ))} + + ) : ( + + {definitions.map((definition) => ( + { + setSelectedDefId( + definition.id, + ); + setCommandInput(""); + commandInputRef.current?.focus(); + }} + > + + + {definition.label} + + + ))} + + )} + + + + + +
    + ); +}; diff --git a/frontend/packages/components/src/ui/form.tsx b/frontend/packages/components/src/ui/form.tsx index d5fc40a3fe..4d631972a6 100644 --- a/frontend/packages/components/src/ui/form.tsx +++ b/frontend/packages/components/src/ui/form.tsx @@ -159,6 +159,8 @@ const FormMessage = React.forwardRef< const msg = error?.root?.message || error?.message; const body = msg ? String(msg) : children; + console.log({ error, msg, body }); + if (!error) { return null; } diff --git a/frontend/packages/components/src/ui/popover.tsx b/frontend/packages/components/src/ui/popover.tsx index 52b1d89db1..b235499bae 100644 --- a/frontend/packages/components/src/ui/popover.tsx +++ b/frontend/packages/components/src/ui/popover.tsx @@ -28,4 +28,6 @@ const PopoverContent = React.forwardRef< )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent }; +const PopoverAnchor = PopoverPrimitive.Anchor; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/frontend/packages/components/src/ui/table.tsx b/frontend/packages/components/src/ui/table.tsx index 23125acf7f..d27ad12808 100644 --- a/frontend/packages/components/src/ui/table.tsx +++ b/frontend/packages/components/src/ui/table.tsx @@ -9,9 +9,11 @@ import { const Table = React.forwardRef< HTMLTableElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
    + React.HTMLAttributes & { + containerRef?: React.Ref; + } +>(({ className, containerRef, ...props }, ref) => ( +
    > virtualizerRef?: RefObject>; viewportRef?: RefObject; scrollerProps?: ComponentPropsWithoutRef<"div">; + children?: ReactNode; } // biome-ignore lint/suspicious/noExplicitAny: we don't care about the type of the row @@ -45,6 +47,7 @@ export function VirtualScrollArea>({ virtualizerRef, viewportRef, scrollerProps, + children, ...rowVirtualizerOptions }: VirtualScrollAreaProps) { const rowVirtualizer = useVirtualizer({ @@ -54,6 +57,8 @@ export function VirtualScrollArea>({ useImperativeHandle(virtualizerRef, () => rowVirtualizer, [rowVirtualizer]); + const totalSize = rowVirtualizer.getTotalSize(); + return ( >({ {...scrollerProps} className={cn("relative w-full", scrollerProps?.className)} style={{ - height: `${rowVirtualizer.getTotalSize()}px`, + height: totalSize === 0 ? "100%" : `${totalSize}px`, }} > + {children} {rowVirtualizer.getVirtualItems().map((virtualItem) => ( =10.0.0, @types/node@npm:^22.13.4, @types/node@npm:^22.13.9, @types/node@npm:^22.5.5": - version: 22.14.0 - resolution: "@types/node@npm:22.14.0" +"@types/node@npm:*, @types/node@npm:>=10.0.0, @types/node@npm:^22.5.5": + version: 22.13.5 + resolution: "@types/node@npm:22.13.5" dependencies: - undici-types: "npm:~6.21.0" - checksum: 10c0/9d79f3fa1af9c2c869514f419c4a4905b34c10e12915582fd1784868ac4e74c6d306cf5eb47ef889b6750ab85a31be96618227b86739c4a3e0b1c15063f384c6 + undici-types: "npm:~6.20.0" + checksum: 10c0/a2e7ed7bb0690e439004779baedeb05159c5cc41ef6d81c7a6ebea5303fde4033669e1c0e41ff7453b45fd2fea8dbd55fddfcd052950c7fcae3167c970bca725 languageName: node linkType: hard @@ -6298,6 +6358,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.13.4, @types/node@npm:^22.13.9": + version: 22.13.10 + resolution: "@types/node@npm:22.13.10" + dependencies: + undici-types: "npm:~6.20.0" + checksum: 10c0/a3865f9503d6f718002374f7b87efaadfae62faa499c1a33b12c527cfb9fd86f733e1a1b026b80c5a0e4a965701174bc3305595a7d36078aa1abcf09daa5dee9 + languageName: node + linkType: hard + "@types/prop-types@npm:*": version: 15.7.14 resolution: "@types/prop-types@npm:15.7.14" @@ -6454,7 +6523,7 @@ __metadata: languageName: node linkType: hard -"@uiw/codemirror-extensions-basic-setup@npm:4.23.10, @uiw/codemirror-extensions-basic-setup@npm:^4.23.0": +"@uiw/codemirror-extensions-basic-setup@npm:4.23.10": version: 4.23.10 resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.23.10" dependencies: @@ -6477,6 +6546,29 @@ __metadata: languageName: node linkType: hard +"@uiw/codemirror-extensions-basic-setup@npm:^4.23.0": + version: 4.23.8 + resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.23.8" + dependencies: + "@codemirror/autocomplete": "npm:^6.0.0" + "@codemirror/commands": "npm:^6.0.0" + "@codemirror/language": "npm:^6.0.0" + "@codemirror/lint": "npm:^6.0.0" + "@codemirror/search": "npm:^6.0.0" + "@codemirror/state": "npm:^6.0.0" + "@codemirror/view": "npm:^6.0.0" + peerDependencies: + "@codemirror/autocomplete": ">=6.0.0" + "@codemirror/commands": ">=6.0.0" + "@codemirror/language": ">=6.0.0" + "@codemirror/lint": ">=6.0.0" + "@codemirror/search": ">=6.0.0" + "@codemirror/state": ">=6.0.0" + "@codemirror/view": ">=6.0.0" + checksum: 10c0/167397afe54a8840b50225532419b3e0b2c12b6c380ef2862d5a0377df2fc9fe48a36b21133f2d48275465433c79a96a2cddd2a243cbd38d67ba14e57c4341fb + languageName: node + linkType: hard + "@uiw/codemirror-theme-github@npm:^4.23.0": version: 4.23.10 resolution: "@uiw/codemirror-theme-github@npm:4.23.10" @@ -6690,7 +6782,16 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.0.0, acorn@npm:^8.11.0, acorn@npm:^8.14.0, acorn@npm:^8.14.1, acorn@npm:^8.4.1, acorn@npm:^8.8.1, acorn@npm:^8.9.0": +"acorn@npm:^8.0.0, acorn@npm:^8.14.0, acorn@npm:^8.8.1, acorn@npm:^8.9.0": + version: 8.14.0 + resolution: "acorn@npm:8.14.0" + bin: + acorn: bin/acorn + checksum: 10c0/6d4ee461a7734b2f48836ee0fbb752903606e576cc100eb49340295129ca0b452f3ba91ddd4424a1d4406a98adfb2ebb6bd0ff4c49d7a0930c10e462719bbfd7 + languageName: node + linkType: hard + +"acorn@npm:^8.11.0, acorn@npm:^8.14.1, acorn@npm:^8.4.1": version: 8.14.1 resolution: "acorn@npm:8.14.1" bin: @@ -6699,9 +6800,9 @@ __metadata: languageName: node linkType: hard -"actor-core@portal:../../actor-core/packages/actor-core::locator=rivet%40workspace%3A.": +"actor-core@portal:../actor-core/packages/actor-core::locator=rivet%40workspace%3A.": version: 0.0.0-use.local - resolution: "actor-core@portal:../../actor-core/packages/actor-core::locator=rivet%40workspace%3A." + resolution: "actor-core@portal:../actor-core/packages/actor-core::locator=rivet%40workspace%3A." dependencies: cbor-x: "npm:^1.6.0" hono: "npm:^4.7.0" @@ -7707,7 +7808,7 @@ __metadata: languageName: node linkType: hard -"cmdk@npm:^1.0.0": +"cmdk@npm:^1.1.1": version: 1.1.1 resolution: "cmdk@npm:1.1.1" dependencies: @@ -8101,13 +8202,6 @@ __metadata: languageName: node linkType: hard -"date-fns-jalali@npm:^4.1.0-0": - version: 4.1.0-0 - resolution: "date-fns-jalali@npm:4.1.0-0" - checksum: 10c0/f9ad98d9f7e8e5abe0d070dc806b0c8baded2b1208626c42e92cbd2605b5171f5714d6b79b20cc2666267d821699244c9d0b5e93274106cf57d6232da77596ed - languageName: node - linkType: hard - "date-fns@npm:^3.6.0": version: 3.6.0 resolution: "date-fns@npm:3.6.0" @@ -10470,13 +10564,20 @@ __metadata: languageName: node linkType: hard -"hono@npm:^4.6.16, hono@npm:^4.6.17, hono@npm:^4.7.0, hono@npm:^4.7.2": +"hono@npm:^4.6.16": version: 4.7.6 resolution: "hono@npm:4.7.6" checksum: 10c0/6fc1d75b691f8a56c1ef60898b7017ea77d3cad6bedd103aa0f76a24b17b75ac28486c19adcb3d1520a0a7cc136f1b19fb82bdeb88ddc19dff5508ef131c1dff languageName: node linkType: hard +"hono@npm:^4.6.17, hono@npm:^4.7.0, hono@npm:^4.7.2": + version: 4.7.4 + resolution: "hono@npm:4.7.4" + checksum: 10c0/b189c5b75527a0b2c6669a8b2d46a2c522c9f429c08da801373b13d25b476f20e639f667a9dac2fe42074e9b6f851d542d28026f158c5728b20034b9b0e3e738 + languageName: node + linkType: hard + "html-url-attributes@npm:^3.0.0": version: 3.0.1 resolution: "html-url-attributes@npm:3.0.1" @@ -12733,6 +12834,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^5.1.5": + version: 5.1.5 + resolution: "nanoid@npm:5.1.5" + bin: + nanoid: bin/nanoid.js + checksum: 10c0/e6004f1ad6c7123eeb037062c4441d44982037dc043aabb162457ef6986e99964ba98c63c975f96c547403beb0bf95bc537bd7bf9a09baf381656acdc2975c3c + languageName: node + linkType: hard + "nanospinner@npm:^1.2.2": version: 1.2.2 resolution: "nanospinner@npm:1.2.2" @@ -13863,16 +13973,13 @@ __metadata: languageName: node linkType: hard -"react-day-picker@npm:^9.0.9": - version: 9.6.5 - resolution: "react-day-picker@npm:9.6.5" - dependencies: - "@date-fns/tz": "npm:^1.2.0" - date-fns: "npm:^4.1.0" - date-fns-jalali: "npm:^4.1.0-0" +"react-day-picker@npm:8.10.1": + version: 8.10.1 + resolution: "react-day-picker@npm:8.10.1" peerDependencies: - react: ">=16.8.0" - checksum: 10c0/a5669ef02b5383e375371353ee012e22ed22d1cbee18f2e4d18fcebeb177a10fa850396c4773c2931cbde45b7ad10df2300550d315546db0988a2e1d4b4b6492 + date-fns: ^2.28.0 || ^3.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 10c0/a0ff28c4b61b3882e6a825b19e5679e2fdf3256cf1be8eb0a0c028949815c1ae5a6561474c2c19d231c010c8e0e0b654d3a322610881e0655abca05a2e03d9df languageName: node linkType: hard @@ -16464,10 +16571,10 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~6.21.0": - version: 6.21.0 - resolution: "undici-types@npm:6.21.0" - checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04 +"undici-types@npm:~6.20.0": + version: 6.20.0 + resolution: "undici-types@npm:6.20.0" + checksum: 10c0/68e659a98898d6a836a9a59e6adf14a5d799707f5ea629433e025ac90d239f75e408e2e5ff086afc3cace26f8b26ee52155293564593fbb4a2f666af57fc59bf languageName: node linkType: hard