mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-12-29 03:53:14 +03:00
Adds stats page
This commit is contained in:
parent
080f7cbacc
commit
68b88b2bcf
@ -17,7 +17,15 @@ app waspAi {
|
||||
("async-mutex", "0.4.0"),
|
||||
("@headlessui/react", "1.7.15"),
|
||||
("@heroicons/react", "2.0.18"),
|
||||
("react-parallax-tilt", "1.7.151")
|
||||
("react-parallax-tilt", "1.7.151"),
|
||||
("timeago.js", "4.0.2"),
|
||||
("@visx/mock-data", "3.0.0"),
|
||||
("@visx/group", "3.0.0"),
|
||||
("@visx/shape", "3.0.0"),
|
||||
("@visx/scale", "3.2.0"),
|
||||
("@visx/responsive", "3.0.0"),
|
||||
("@visx/gradient", "3.0.0"),
|
||||
("@visx/axis", "3.2.0")
|
||||
],
|
||||
client: {
|
||||
rootComponent: import { RootComponent } from "@client/RootComponent.jsx",
|
||||
@ -37,6 +45,11 @@ page ResultPage {
|
||||
component: import { ResultPage } from "@client/pages/ResultPage.jsx"
|
||||
}
|
||||
|
||||
route StatsRoute { path: "/stats", to: StatsPage }
|
||||
page StatsPage {
|
||||
component: import { Stats } from "@client/pages/StatsPage.jsx"
|
||||
}
|
||||
|
||||
action startGeneratingNewApp {
|
||||
fn: import { startGeneratingNewApp } from "@server/operations.js",
|
||||
entities: [
|
||||
@ -53,6 +66,13 @@ query getAppGenerationResult {
|
||||
]
|
||||
}
|
||||
|
||||
query getStats {
|
||||
fn: import { getStats } from "@server/operations.js",
|
||||
entities: [
|
||||
Project
|
||||
]
|
||||
}
|
||||
|
||||
entity Project {=psl
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
|
120
wasp-ai/src/client/components/BarChart.jsx
Normal file
120
wasp-ai/src/client/components/BarChart.jsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useMemo } from "react";
|
||||
import { Bar } from "@visx/shape";
|
||||
import { Group } from "@visx/group";
|
||||
import { scaleBand, scaleLinear } from "@visx/scale";
|
||||
import { AxisBottom, AxisLeft } from "@visx/axis";
|
||||
|
||||
function generateLast24HoursData(projects) {
|
||||
const buckets = [];
|
||||
const now = new Date();
|
||||
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const bucketStart = new Date(last24Hours.getTime() + i * 60 * 60 * 1000);
|
||||
const bucket = {
|
||||
date: bucketStart,
|
||||
count: 0,
|
||||
};
|
||||
buckets.push(bucket);
|
||||
}
|
||||
projects.forEach((project) => {
|
||||
const createdAt = new Date(project.createdAt);
|
||||
const bucketIndex = Math.floor(
|
||||
(createdAt.getTime() - last24Hours.getTime()) / (60 * 60 * 1000)
|
||||
);
|
||||
// Count only projects that were created in the last 24 hours
|
||||
if (bucketIndex >= 0 && bucketIndex < 24) {
|
||||
buckets[bucketIndex].count++;
|
||||
}
|
||||
});
|
||||
return buckets;
|
||||
}
|
||||
|
||||
const verticalMargin = 50;
|
||||
const margins = {
|
||||
left: 30,
|
||||
};
|
||||
|
||||
const getLetter = (d) => d.letter;
|
||||
const getLetterFrequency = (d) => Number(d.frequency) * 100;
|
||||
|
||||
export function BarChart({ projects, width, height, events = false }) {
|
||||
const data = useMemo(() => generateLast24HoursData(projects), [projects]);
|
||||
// bounds
|
||||
const xMax = width - margins.left;
|
||||
const yMax = height - verticalMargin;
|
||||
|
||||
// scales, memoize for performance
|
||||
const xScale = useMemo(
|
||||
() =>
|
||||
scaleBand({
|
||||
range: [0, xMax],
|
||||
round: true,
|
||||
domain: data.map((bucket) => {
|
||||
const hour = bucket.date.getHours();
|
||||
return hour;
|
||||
}),
|
||||
padding: 0.4,
|
||||
}),
|
||||
[xMax]
|
||||
);
|
||||
const yScale = useMemo(
|
||||
() =>
|
||||
scaleLinear({
|
||||
range: [yMax, 0],
|
||||
round: true,
|
||||
domain: [0, Math.max(...data.map((bucket) => bucket.count))],
|
||||
}),
|
||||
[yMax]
|
||||
);
|
||||
|
||||
return width < 10 ? null : (
|
||||
<svg width={width} height={height}>
|
||||
<rect width={width} height={height} className="fill-slate-100" rx={14} />
|
||||
<Group top={verticalMargin / 2} left={margins.left}>
|
||||
{data.map((d) => {
|
||||
const letter = getLetter(d);
|
||||
const barWidth = xScale.bandwidth();
|
||||
const barHeight = yMax - (yScale(getLetterFrequency(d)) ?? 0);
|
||||
const barX = xScale(letter);
|
||||
const barY = yMax - barHeight;
|
||||
return (
|
||||
<Bar
|
||||
key={`bar-${letter}`}
|
||||
x={barX}
|
||||
y={barY}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
className="fill-pink-300"
|
||||
onClick={() => {
|
||||
if (events)
|
||||
alert(`clicked: ${JSON.stringify(Object.values(d))}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<AxisBottom
|
||||
numTicks={data.length}
|
||||
top={yMax}
|
||||
scale={xScale}
|
||||
tickLabelProps={() => ({
|
||||
fill: "#333",
|
||||
fontSize: 11,
|
||||
textAnchor: "middle",
|
||||
})}
|
||||
/>
|
||||
<AxisLeft
|
||||
scale={yScale.nice()}
|
||||
numTicks={10}
|
||||
top={0}
|
||||
tickLabelProps={(e) => ({
|
||||
fill: "#333",
|
||||
fontSize: 10,
|
||||
textAnchor: "end",
|
||||
x: -12,
|
||||
y: (yScale(e) ?? 0) + 3,
|
||||
})}
|
||||
/>
|
||||
</Group>
|
||||
</svg>
|
||||
);
|
||||
}
|
38
wasp-ai/src/client/components/Color.jsx
Normal file
38
wasp-ai/src/client/components/Color.jsx
Normal file
@ -0,0 +1,38 @@
|
||||
import tailwindColors from "tailwindcss/colors";
|
||||
|
||||
export function Color({ value }) {
|
||||
return (
|
||||
<div
|
||||
className="w-5 h-5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: value,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
|
||||
export const availableColors = Object.entries(tailwindColors)
|
||||
.map(([name, color]) => {
|
||||
return {
|
||||
name,
|
||||
color: color[500],
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(color) =>
|
||||
![
|
||||
"black",
|
||||
"white",
|
||||
"transparent",
|
||||
"inherit",
|
||||
"current",
|
||||
"lightBlue",
|
||||
"warmGray",
|
||||
"trueGray",
|
||||
"coolGray",
|
||||
"blueGray",
|
||||
"gray",
|
||||
"neutral",
|
||||
"zinc",
|
||||
].includes(color.name)
|
||||
);
|
@ -1,7 +1,7 @@
|
||||
// @ts-check
|
||||
import { Fragment, useState } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid";
|
||||
import { Color } from "./Color";
|
||||
|
||||
export function MyDropdown({ value, onChange, options }) {
|
||||
return (
|
||||
@ -63,12 +63,7 @@ function Option({ value, selected = false }) {
|
||||
>
|
||||
{value.color && (
|
||||
<span className="mr-2">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full`}
|
||||
style={{
|
||||
backgroundColor: value.color,
|
||||
}}
|
||||
></div>
|
||||
<Color value={value.color} />
|
||||
</span>
|
||||
)}
|
||||
{value.name}
|
||||
|
@ -1,4 +1,4 @@
|
||||
export function StatusPill({ children, status, className = "" }) {
|
||||
export function StatusPill({ children, status, className = "", sm = false }) {
|
||||
const statusToClassName = {
|
||||
idle: "bg-gray-100 text-gray-700",
|
||||
inProgress: "bg-sky-100 text-sky-700",
|
||||
@ -9,7 +9,9 @@ export function StatusPill({ children, status, className = "" }) {
|
||||
return (
|
||||
<div className={`flex items-center ${className}`}>
|
||||
<span
|
||||
className={`text-center inline-flex items-center pl-3 pr-4 py-2 rounded-lg shadow-md ${statusToClassName[status]}`}
|
||||
className={`text-center inline-flex items-center rounded-lg shadow-md ${statusToClassName[status]} ${
|
||||
sm ? "py-1 pl-2 pr-2" : "py-2 pl-3 pr-4"
|
||||
}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full mr-2 bg-current"></span>
|
||||
{children}
|
||||
|
@ -2,9 +2,9 @@ import { useState, useMemo } from "react";
|
||||
import startGeneratingNewApp from "@wasp/actions/startGeneratingNewApp";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { MyDropdown } from "../components/Dropdown";
|
||||
import tailwindColors from "tailwindcss/colors";
|
||||
import { ExampleCard } from "../components/ExampleCard";
|
||||
import { Header } from "../components/Header";
|
||||
import { availableColors } from "../components/Color";
|
||||
|
||||
const MainPage = () => {
|
||||
const [appName, setAppName] = useState("");
|
||||
@ -15,34 +15,6 @@ const MainPage = () => {
|
||||
});
|
||||
const history = useHistory();
|
||||
|
||||
const availableColors = useMemo(() => {
|
||||
return Object.entries(tailwindColors)
|
||||
.map(([name, color]) => {
|
||||
return {
|
||||
name,
|
||||
color: color[500],
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(color) =>
|
||||
![
|
||||
"black",
|
||||
"white",
|
||||
"transparent",
|
||||
"inherit",
|
||||
"current",
|
||||
"lightBlue",
|
||||
"warmGray",
|
||||
"trueGray",
|
||||
"coolGray",
|
||||
"blueGray",
|
||||
"gray",
|
||||
"neutral",
|
||||
"zinc",
|
||||
].includes(color.name)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const [appPrimaryColor, setAppPrimaryColor] = useState(
|
||||
availableColors.find((color) => color.name === "sky")
|
||||
);
|
||||
|
120
wasp-ai/src/client/pages/StatsPage.jsx
Normal file
120
wasp-ai/src/client/pages/StatsPage.jsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useMemo } from "react";
|
||||
import getStats from "@wasp/queries/getStats";
|
||||
import { useQuery } from "@wasp/queries";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Color, availableColors } from "../components/Color";
|
||||
import { format } from "timeago.js";
|
||||
import { StatusPill } from "../components/StatusPill";
|
||||
import { BarChart } from "../components/BarChart";
|
||||
import ParentSize from "@visx/responsive/lib/components/ParentSize";
|
||||
|
||||
export function Stats() {
|
||||
const { data: stats, isLoading, error } = useQuery(getStats);
|
||||
|
||||
function getColorValue(colorName) {
|
||||
return availableColors.find((color) => color.name === colorName).color;
|
||||
}
|
||||
|
||||
function getStatusName(status) {
|
||||
switch (status) {
|
||||
case "in-progress":
|
||||
return "inProgress";
|
||||
case "success":
|
||||
return "success";
|
||||
case "failure":
|
||||
return "error";
|
||||
default:
|
||||
return "idle";
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(status) {
|
||||
switch (status) {
|
||||
case "in-progress":
|
||||
return "In progress";
|
||||
case "success":
|
||||
return "Success";
|
||||
case "failure":
|
||||
return "Error";
|
||||
default:
|
||||
return "Idle";
|
||||
}
|
||||
}
|
||||
|
||||
// Visx projects throught last 24 hours time bar chart
|
||||
const projectInLast24Hours = useMemo(() => {
|
||||
if (!stats) {
|
||||
return [];
|
||||
}
|
||||
const now = new Date();
|
||||
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
return stats.projects.filter((project) => {
|
||||
return project.createdAt > last24Hours;
|
||||
});
|
||||
}, [stats]);
|
||||
|
||||
return (
|
||||
<div className="big-box">
|
||||
<h1 className="text-3xl font-semibold text-slate-800 mb-4">Stats</h1>
|
||||
|
||||
{isLoading && <p>Loading...</p>}
|
||||
|
||||
{error && <p>Error: {error.message}</p>}
|
||||
|
||||
{stats && (
|
||||
<>
|
||||
<div style={{ height: 300, width: "100%" }} className="mb-4">
|
||||
<ParentSize>
|
||||
{({ width, height }) => <BarChart projects={stats.projects} width={width} height={height} />}
|
||||
</ParentSize>
|
||||
</div>
|
||||
<div className="relative overflow-x-auto shadow-md sm:rounded-lg">
|
||||
<table className="w-full text-sm text-left text-slate-500">
|
||||
<thead className="text-xs text-slate-700 uppercase bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
App Name
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
Created At
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.projects.map((stat) => (
|
||||
<tr className="bg-white border-b" key={stat.id}>
|
||||
<th
|
||||
scope="row"
|
||||
className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap flex items-center gap-2"
|
||||
>
|
||||
<Color value={getColorValue(stat.primaryColor)} />{" "}
|
||||
{stat.name}
|
||||
</th>
|
||||
<td className="px-6 py-4">
|
||||
<StatusPill status={getStatusName(stat.status)} sm>
|
||||
{getStatusText(stat.status)}
|
||||
</StatusPill>
|
||||
</td>
|
||||
<td className="px-6 py-4" title={`${stat.createdAt.toLocaleDateString()} ${stat.createdAt.toLocaleTimeString()}`}>{format(stat.createdAt)}</td>
|
||||
<td className="px-6 py-4">
|
||||
<Link
|
||||
to={`/result/${stat.id}`}
|
||||
className="font-medium text-sky-600 hover:underline"
|
||||
>
|
||||
View the app →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { StartGeneratingNewApp } from "@wasp/actions/types";
|
||||
import { GetAppGenerationResult } from "@wasp/queries/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";
|
||||
@ -196,3 +196,15 @@ export const getAppGenerationResult = (async (args, context) => {
|
||||
}) satisfies GetAppGenerationResult<{
|
||||
appId: string;
|
||||
}>;
|
||||
|
||||
export const getStats = (async (args, context) => {
|
||||
const { Project } = context.entities;
|
||||
const projects = await Project.findMany({
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
return {
|
||||
projects,
|
||||
};
|
||||
}) satisfies GetStats<{}>;
|
||||
|
Loading…
Reference in New Issue
Block a user