+
+
+ {data.length}{" "}
+ {data.length === 1 ? "connection" : "connections"}
+
-
-
+ {data.length === 0 ? (
+
+ ) : (
+
+
+
+
+ Connection ID
+ Type
+ Hibernatable
+ Params
+ State
+
+
+
+ {data.map((connection) => {
+ const details = (connection.details ??
+ {}) as ConnectionDetails;
+ const type =
+ details.connectionType ??
+ details.type ??
+ "—";
+ return (
+
+
+
+ {connection.id}
+
+
+
+ {type}
+
+
+ {details.isHibernatable
+ ? "Yes"
+ : "No"}
+
+
+
+
+
+ {details.stateEnabled === false ? (
+
+ Disabled
+
+ ) : (
+
+ )}
+
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
+
+function ConnectionValue({ name, value }: { name: string; value: unknown }) {
+ if (value === undefined || value === null) {
+ return
— ;
+ }
+ return (
+
+ );
+}
+
+function EmptyConnections() {
+ return (
+
+
+
-
+
No active connections
+
+ Clients connected to this actor, their params, and connection
+ state will appear here.
+
+
);
}
diff --git a/frontend/src/components/actors/actor-database.tsx b/frontend/src/components/actors/actor-database.tsx
index d57899962a..2e9d0594a4 100644
--- a/frontend/src/components/actors/actor-database.tsx
+++ b/frontend/src/components/actors/actor-database.tsx
@@ -20,12 +20,14 @@ import {
Textarea,
WithTooltip,
} from "@/components";
-import { ShimmerLine } from "../shimmer-line";
import { formatValue } from "@/lib/format-value";
+import { ShimmerLine } from "../shimmer-line";
import {
Select,
SelectContent,
+ SelectGroup,
SelectItem,
+ SelectLabel,
SelectTrigger,
SelectValue,
} from "../ui/select";
@@ -105,18 +107,33 @@ export function ActorDatabase({ actorId }: ActorDatabaseProps) {
);
}
+/**
+ * Tables created by Rivet itself (actor metadata, workflow KV, migration
+ * bookkeeping). They are still browsable but should not be the first thing a
+ * user sees when opening the Database tab.
+ */
+function isInternalTable(name: string) {
+ return name.startsWith("_rivet");
+}
+
+function defaultTableName(names: string[] | undefined) {
+ if (!names?.length) return undefined;
+ return names.find((name) => !isInternalTable(name)) ?? names[0];
+}
+
function ActorDatabaseBrowser({ actorId }: ActorDatabaseProps) {
const actorInspector = useActorInspector();
const queryClient = useQueryClient();
const { data, refetch } = useQuery(
actorInspector.actorDatabaseQueryOptions(actorId),
);
- const [table, setTable] = useState
(
- () => data?.tables?.[0]?.table.name,
+ const [table, setTable] = useState(() =>
+ defaultTableName(data?.tables?.map((t) => t.table.name)),
);
const [page, setPage] = useState(0);
- const selectedTable = table || data?.tables?.[0]?.table.name;
+ const selectedTable =
+ table || defaultTableName(data?.tables?.map((t) => t.table.name));
const {
data: rows,
@@ -811,6 +828,10 @@ function TableSelect({
const { data: tables } = useQuery(
actorInspector.actorDatabaseTablesQueryOptions(actorId),
);
+ const userTables =
+ tables?.filter((table) => !isInternalTable(table.name)) ?? [];
+ const internalTables =
+ tables?.filter((table) => isInternalTable(table.name)) ?? [];
return (
@@ -826,7 +847,7 @@ function TableSelect({
) : null}
- {tables?.map((table) => (
+ {userTables.map((table) => (
@@ -834,6 +855,24 @@ function TableSelect({
))}
+ {internalTables.length > 0 ? (
+
+
+ Internal
+
+ {internalTables.map((table) => (
+
+
+
+ {table.name}
+
+
+ ))}
+
+ ) : null}
);
diff --git a/frontend/src/components/actors/actor-details-iframe.tsx b/frontend/src/components/actors/actor-details-iframe.tsx
index 3f497f2b31..40a25e8d7f 100644
--- a/frontend/src/components/actors/actor-details-iframe.tsx
+++ b/frontend/src/components/actors/actor-details-iframe.tsx
@@ -22,6 +22,7 @@ import { useTimeout } from "../hooks/use-timeout";
import { ActorDetailsLegacy } from "./actor-details-legacy";
import {
CLOUD_TABS,
+ orderInspectorTabs,
SKELETON_INSPECTOR_TABS,
useHasManagedPool,
useShowTabLabels,
@@ -575,7 +576,7 @@ function ActorDetailsIframePath({
return;
}
if (msg.type === "tabs-available") {
- setInspectorTabs(msg.tabs);
+ setInspectorTabs(orderInspectorTabs(msg.tabs));
return;
}
if (msg.type === "token-refresh-needed") {
diff --git a/frontend/src/components/actors/actor-details-shared.tsx b/frontend/src/components/actors/actor-details-shared.tsx
index c0c4885513..44392c26a6 100644
--- a/frontend/src/components/actors/actor-details-shared.tsx
+++ b/frontend/src/components/actors/actor-details-shared.tsx
@@ -145,10 +145,40 @@ export const CLOUD_TABS: readonly CloudTabSpec[] = [
*/
export const SKELETON_INSPECTOR_TABS: readonly InspectorTabDescriptor[] = [
{ id: "workflow", label: "Workflow", icon: "workflow" },
- { id: "database", label: "Database", icon: "database" },
{ id: "state", label: "State", icon: "state" },
+ { id: "database", label: "Database", icon: "database" },
{ id: "queue", label: "Queue", icon: "queue" },
{ id: "schedules", label: "Schedules", icon: "calendar" },
{ id: "connections", label: "Connections", icon: "plug" },
{ id: "console", label: "Console", icon: "terminal" },
];
+
+const KNOWN_INSPECTOR_TAB_ORDER = new Map(
+ SKELETON_INSPECTOR_TABS.map((tab, index) => [tab.id, index]),
+);
+
+/**
+ * Orders tabs advertised by an actor's inspector bundle to match the
+ * dashboard's canonical order. The bundle is shipped with the runner, so older
+ * runners may advertise tabs in a different order (e.g. Database before
+ * State); the dashboard owns the strip and picks `displayedTabs[0]` as the
+ * default, so it also owns the order. Custom tabs the dashboard doesn't know
+ * keep their advertised order and follow the built-in ones.
+ */
+export function orderInspectorTabs(
+ tabs: readonly T[],
+): T[] {
+ return tabs
+ .map((tab, index) => ({ tab, index }))
+ .sort((a, b) => {
+ const aKnown = KNOWN_INSPECTOR_TAB_ORDER.get(a.tab.id);
+ const bKnown = KNOWN_INSPECTOR_TAB_ORDER.get(b.tab.id);
+ if (aKnown !== undefined && bKnown !== undefined) {
+ return aKnown - bKnown;
+ }
+ if (aKnown !== undefined) return -1;
+ if (bKnown !== undefined) return 1;
+ return a.index - b.index;
+ })
+ .map(({ tab }) => tab);
+}
diff --git a/frontend/src/components/actors/actor-details-skeleton.tsx b/frontend/src/components/actors/actor-details-skeleton.tsx
index babfb3c3ac..cd728d5a68 100644
--- a/frontend/src/components/actors/actor-details-skeleton.tsx
+++ b/frontend/src/components/actors/actor-details-skeleton.tsx
@@ -13,8 +13,8 @@ const PLACEHOLDER_TABS: ReadonlyArray<{
icon: string;
}> = [
{ id: "workflow", label: "Workflow", icon: "workflow" },
- { id: "database", label: "Database", icon: "database" },
{ id: "state", label: "State", icon: "state" },
+ { id: "database", label: "Database", icon: "database" },
{ id: "queue", label: "Queue", icon: "queue" },
{ id: "connections", label: "Connections", icon: "plug" },
{ id: "console", label: "Console", icon: "terminal" },
diff --git a/frontend/src/components/actors/actor-general.tsx b/frontend/src/components/actors/actor-general.tsx
index f9c7b61872..d82e628e9b 100644
--- a/frontend/src/components/actors/actor-general.tsx
+++ b/frontend/src/components/actors/actor-general.tsx
@@ -18,7 +18,6 @@ import {
ActorSleepButton,
ActorStopButton,
} from "./actor-stop-button";
-import { ActorObjectInspector } from "./console/actor-inspector";
import { useDataProvider } from "./data-provider";
import type { ActorId } from "./queries";
@@ -91,19 +90,19 @@ export function ActorGeneral({ actorId }: ActorGeneralProps) {
actorId={actorId}
/>
- Keys
-
-
-
-
+ Key
+
+ {keys ? (
+
+ {keys}
+
+ ) : (
+ -
+ )}
{runner ? (
<>
@@ -129,7 +128,7 @@ export function ActorGeneral({ actorId }: ActorGeneralProps) {
) : null}
{connectableTs ? (
<>
- Connectable
+ Connectable since
@@ -137,7 +136,7 @@ export function ActorGeneral({ actorId }: ActorGeneralProps) {
) : null}
{sleepTs ? (
<>
- Sleeping
+ Sleeping since
diff --git a/frontend/src/components/actors/actor-queue.tsx b/frontend/src/components/actors/actor-queue.tsx
index 4e8b4b7ac4..af021889fe 100644
--- a/frontend/src/components/actors/actor-queue.tsx
+++ b/frontend/src/components/actors/actor-queue.tsx
@@ -1,4 +1,4 @@
-import { faSpinnerThird, Icon } from "@rivet-gg/icons";
+import { faInbox, faSpinnerThird, Icon } from "@rivet-gg/icons";
import { useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
import { LiveBadge, ScrollArea } from "@/components";
@@ -53,8 +53,18 @@ export function ActorQueue({ actorId }: { actorId: ActorId }) {
{status.messages.length === 0 ? (
-
- Queue is empty.
+
+
+
+
+
Queue is empty
+
+ Messages waiting to be processed by this actor will
+ appear here.
+
) : (
status.messages.map((message) => (
diff --git a/frontend/src/components/actors/actors-list.tsx b/frontend/src/components/actors/actors-list.tsx
index fef9023381..64d8c7bf5d 100644
--- a/frontend/src/components/actors/actors-list.tsx
+++ b/frontend/src/components/actors/actors-list.tsx
@@ -517,11 +517,13 @@ function EmptyState({ count }: { count: number }) {
>
)
- ) : (
+ ) : count > RECORDS_PER_PAGE ? (
+ // Only worth saying once the user has actually scrolled through
+ // more than one page; for short lists it is just noise.
{copy.noMoreActors}
- )}
+ ) : null}
);
}
diff --git a/frontend/src/components/actors/inspector-tab-registry.tsx b/frontend/src/components/actors/inspector-tab-registry.tsx
index 1e560710ab..fd1804bea5 100644
--- a/frontend/src/components/actors/inspector-tab-registry.tsx
+++ b/frontend/src/components/actors/inspector-tab-registry.tsx
@@ -75,25 +75,29 @@ interface TabRegistration {
render: (actorId: ActorId) => ReactNode;
}
-// Tab list — preserved order matches the dashboard tab strip. Adding a new
-// inspector tab here automatically advertises it to the dashboard (in the
-// iframe path) and renders it inline (in the legacy path).
+// Tab list — preserved order matches the dashboard tab strip, and the first
+// available tab is the default the dashboard opens. State sits before
+// Database on purpose: every actor has a SQLite database (so Database would
+// otherwise always win), but the state object is what the actor's code
+// actually defines. Adding a new inspector tab here automatically advertises
+// it to the dashboard (in the iframe path) and renders it inline (in the
+// legacy path).
export const INSPECTOR_TAB_REGISTRATIONS: readonly TabRegistration[] = [
{
descriptor: { id: "workflow", label: "Workflow", icon: "workflow" },
available: (caps) => caps.isWorkflowEnabled,
render: (actorId) =>
,
},
- {
- descriptor: { id: "database", label: "Database", icon: "database" },
- available: (caps) => caps.isDatabaseEnabled,
- render: (actorId) =>
,
- },
{
descriptor: { id: "state", label: "State", icon: "state" },
available: (caps) => caps.isStateEnabled,
render: (actorId) =>
,
},
+ {
+ descriptor: { id: "database", label: "Database", icon: "database" },
+ available: (caps) => caps.isDatabaseEnabled,
+ render: (actorId) =>
,
+ },
{
descriptor: { id: "queue", label: "Queue", icon: "queue" },
available: (caps) => caps.isQueueSupported,
diff --git a/frontend/src/components/actors/no-providers-alert.tsx b/frontend/src/components/actors/no-providers-alert.tsx
index 01d6402238..9dd8c80d36 100644
--- a/frontend/src/components/actors/no-providers-alert.tsx
+++ b/frontend/src/components/actors/no-providers-alert.tsx
@@ -1,4 +1,4 @@
-import { faBook, faExclamationTriangle, faPlus, Icon } from "@rivet-gg/icons";
+import { faBook, faPlug, faPlus, Icon } from "@rivet-gg/icons";
import { Link } from "@tanstack/react-router";
import { ProviderDropdown } from "@/app/provider-dropdown";
import { docsLinks } from "@/content/data";
@@ -16,14 +16,14 @@ export function NoProvidersAlert({
- No Providers Connected
+ No providers connected
- You can't run any Actors yet. Use provider of your choice to
- connect and start deploying and running Rivet Actors.
+ Actors need somewhere to run. Add a provider to connect a
+ cloud of your choice and start running Rivet Actors.
@@ -65,7 +65,7 @@ export function NoProvidersAlert({
size="sm"
className="w-full"
>
- Connect Provider
+ Add Provider
)}
diff --git a/frontend/src/components/live-badge.tsx b/frontend/src/components/live-badge.tsx
index 45e1c1f324..6b201ad084 100644
--- a/frontend/src/components/live-badge.tsx
+++ b/frontend/src/components/live-badge.tsx
@@ -11,7 +11,8 @@ export function LiveBadge({ className }: LiveBadgeProps) {
className={cn(className, "flex justify-center items-center")}
variant="outline"
>
-
+ {/* Accent, not destructive: red reads as an error elsewhere in the app. */}
+
Live
);
diff --git a/frontend/src/routes/_context.tsx b/frontend/src/routes/_context.tsx
index 2bab960caa..15feffba23 100644
--- a/frontend/src/routes/_context.tsx
+++ b/frontend/src/routes/_context.tsx
@@ -44,7 +44,16 @@ const searchSchema = z
export const Route = createFileRoute("/_context")({
component: RouteComponent,
validateSearch: (search) => {
- const validated = searchSchema.parse(search);
+ // Hand-typed and shared links often carry `?n=counter` instead of the
+ // serialized array form. Accept both so a bare string never surfaces
+ // as a raw validation error page. Normalized before parsing because
+ // the schema is an intersection, and a per-field transform would
+ // conflict with the untouched value from the `z.record` half.
+ const normalized =
+ typeof search.n === "string"
+ ? { ...search, n: [search.n] }
+ : search;
+ const validated = searchSchema.parse(normalized);
// `pool` is scoped to the pages that actually use it: the Logs route
// re-declares it in its own validateSearch, and the compute settings tab
// needs it while open. Drop it everywhere else so the selected pool does