mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: implement efficient backend querying on the tasks page (#18488)
Use the `/workspaces?q=has-ai-task=true`, `/templates?q=has-ai-task=true` and `/aitasks/prompts` endpoints to fetch Task templates and workspaces on the `/tasks` page. Also: - remove documentation link placeholders: the documentation is not in place yet and is not going to be available before the June 24th code freeze - load workspaces and templates in parallel - replace loading spinners with content skeletons Related to https://github.com/coder/coder/issues/18454 and https://github.com/coder/internal/issues/660.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { API } from "api/api";
|
||||
import { getErrorDetail, getErrorMessage } from "api/errors";
|
||||
import { disabledRefetchOptions } from "api/queries/util";
|
||||
@@ -5,6 +6,7 @@ import type { Template } from "api/typesGenerated";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Form, FormFields, FormSection } from "components/Form/Form";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
@@ -30,10 +32,14 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "components/Table/Table";
|
||||
import {
|
||||
TableLoaderSkeleton,
|
||||
TableRowSkeleton,
|
||||
} from "components/TableLoader/TableLoader";
|
||||
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { useExternalAuth } from "hooks/useExternalAuth";
|
||||
import { ExternalLinkIcon, RotateCcwIcon, SendIcon } from "lucide-react";
|
||||
import { RotateCcwIcon, SendIcon } from "lucide-react";
|
||||
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
|
||||
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
|
||||
import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName";
|
||||
@@ -50,17 +56,7 @@ import { type UserOption, UsersCombobox } from "./UsersCombobox";
|
||||
type TasksFilter = {
|
||||
user: UserOption | undefined;
|
||||
};
|
||||
|
||||
const TasksPage: FC = () => {
|
||||
const {
|
||||
data: templates,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["templates", "ai"],
|
||||
queryFn: data.fetchAITemplates,
|
||||
...disabledRefetchOptions,
|
||||
});
|
||||
const { user, permissions } = useAuthenticated();
|
||||
const [filter, setFilter] = useState<TasksFilter>({
|
||||
user: {
|
||||
@@ -70,92 +66,132 @@ const TasksPage: FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
let content: ReactNode = null;
|
||||
|
||||
if (error) {
|
||||
const message = getErrorMessage(error, "Error loading AI templates");
|
||||
const detail = getErrorDetail(error) ?? "Please, try again";
|
||||
|
||||
content = (
|
||||
<div className="border border-solid rounded-lg w-full min-h-80 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
{message}
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">{detail}</span>
|
||||
<Button size="sm" onClick={() => refetch()} className="mt-4">
|
||||
<RotateCcwIcon />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (templates) {
|
||||
content =
|
||||
templates.length === 0 ? (
|
||||
<div className="rounded-lg border border-solid border-border w-full min-h-80 p-4 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
No AI templates found
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
Create an AI template to get started
|
||||
</span>
|
||||
<Button size="sm" className="mt-4">
|
||||
<ExternalLinkIcon />
|
||||
Read the docs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TaskForm templates={templates} />
|
||||
{permissions.viewDeploymentConfig && (
|
||||
<TasksFilter filter={filter} onFilterChange={setFilter} />
|
||||
)}
|
||||
<TasksTable templates={templates} filter={filter} />
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className="rounded-lg border border-solid border-border w-full min-h-80 p-4 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<Spinner loading className="mb-4" />
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
Loading AI templates
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
This might take a few minutes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("AI Tasks")}</title>
|
||||
</Helmet>
|
||||
<Margins>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button variant="outline">
|
||||
<ExternalLinkIcon />
|
||||
Read the docs
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<PageHeader>
|
||||
<PageHeaderTitle>Tasks</PageHeaderTitle>
|
||||
<PageHeaderSubtitle>Automate tasks with AI</PageHeaderSubtitle>
|
||||
</PageHeader>
|
||||
|
||||
<main className="pb-8">{content}</main>
|
||||
<main className="pb-8">
|
||||
<TaskFormSection
|
||||
showFilter={permissions.viewDeploymentConfig}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
<TasksTable filter={filter} />
|
||||
</main>
|
||||
</Margins>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const textareaPlaceholder = "Prompt your AI agent to start a task...";
|
||||
|
||||
const LoadingTemplatesPlaceholder: FC = () => {
|
||||
return (
|
||||
<div className="border border-border border-solid rounded-lg p-4">
|
||||
<div className="space-y-4">
|
||||
{/* Textarea skeleton */}
|
||||
<TextareaAutosize
|
||||
disabled
|
||||
id="prompt"
|
||||
name="prompt"
|
||||
placeholder={textareaPlaceholder}
|
||||
className={`border-0 resize-none w-full h-full bg-transparent rounded-lg outline-none flex min-h-[60px]
|
||||
text-sm shadow-sm text-content-primary placeholder:text-content-secondary md:text-sm`}
|
||||
/>
|
||||
|
||||
{/* Bottom controls skeleton */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Skeleton variant="rounded" width={208} height={32} />
|
||||
<Skeleton variant="rounded" width={96} height={32} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NoTemplatesPlaceholder: FC = () => {
|
||||
return (
|
||||
<div className="rounded-lg border border-solid border-border w-full min-h-80 p-4 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
No Task templates found
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
Create a Task template to get started
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorContent: FC<{
|
||||
title: string;
|
||||
detail: string;
|
||||
onRetry: () => void;
|
||||
}> = ({ title, detail, onRetry }) => {
|
||||
return (
|
||||
<div className="border border-solid rounded-lg w-full min-h-80 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
{title}
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">{detail}</span>
|
||||
<Button size="sm" onClick={onRetry} className="mt-4">
|
||||
<RotateCcwIcon />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TaskFormSection: FC<{
|
||||
showFilter: boolean;
|
||||
filter: TasksFilter;
|
||||
onFilterChange: (filter: TasksFilter) => void;
|
||||
}> = ({ showFilter, filter, onFilterChange }) => {
|
||||
const {
|
||||
data: templates,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["templates", "ai"],
|
||||
queryFn: data.fetchAITemplates,
|
||||
...disabledRefetchOptions,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorContent
|
||||
title={getErrorMessage(error, "Error loading Task templates")}
|
||||
detail={getErrorDetail(error) ?? "Please try again"}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (templates === undefined) {
|
||||
return <LoadingTemplatesPlaceholder />;
|
||||
}
|
||||
if (templates.length === 0) {
|
||||
return <NoTemplatesPlaceholder />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<TaskForm templates={templates} />
|
||||
{showFilter && (
|
||||
<TasksFilter filter={filter} onFilterChange={onFilterChange} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type CreateTaskMutationFnProps = { prompt: string; templateId: string };
|
||||
|
||||
type TaskFormProps = {
|
||||
@@ -211,7 +247,7 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "Error creating task");
|
||||
const detail = getErrorDetail(error) ?? "Please, try again";
|
||||
const detail = getErrorDetail(error) ?? "Please try again";
|
||||
displayError(message, detail);
|
||||
}
|
||||
};
|
||||
@@ -231,7 +267,7 @@ const TaskForm: FC<TaskFormProps> = ({ templates }) => {
|
||||
required
|
||||
id="prompt"
|
||||
name="prompt"
|
||||
placeholder="Prompt your AI agent to start a task..."
|
||||
placeholder={textareaPlaceholder}
|
||||
className={`border-0 resize-none w-full h-full bg-transparent rounded-lg outline-none flex min-h-[60px]
|
||||
text-sm shadow-sm text-content-primary placeholder:text-content-secondary md:text-sm`}
|
||||
/>
|
||||
@@ -322,18 +358,17 @@ const TasksFilter: FC<TasksFilterProps> = ({ filter, onFilterChange }) => {
|
||||
};
|
||||
|
||||
type TasksTableProps = {
|
||||
templates: Template[];
|
||||
filter: TasksFilter;
|
||||
};
|
||||
|
||||
const TasksTable: FC<TasksTableProps> = ({ templates, filter }) => {
|
||||
const TasksTable: FC<TasksTableProps> = ({ filter }) => {
|
||||
const {
|
||||
data: tasks,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["tasks", filter],
|
||||
queryFn: () => data.fetchTasks(templates, filter),
|
||||
queryFn: () => data.fetchTasks(filter),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
@@ -341,7 +376,7 @@ const TasksTable: FC<TasksTableProps> = ({ templates, filter }) => {
|
||||
|
||||
if (error) {
|
||||
const message = getErrorMessage(error, "Error loading tasks");
|
||||
const detail = getErrorDetail(error) ?? "Please, try again";
|
||||
const detail = getErrorDetail(error) ?? "Please try again";
|
||||
|
||||
body = (
|
||||
<TableRow>
|
||||
@@ -382,11 +417,6 @@ const TasksTable: FC<TasksTableProps> = ({ templates, filter }) => {
|
||||
tasks.map(({ workspace, prompt }) => {
|
||||
const templateDisplayName =
|
||||
workspace.template_display_name ?? workspace.template_name;
|
||||
const status = workspace.latest_app_status;
|
||||
const agent = workspace.latest_build.resources
|
||||
.flatMap((r) => r.agents)
|
||||
.find((a) => a?.id === status?.agent_id);
|
||||
const app = agent?.apps.find((a) => a.id === status?.app_id);
|
||||
|
||||
return (
|
||||
<TableRow key={workspace.id} className="relative" hover>
|
||||
@@ -439,21 +469,19 @@ const TasksTable: FC<TasksTableProps> = ({ templates, filter }) => {
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<div className="rounded-lg w-full min-h-80 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<Spinner loading className="mb-4" />
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
Loading tasks
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
This might take a few minutes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableLoaderSkeleton>
|
||||
<TableRowSkeleton>
|
||||
<TableCell>
|
||||
<AvatarDataSkeleton />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton variant="rounded" width={100} height={24} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AvatarDataSkeleton />
|
||||
</TableCell>
|
||||
</TableRowSkeleton>
|
||||
</TableLoaderSkeleton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -472,69 +500,33 @@ const TasksTable: FC<TasksTableProps> = ({ templates, filter }) => {
|
||||
};
|
||||
|
||||
export const data = {
|
||||
// TODO: This function is currently inefficient because it fetches all templates
|
||||
// and their parameters individually, resulting in many API calls and slow
|
||||
// performance. After confirming the requirements, consider adding a backend
|
||||
// endpoint that returns only AI templates (those with an "AI Prompt" parameter)
|
||||
// in a single request.
|
||||
async fetchAITemplates() {
|
||||
const templates = await API.getTemplates();
|
||||
const parameters = await Promise.all(
|
||||
templates.map(async (template) =>
|
||||
API.getTemplateVersionRichParameters(template.active_version_id),
|
||||
),
|
||||
);
|
||||
return templates.filter((_template, index) => {
|
||||
return parameters[index].some((p) => p.name === AI_PROMPT_PARAMETER_NAME);
|
||||
});
|
||||
return API.getTemplates({ q: "has-ai-task:true" });
|
||||
},
|
||||
|
||||
// TODO: This function is inefficient because it fetches workspaces for each
|
||||
// template individually and its build parameters resulting in excessive API
|
||||
// calls and slow performance. Consider implementing a backend endpoint that
|
||||
// returns all AI-related workspaces in a single request to improve efficiency.
|
||||
async fetchTasks(aiTemplates: Template[], filter: TasksFilter) {
|
||||
const workspaces = await Promise.all(
|
||||
aiTemplates.map((template) => {
|
||||
const queryParts = [`template:${template.name}`];
|
||||
if (filter.user) {
|
||||
queryParts.push(`owner:${filter.user.value}`);
|
||||
}
|
||||
|
||||
return API.getWorkspaces({
|
||||
q: queryParts.join(" "),
|
||||
limit: 100,
|
||||
});
|
||||
}),
|
||||
).then((results) =>
|
||||
results
|
||||
.flatMap((r) => r.workspaces)
|
||||
.toSorted((a, b) => {
|
||||
return (
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
}),
|
||||
async fetchTasks(filter: TasksFilter) {
|
||||
let filterQuery = "has-ai-task:true";
|
||||
if (filter.user) {
|
||||
filterQuery += ` owner:${filter.user.value}`;
|
||||
}
|
||||
const workspaces = await API.getWorkspaces({
|
||||
q: filterQuery,
|
||||
});
|
||||
const prompts = await API.experimental.getAITasksPrompts(
|
||||
workspaces.workspaces.map((workspace) => workspace.latest_build.id),
|
||||
);
|
||||
|
||||
return Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
const parameters = await API.getWorkspaceBuildParameters(
|
||||
workspace.latest_build.id,
|
||||
);
|
||||
const prompt = parameters.find(
|
||||
(p) => p.name === AI_PROMPT_PARAMETER_NAME,
|
||||
)?.value;
|
||||
|
||||
if (!prompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
workspace,
|
||||
prompt,
|
||||
} satisfies Task;
|
||||
}),
|
||||
).then((tasks) => tasks.filter((t) => t !== undefined));
|
||||
return workspaces.workspaces.map((workspace) => {
|
||||
let prompt = prompts.prompts[workspace.latest_build.id];
|
||||
if (prompt === undefined) {
|
||||
prompt = "Unknown prompt";
|
||||
} else if (prompt === "") {
|
||||
prompt = "Empty prompt";
|
||||
}
|
||||
return {
|
||||
workspace,
|
||||
prompt,
|
||||
} satisfies Task;
|
||||
});
|
||||
},
|
||||
|
||||
async createTask(
|
||||
|
||||
Reference in New Issue
Block a user