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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,12 +1,13 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { // import {
FaEthernet, // FaEthernet,
FaHardDrive, // FaHardDrive,
FaMemory, // FaMemory,
FaMicrochip, // FaMicrochip,
} from "react-icons/fa6"; // } from "react-icons/fa6";
import { Cpu, HardDrive, MemoryStick, Router } from "lucide-react";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import { type RouterOutputs } from "~/trpc/shared"; import { type RouterOutputs } from "~/trpc/shared";
import { StatCard } from "./StatCard"; import { StatCard } from "./StatCard";
@ -44,7 +45,7 @@ export function SystemStatistics(props: {
value={data.cpu.usage} value={data.cpu.usage}
unit="%" unit="%"
subvalue={`of ${data.cpu.cores} CPUs`} subvalue={`of ${data.cpu.cores} CPUs`}
icon={FaMicrochip} icon={Cpu}
data={historicalData} data={historicalData}
dataKey="cpuUsage" dataKey="cpuUsage"
/> />
@ -57,7 +58,7 @@ export function SystemStatistics(props: {
subvalue={`${data.memory.used.toFixed(2)} / ${data.memory.total.toFixed( subvalue={`${data.memory.used.toFixed(2)} / ${data.memory.total.toFixed(
2, 2,
)} GB`} )} GB`}
icon={FaMemory} icon={MemoryStick}
data={historicalData} data={historicalData}
dataKey="memoryUsage" dataKey="memoryUsage"
/> />
@ -69,7 +70,7 @@ export function SystemStatistics(props: {
subvalue={`${data.storage.used.toFixed( subvalue={`${data.storage.used.toFixed(
2, 2,
)} / ${data.storage.total.toFixed(2)} GB`} )} / ${data.storage.total.toFixed(2)} GB`}
icon={FaHardDrive} icon={HardDrive}
data={historicalData} data={historicalData}
dataKey="diskUsage" dataKey="diskUsage"
/> />
@ -85,7 +86,7 @@ export function SystemStatistics(props: {
// secondaryUnit="Mbps" // secondaryUnit="Mbps"
secondarySubvalue="RX / Mbps" secondarySubvalue="RX / Mbps"
// misc // misc
icon={FaEthernet} icon={Router}
data={historicalData} data={historicalData}
dataKey="network" 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 { useForm } from "@mantine/form";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import React, { Suspense } from "react"; import React, { Suspense, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { FormInputGroup } from "~/components/FormInput"; import { FormInputGroup } from "~/components/FormInput";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@ -67,31 +67,25 @@ export function CreateProjectButton() {
}, },
}); });
const fetchComposeFile = useQuery( const fetchComposeFile = useQuery({
["fetchComposeFile", form.values.composeURL], queryKey: ["fetchComposeFile", form.values.composeURL],
async () => { queryFn: async () => {
const response = await fetch(form.values.composeURL); const response = await fetch(form.values.composeURL);
return await response.text(); return await response.text();
}, },
{ enabled: false,
enabled: false, // cacheTime: 0,
cacheTime: 0, retry: false,
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);
},
},
);
// reset fetchComposeFile when the URL changes useEffect(() => {
React.useEffect(() => { if (fetchComposeFile.isError) {
fetchComposeFile.remove(); toast.error("Failed to fetch Docker Compose file");
}, [form.values.composeURL]); } else if (fetchComposeFile.isSuccess) {
form.setFieldValue("composeFile", fetchComposeFile.data);
toast.success("Fetched Docker Compose file");
}
}, [fetchComposeFile, form]);
return ( return (
<Dialog> <Dialog>
@ -220,7 +214,7 @@ export function CreateProjectButton() {
<Button <Button
type="submit" type="submit"
form="create-project-form" form="create-project-form"
isLoading={create.isLoading} isLoading={create.isPending}
> >
Create Project Create Project
</Button> </Button>

View file

@ -36,7 +36,7 @@ export function Project({ project }: { project: Project }) {
{/* Name and health */} {/* Name and health */}
<div> <div>
<CardTitle className="font-semibold"> <CardTitle className="font-semibold">
<Link href={`/projects/${project.internalName}`}> <Link href={`/project/${project.internalName}`}>
{project.friendlyName}{" "} {project.friendlyName}{" "}
<FaArrowUpRightFromSquare className="inline-block h-[10px]" /> <FaArrowUpRightFromSquare className="inline-block h-[10px]" />
</Link> </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 { api } from "~/trpc/server";
import { SystemStatistics } from "./_components/SystemStatistics"; import { SystemStatistics } from "./_components/SystemStatistics";
import { ProjectList } from "./_projects/ProjectList"; import { ProjectList } from "./_projects/ProjectList";
export default async function DashboardHome() { 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.currentStats.query(),
api.system.history.query(), api.system.history.query(),
api.projects.list.query(), api.projects.list.query(),
api.auth.me.query(),
]); ]);
return ( return (
<div className="mx-auto max-w-[1500px]"> <div className="mx-auto">
<SystemStatistics <SystemStatistics
initialData={initialStats} initialData={initialStats}
historicalData={historicalData} historicalData={historicalData}
/> />
<div className="w-full"> <Card className="mt-8 bg-gradient-to-br from-primary to-accent py-4 text-accent-foreground">
<h1>Welcome back!</h1> <CardHeader className="pb-4">
<p className="text-muted-foreground"> <CardTitle className="text-2xl">
Here&apos;s a quick overview of your projects. Welcome Back, {user.username}
</p> </CardTitle>
</div> </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} /> <ProjectList defaultValue={projects} />
</div> </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 { Inter } from "next/font/google";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { TRPCReactProvider } from "~/trpc/react";
import { ThemeProvider } from "~/components/contexts/ThemeProvider"; import { ThemeProvider } from "~/components/contexts/ThemeProvider";
import { ToastProvider } from "~/components/contexts/ToastProvider"; import { ToastProvider } from "~/components/contexts/ToastProvider";
import { TRPCReactProvider } from "~/trpc/react";
import Footer from "./_footer/Footer";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
@ -29,7 +30,11 @@ export default function RootLayout({
> >
<TRPCReactProvider cookies={cookies().toString()}> <TRPCReactProvider cookies={cookies().toString()}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem> <ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
<ToastProvider>{children}</ToastProvider> <ToastProvider>
{children}
<Footer />
</ToastProvider>
</ThemeProvider> </ThemeProvider>
</TRPCReactProvider> </TRPCReactProvider>
</body> </body>

View file

@ -1,8 +1,9 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; 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 { CgSpinner } from "react-icons/cg";
import { cn } from "~/utils/utils";
const buttonVariants = cva( 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", "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> { VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean;
isLoading?: boolean; isLoading?: boolean;
icon?: LucideIcon;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
@ -51,11 +53,14 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
asChild = false, asChild = false,
children, children,
isLoading, isLoading,
icon: Icon,
...props ...props
}, },
ref, ref,
) => { ) => {
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button";
const Child = asChild ? "span" : React.Fragment;
return ( return (
<Comp <Comp
className={cn( className={cn(
@ -64,10 +69,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
)} )}
ref={ref} ref={ref}
{...props} {...props}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
disabled={isLoading || props.disabled} disabled={isLoading || props.disabled}
> >
{isLoading && <CgSpinner className="mr-2 animate-spin" />} <Child>
{children} {isLoading && <CgSpinner className="mr-2 animate-spin" />}
{Icon && !isLoading && <Icon className="mr-2" size={18} />}
{children}
</Child>
</Comp> </Comp>
); );
}, },

View file

@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { projects, service } from "~/server/db/schema"; import { projects, service } from "~/server/db/schema";
import { authenticatedProcedure, createTRPCRouter } from "../../trpc"; import { authenticatedProcedure, createTRPCRouter } from "../../trpc";
import { getProject } from "./project";
export const projectRouter = createTRPCRouter({ export const projectRouter = createTRPCRouter({
list: authenticatedProcedure list: authenticatedProcedure
@ -72,4 +73,6 @@ export const projectRouter = createTRPCRouter({
return data.id; 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. * need to use are documented accordingly near the end.
*/ */
import { TRPCError, initTRPC } from "@trpc/server"; 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 superjson from "superjson";
import { ZodError } from "zod"; import { ZodError, z } from "zod";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { Session } from "../auth/Session"; import { Session } from "../auth/Session";
import ipaddr from "ipaddr.js"; import { projects } from "../db/schema";
import { IncomingMessage, ServerResponse } from "http";
import logger from "../utils/logger";
import cookie from "cookie";
import { getDockerInstance } from "../docker"; import { getDockerInstance } from "../docker";
import Dockerode from "dockerode"; import logger from "../utils/logger";
import { OpenApiMeta, generateOpenApiDocument } from "trpc-openapi"; // import { OpenApiMeta, generateOpenApiDocument } from "trpc-openapi";
export type ExtendedRequest = IncomingMessage & { export type ExtendedRequest = IncomingMessage & {
cookies: Record<string, string>; cookies: Record<string, string>;
@ -101,7 +102,7 @@ export const createTRPCContext = async (opts: {
const cookies = ((opts.req as ExtendedRequest).cookies = cookie.parse( const cookies = ((opts.req as ExtendedRequest).cookies = cookie.parse(
opts.req.headers.cookie ?? "", opts.req.headers.cookie ?? "",
)); ));
const sessionToken = cookies["sessionToken"]; const sessionToken = cookies.sessionToken;
// fetch session data from token // fetch session data from token
const session: Session | null = sessionToken const session: Session | null = sessionToken
@ -127,7 +128,7 @@ export const createTRPCContext = async (opts: {
* errors on the backend. * errors on the backend.
*/ */
export const t = initTRPC export const t = initTRPC
.meta<OpenApiMeta>() // .meta<OpenApiMeta>()
.context<typeof createTRPCContext>() .context<typeof createTRPCContext>()
.create({ .create({
transformer: superjson, 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 EventEmitter from "events";
import TypedEmitter from "typed-emitter";
import osu from "node-os-utils"; import osu from "node-os-utils";
import os from "os"; import os from "os";
import baseLogger from "../../utils/logger"; import type TypedEmitter from "typed-emitter";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { systemStats } from "~/server/db/schema"; import { systemStats } from "~/server/db/schema";
import { between, lte } from "drizzle-orm"; import baseLogger from "../../utils/logger";
export type BasicServerStats = { export type BasicServerStats = {
collectedAt: Date; collectedAt: Date;
@ -73,7 +73,7 @@ type StatEvents = {
*/ */
newListener: ( newListener: (
event: string | symbol, event: string | symbol,
listener: (...args: any[]) => void, listener: (...args: unknown[]) => void,
) => void; ) => void;
/** /**
@ -81,7 +81,7 @@ type StatEvents = {
*/ */
removeListener: ( removeListener: (
event: string | symbol, event: string | symbol,
listener: (...args: any[]) => void, listener: (...args: unknown[]) => void,
) => void; ) => void;
}; };
@ -130,13 +130,13 @@ export class StatManager {
private liveInterval: NodeJS.Timeout | null = null; private liveInterval: NodeJS.Timeout | null = null;
constructor() { constructor() {
this.update().then(() => this.updateDatabase()); void this.update().then(() => this.updateDatabase());
// whenever a new listener is added, start the live interval // whenever a new listener is added, start the live interval
this.events.on("newListener", (event) => { this.events.on("newListener", (event) => {
if (event === "onUpdate") { if (event === "onUpdate") {
this.liveInterval ??= setInterval(async () => { this.liveInterval ??= setInterval(() => {
await this.update(); void this.update();
}, 3 * 1000); }, 3 * 1000);
} }
}); });
@ -152,16 +152,37 @@ export class StatManager {
}); });
} }
start() { async start() {
this.logger.info("Starting hourly stat collection."); this.logger.info("Starting hourly stat collection.");
// collect stats every hour // collect stats every hour
setInterval( setInterval(
async () => { () => {
await this.update(); void this.update().then(() => this.updateDatabase());
await this.updateDatabase();
}, },
60 * 60 * 1000, 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 export const stats = (((globalThis as any).statsManager as
| StatManager | StatManager
| undefined) ??= new StatManager()); | undefined) ??= new StatManager());

View file

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

View file

@ -36,35 +36,36 @@
} }
.dark { .dark {
--background: 22.5 66.67% 2.35%; --background: 240 10% 4%;
--foreground: 20 100% 94.12%; /* --background: 240 6% 10%; Dark blue-grey background */
--foreground: 200 10% 90%; /* Light grey text */
--primary: 19.5 100% 60.78%; --primary: 19 93% 43%; /* Keep original orange */
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%; /* White on orange */
--card: 22.5 66.67% 2.35%; --card: 240 6% 10%; /* Slightly lighter background for cards */
--card-foreground: 20 100% 94.12%; --card-foreground: inherit; /* Same text color as foreground */
--popover: 22.5 66.67% 2.35%; --popover: 22.5 66.67% 2.35%; /* Same card background for popovers */
--popover-foreground: 20 100% 94.12%; --popover-foreground: inherit; /* Same text color as foreground */
--secondary: 19.71 64.81% 21.18%; --secondary: 19.71 64.81% 21.18%; /* Slightly more muted orange */
--secondary-foreground: 0 0% 100%; --secondary-foreground: 0 0% 100%; /* White on secondary orange */
--muted: 25.71 11.48% 11.96%; --muted: 25.71 11.48% 11.96%; /* Slightly lighter grey for muted elements */
--muted-foreground: 15 1.61% 51.37%; --muted-foreground: 15 1.61% 51.37%; /* Slightly darker text for muted elements */
--accent: 19.71 64.81% 21.18%; --accent: 19.71 64.81% 21.18%; /* Same as secondary orange for consistency */
--accent-foreground: 20 100% 94.12%; --accent-foreground: inherit; /* Same text color as foreground */
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%; /* Red alert color */
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%; /* White on red for high contrast */
--border: 0 0% 14.9%; --border: 0 0% 18%; /* Same border color */
--input: 0 0% 14.9%; --input: 0 0% 18%; /* Same input border color */
--ring: 19.5 100% 60.78%; --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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { import {
type HTTPBatchStreamLinkOptions, createWSClient,
loggerLink, loggerLink,
splitLink,
unstable_httpBatchStreamLink, unstable_httpBatchStreamLink,
wsLink, wsLink,
createWSClient, type HTTPBatchStreamLinkOptions,
splitLink,
httpLink,
} from "@trpc/client"; } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query"; import { createTRPCReact } from "@trpc/react-query";
import { useState } from "react"; import { useState } from "react";
import { type AppRouter } from "~/server/api/root"; import { type AppRouter } from "~/server/api/root";
import { getUrl, transformer } from "./shared";
import { authLink } from "./links"; import { authLink } from "./links";
import { getUrl, transformer } from "./shared";
export const api = createTRPCReact<AppRouter>(); export const api = createTRPCReact<AppRouter>();
@ -66,6 +65,10 @@ export function TRPCReactProvider(props: {
true: wsLink({ true: wsLink({
client: createWSClient({ client: createWSClient({
url: getUrl().replace("http", "ws"), url: getUrl().replace("http", "ws"),
lazy: {
enabled: true,
closeMs: 5_000,
},
}), }),
}), }),

View file

@ -1,3 +1,5 @@
"use server";
import { import {
TRPCClientError, TRPCClientError,
createTRPCProxyClient, createTRPCProxyClient,
@ -5,11 +7,11 @@ import {
unstable_httpBatchStreamLink, unstable_httpBatchStreamLink,
} from "@trpc/client"; } 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 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>({ export const api = createTRPCProxyClient<AppRouter>({
transformer, transformer,