feat: service page
This commit is contained in:
parent
83a8510790
commit
bd34298eb5
29
README.md
29
README.md
|
@ -1 +1,30 @@
|
|||
# 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)
|
55
package.json
55
package.json
|
@ -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"
|
||||
|
|
3466
pnpm-lock.yaml
3466
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -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;
|
||||
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
50
src/app/(dashboard)/_navbar/Navbar.tsx
Normal file
50
src/app/(dashboard)/_navbar/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
10
src/app/(dashboard)/layout.tsx
Normal file
10
src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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'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>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
"use client";
|
||||
|
||||
export function CreateService() {}
|
94
src/app/(dashboard)/project/[id]/layout.tsx
Normal file
94
src/app/(dashboard)/project/[id]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
7
src/app/(dashboard)/project/[id]/page.tsx
Normal file
7
src/app/(dashboard)/project/[id]/page.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
export default function ProjectHome() {
|
||||
// return (
|
||||
// // <pre>{JSON.stringify(project, null, 2)}</pre>
|
||||
// )
|
||||
|
||||
return <p>hello world</p>;
|
||||
}
|
21
src/app/_footer/Footer.tsx
Normal file
21
src/app/_footer/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
38
src/server/api/routers/projects/project.ts
Normal file
38
src/server/api/routers/projects/project.ts
Normal 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),
|
||||
})),
|
||||
};
|
||||
});
|
|
@ -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() }));
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue