Adds jobs. Adds better stats.

Signed-off-by: Mihovil Ilakovac <mihovil@ilakovac.com>
This commit is contained in:
Mihovil Ilakovac 2023-07-04 13:05:04 +02:00
parent cf9b85466e
commit 307a56637c
12 changed files with 461 additions and 203 deletions

View File

@ -71,8 +71,6 @@ action startGeneratingNewApp {
fn: import { startGeneratingNewApp } from "@server/operations.js",
entities: [
Project,
File,
Log,
]
}
@ -95,6 +93,7 @@ entity User {=psl
email String @unique
externalAuthAssociations SocialLogin[]
projects Project[]
psl=}
entity SocialLogin {=psl
@ -114,7 +113,9 @@ entity Project {=psl
primaryColor String @default("sky")
authMethod String @default("usernameAndPassword")
createdAt DateTime @default(now())
status String @default("in-progress")
status String @default("pending")
userId Int?
user User? @relation(fields: [userId], references: [id])
files File[]
logs Log[]
psl=}
@ -137,3 +138,37 @@ entity Log {=psl
projectId String
project Project @relation(fields: [projectId], references: [id])
psl=}
job checkPendingAppsJob {
executor: PgBoss,
schedule: {
cron: "* * * * *",
},
perform: {
fn: import { checkForPendingApps } from "@server/jobs/checkForPendingApps.js"
},
entities: [Project]
}
job failStaleAppsJobs {
executor: PgBoss,
schedule: {
cron: "* * * * *",
},
perform: {
fn: import { failStaleGenerations } from "@server/jobs/failStaleGenerations.js",
},
entities: [Project, Log]
}
job generateAppJob {
executor: PgBoss,
perform: {
fn: import { generateApp } from "@server/jobs/generateApp.js",
},
entities: [
Project,
File,
Log
]
}

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Project" ALTER COLUMN "status" SET DEFAULT 'pending';

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "userId" INTEGER;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -61,7 +61,7 @@ export function Logs({ logs, status, onRetry }) {
/>
<div className="flex justify-between items-flex-start">
<div className="flex-shrink-0 mr-3">
{status === "inProgress" && <Loader />}
{(status === "inProgress" || status === "pending") && <Loader />}
{status === "success" && (
<div className="status-icon bg-green-500">
<CheckIcon className="w-4 h-4 text-white" />

View File

@ -1,9 +1,11 @@
export function StatusPill({ children, status, className = "", sm = false }) {
const statusToClassName = {
idle: "bg-gray-100 text-gray-700",
pending: "bg-yellow-100 text-yellow-700",
inProgress: "bg-sky-100 text-sky-700",
success: "bg-green-100 text-green-700",
error: "bg-red-100 text-red-700",
cancelled: "bg-red-100 text-red-700",
warning: "bg-yellow-100 text-yellow-700",
};
return (

View File

@ -49,8 +49,8 @@ const MainPage = () => {
async function startGenerating(event) {
event.preventDefault();
setCurrentStatus({
status: "inProgress",
message: "Booting up AI",
status: "idle",
message: "Starting...",
});
try {
const appId = await startGeneratingNewApp({

View File

@ -28,36 +28,42 @@ export const ResultPage = () => {
const [activeFilePath, setActiveFilePath] = useState(null);
const [currentStatus, setCurrentStatus] = useState({
status: "idle",
message: "Waiting for instructions",
message: "Waiting",
});
const [currentFiles, setCurrentFiles] = useState({});
const history = useHistory();
const [isMobileFileBrowserOpen, setIsMobileFileBrowserOpen] = useState(false);
useEffect(() => {
const backendStatusToPillStatus = {
pending: "pending",
"in-progress": "inProgress",
success: "success",
failure: "error",
cancelled: "cancelled",
};
const backendStatusToPillText = {
pending: "In the queue",
"in-progress": "Generating app",
success: "Finished",
failure: "There was an error",
cancelled: "The generation was cancelled",
};
if (!appGenerationResult?.project) {
return;
}
if (
appGenerationResult?.project?.status === "success" ||
appGenerationResult?.project?.status === "failure"
appGenerationResult?.project?.status === "failure" ||
appGenerationResult?.project?.status === "cancelled" ||
isError
) {
setGenerationDone(true);
setCurrentStatus({
status:
appGenerationResult.project.status === "success"
? "success"
: "error",
message:
appGenerationResult.project.status === "success"
? "Finished"
: "There was an error",
});
} else if (isError) {
setGenerationDone(true);
} else {
setCurrentStatus({
status: "inProgress",
message: "Generating app",
});
}
setCurrentStatus({
status: backendStatusToPillStatus[appGenerationResult.project.status],
message: backendStatusToPillText[appGenerationResult.project.status],
});
}, [appGenerationResult, isError]);
useEffect(() => {

View File

@ -9,9 +9,11 @@ import { BarChart } from "../components/BarChart";
import ParentSize from "@visx/responsive/lib/components/ParentSize";
import { poolOfExampleIdeas } from "../examples";
import logout from "@wasp/auth/logout";
import { WaspIcon } from "../components/WaspIcon";
export function Stats() {
const [filterOutExampleApps, setFilterOutExampleApps] = useState(true);
const [filterOutExampleApps, setFilterOutExampleApps] = useState(false);
const [filterOutKnownUsers, setFilterOutKnownUsers] = useState(false);
const { data: stats, isLoading, error } = useQuery(getStats);
@ -27,6 +29,8 @@ export function Stats() {
return "success";
case "failure":
return "error";
case "cancelled":
return "cancelled";
default:
return "idle";
}
@ -40,35 +44,64 @@ export function Stats() {
return "Success";
case "failure":
return "Error";
case "cancelled":
return "Cancelled";
case "pending":
return "Pending";
default:
return "Idle";
return "Unknown";
}
}
const filteredStats = useMemo(
() =>
stats
? stats.projects.filter((stat) => {
if (filterOutExampleApps) {
return !poolOfExampleIdeas.some(
(example) =>
example.name === stat.name &&
example.description === stat.description
);
} else {
return true;
}
})
: [],
[stats, filterOutExampleApps]
);
const filteredStats = useMemo(() => {
const filters = [];
if (filterOutExampleApps) {
filters.push(
(stat) =>
!poolOfExampleIdeas.some(
(example) =>
example.name === stat.name &&
example.description === stat.description
)
);
}
if (filterOutKnownUsers) {
filters.push((stat) => !stat.user);
}
return stats
? stats.projects.filter((stat) => {
return filters.every((filter) => filter(stat));
})
: [];
}, [stats, stats?.projects, filterOutExampleApps, filterOutKnownUsers]);
function getFormattedDiff(start, end) {
const diff = (end - start) / 1000;
const minutes = Math.round(diff / 60);
const remainingSeconds = Math.round(diff % 60);
return `${minutes}m ${remainingSeconds}s`;
}
function getDuration(stat) {
const start = stat.logs[stat.logs.length - 1].createdAt;
const end = stat.logs[0].createdAt;
return getFormattedDiff(start, end);
}
function getWaitingInQueueDuration(stat) {
const start = stat.createdAt;
const end = stat.logs[stat.logs.length - 1].createdAt;
return getFormattedDiff(start, end);
}
return (
<div className="big-box">
<div className="flex justify-between items-center mb-4">
<h1 className="text-3xl font-semibold text-slate-800">Stats</h1>
<div>
<button className="button sm" onClick={logout}>Logout</button>
<button className="button sm" onClick={logout}>
Logout
</button>
</div>
</div>
@ -76,13 +109,11 @@ export function Stats() {
{error && <p>Error: {error.message}</p>}
{stats && filteredStats.length === 0 && (
<p className="text-sm text-slate-500">
No projects created yet.
</p>
{stats && stats.projects.length === 0 && (
<p className="text-sm text-slate-500">No projects created yet.</p>
)}
{stats && filteredStats.length > 0 && (
{stats && stats.projects.length > 0 && (
<>
<p className="text-sm text-slate-500 mb-2">
Number of projects created in the last 24 hours:{" "}
@ -100,23 +131,43 @@ export function Stats() {
</div>
<div className="py-2 flex justify-between items-center">
<div className="flex items-center mb-4">
<input
id="default-checkbox"
type="checkbox"
checked={filterOutExampleApps}
onChange={(event) =>
setFilterOutExampleApps(event.target.checked)
}
className="w-4 h-4 text-sky-600 bg-gray-100 border-gray-300 rounded focus:ring-sky-500"
/>
<label
htmlFor="default-checkbox"
className="ml-2 text-sm font-medium text-gray-900"
>
Filter out example apps
</label>
<div className="flex gap-3">
<div className="flex items-center mb-4">
<input
id="filter"
type="checkbox"
checked={filterOutExampleApps}
onChange={(event) =>
setFilterOutExampleApps(event.target.checked)
}
className="w-4 h-4 text-sky-600 bg-gray-100 border-gray-300 rounded focus:ring-sky-500"
/>
<label
htmlFor="filter"
className="ml-2 text-sm font-medium text-gray-900"
>
Filter out example apps
</label>
</div>
<div className="flex items-center mb-4">
<input
id="default-checkbox"
type="checkbox"
checked={filterOutKnownUsers}
onChange={(event) =>
setFilterOutKnownUsers(event.target.checked)
}
className="w-4 h-4 text-sky-600 bg-gray-100 border-gray-300 rounded focus:ring-sky-500"
/>
<label
htmlFor="default-checkbox"
className="ml-2 text-sm font-medium text-gray-900"
>
Filter out known users
</label>
</div>
</div>
<p className="text-sm text-slate-500">
Number of displayed apps: {filteredStats.length}
</p>
@ -135,6 +186,9 @@ export function Stats() {
<th scope="col" className="px-6 py-3">
Created At
</th>
<th scope="col" className="px-6 py-3">
Time in Queue &rarr; Build
</th>
<th scope="col" className="px-6 py-3"></th>
</tr>
</thead>
@ -144,10 +198,17 @@ export function Stats() {
<th
scope="row"
className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap flex items-center gap-2"
title={stat.description}
>
<Color value={getColorValue(stat.primaryColor)} />{" "}
{stat.name}
<span title={stat.description}>{stat.name}</span>{" "}
{stat.user && (
<span
className="text-slate-300"
title={stat.user.email}
>
<WaspIcon className="w-5 h-5" />
</span>
)}
</th>
<td className="px-6 py-4">
<StatusPill status={getStatusName(stat.status)} sm>
@ -160,6 +221,9 @@ export function Stats() {
>
{format(stat.createdAt)}
</td>
<td className="px-6 py-4">
{getWaitingInQueueDuration(stat)} &rarr; {getDuration(stat)}
</td>
<td className="px-6 py-4">
<Link
to={`/result/${stat.id}`}

View File

@ -0,0 +1,32 @@
import { generateAppJob } from "@wasp/jobs/generateAppJob.js";
const maxProjectsInProgress = process.env.MAX_PROJECTS_IN_PROGRESS
? parseInt(process.env.MAX_PROJECTS_IN_PROGRESS, 10)
: 5;
export async function checkForPendingApps(
_args: void,
context: {
entities: {
Project: any;
};
}
) {
console.log("Checking for pending apps");
const { Project } = context.entities;
const pendingProjects = await Project.findMany({
where: { status: "pending" },
orderBy: { createdAt: "asc" },
});
const inProgressProjects = await Project.findMany({
where: { status: "in-progress" },
});
// Generate X new apps until there are 5 in progress
const numAppsToGenerate = maxProjectsInProgress - inProgressProjects.length;
const appsToGenerate = pendingProjects.slice(0, numAppsToGenerate);
for (const app of appsToGenerate) {
generateAppJob.submit({ appId: app.id });
}
}

View File

@ -0,0 +1,57 @@
export async function failStaleGenerations(
_args: void,
context: {
entities: {
Project: any;
Log: any;
};
}
) {
// If a generation has been in progress for > 5 minutes, it fails it
console.log("Failing stale generations");
const { Project, Log } = context.entities;
const now = getNowInUTC();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
try {
const staleProjects = await Project.findMany({
where: {
status: "in-progress",
logs: {
every: {
createdAt: {
lte: fiveMinutesAgo,
},
},
},
},
});
for (const project of staleProjects) {
await Project.update({
where: { id: project.id },
data: { status: "cancelled" },
});
await Log.create({
data: {
project: { connect: { id: project.id } },
content: "The generation took too long.",
},
});
}
return {
success: true,
};
} catch (e) {
console.log("Error fetching projects:", e);
return {
success: false,
};
}
}
function getNowInUTC() {
const now = new Date();
return new Date(now.toUTCString());
}

View File

@ -0,0 +1,169 @@
import { spawn } from "child_process";
import { Mutex } from "async-mutex";
const appGenerationResults: Record<string, any> = {};
export async function generateApp(
args: { appId: string },
context: {
entities: {
Project: any;
Log: any;
File: any;
};
}
) {
// Given the appID, it generates the app
console.log("Generating app");
const appId = args.appId;
const { Project, Log, File } = context.entities;
const project = await Project.findUniqueOrThrow({
where: { id: appId },
});
if (project.status !== "pending") {
return {
success: false,
};
}
await Project.update({
where: { id: appId },
data: { status: "in-progress" },
});
appGenerationResults[appId] = {
unconsumedStdout: "",
};
// { auth: 'UsernameAndPassword', primaryColor: string }
const projectConfig = {
primaryColor: project.primaryColor,
};
const stdoutMutex = new Mutex();
let waspCliProcess = null;
const waspCliProcessArgs = [
"new-ai",
project.name,
project.description,
JSON.stringify(projectConfig),
];
if (process.env.NODE_ENV === "production") {
waspCliProcess = spawn("wasp", waspCliProcessArgs);
} else {
// NOTE: In dev when we use `wasp-cli`, we want to make sure that if this app is run via `wasp` that its datadir env var does not propagate,
// so we reset it here. This is problem only if you run app with `wasp` and let it call `wasp-cli` here.
waspCliProcess = spawn("wasp-cli", waspCliProcessArgs, {
env: { ...process.env, waspc_datadir: undefined },
});
}
waspCliProcess.stdout.on("data", async (data) => {
const release = await stdoutMutex.acquire();
try {
appGenerationResults[appId].unconsumedStdout += data;
const patterns = [
{
regex: /==== WASP AI: LOG ====\n([\s\S]*?)\n===\/ WASP AI: LOG ====/,
action: async (match: RegExpMatchArray) => {
const content = match[1];
// console.log(`Log: ${content}`);
await Log.create({
data: {
content,
project: {
connect: { id: appId },
},
},
});
},
},
{
regex:
/==== WASP AI: WRITE FILE ====\n([\s\S]*?)\n===\/ WASP AI: WRITE FILE ====/,
action: async (match: RegExpMatchArray) => {
const text = match[1];
const [filename, ...rest] = text.split("\n");
const content = rest.join("\n");
const file = await File.findFirst({
where: { name: filename, projectId: appId },
});
if (file) {
// console.log(`Updating file ${filename} in project ${appId}.`);
await File.update({
where: { id: file.id },
data: { content },
});
} else {
// console.log(`Creating file ${filename} in project ${appId}.`);
await File.create({
data: {
name: filename,
content,
projectId: appId,
},
});
}
},
},
];
let match: any = null;
do {
match = null;
for (const pattern of patterns) {
const newMatch = pattern.regex.exec(
appGenerationResults[appId].unconsumedStdout
);
if (newMatch && (!match || newMatch.index < match.index)) {
match = {
...newMatch,
action: pattern.action,
};
}
}
if (match) {
await match.action(match);
appGenerationResults[appId].unconsumedStdout = appGenerationResults[
appId
].unconsumedStdout.replace(match[0], "");
}
} while (match);
} finally {
release();
}
});
waspCliProcess.stderr.on("data", (data) => {
console.error(data.toString());
});
waspCliProcess.on("close", async (code) => {
if (code === 0) {
await Project.update({
where: { id: appId },
data: { status: "success" },
});
} else {
await Project.update({
where: { id: appId },
data: { status: "failure" },
});
}
});
waspCliProcess.on("error", async (err) => {
console.error("WASP CLI PROCESS ERROR", err);
await Project.update({
where: { id: appId },
data: { status: "failure" },
});
});
return { success: true };
}

View File

@ -1,10 +1,7 @@
import { StartGeneratingNewApp } from "@wasp/actions/types";
import { GetAppGenerationResult, GetStats } from "@wasp/queries/types";
import HttpError from "@wasp/core/HttpError.js";
import { spawn } from "child_process";
import { Mutex } from "async-mutex";
const appGenerationResults: Record<string, any> = {};
import { checkPendingAppsJob } from "@wasp/jobs/checkPendingAppsJob.js";
export const startGeneratingNewApp: StartGeneratingNewApp<
{
@ -27,147 +24,24 @@ export const startGeneratingNewApp: StartGeneratingNewApp<
if (!args.appDesc) {
throw new HttpError(422, "App description is required.");
}
const { Project, File, Log } = context.entities;
const { Project } = context.entities;
const project = await Project.create({
data: {
name: args.appName,
description: args.appDesc,
primaryColor: args.appPrimaryColor,
authMethod: args.appAuthMethod,
user: {
connect: {
id: context.user?.id,
},
},
},
});
const appId = project.id;
appGenerationResults[appId] = {
unconsumedStdout: "",
};
// { auth: 'UsernameAndPassword', primaryColor: string }
const projectConfig = {
primaryColor: args.appPrimaryColor,
};
const stdoutMutex = new Mutex();
let waspCliProcess = null;
const waspCliProcessArgs = [
"new-ai",
args.appName,
args.appDesc,
JSON.stringify(projectConfig),
];
if (process.env.NODE_ENV === "production") {
waspCliProcess = spawn("wasp", waspCliProcessArgs);
} else {
// NOTE: In dev when we use `wasp-cli`, we want to make sure that if this app is run via `wasp` that its datadir env var does not propagate,
// so we reset it here. This is problem only if you run app with `wasp` and let it call `wasp-cli` here.
waspCliProcess = spawn("wasp-cli", waspCliProcessArgs, {
env: { ...process.env, waspc_datadir: undefined },
});
}
waspCliProcess.stdout.on("data", async (data) => {
const release = await stdoutMutex.acquire();
try {
appGenerationResults[appId].unconsumedStdout += data;
const patterns = [
{
regex: /==== WASP AI: LOG ====\n([\s\S]*?)\n===\/ WASP AI: LOG ====/,
action: async (match: RegExpMatchArray) => {
const content = match[1];
// console.log(`Log: ${content}`);
await Log.create({
data: {
content,
project: {
connect: { id: appId },
},
},
});
},
},
{
regex:
/==== WASP AI: WRITE FILE ====\n([\s\S]*?)\n===\/ WASP AI: WRITE FILE ====/,
action: async (match: RegExpMatchArray) => {
const text = match[1];
const [filename, ...rest] = text.split("\n");
const content = rest.join("\n");
const file = await File.findFirst({
where: { name: filename, projectId: appId },
});
if (file) {
// console.log(`Updating file ${filename} in project ${appId}.`);
await File.update({
where: { id: file.id },
data: { content },
});
} else {
// console.log(`Creating file ${filename} in project ${appId}.`);
await File.create({
data: {
name: filename,
content,
projectId: appId,
},
});
}
},
},
];
let match: any = null;
do {
match = null;
for (const pattern of patterns) {
const newMatch = pattern.regex.exec(
appGenerationResults[appId].unconsumedStdout
);
if (newMatch && (!match || newMatch.index < match.index)) {
match = {
...newMatch,
action: pattern.action,
};
}
}
if (match) {
await match.action(match);
appGenerationResults[appId].unconsumedStdout = appGenerationResults[
appId
].unconsumedStdout.replace(match[0], "");
}
} while (match);
} finally {
release();
}
});
waspCliProcess.stderr.on("data", (data) => {
console.error(data.toString());
});
waspCliProcess.on("close", async (code) => {
if (code === 0) {
await Project.update({
where: { id: appId },
data: { status: "success" },
});
} else {
await Project.update({
where: { id: appId },
data: { status: "failure" },
});
}
});
waspCliProcess.on("error", async (err) => {
console.error("WASP CLI PROCESS ERROR", err);
await Project.update({
where: { id: appId },
data: { status: "failure" },
});
});
checkPendingAppsJob.submit({});
return appId;
};
@ -197,7 +71,7 @@ export const getAppGenerationResult = (async (args, context) => {
appId: string;
}>;
export const getStats = (async (args, context) => {
export const getStats = (async (_args, context) => {
const emailsWhitelist = process.env.ADMIN_EMAILS_WHITELIST?.split(",") || [];
if (!context.user || !emailsWhitelist.includes(context.user.email)) {
throw new HttpError(401, "Only admins can access stats.");
@ -208,6 +82,18 @@ export const getStats = (async (args, context) => {
orderBy: {
createdAt: "desc",
},
include: {
user: {
select: {
email: true,
},
},
logs: {
orderBy: {
createdAt: "desc",
},
},
},
});
return {
projects,