Skip to content

Commit 8ba9feb

Browse files
committed
[dashboard] Initial infra rollout page, incl. list running workspaces
1 parent 6a6052f commit 8ba9feb

File tree

4 files changed

+194
-0
lines changed

4 files changed

+194
-0
lines changed

components/dashboard/src/app/AppRoutes.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const ConfigurationDetailPage = React.lazy(
7979
);
8080

8181
const PrebuildListPage = React.lazy(() => import(/* webpackPrefetch: true */ "../prebuilds/list/PrebuildListPage"));
82+
const AdminPage = React.lazy(() => import(/* webpackPrefetch: true */ "../org-admin/AdminPage"));
8283

8384
export const AppRoutes = () => {
8485
const hash = getURLHash();
@@ -205,6 +206,7 @@ export const AppRoutes = () => {
205206
{/* TODO: migrate other org settings pages underneath /settings prefix so we can utilize nested routes */}
206207
<Route exact path="/billing" component={TeamUsageBasedBilling} />
207208
<Route exact path="/sso" component={SSO} />
209+
<Route exact path="/org-admin" component={AdminPage} />
208210

209211
<Route exact path={`/prebuilds`} component={PrebuildListPage} />
210212
<Route path="/prebuilds/:prebuildId" component={PrebuildDetailPage} />

components/dashboard/src/menu/OrganizationSelector.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ export default function OrganizationSelector() {
9898
}
9999
// Show billing if user is an owner of current org
100100
if (isOwner) {
101+
// Add Admin link for owners
102+
linkEntries.push({
103+
title: "Admin",
104+
customContent: <LinkEntry>Admin</LinkEntry>,
105+
active: false,
106+
separator: false,
107+
link: "/org-admin",
108+
});
109+
101110
if (billingMode?.mode === "usage-based") {
102111
linkEntries.push({
103112
title: "Billing",
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Copyright (c) 2025 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import React, { useEffect } from "react";
8+
import { useHistory } from "react-router-dom";
9+
import { useUserLoader } from "../hooks/use-user-loader";
10+
import { useCurrentOrg } from "../data/organizations/orgs-query";
11+
import { useIsOwner } from "../data/organizations/members-query";
12+
import Header from "../components/Header";
13+
import { SpinnerLoader } from "../components/Loader";
14+
import { RunningWorkspacesCard } from "./RunningWorkspacesCard";
15+
16+
const AdminPage: React.FC = () => {
17+
const history = useHistory();
18+
const { loading: userLoading } = useUserLoader();
19+
const { data: currentOrg, isLoading: orgLoading } = useCurrentOrg();
20+
const isOwner = useIsOwner();
21+
22+
useEffect(() => {
23+
if (userLoading || orgLoading) {
24+
return;
25+
}
26+
if (!isOwner) {
27+
history.replace("/workspaces");
28+
}
29+
}, [isOwner, userLoading, orgLoading, history, currentOrg?.id]);
30+
31+
return (
32+
<div className="flex flex-col w-full">
33+
<Header
34+
title="Organization Administration"
35+
subtitle="Manage your organization's infrastructure and settings."
36+
/>
37+
<div className="app-container py-6">
38+
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-4">Infrastructure Rollout</h2>
39+
40+
{userLoading ||
41+
orgLoading ||
42+
(!isOwner && (
43+
<div className="flex items-center justify-center w-full p-8">
44+
<SpinnerLoader />
45+
</div>
46+
))}
47+
48+
{!orgLoading && !currentOrg && (
49+
<div className="text-red-500 p-4 bg-red-100 dark:bg-red-900 border border-red-500 rounded-md">
50+
Could not load organization details. Please ensure you are part of an organization.
51+
</div>
52+
)}
53+
54+
{currentOrg && <RunningWorkspacesCard />}
55+
56+
{/* Other admin cards/sections will go here in the future */}
57+
</div>
58+
</div>
59+
);
60+
};
61+
62+
export default AdminPage;
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Copyright (c) Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { FC, useEffect, useMemo } from "react";
8+
import dayjs from "dayjs";
9+
import { WorkspaceSession, WorkspacePhase_Phase } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
10+
import { useWorkspaceSessions } from "../data/insights/list-workspace-sessions-query";
11+
import { Item, ItemField, ItemsList } from "../components/ItemsList";
12+
import Alert from "../components/Alert";
13+
import Spinner from "../icons/Spinner.svg";
14+
import { toRemoteURL } from "../projects/render-utils";
15+
import { displayTime } from "../usage/UsageEntry";
16+
import { Timestamp } from "@bufbuild/protobuf";
17+
import { WorkspaceStatusIndicator } from "../workspaces/WorkspaceStatusIndicator";
18+
19+
interface RunningWorkspacesCardProps {}
20+
21+
const isWorkspaceNotStopped = (session: WorkspaceSession): boolean => {
22+
return session.workspace?.status?.phase?.name !== WorkspacePhase_Phase.STOPPED;
23+
};
24+
25+
export const RunningWorkspacesCard: FC<RunningWorkspacesCardProps> = () => {
26+
const lookbackHours = 48;
27+
28+
const { data, fetchNextPage, hasNextPage, isLoading, isError, error, isFetchingNextPage } = useWorkspaceSessions({
29+
from: Timestamp.fromDate(dayjs().subtract(lookbackHours, "hours").startOf("day").toDate()),
30+
});
31+
32+
useEffect(() => {
33+
if (hasNextPage && !isFetchingNextPage) {
34+
fetchNextPage();
35+
}
36+
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
37+
38+
const runningWorkspaces = useMemo(() => {
39+
if (!data?.pages) {
40+
return [];
41+
}
42+
const allSessions = data.pages.flatMap((page) => page);
43+
return allSessions.filter(isWorkspaceNotStopped);
44+
}, [data]);
45+
46+
if (isLoading) {
47+
return (
48+
<div className="flex items-center justify-center w-full space-x-2 text-gray-400 text-sm p-8">
49+
<img alt="Loading Spinner" className="h-4 w-4 animate-spin" src={Spinner} />
50+
<span>Loading running workspaces...</span>
51+
</div>
52+
);
53+
}
54+
55+
if (isError && error) {
56+
return (
57+
<Alert type="error" className="m-4">
58+
<p>Error loading running workspaces:</p>
59+
<pre>{error instanceof Error ? error.message : String(error)}</pre>
60+
</Alert>
61+
);
62+
}
63+
64+
return (
65+
<div className="bg-white dark:bg-gray-800 shadow-md rounded-lg p-4 mt-6">
66+
<h3 className="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-3">
67+
Currently Running Workspaces ({runningWorkspaces.length})
68+
</h3>
69+
{runningWorkspaces.length === 0 ? (
70+
<p className="text-gray-500 dark:text-gray-400">No workspaces are currently running.</p>
71+
) : (
72+
<ItemsList className="text-gray-400 dark:text-gray-500">
73+
<Item header={true} className="grid grid-cols-5 gap-x-3 bg-pk-surface-secondary dark:bg-gray-700">
74+
<ItemField className="col-span-1 my-auto font-semibold">Status</ItemField>
75+
<ItemField className="col-span-1 my-auto font-semibold">Workspace ID</ItemField>
76+
<ItemField className="col-span-1 my-auto font-semibold">User</ItemField>
77+
<ItemField className="col-span-1 my-auto font-semibold">Project</ItemField>
78+
<ItemField className="col-span-1 my-auto font-semibold">Started</ItemField>
79+
</Item>
80+
{runningWorkspaces.map((session) => {
81+
const workspace = session.workspace;
82+
const owner = session.owner;
83+
const context = session.context;
84+
const status = workspace?.status;
85+
86+
const startedTimeString = session.startedTime
87+
? displayTime(session.startedTime.toDate().getTime())
88+
: "-";
89+
const projectContextURL =
90+
context?.repository?.cloneUrl || workspace?.metadata?.originalContextUrl;
91+
92+
return (
93+
<Item
94+
key={session.id}
95+
className="grid grid-cols-5 gap-x-3 hover:bg-gray-50 dark:hover:bg-gray-750"
96+
>
97+
<ItemField className="col-span-1 my-auto truncate">
98+
<WorkspaceStatusIndicator status={status} />
99+
</ItemField>
100+
<ItemField className="col-span-1 my-auto truncate font-mono text-xs">
101+
<span title={workspace?.id}>{workspace?.id || "-"}</span>
102+
</ItemField>
103+
<ItemField className="col-span-1 my-auto truncate">
104+
<span title={owner?.name}>{owner?.name || "-"}</span>
105+
</ItemField>
106+
<ItemField className="col-span-1 my-auto truncate">
107+
<span title={projectContextURL ? toRemoteURL(projectContextURL) : ""}>
108+
{projectContextURL ? toRemoteURL(projectContextURL) : "-"}
109+
</span>
110+
</ItemField>
111+
<ItemField className="col-span-1 my-auto truncate">
112+
<span title={startedTimeString}>{startedTimeString}</span>
113+
</ItemField>
114+
</Item>
115+
);
116+
})}
117+
</ItemsList>
118+
)}
119+
</div>
120+
);
121+
};

0 commit comments

Comments
 (0)