Brijesh's Git Server — watchman @ 620f0d0941040f67e80c42836d12911027389218

observability tool, needs to be rewritten once identity is stable

feat(client): create client and basic UI for managing projects

Created a web dashboard for watchman using Next.js, I could have used go html templates but using Next.js I can finish building frontend faster

Might build a client using server side go templates if this works well enough

Also created pages for managing projects
Brijesh ops@brijesh.dev
Fri, 28 Jun 2024 02:32:38 +0530
commit

620f0d0941040f67e80c42836d12911027389218

parent

d476f7c99fed9d44089b5126a7a8cf218eeee0cf

A client/.eslintrc.json

@@ -0,0 +1,3 @@

+{ + "extends": "next/core-web-vitals" +}
A client/.gitignore

@@ -0,0 +1,36 @@

+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts
A client/next.config.mjs

@@ -0,0 +1,6 @@

+/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +}; + +export default nextConfig;
A client/package.json

@@ -0,0 +1,26 @@

+{ + "name": "client", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "react": "^18", + "react-dom": "^18", + "next": "14.2.4" + }, + "devDependencies": { + "typescript": "^5", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "eslint": "^8", + "eslint-config-next": "14.2.4" + } +}
A client/postcss.config.mjs

@@ -0,0 +1,8 @@

+/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config;
A client/public/next.svg

@@ -0,0 +1,1 @@

+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
A client/public/vercel.svg

@@ -0,0 +1,1 @@

+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
A client/src/api/projects.ts

@@ -0,0 +1,78 @@

+// type APIResponseType = { +// Status: string; +// Message: string; +// RequestID: string; +// Data: any; +// }; + +type ProjectType = { + ID?: string; + Name?: string; +}; + +async function CreatePorject( + projectObject: ProjectType, + setter: (response: any) => void, +) { + fetch("http://127.0.0.1:4000/projects", { + method: "POST", + body: JSON.stringify(projectObject), + }) + .then((res) => res.json()) + .then((data) => { + setter(data); + }); +} + +function ListProjects(setter: any) { + fetch("http://127.0.0.1:4000/projects") + .then((res) => res.json()) + .then((data) => { + setter(data); + }); +} + +function GetProjectById(projectID: string, setter: any) { + projectID && + fetch("http://127.0.0.1:4000/project?id=" + projectID) + .then((res) => res.json()) + .then((data) => { + setter(data); + }); +} + +function UpdateProject( + projectObject: ProjectType, + setter: (response: any) => void, +) { + fetch("http://127.0.0.1:4000/project", { + method: "PUT", + body: JSON.stringify(projectObject), + }) + .then((res) => res.json()) + .then((data) => { + setter(data); + }); +} + +function DeleteProject( + projectObject: ProjectType, + setter: (response: any) => void, +) { + fetch("http://127.0.0.1:4000/project", { + method: "DELETE", + body: JSON.stringify(projectObject), + }) + .then((res) => res.json()) + .then((data) => { + setter(data); + }); +} + +export { + CreatePorject, + ListProjects, + GetProjectById, + UpdateProject, + DeleteProject, +};
A client/src/components/Breadcrumb.tsx

@@ -0,0 +1,40 @@

+import Link from "next/link"; +import { useRouter } from "next/router"; + +const Breadcrumb = () => { + const router = useRouter(); + return ( + <div className="bg-slate-100 py-1 px-2 border-b border-slate-200"> + {router.pathname.startsWith("/projects") && ( + <div className="flex gap-0.5 items-center"> + <p>/</p> + <Link className="hover:underline" href="/projects"> + Projects + </Link> + {router.pathname.replace("/projects", "") === "/create" && ( + <> + <p>/</p> + <Link className="hover:underline" href={"/projects/create"}> + Create Project + </Link> + </> + )} + {/* if path contains only one slash, it means page is project details */} + {(router.pathname.match(new RegExp("/", "g")) || []).length == 2 && ( + <> + <p>/</p> + <Link + className="hover:underline" + href={`/projects/${router.query.id}`} + > + Project Details + </Link> + </> + )} + </div> + )} + </div> + ); +}; + +export default Breadcrumb;
A client/src/components/Button.tsx

@@ -0,0 +1,32 @@

+const PrimaryButton = ({ + text, + onClick = () => {}, + disabled = false, + type = "button", + intent = "primary", +}: { + text: string; + onClick?: () => void; + disabled?: boolean; + type?: "button" | "submit" | "reset"; + intent?: "primary" | "danger"; +}) => { + return ( + <div> + <button + onClick={onClick} + disabled={disabled} + type={type} + className={`text-white font-medium px-2 py-1 rounded ${ + intent === "primary" + ? "bg-slate-700 disabled:bg-slate-500" + : "bg-red-700 disabled:bg-red-500" + }`} + > + {text} + </button> + </div> + ); +}; + +export { PrimaryButton };
A client/src/components/MainLayout.tsx

@@ -0,0 +1,94 @@

+import { Kode_Mono } from "next/font/google"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import Breadcrumb from "./Breadcrumb"; + +const LogoFont = Kode_Mono({ subsets: ["latin"] }); + +type SidebarOptionType = { + name: string; + link: string; +}; + +const SidebarOptions: SidebarOptionType[] = [ + { name: "Home", link: "/" }, + { name: "Projects", link: "/projects" }, + { name: "Logs", link: "/logs" }, + { name: "Metrics", link: "/metrics" }, + { name: "Analytics", link: "/analytics" }, +]; + +const BookmarkOptions: SidebarOptionType[] = [ + { name: "Example Project", link: "/projects/1" }, +]; + +const MainLayout = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); + return ( + <main className="text-slate-600 text-sm"> + <div className="flex"> + <div className="w-72 h-[calc(100vh-0px)] bg-white border-r border-slate-200 flex flex-col justify-between"> + <div> + <div className="h-14 w-full px-4 border-b border-slate-200 flex items-center justify-between"> + <div> + <p + className={`text-xl text-black font-semibold ${LogoFont.className}`} + > + WATCHMAN + </p> + </div> + </div> + <div className="flex flex-col"> + <div className="bg-slate-400 text-white text-xs font-bold py-0.5 px-3"> + MENU + </div> + {SidebarOptions.map((option) => ( + <Link + key={option.name} + href={`${option.link}`} + className={`border-b border-slate-200 py-2 px-3 hover:bg-slate-100 ${ + router.pathname.startsWith(option.link) && + option.link !== "/" + ? "bg-slate-200" + : "" + } ${ + router.pathname === option.link && option.link == "/" + ? "bg-slate-200" + : "" + }`} + > + {option.name} + </Link> + ))} + <div className="bg-slate-400 text-white text-xs font-bold py-0.5 px-3"> + BOOKMARKS + </div> + {BookmarkOptions.map((option) => ( + <Link + key={option.name} + href={option.link} + className={`border-b border-slate-200 py-2 px-3 hover:bg-slate-100 ${ + router.pathname == option.link ? "bg-slate-200" : "" + }`} + > + {option.name} + </Link> + ))} + </div> + </div> + + <div className="border-b border-slate-200 py-2 px-3"> + <p>Settings</p> + </div> + </div> + <div className="w-[calc(100vw-286px)]"> + <Breadcrumb /> + + <div className="p-4">{children}</div> + </div> + </div> + </main> + ); +}; + +export default MainLayout;
A client/src/components/table.tsx

@@ -0,0 +1,49 @@

+export default function Table({ + columns, + data, +}: { + columns: string[]; + data: any[]; +}) { + return ( + <div> + <div className="flow-root"> + <div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> + <div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> + <div className="overflow-hidden shadow ring-1 ring-slate-200 sm:-lg"> + <table className="min-w-full divide-y divide-slate-200"> + <thead className="bg-slate-100"> + <tr> + {columns.map((column: string) => ( + <th + key={column} + scope="col" + className="px-3 py-2 text-left text-sm font-semibold text-slate-500" + > + {column} + </th> + ))} + </tr> + </thead> + <tbody className="divide-y divide-slate-200 bg-white"> + {data.map((row: any) => ( + <tr key={row.id}> + {columns.map((column: string) => ( + <td + key={column} + className="whitespace-nowrap py-2 px-3 text-sm text-slate-600" + > + {row[column.toLowerCase()]} + </td> + ))} + </tr> + ))} + </tbody> + </table> + </div> + </div> + </div> + </div> + </div> + ); +}
A client/src/pages/_app.tsx

@@ -0,0 +1,6 @@

+import "@/styles/globals.css"; +import type { AppProps } from "next/app"; + +export default function App({ Component, pageProps }: AppProps) { + return <Component {...pageProps} />; +}
A client/src/pages/_document.tsx

@@ -0,0 +1,13 @@

+import { Html, Head, Main, NextScript } from "next/document"; + +export default function Document() { + return ( + <Html lang="en"> + <Head /> + <body> + <Main /> + <NextScript /> + </body> + </Html> + ); +}
A client/src/pages/api/hello.ts

@@ -0,0 +1,13 @@

+// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from "next"; + +type Data = { + name: string; +}; + +export default function handler( + req: NextApiRequest, + res: NextApiResponse<Data>, +) { + res.status(200).json({ name: "John Doe" }); +}
A client/src/pages/index.tsx

@@ -0,0 +1,14 @@

+import MainLayout from "@/components/MainLayout"; + +const Homepage = () => { + return ( + <MainLayout> + <h1 className="text-xl font-medium">Homepage</h1> + <p className="my-2"> + Please check this page later, as it is still under construction. + </p> + </MainLayout> + ); +}; + +export default Homepage;
A client/src/pages/projects/[id]/delete.tsx

@@ -0,0 +1,49 @@

+import { DeleteProject, GetProjectById, ListProjects } from "@/api/projects"; +import { PrimaryButton } from "@/components/Button"; +import MainLayout from "@/components/MainLayout"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; + +const ProjectDeletePage = () => { + const router = useRouter(); + const { id } = router.query; + + const [project, setProject] = useState<any>(); + const [apiResponse, setApiResponse] = useState<any>(); + + useEffect(() => { + GetProjectById(id ? id.toString() : "", setProject); + }, [id]); + + function deleteProject() { + let project = { ID: id ? id.toString() : "" }; + + DeleteProject(project, setApiResponse); + } + + useEffect(() => { + apiResponse && apiResponse.status === "OK" && router.push("/projects"); + }, [apiResponse]); + + return ( + <MainLayout> + <div className="mb-4 flex justify-between items-center uppercase"> + {project?.data?.name && ( + <h1 className="text-xl font-medium">{project.data.name}</h1> + )} + </div> + {project && project.data && project.data.name && ( + <p>Are you sure you want to delete this {project.data.name} project?</p> + )} + <div className="my-4 flex gap-2"> + <PrimaryButton + text="Delete" + intent="danger" + onClick={() => deleteProject()} + /> + <PrimaryButton text="Cancel" onClick={() => router.push("/projects")} /> + </div> + </MainLayout> + ); +}; +export default ProjectDeletePage;
A client/src/pages/projects/[id]/index.tsx

@@ -0,0 +1,50 @@

+import { GetProjectById, ListProjects } from "@/api/projects"; +import { PrimaryButton } from "@/components/Button"; +import MainLayout from "@/components/MainLayout"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; + +const ProjectDetailsPage = () => { + const router = useRouter(); + const { id } = router.query; + + const [project, setProject] = useState<any>(); + + useEffect(() => { + GetProjectById(id ? id.toString() : "", setProject); + }, [id]); + + return ( + <MainLayout> + <div className="mb-4 flex justify-between items-center uppercase"> + {project?.data?.name && ( + <h1 className="text-xl font-medium">{project.data.name}</h1> + )} + <div className="flex items-center gap-2"> + <PrimaryButton + text="Update" + onClick={() => router.push("/projects/" + id + "/update")} + /> + <PrimaryButton + text="Delete" + intent="danger" + onClick={() => router.push("/projects/" + id + "/delete")} + /> + </div> + </div> + {project && project.data && project.data.name && ( + <> + <div className="flex justify-start"> + <div className="w-48">Name</div> + <p>{project.data.name}</p> + </div> + <div className="flex justify-start"> + <div className="w-48">ID</div> + <p>{project.data.id}</p> + </div> + </> + )} + </MainLayout> + ); +}; +export default ProjectDetailsPage;
A client/src/pages/projects/[id]/update.tsx

@@ -0,0 +1,53 @@

+import { GetProjectById, UpdateProject } from "@/api/projects"; +import { PrimaryButton } from "@/components/Button"; +import MainLayout from "@/components/MainLayout"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; + +const UpdateProjectPage = () => { + const router = useRouter(); + const { id } = router.query; + + const [project, setProject] = useState<any>(); + + useEffect(() => { + GetProjectById(id ? id.toString() : "", setProject); + }, [id]); + + const [name, setName] = useState(project?.data?.name || ""); + const [apiResponse, setApiResponse] = useState<any>(); + + async function formSubmit(e: React.FormEvent) { + e.preventDefault(); + let project = { Name: name, Id: id }; + + UpdateProject(project, setApiResponse); + } + + useEffect(() => { + apiResponse && apiResponse.status === "OK" && router.push("/projects"); + }, [apiResponse]); + + useEffect(() => { + setName(project?.data?.name || ""); + }, [project]); + + return ( + <MainLayout> + <h1 className="text-xl font-medium">Update Project</h1> + + <form onSubmit={formSubmit} className="my-4 flex flex-col gap-4"> + <div> + <label className="block text-sm font-medium">Name</label> + <input + value={name} + onChange={(e) => setName(e.target.value)} + className="w-80 border border-slate-200 p-1" + /> + </div> + <PrimaryButton type="submit" text="Update Project" /> + </form> + </MainLayout> + ); +}; +export default UpdateProjectPage;
A client/src/pages/projects/create.tsx

@@ -0,0 +1,42 @@

+import { CreatePorject } from "@/api/projects"; +import { PrimaryButton } from "@/components/Button"; +import MainLayout from "@/components/MainLayout"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; + +const CreateProjectPage = () => { + const router = useRouter(); + + const [name, setName] = useState(""); + const [apiResponse, setApiResponse] = useState<any>(); + + async function formSubmit(e: React.FormEvent) { + e.preventDefault(); + let project = { Name: name }; + + CreatePorject(project, setApiResponse); + } + + useEffect(() => { + apiResponse && apiResponse.status === "OK" && router.push("/projects"); + }, [apiResponse]); + + return ( + <MainLayout> + <h1 className="text-xl font-medium">Create Project</h1> + + <form onSubmit={formSubmit} className="my-4 flex flex-col gap-4"> + <div> + <label className="block text-sm font-medium">Name</label> + <input + value={name} + onChange={(e) => setName(e.target.value)} + className="w-80 border border-slate-200 p-1" + /> + </div> + <PrimaryButton type="submit" text="Create Project" /> + </form> + </MainLayout> + ); +}; +export default CreateProjectPage;
A client/src/pages/projects/index.tsx

@@ -0,0 +1,43 @@

+import { ListProjects } from "@/api/projects"; +import { PrimaryButton } from "@/components/Button"; +import MainLayout from "@/components/MainLayout"; +import Table from "@/components/table"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; + +const ProjectsHomePage = () => { + const router = useRouter(); + + const [projects, setProjects] = useState<any>([]); + + useEffect(() => { + ListProjects(setProjects); + }, []); + + return ( + <MainLayout> + <div className="mb-4 flex justify-between items-center"> + <h1 className="text-xl font-medium">Projects</h1> + <PrimaryButton + text="Create Project" + onClick={() => router.push("/projects/create")} + /> + </div> + {projects.data !== undefined && ( + <Table + columns={["Name", "ID"]} + data={projects.data.map((project: any) => { + return { + name: ( + <Link href={"/projects/" + project.id}>{project.name}</Link> + ), + id: project.id, + }; + })} + /> + )} + </MainLayout> + ); +}; +export default ProjectsHomePage;
A client/src/styles/globals.css

@@ -0,0 +1,3 @@

+@tailwind base; +@tailwind components; +@tailwind utilities;
A client/tailwind.config.ts

@@ -0,0 +1,20 @@

+import type { Config } from "tailwindcss"; + +const config: Config = { + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +}; +export default config;
A client/tsconfig.json

@@ -0,0 +1,21 @@

+{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +}