Skip to content

Commit

Permalink
Cloud UI: Fix 401 error on the Project page due to stale JWT (#5080)
Browse files Browse the repository at this point in the history
* Make sure to refetch stale JWTs

* Make sure we don't keep a stale JWT in the cache

* Don't show a full-screen error page for runtime 401s on the project page

* Destructure store to avoid remounting Query Observer on navigation

* Use correct query key for runtime information

* Fix breadcrumb flicker

* Only increment `receivedAt` if the JWT has actually changed

* Backwards-compatibility
  • Loading branch information
ericpgreen2 committed Jun 17, 2024
1 parent c97a772 commit 37de9fb
Show file tree
Hide file tree
Showing 8 changed files with 87 additions and 81 deletions.
40 changes: 11 additions & 29 deletions web-admin/src/features/dashboards/listing/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { V1GetProjectResponse } from "@rilldata/web-admin/client";
import {
createAdminServiceGetProject,
V1DeploymentStatus,
Expand All @@ -15,11 +14,11 @@ import type { V1Resource } from "@rilldata/web-common/runtime-client";
import {
createRuntimeServiceListResources,
getRuntimeServiceListResourcesQueryKey,
runtimeServiceListResources,
V1ReconcileStatus,
} from "@rilldata/web-common/runtime-client";
import { invalidateMetricsViewData } from "@rilldata/web-common/runtime-client/invalidation";
import type { CreateQueryResult, QueryClient } from "@tanstack/svelte-query";
import Axios from "axios";
import { derived } from "svelte/store";

export interface DashboardListItem {
Expand All @@ -29,36 +28,19 @@ export interface DashboardListItem {
isValid: boolean;
}

// TODO: use the creator pattern to get rid of the raw call to http endpoint
export async function getDashboardsForProject(
projectData: V1GetProjectResponse,
export async function listDashboards(
queryClient: QueryClient,
instanceId: string,
): Promise<V1Resource[]> {
// There may not be a prodDeployment if the project was hibernated
if (!projectData.prodDeployment) {
return [];
}

// Hack: in development, the runtime host is actually on port 8081
const runtimeHost = projectData.prodDeployment.runtimeHost.replace(
"localhost:9091",
"localhost:8081",
);

const axios = Axios.create({
baseURL: runtimeHost,
headers: {
Authorization: `Bearer ${projectData.jwt}`,
},
});

// TODO: use resource API
const catalogEntriesResponse = await axios.get(
`/v1/instances/${projectData.prodDeployment.runtimeInstanceId}/resources?kind=${ResourceKind.MetricsView}`,
);
// Fetch all resources
const queryKey = getRuntimeServiceListResourcesQueryKey(instanceId);
const queryFn = () => runtimeServiceListResources(instanceId);
const resp = await queryClient.fetchQuery(queryKey, queryFn);

const catalogEntries = catalogEntriesResponse.data?.resources as V1Resource[];
// Filter for metricsViews client-side (to reduce calls to ListResources)
const metricsViews = resp.resources.filter((res) => !!res.metricsView);

return catalogEntries.filter((e) => !!e.metricsView);
return metricsViews;
}

export function useDashboardsLastUpdated(
Expand Down
29 changes: 19 additions & 10 deletions web-admin/src/features/errors/error-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,27 @@ export function createGlobalErrorCallback(queryClient: QueryClient) {

// Special handling for some errors on the Project page
const onProjectPage = isProjectPage(get(page));
if (onProjectPage && error.response?.status === 400) {
// If "repository not found", ignore the error and show the page
if (
(error.response.data as RpcStatus).message === "repository not found"
) {
return;
if (onProjectPage) {
if (error.response?.status === 400) {
// If "repository not found", ignore the error and show the page
if (
(error.response.data as RpcStatus).message === "repository not found"
) {
return;
}

// This error is the error:`driver.ErrNotFound` thrown while looking up an instance in the runtime.
if (
(error.response.data as RpcStatus).message === "driver: not found"
) {
const [, org, proj] = get(page).url.pathname.split("/");
void queryClient.resetQueries(getProjectRuntimeQueryKey(org, proj));
return;
}
}

// This error is the error:`driver.ErrNotFound` thrown while looking up an instance in the runtime.
if ((error.response.data as RpcStatus).message === "driver: not found") {
const [, org, proj] = get(page).url.pathname.split("/");
void queryClient.resetQueries(getProjectRuntimeQueryKey(org, proj));
// If the runtime throws a 401, it's likely due to a stale JWT that will soon be refreshed
if (isRuntimeQuery(query) && error.response?.status === 401) {
return;
}
}
Expand Down
7 changes: 5 additions & 2 deletions web-admin/src/features/projects/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ export function useProjectRuntime(orgName: string, projName: string) {
return createAdminServiceGetProject(orgName, projName, undefined, {
query: {
queryKey: getProjectRuntimeQueryKey(orgName, projName),
// Proactively refetch the JWT before it expires
refetchInterval: RUNTIME_ACCESS_TOKEN_DEFAULT_TTL / 2,
cacheTime: Math.min(RUNTIME_ACCESS_TOKEN_DEFAULT_TTL, 1000 * 60 * 5), // Make sure we don't keep a stale JWT in the cache
refetchInterval: RUNTIME_ACCESS_TOKEN_DEFAULT_TTL / 2, // Proactively refetch the JWT before it expires
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
select: (data) => {
// There may not be a prodDeployment if the project was hibernated
if (!data.prodDeployment) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
V1DeploymentStatus,
} from "@rilldata/web-admin/client";
import {
getDashboardsForProject,
listDashboards,
useDashboardsStatus,
} from "@rilldata/web-admin/features/dashboards/listing/selectors";
import { invalidateDashboardsQueries } from "@rilldata/web-admin/features/projects/invalidations";
Expand Down Expand Up @@ -60,10 +60,8 @@
}
async function getDashboardsAndInvalidate() {
const dashboardListItems = await getDashboardsForProject($proj.data);
const dashboardNames = dashboardListItems.map(
(listing) => listing.meta.name.name,
);
const dashboards = await listDashboards(queryClient, instanceId);
const dashboardNames = dashboards.map((d) => d.meta.name.name);
return invalidateDashboardsQueries(queryClient, dashboardNames);
}
Expand Down
25 changes: 13 additions & 12 deletions web-admin/src/routes/[organization]/[project]/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,35 @@
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { createAdminServiceGetCurrentUser } from "@rilldata/web-admin/client";
import { isProjectPage } from "@rilldata/web-admin/features/navigation/nav-utils";
import ProjectDashboardsListener from "@rilldata/web-admin/features/projects/ProjectDashboardsListener.svelte";
import { metricsService } from "@rilldata/web-common/metrics/initMetrics";
import RuntimeProvider from "@rilldata/web-common/runtime-client/RuntimeProvider.svelte";
import { isProjectPage } from "@rilldata/web-admin/features/navigation/nav-utils";
import ProjectTabs from "../../../features/projects/ProjectTabs.svelte";
import { useProjectRuntime } from "../../../features/projects/selectors";
import { viewAsUserStore } from "../../../features/view-as-user/viewAsUserStore";
$: projRuntime = useProjectRuntime(
$page.params.organization,
$page.params.project,
);
$: ({ organization, project } = $page.params);
$: projRuntime = useProjectRuntime(organization, project);
$: ({ data: runtime } = $projRuntime);
const user = createAdminServiceGetCurrentUser();
$: isRuntimeHibernating = $projRuntime.isSuccess && !$projRuntime.data;
$: if (isRuntimeHibernating) {
// Redirect any nested routes (notably dashboards) to the project page
goto(`/${$page.params.organization}/${$page.params.project}`);
goto(`/${organization}/${project}`);
}
$: onProjectPage = isProjectPage($page);
$: if ($page.params.project && $user.data?.user?.id) {
$: if (project && $user.data?.user?.id) {
metricsService.loadCloudFields({
isDev: window.location.host.startsWith("localhost"),
projectId: $page.params.project,
organizationId: $page.params.organization,
projectId: project,
organizationId: organization,
userId: $user.data?.user?.id,
});
}
Expand All @@ -44,9 +45,9 @@
<slot />
{:else}
<RuntimeProvider
host={$projRuntime.data?.host}
instanceId={$projRuntime.data?.instanceId}
jwt={$projRuntime.data?.jwt}
host={runtime?.host}
instanceId={runtime?.instanceId}
jwt={runtime?.jwt}
>
<ProjectDashboardsListener>
<!-- We make sure to put the project tabs within the `RuntimeProvider` so we can add decoration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
import { page } from "$app/stores";
import {
createAdminServiceGetCurrentUser,
createAdminServiceGetProject,
V1DeploymentStatus,
} from "@rilldata/web-admin/client";
import DashboardBookmarksStateProvider from "@rilldata/web-admin/features/dashboards/DashboardBookmarksStateProvider.svelte";
import { getDashboardsForProject } from "@rilldata/web-admin/features/dashboards/listing/selectors";
import { listDashboards } from "@rilldata/web-admin/features/dashboards/listing/selectors";
import { invalidateDashboardsQueries } from "@rilldata/web-admin/features/projects/invalidations";
import ProjectErrored from "@rilldata/web-admin/features/projects/ProjectErrored.svelte";
import { useProjectDeploymentStatus } from "@rilldata/web-admin/features/projects/status/selectors";
Expand All @@ -33,8 +32,6 @@
const user = createAdminServiceGetCurrentUser();
$: project = createAdminServiceGetProject(orgName, projectName);
$: projectDeploymentStatus = useProjectDeploymentStatus(orgName, projectName); // polls
$: isProjectPending =
$projectDeploymentStatus.data ===
Expand Down Expand Up @@ -66,10 +63,8 @@
}
async function getDashboardsAndInvalidate() {
const dashboardListings = await getDashboardsForProject($project.data);
const dashboardNames = dashboardListings.map(
(listing) => listing.meta.name.name,
);
const dashboards = await listDashboards(queryClient, instanceId);
const dashboardNames = dashboards.map((d) => d.meta.name.name);
return invalidateDashboardsQueries(queryClient, dashboardNames);
}
Expand Down
13 changes: 2 additions & 11 deletions web-common/src/runtime-client/RuntimeProvider.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,9 @@
export let instanceId: string;
export let jwt: string | undefined = undefined;
$: runtime.set({
host: host,
instanceId: instanceId,
jwt: jwt
? {
token: jwt,
receivedAt: Date.now(),
}
: undefined,
});
$: runtime.setRuntime(host, instanceId, jwt);
</script>

{#if $runtime.host !== undefined && $runtime.instanceId}
{#if $runtime.host && $runtime.instanceId}
<slot />
{/if}
35 changes: 31 additions & 4 deletions web-common/src/runtime-client/runtime-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,34 @@ export interface Runtime {
jwt?: JWT;
}

export const runtime = writable<Runtime>({
host: "",
instanceId: "",
});
const createRuntimeStore = () => {
const { subscribe, set, update } = writable<Runtime>({
host: "",
instanceId: "",
});

return {
subscribe,
update,
set, // backwards-compatibility for web-local (where there's no JWT)
setRuntime: (host: string, instanceId: string, jwt?: string) => {
update((current) => {
// Only update the store (particularly, the JWT `receivedAt`) if the values have changed
if (
host !== current.host ||
instanceId !== current.instanceId ||
jwt !== current.jwt?.token
) {
return {
host,
instanceId,
jwt: jwt ? { token: jwt, receivedAt: Date.now() } : undefined,
};
}
return current;
});
},
};
};

export const runtime = createRuntimeStore();

1 comment on commit 37de9fb

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.