feat: service page

This commit is contained in:
Derock 2023-12-17 21:42:39 -05:00
parent 83a8510790
commit bd34298eb5
No known key found for this signature in database
32 changed files with 1500 additions and 2659 deletions

View file

@ -1 +1,30 @@
# Hostforge 🔥
# Hostforge 🔥
Hostforge takes the complexity out of hosting your applications on your servers. It offers a simple and intuitive, yet powerful interface built on top of Docker Swarm.
<!-- video here -->
## Features
- **Simple** - With the power of Nixpacks, you don't even need to write a Dockerfile. Just point Hostforge to your git repository and it will build your image and deploy it for you.
- **Secure** - Hostforge is built with security in mind. All projects use their own network and are isolated from each other.
- **Automated** - Hostforge can watch for changes in your git repository and automatically update your application when you push a commit.
- **Scalable** - Hostforge is built on top of Docker Swarm, which means you can scale your application with a single click.
- note: the Hostforge web-panel cannot be scaled to multiple instances, but your applications can.
## Motivation
I've been using Portainer for quite a while, but I found it to be cumbersome to have to write out my own compose files. Constantly googling to find the right keys and values to use. I wanted something that would allow me to create a service with a few clicks and have it up and running in seconds.
I also wanted something that could automatically build my images from a git repository so I didn't have to fiddle around with CI/CD pipelines. I wanted to be able to push a commit to my repository and have my application automatically update.
And so, that's when I started working on Hostforge -- my largest open source project to date.
## Donate
That being said, this project has taken me a lot of time and effort to build. If it manages to save you some time and effort, please consider donating to help me keep this project alive.
<!-- kofi -->
[![ko-fi](https://www.ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/derock)
<!-- gh sponsors -->
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&link=https://github.com/ItzDerock/sponsor)](https://github.com/ItzDerock/sponsor)

View file

@ -16,9 +16,9 @@
"fetch-compose-types": "curl -s https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json | json2ts > src/server/docker/compose.d.ts"
},
"dependencies": {
"@mantine/form": "^7.2.2",
"@mantine/form": "^7.3.2",
"@nicktomlin/codemirror-lang-yaml-lite": "^0.0.3",
"@prisma/migrate": "^5.6.0",
"@prisma/migrate": "^5.7.0",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
@ -27,16 +27,16 @@
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@t3-oss/env-nextjs": "^0.7.1",
"@tanstack/react-query": "^4.36.1",
"@tanstack/react-query": "^5.14.0",
"@tanstack/react-table": "^8.10.7",
"@trpc/client": "^10.44.1",
"@trpc/next": "^10.44.1",
"@trpc/react-query": "^10.44.1",
"@trpc/server": "^10.44.1",
"@trpc/client": "11.0.0-alpha-next-2023-12-15-18-34-32.118",
"@trpc/next": "11.0.0-alpha-next-2023-12-15-18-34-32.118",
"@trpc/react-query": "11.0.0-alpha-next-2023-12-15-18-34-32.118",
"@trpc/server": "11.0.0-alpha-next-2023-12-15-18-34-32.118",
"@uiw/codemirror-extensions-langs": "^4.21.21",
"@uiw/react-codemirror": "^4.21.21",
"argon2": "^0.31.2",
"better-sqlite3": "^9.1.1",
"better-sqlite3": "^9.2.2",
"bufferutil": "^4.0.8",
"chalk": "^5.3.0",
"class-variance-authority": "^0.7.0",
@ -46,55 +46,56 @@
"dockerode": "^4.0.0",
"dotenv": "^16.3.1",
"drizzle-orm": "^0.29.1",
"extensionless": "^1.7.3",
"framer-motion": "^10.16.9",
"extensionless": "^1.9.6",
"framer-motion": "^10.16.16",
"ipaddr.js": "^2.1.0",
"next": "14.0.3",
"lucide-react": "^0.298.0",
"next": "14.0.4",
"next-themes": "^0.2.1",
"node-os-utils": "^1.3.7",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "^4.12.0",
"react-simple-code-editor": "^0.13.1",
"recharts": "^2.10.2",
"recharts": "^2.10.3",
"sonner": "^1.2.4",
"superjson": "^2.2.1",
"tailwind-merge": "^2.0.0",
"tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7",
"trpc-openapi": "^1.2.0",
"ts-permissions": "^1.0.0",
"ua-parser-js": "^1.0.37",
"winston": "^3.11.0",
"ws": "^8.14.2",
"ws": "^8.15.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/cookie": "^0.6.0",
"@types/dockerode": "^3.3.23",
"@types/eslint": "^8.44.8",
"@types/node": "^20.10.1",
"@types/eslint": "^8.44.9",
"@types/node": "^20.10.4",
"@types/node-os-utils": "^1.3.4",
"@types/react": "^18.2.39",
"@types/react-dom": "^18.2.17",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@types/ua-parser-js": "^0.7.39",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"autoprefixer": "^10.4.16",
"drizzle-kit": "^0.20.6",
"eslint": "^8.54.0",
"eslint-config-next": "^14.0.3",
"eslint": "^8.56.0",
"eslint-config-next": "^14.0.4",
"json-schema-to-typescript": "^13.1.1",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.31",
"prettier": "^3.1.0",
"prettier-plugin-tailwindcss": "^0.5.7",
"tailwindcss": "^3.3.5",
"postcss": "^8.4.32",
"prettier": "^3.1.1",
"prettier-plugin-tailwindcss": "^0.5.9",
"tailwindcss": "^3.3.6",
"tscpaths": "^0.0.9",
"tsup": "^8.0.1",
"typed-emitter": "^2.1.0",
"typescript": "^5.3.2"
"typescript": "^5.3.3"
},
"ct3aMetadata": {
"initVersion": "7.20.3-beta.2e8399f"

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
"use client";
import { type LucideIcon } from "lucide-react";
import { Area, AreaChart, ResponsiveContainer } from "recharts";
import { AnimatedNumber } from "~/components/AnimatedPercent";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@ -8,7 +9,7 @@ import styles from "./StatCard.module.css";
export function StatCard<T extends Record<string, unknown>>(props: {
title: string;
icon: React.FC<{ className: string }>;
icon: React.FC<{ className: string }> | LucideIcon;
data: T[];
dataKey: keyof T & string;

View file

@ -1,12 +1,13 @@
"use client";
import { useMemo, useState } from "react";
import {
FaEthernet,
FaHardDrive,
FaMemory,
FaMicrochip,
} from "react-icons/fa6";
// import {
// FaEthernet,
// FaHardDrive,
// FaMemory,
// FaMicrochip,
// } from "react-icons/fa6";
import { Cpu, HardDrive, MemoryStick, Router } from "lucide-react";
import { api } from "~/trpc/react";
import { type RouterOutputs } from "~/trpc/shared";
import { StatCard } from "./StatCard";
@ -44,7 +45,7 @@ export function SystemStatistics(props: {
value={data.cpu.usage}
unit="%"
subvalue={`of ${data.cpu.cores} CPUs`}
icon={FaMicrochip}
icon={Cpu}
data={historicalData}
dataKey="cpuUsage"
/>
@ -57,7 +58,7 @@ export function SystemStatistics(props: {
subvalue={`${data.memory.used.toFixed(2)} / ${data.memory.total.toFixed(
2,
)} GB`}
icon={FaMemory}
icon={MemoryStick}
data={historicalData}
dataKey="memoryUsage"
/>
@ -69,7 +70,7 @@ export function SystemStatistics(props: {
subvalue={`${data.storage.used.toFixed(
2,
)} / ${data.storage.total.toFixed(2)} GB`}
icon={FaHardDrive}
icon={HardDrive}
data={historicalData}
dataKey="diskUsage"
/>
@ -85,7 +86,7 @@ export function SystemStatistics(props: {
// secondaryUnit="Mbps"
secondarySubvalue="RX / Mbps"
// misc
icon={FaEthernet}
icon={Router}
data={historicalData}
dataKey="network"
/>

View file

@ -0,0 +1,50 @@
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Settings } from "lucide-react";
import { api } from "~/trpc/server";
export default async function Navbar() {
const user = await api.auth.me.query();
return (
<nav className="py-4">
<div className="mx-auto flex h-12 flex-row items-center gap-4">
<div>Hostforge</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost">Project</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Project A</DropdownMenuItem>
<DropdownMenuItem>Project B</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-grow" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost">
<Settings />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Logged in as {user.username}</DropdownMenuLabel>
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</nav>
);
}

View file

@ -2,7 +2,7 @@
import { useForm } from "@mantine/form";
import { useQuery } from "@tanstack/react-query";
import React, { Suspense } from "react";
import React, { Suspense, useEffect } from "react";
import { toast } from "sonner";
import { FormInputGroup } from "~/components/FormInput";
import { Button } from "~/components/ui/button";
@ -67,31 +67,25 @@ export function CreateProjectButton() {
},
});
const fetchComposeFile = useQuery(
["fetchComposeFile", form.values.composeURL],
async () => {
const fetchComposeFile = useQuery({
queryKey: ["fetchComposeFile", form.values.composeURL],
queryFn: async () => {
const response = await fetch(form.values.composeURL);
return await response.text();
},
{
enabled: false,
cacheTime: 0,
retry: false,
onSuccess(data) {
form.setFieldValue("composeFile", data);
toast.success("Fetched Docker Compose file");
},
onError(error) {
toast.error("Failed to fetch Docker Compose file");
console.error(error);
},
},
);
enabled: false,
// cacheTime: 0,
retry: false,
});
// reset fetchComposeFile when the URL changes
React.useEffect(() => {
fetchComposeFile.remove();
}, [form.values.composeURL]);
useEffect(() => {
if (fetchComposeFile.isError) {
toast.error("Failed to fetch Docker Compose file");
} else if (fetchComposeFile.isSuccess) {
form.setFieldValue("composeFile", fetchComposeFile.data);
toast.success("Fetched Docker Compose file");
}
}, [fetchComposeFile, form]);
return (
<Dialog>
@ -220,7 +214,7 @@ export function CreateProjectButton() {
<Button
type="submit"
form="create-project-form"
isLoading={create.isLoading}
isLoading={create.isPending}
>
Create Project
</Button>

View file

@ -36,7 +36,7 @@ export function Project({ project }: { project: Project }) {
{/* Name and health */}
<div>
<CardTitle className="font-semibold">
<Link href={`/projects/${project.internalName}`}>
<Link href={`/project/${project.internalName}`}>
{project.friendlyName}{" "}
<FaArrowUpRightFromSquare className="inline-block h-[10px]" />
</Link>

View file

@ -0,0 +1,10 @@
import Navbar from "./_navbar/Navbar";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto max-w-[1500px]">
<Navbar />
{children}
</div>
);
}

View file

@ -1,27 +1,40 @@
import { Check } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { api } from "~/trpc/server";
import { SystemStatistics } from "./_components/SystemStatistics";
import { ProjectList } from "./_projects/ProjectList";
export default async function DashboardHome() {
const [initialStats, historicalData, projects] = await Promise.all([
const [initialStats, historicalData, projects, user] = await Promise.all([
api.system.currentStats.query(),
api.system.history.query(),
api.projects.list.query(),
api.auth.me.query(),
]);
return (
<div className="mx-auto max-w-[1500px]">
<div className="mx-auto">
<SystemStatistics
initialData={initialStats}
historicalData={historicalData}
/>
<div className="w-full">
<h1>Welcome back!</h1>
<p className="text-muted-foreground">
Here&apos;s a quick overview of your projects.
</p>
</div>
<Card className="mt-8 bg-gradient-to-br from-primary to-accent py-4 text-accent-foreground">
<CardHeader className="pb-4">
<CardTitle className="text-2xl">
Welcome Back, {user.username}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-primary-foreground/70">
<Check className="mr-2 inline-block" />
Hostforge is up to date. (v1.0.0)
<br />
<Check className="mr-2 inline-block" />
All services are running.
</p>
</CardContent>
</Card>
<ProjectList defaultValue={projects} />
</div>

View file

@ -0,0 +1,3 @@
"use client";
export function CreateService() {}

View file

@ -0,0 +1,94 @@
import { Home, Plus, Settings2, UploadCloud } from "lucide-react";
import Link from "next/link";
import { Button } from "~/components/ui/button";
import { api } from "~/trpc/server";
export default async function ProjectPage(props: {
params: { id: string };
children: React.ReactNode;
}) {
const project = await api.projects.get.query({ projectId: props.params.id });
const healthy = project.services.filter(
(s) =>
s.stats?.ServiceStatus?.RunningTasks ==
s.stats?.ServiceStatus?.DesiredTasks,
).length;
return (
<div>
<h1 className="text-3xl font-bold">
{project.friendlyName}{" "}
<span className="text-sm font-normal text-muted-foreground">
({project.internalName})
</span>
</h1>
<h2 className="text-muted-foreground">
{/* green dot = healthy, yellow = partial, red = all off */}
<span
className={`mr-1 inline-block h-3 w-3 rounded-full bg-${
healthy == project.services.length
? "green"
: healthy > 0
? "yellow"
: "red"
}-500`}
/>
{healthy}/{project.services.length} Healthy Services
</h2>
<div className="mt-4 flex flex-row flex-wrap gap-4">
{/* home */}
<Button variant="outline" icon={Home} asChild>
<Link href={`/project/${props.params.id}/home`}>Home</Link>
</Button>
{/* deploy changes */}
<Button variant="outline" icon={UploadCloud}>
Deploy Changes
</Button>
{/* new */}
<Button variant="outline" icon={Plus}>
New Service
</Button>
{/* settings */}
<Button variant="outline" icon={Settings2}>
<Link href={`/project/${props.params.id}/settings`}>Settings</Link>
</Button>
</div>
<div className="flex flex-row gap-4 border-b-2 border-b-border py-8">
{/* services */}
<div className="flex flex-grow flex-row gap-2 overflow-x-auto">
{project.services.map((service) => (
<Link
key={service.id}
href={`/project/${props.params.id}/service/${service.id}`}
>
<div className="flex flex-row items-center gap-1">
<div
className={`mr-1 inline-block h-3 w-3 rounded-full bg-${
service.stats?.ServiceStatus?.RunningTasks ==
service.stats?.ServiceStatus?.DesiredTasks
? "green"
: service.stats?.ServiceStatus?.RunningTasks ?? 0 > 0
? "yellow"
: "red"
}-500`}
/>
<span>{service.name}</span>
</div>
</Link>
))}
{project.services.length == 0 && (
<span className="text-muted-foreground">No services found.</span>
)}
</div>
</div>
{props.children}
</div>
);
}

View file

@ -0,0 +1,7 @@
export default function ProjectHome() {
// return (
// // <pre>{JSON.stringify(project, null, 2)}</pre>
// )
return <p>hello world</p>;
}

View file

@ -0,0 +1,21 @@
export default function Footer() {
return (
<div className="mt-8 flex flex-row items-center justify-center text-center text-sm">
<div className="text-muted-foreground">
💖 100% open source,{" "}
<a
href="https://github.com/ItzDerock/hostforge?utm_source=hostforge&utm_medium=footer&utm_campaign=hostforge"
className="underline"
>
view on GitHub
</a>
<br />
Please consider{" "}
<a href="https://github.com/sponsors/ItzDerock" className="underline">
sponsoring
</a>{" "}
this project if you find it useful.
</div>
</div>
);
}

View file

@ -2,9 +2,10 @@ import "~/styles/globals.css";
import { Inter } from "next/font/google";
import { cookies } from "next/headers";
import { TRPCReactProvider } from "~/trpc/react";
import { ThemeProvider } from "~/components/contexts/ThemeProvider";
import { ToastProvider } from "~/components/contexts/ToastProvider";
import { TRPCReactProvider } from "~/trpc/react";
import Footer from "./_footer/Footer";
const inter = Inter({
subsets: ["latin"],
@ -29,7 +30,11 @@ export default function RootLayout({
>
<TRPCReactProvider cookies={cookies().toString()}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
<ToastProvider>{children}</ToastProvider>
<ToastProvider>
{children}
<Footer />
</ToastProvider>
</ThemeProvider>
</TRPCReactProvider>
</body>

View file

@ -1,8 +1,9 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/utils/utils";
import { type LucideIcon } from "lucide-react";
import * as React from "react";
import { CgSpinner } from "react-icons/cg";
import { cn } from "~/utils/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
@ -40,6 +41,7 @@ export interface ButtonProps
VariantProps<typeof buttonVariants> {
asChild?: boolean;
isLoading?: boolean;
icon?: LucideIcon;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
@ -51,11 +53,14 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
asChild = false,
children,
isLoading,
icon: Icon,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const Child = asChild ? "span" : React.Fragment;
return (
<Comp
className={cn(
@ -64,10 +69,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
)}
ref={ref}
{...props}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
disabled={isLoading || props.disabled}
>
{isLoading && <CgSpinner className="mr-2 animate-spin" />}
{children}
<Child>
{isLoading && <CgSpinner className="mr-2 animate-spin" />}
{Icon && !isLoading && <Icon className="mr-2" size={18} />}
{children}
</Child>
</Comp>
);
},

View file

@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { z } from "zod";
import { projects, service } from "~/server/db/schema";
import { authenticatedProcedure, createTRPCRouter } from "../../trpc";
import { getProject } from "./project";
export const projectRouter = createTRPCRouter({
list: authenticatedProcedure
@ -72,4 +73,6 @@ export const projectRouter = createTRPCRouter({
return data.id;
}),
get: getProject,
});

View file

@ -0,0 +1,38 @@
import { eq } from "drizzle-orm";
import { z } from "zod";
import { service } from "~/server/db/schema";
import { projectAuthenticatedProcedure } from "../../trpc";
export const getProject = projectAuthenticatedProcedure
.meta({
openapi: {
method: "GET",
path: "/api/projects/:projectId",
summary: "Get project",
},
})
.output(z.unknown())
.query(async ({ ctx }) => {
const projServices = await ctx.db
.select({
id: service.id,
name: service.name,
})
.from(service)
.where(eq(service.projectId, ctx.project.id));
// get docker stats
const stats = await ctx.docker.listServices({
filters: {
label: [`com.docker.stack.namespace=${ctx.project.internalName}`],
},
});
return {
...ctx.project,
services: projServices.map((service) => ({
...service,
stats: stats.find((stat) => stat.Spec?.Name === service.name),
})),
};
});

View file

@ -7,18 +7,19 @@
* need to use are documented accordingly near the end.
*/
import { TRPCError, initTRPC } from "@trpc/server";
import { type NextRequest } from "next/server.js";
import cookie from "cookie";
import type Dockerode from "dockerode";
import { eq, or } from "drizzle-orm";
import { type IncomingMessage, type ServerResponse } from "http";
import ipaddr from "ipaddr.js";
import superjson from "superjson";
import { ZodError } from "zod";
import { ZodError, z } from "zod";
import { db } from "~/server/db";
import { Session } from "../auth/Session";
import ipaddr from "ipaddr.js";
import { IncomingMessage, ServerResponse } from "http";
import logger from "../utils/logger";
import cookie from "cookie";
import { projects } from "../db/schema";
import { getDockerInstance } from "../docker";
import Dockerode from "dockerode";
import { OpenApiMeta, generateOpenApiDocument } from "trpc-openapi";
import logger from "../utils/logger";
// import { OpenApiMeta, generateOpenApiDocument } from "trpc-openapi";
export type ExtendedRequest = IncomingMessage & {
cookies: Record<string, string>;
@ -101,7 +102,7 @@ export const createTRPCContext = async (opts: {
const cookies = ((opts.req as ExtendedRequest).cookies = cookie.parse(
opts.req.headers.cookie ?? "",
));
const sessionToken = cookies["sessionToken"];
const sessionToken = cookies.sessionToken;
// fetch session data from token
const session: Session | null = sessionToken
@ -127,7 +128,7 @@ export const createTRPCContext = async (opts: {
* errors on the backend.
*/
export const t = initTRPC
.meta<OpenApiMeta>()
// .meta<OpenApiMeta>()
.context<typeof createTRPCContext>()
.create({
transformer: superjson,
@ -188,3 +189,55 @@ export const authenticatedProcedure = t.procedure.use(
});
}),
);
/**
* Project-related procedures
*
* Abstracts away finding a project by ID or internal name, and returns the project object.
* REMINDER: if you override `input`, you must include `projectId` in the new input object.
*/
export const projectAuthenticatedProcedure = authenticatedProcedure
.use(
t.middleware(async ({ ctx, input, next }) => {
if (
!input ||
typeof input != "object" ||
!("projectId" in input) ||
typeof input.projectId != "string"
) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Expected a project ID or internal name.",
});
}
const [project] = await ctx.db
.select({
id: projects.id,
friendlyName: projects.friendlyName,
internalName: projects.internalName,
createdAt: projects.createdAt,
})
.from(projects)
.where(
or(
eq(projects.id, input.projectId),
eq(projects.internalName, input.projectId),
),
)
.limit(1);
if (!project)
throw new TRPCError({
code: "NOT_FOUND",
message: "Project not found or insufficient permissions.",
});
return next({
ctx: {
project: project,
},
});
}),
)
.input(z.object({ projectId: z.string() }));

View file

@ -1,11 +1,11 @@
import { between, count, lte } from "drizzle-orm";
import EventEmitter from "events";
import TypedEmitter from "typed-emitter";
import osu from "node-os-utils";
import os from "os";
import baseLogger from "../../utils/logger";
import type TypedEmitter from "typed-emitter";
import { db } from "~/server/db";
import { systemStats } from "~/server/db/schema";
import { between, lte } from "drizzle-orm";
import baseLogger from "../../utils/logger";
export type BasicServerStats = {
collectedAt: Date;
@ -73,7 +73,7 @@ type StatEvents = {
*/
newListener: (
event: string | symbol,
listener: (...args: any[]) => void,
listener: (...args: unknown[]) => void,
) => void;
/**
@ -81,7 +81,7 @@ type StatEvents = {
*/
removeListener: (
event: string | symbol,
listener: (...args: any[]) => void,
listener: (...args: unknown[]) => void,
) => void;
};
@ -130,13 +130,13 @@ export class StatManager {
private liveInterval: NodeJS.Timeout | null = null;
constructor() {
this.update().then(() => this.updateDatabase());
void this.update().then(() => this.updateDatabase());
// whenever a new listener is added, start the live interval
this.events.on("newListener", (event) => {
if (event === "onUpdate") {
this.liveInterval ??= setInterval(async () => {
await this.update();
this.liveInterval ??= setInterval(() => {
void this.update();
}, 3 * 1000);
}
});
@ -152,16 +152,37 @@ export class StatManager {
});
}
start() {
async start() {
this.logger.info("Starting hourly stat collection.");
// collect stats every hour
setInterval(
async () => {
await this.update();
await this.updateDatabase();
() => {
void this.update().then(() => this.updateDatabase());
},
60 * 60 * 1000,
);
// fill data with some initial datapoints if empty
const [data] = await db
.select({ value: count() })
.from(systemStats)
.where(lte(systemStats.timestamp, Date.now() - 60 * 60 * 1000))
.execute();
if (data?.value ?? 1 > 2) {
return;
}
this.logger.info("Initial stat collection needed, filling database.");
for (let i = 0; i < 3; i++) {
await this.update();
await this.updateDatabase();
// wait 5 seconds between each update
await new Promise((r) => setTimeout(r, 5 * 1000));
}
this.logger.info("Finished initial stat collection.");
}
/**
@ -266,6 +287,7 @@ export class StatManager {
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
export const stats = (((globalThis as any).statsManager as
| StatManager
| undefined) ??= new StatManager());

View file

@ -1,23 +1,23 @@
import "dotenv/config";
import next from "next";
import { env } from "~/env";
import { createServer } from "http";
import logger from "./utils/logger";
import { WebSocketServer } from "ws";
import { nodeHTTPRequestHandler } from "@trpc/server/adapters/node-http";
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import "dotenv/config";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { mkdir, stat } from "fs/promises";
import { createServer } from "http";
import next from "next";
import path from "path";
import { WebSocketServer } from "ws";
import { env } from "~/env";
import { version } from "../../package.json";
import { appRouter } from "./api/root";
import { createTRPCContext } from "./api/trpc";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./db";
import { mkdir, stat } from "fs/promises";
import path from "path";
import { version } from "../../package.json";
import { stats } from "./modules/stats";
import { nodeHTTPRequestHandler } from "@trpc/server/adapters/node-http";
import {
createOpenApiHttpHandler,
generateOpenApiDocument,
} from "trpc-openapi";
import logger from "./utils/logger";
// import {
// createOpenApiHttpHandler,
// generateOpenApiDocument,
// } from "trpc-openapi";
// check if database folder exists
try {
@ -40,7 +40,7 @@ if (env.NODE_ENV === "production") {
}
// start statistics
stats.start();
void stats.start();
// initialize the next app
const app = next({
@ -53,24 +53,24 @@ const app = next({
await app.prepare();
// create openapi documentation if in development
const openAPIDocument =
env.NODE_ENV === "development"
? JSON.stringify(
generateOpenApiDocument(appRouter, {
title: "Hostforge API Documentation",
version,
baseUrl: `http://${env.HOSTNAME}:${env.PORT}`,
}),
)
: null;
// const openAPIDocument =
// env.NODE_ENV === "development"
// ? JSON.stringify(
// generateOpenApiDocument(appRouter, {
// title: "Hostforge API Documentation",
// version,
// baseUrl: `http://${env.HOSTNAME}:${env.PORT}`,
// }),
// )
// : null;
// get the handles
const getHandler = app.getRequestHandler();
const upgradeHandler = app.getUpgradeHandler();
const openAPIHandle = createOpenApiHttpHandler({
router: appRouter,
createContext: createTRPCContext,
});
// const openAPIHandle = createOpenApiHttpHandler({
// router: appRouter,
// createContext: createTRPCContext,
// });
// create the http server
const server = createServer((req, res) => {
@ -95,16 +95,16 @@ const server = createServer((req, res) => {
}
// handle openAPI routes
if (req.url?.startsWith("/api/")) {
return void openAPIHandle(req, res);
}
// if (req.url?.startsWith("/api/")) {
// return void openAPIHandle(req, res);
// }
// serve openapi documentation
if (req.url?.startsWith("/openapi.json") && openAPIDocument) {
res.setHeader("Content-Type", "application/json");
res.end(openAPIDocument);
return;
}
// if (req.url?.startsWith("/openapi.json") && openAPIDocument) {
// res.setHeader("Content-Type", "application/json");
// res.end(openAPIDocument);
// return;
// }
getHandler(req, res).catch((error) => {
logger.error(error);

View file

@ -36,35 +36,36 @@
}
.dark {
--background: 22.5 66.67% 2.35%;
--foreground: 20 100% 94.12%;
--background: 240 10% 4%;
/* --background: 240 6% 10%; Dark blue-grey background */
--foreground: 200 10% 90%; /* Light grey text */
--primary: 19.5 100% 60.78%;
--primary-foreground: 0 0% 100%;
--primary: 19 93% 43%; /* Keep original orange */
--primary-foreground: 0 0% 100%; /* White on orange */
--card: 22.5 66.67% 2.35%;
--card-foreground: 20 100% 94.12%;
--card: 240 6% 10%; /* Slightly lighter background for cards */
--card-foreground: inherit; /* Same text color as foreground */
--popover: 22.5 66.67% 2.35%;
--popover-foreground: 20 100% 94.12%;
--popover: 22.5 66.67% 2.35%; /* Same card background for popovers */
--popover-foreground: inherit; /* Same text color as foreground */
--secondary: 19.71 64.81% 21.18%;
--secondary-foreground: 0 0% 100%;
--secondary: 19.71 64.81% 21.18%; /* Slightly more muted orange */
--secondary-foreground: 0 0% 100%; /* White on secondary orange */
--muted: 25.71 11.48% 11.96%;
--muted-foreground: 15 1.61% 51.37%;
--muted: 25.71 11.48% 11.96%; /* Slightly lighter grey for muted elements */
--muted-foreground: 15 1.61% 51.37%; /* Slightly darker text for muted elements */
--accent: 19.71 64.81% 21.18%;
--accent-foreground: 20 100% 94.12%;
--accent: 19.71 64.81% 21.18%; /* Same as secondary orange for consistency */
--accent-foreground: inherit; /* Same text color as foreground */
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--destructive: 0 84.2% 60.2%; /* Red alert color */
--destructive-foreground: 210 40% 98%; /* White on red for high contrast */
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 19.5 100% 60.78%;
--border: 0 0% 18%; /* Same border color */
--input: 0 0% 18%; /* Same input border color */
--ring: 19.5 100% 60.78%; /* Same orange for focus ring */
--radius: 0.5rem;
--radius: 0.5rem; /* Same radius for rounded corners */
}
}

View file

@ -2,20 +2,19 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
type HTTPBatchStreamLinkOptions,
createWSClient,
loggerLink,
splitLink,
unstable_httpBatchStreamLink,
wsLink,
createWSClient,
splitLink,
httpLink,
type HTTPBatchStreamLinkOptions,
} from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useState } from "react";
import { type AppRouter } from "~/server/api/root";
import { getUrl, transformer } from "./shared";
import { authLink } from "./links";
import { getUrl, transformer } from "./shared";
export const api = createTRPCReact<AppRouter>();
@ -66,6 +65,10 @@ export function TRPCReactProvider(props: {
true: wsLink({
client: createWSClient({
url: getUrl().replace("http", "ws"),
lazy: {
enabled: true,
closeMs: 5_000,
},
}),
}),

View file

@ -1,3 +1,5 @@
"use server";
import {
TRPCClientError,
createTRPCProxyClient,
@ -5,11 +7,11 @@ import {
unstable_httpBatchStreamLink,
} from "@trpc/client";
import { type AppRouter } from "~/server/api/root";
import { getUrl, transformer } from "./shared";
import { cookies, headers } from "next/headers";
import logger from "~/server/utils/logger";
import chalk from "chalk";
import { cookies, headers } from "next/headers";
import { type AppRouter } from "~/server/api/root";
import logger from "~/server/utils/logger";
import { getUrl, transformer } from "./shared";
export const api = createTRPCProxyClient<AppRouter>({
transformer,