This commit is contained in:
Benjamin Toby 2026-03-09 16:49:49 +00:00
parent 8491c52639
commit 76bf623116
27 changed files with 460 additions and 46 deletions

View File

@ -12,7 +12,7 @@ type Props = {
export default function AdminHero({ title, ctas, description }: Props) {
return (
<Row className="w-full p-10 justify-between">
<Row className="w-full grid-cell-content justify-between">
<Stack className="gap-2">
<H1 className="admin-h1">{title}</H1>
{description ? (

View File

@ -1,11 +1,32 @@
import { AppContext } from "@/src/pages/_app";
import Row from "@/twui/components/layout/Row";
import Span from "@/twui/components/layout/Span";
import Stack from "@/twui/components/layout/Stack";
import { useContext } from "react";
export default function AdminSummary() {
const { pageProps } = useContext(AppContext);
const { user, deployment, deployment_id } = pageProps;
console.log("pageProps", pageProps);
if (!deployment) return null;
return <Row></Row>;
return (
<Stack className="w-full gap-0">
<Stack className="grid-cell-content">
<Span>{deployment.services.length} Services</Span>
<Row>
{deployment.services.map((service, index) => {
return (
<a
key={index}
href={`/admin/services/${service.service_name}`}
>
<code>{service.service_name}</code>
</a>
);
})}
</Row>
</Stack>
</Stack>
);
}

View File

@ -1,18 +1,31 @@
import Stack from "@/twui/components/layout/Stack";
import AdminSummary from "./(sections)/summary";
import AdminHero from "../../general/admin/hero";
import { useContext } from "react";
import { AppContext } from "@/src/pages/_app";
import twuiSlugToNormalText from "@/twui/components/utils/slug-to-normal-text";
import Divider from "@/twui/components/layout/Divider";
export default function Main() {
const { pageProps } = useContext(AppContext);
const { user, deployment, deployment_id } = pageProps;
const deployment_name = deployment?.deployment_name;
return (
<Stack>
<AdminHero
title={`Dashboard`}
title={`${twuiSlugToNormalText(deployment_name)} Deplyoment Dashboard`}
description={
<>
Deployment <code>ad9asd</code>
Deployment{" "}
<code>{deployment_id?.split("-").shift()}</code>
{` > `}
<code>{deployment?.deployment_name}</code>
</>
}
/>
<Divider />
<AdminSummary />
</Stack>
);

View File

@ -0,0 +1,30 @@
import Stack from "@/twui/components/layout/Stack";
import { useContext } from "react";
import { AppContext } from "@/src/pages/_app";
import twuiSlugToNormalText from "@/twui/components/utils/slug-to-normal-text";
import Divider from "@/twui/components/layout/Divider";
import AdminHero from "@/src/components/general/admin/hero";
export default function Main() {
const { pageProps } = useContext(AppContext);
const { user, deployment, deployment_id } = pageProps;
const deployment_name = deployment?.deployment_name;
return (
<Stack>
<AdminHero
title={`${twuiSlugToNormalText(deployment_name)} Deplyoment Dashboard`}
description={
<>
Deployment{" "}
<code>{deployment_id?.split("-").shift()}</code>
{` > `}
<code>{deployment?.deployment_name}</code>
</>
}
/>
<Divider />
</Stack>
);
}

View File

@ -0,0 +1,30 @@
import Stack from "@/twui/components/layout/Stack";
import { useContext } from "react";
import { AppContext } from "@/src/pages/_app";
import twuiSlugToNormalText from "@/twui/components/utils/slug-to-normal-text";
import Divider from "@/twui/components/layout/Divider";
import AdminHero from "@/src/components/general/admin/hero";
export default function Main() {
const { pageProps } = useContext(AppContext);
const { service, deployment } = pageProps;
const deployment_name = deployment?.deployment_name;
const service_name = service?.service_name;
return (
<Stack>
<AdminHero
title={`${twuiSlugToNormalText(service_name)} Service`}
description={
<>
Deployment <code>{deployment_name}</code>
{` > `}
<code>{service_name}</code>
</>
}
/>
<Divider />
</Stack>
);
}

View File

@ -1,8 +1,10 @@
import { EJSON } from "@/src/exports/client-exports";
import { PagePropsType, URLQueryType, User } from "@/src/types";
import grabDirNames from "@/src/utils/grab-dir-names";
import grabTurboCiConfig from "@/src/utils/grab-turboci-config";
import parsePageUrl from "@/src/utils/parse-page-url";
import userAuth from "@/src/utils/user-auth";
import { readFileSync } from "fs";
import _ from "lodash";
import { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
@ -20,6 +22,8 @@ type Params = {
) => Promise<PagePropsType | false | string>;
};
const { TURBOCI_DEPLOYMENT_ID_FILE } = grabDirNames();
export default async function defaultAdminProps({
ctx,
props,
@ -29,7 +33,29 @@ export default async function defaultAdminProps({
const query: URLQueryType = ctx.query;
const { singleRes: user } = await userAuth({ req });
const config = grabTurboCiConfig();
const deployment = grabTurboCiConfig();
const deployment_id = readFileSync(TURBOCI_DEPLOYMENT_ID_FILE, "utf-8");
const service = query.service_name
? deployment.services.find(
(srv) => srv.service_name == query.service_name,
) || null
: null;
const children_services = service?.service_name
? deployment.services.filter(
(srv) => srv.parent_service_name == service.service_name,
) || null
: null;
if (query.service_name && !service?.service_name) {
return {
redirect: {
destination: `/admin/services`,
statusCode: 307,
},
};
}
if (!user?.id) {
return {
@ -68,7 +94,10 @@ export default async function defaultAdminProps({
query,
user,
pageUrl: finalAdminUrl,
config,
deployment,
deployment_id,
service,
children_services,
};
let finalProps = _.merge(props, propsFnProps, defaultPageProps);

43
src/hooks/use-app-init.ts Normal file
View File

@ -0,0 +1,43 @@
import React, { useEffect } from "react";
import { PagePropsType, ToastType, WebSocketDataType } from "../types";
import useWebSocket from "@/twui/components/hooks/useWebSocket";
import useStatus from "@/twui/components/hooks/useStatus";
export default function useAppInit(pageProps: PagePropsType) {
const wsURL = process.env.NEXT_PUBLIC_WEBSOCKET_URL || "";
const { user } = pageProps;
const [toast, setToast] = React.useState<ToastType>({
toastOpen: false,
});
const { socket, sendData } = useWebSocket<WebSocketDataType>({
url: wsURL,
disableReconnect: false,
keepAliveDuration: 5000,
});
const { loading, setLoading, refresh, setRefresh } = useStatus();
const ws = { socket, sendData };
useEffect(() => {
console.log("wsURL", wsURL);
console.log("socket", socket);
}, [socket]);
return {
socket,
sendData,
loading,
setLoading,
refresh,
setRefresh,
ws,
user,
pageProps,
toast,
setToast,
};
}

View File

@ -11,7 +11,7 @@ export const AdminAsideLinks: (
strict: true,
},
{
title: "Deployments",
url: "/admin/deployments",
title: "Services",
url: "/admin/services",
},
];

View File

@ -8,14 +8,14 @@ export default function Header({ children }: Props) {
return (
<header className="col-span-6">
<Row className="w-full grid grid-cols-6 grid-frame nested-grid-frame">
<Row className="h-full items-stretch grid-cell col-span-1 w-full justify-between">
<Row className="h-full items-stretch grid-cell col-span-3 xl:col-span-1 w-full justify-between">
<Row className="px-4">
<Logo />
</Row>
{/* <Divider vertical className="-mr-[7px]" /> */}
</Row>
<Row className="grid-cell col-span-4"></Row>
<Row className="grid-cell col-span-1"></Row>
<Row className="grid-cell col-span-4 hidden xl:block"></Row>
<Row className="grid-cell col-span-3 xl:col-span-1"></Row>
</Row>
</header>
);

View File

@ -11,18 +11,20 @@ type Props = PropsWithChildren & {};
export default function Layout({ children }: Props) {
return (
<Main className="w-screen h-screen overflow-hidden p-4 lg:p-10">
<div className="grid-frame grid-cols-6 w-full h-full grid-rows-[64px_1fr]">
<div className="grid-frame grid-cols-6 w-full h-full grid-rows-[64px_47px] xl:grid-rows-[64px_auto]">
<Header />
<Stack className="grid-cell col-span-1">
<Stack className="grid-cell col-span-6 xl:col-span-1">
<LinkList
links={AdminAsideLinks}
className="w-full flex-col"
className="w-full xl:flex-col"
linkProps={{
className: "turboci-admin-aside-link",
}}
/>
</Stack>
<Stack className="grid-cell col-span-5">{children}</Stack>
<Stack className="grid-cell col-span-6 xl:col-span-5">
{children}
</Stack>
</div>
</Main>
);

View File

@ -2,14 +2,17 @@ import "@/src/styles/globals.css";
import type { AppProps } from "next/app";
import { createContext } from "react";
import { PagePropsType, TurboCIAdminAppContextType } from "../types";
import useAppInit from "../hooks/use-app-init";
export const AppContext = createContext<TurboCIAdminAppContextType>(
{} as TurboCIAdminAppContextType,
);
export default function App({ Component, pageProps }: AppProps<PagePropsType>) {
const init = useAppInit(pageProps);
return (
<AppContext.Provider value={{ pageProps }}>
<AppContext.Provider value={{ ...init }}>
<Component {...pageProps} />
</AppContext.Provider>
);

View File

@ -0,0 +1,18 @@
import Main from "@/src/components/pages/admin/services/service";
import defaultAdminProps from "@/src/functions/pages/admin/default-admin-props";
import Layout from "@/src/layouts/admin";
import { GetServerSideProps } from "next";
export default function AdminSingleService() {
return (
<Layout>
<Main />
</Layout>
);
}
export const getServerSideProps: GetServerSideProps = async (ctx) => {
return await defaultAdminProps({
ctx,
});
};

View File

@ -0,0 +1,18 @@
import Main from "@/src/components/pages/admin/services";
import defaultAdminProps from "@/src/functions/pages/admin/default-admin-props";
import Layout from "@/src/layouts/admin";
import { GetServerSideProps } from "next";
export default function AdminServices() {
return (
<Layout>
<Main />
</Layout>
);
}
export const getServerSideProps: GetServerSideProps = async (ctx) => {
return await defaultAdminProps({
ctx,
});
};

View File

@ -29,9 +29,8 @@
@apply gap-px p-px;
}
.grid-frame.nested-grid-frame {
@apply grid bg-transparent! dark:bg-transparent!;
@apply gap-px p-0! h-full;
.nested-grid-frame {
@apply bg-foreground-light/10 dark:bg-foreground-dark/10 grid p-0! h-full;
}
.grid-frame.nested-grid-frame .grid-cell {
@ -43,7 +42,7 @@
}
.grid-cell-content {
@apply p-10;
@apply p-4 xl:p-10;
}
.twui-button,
@ -74,7 +73,7 @@
}
.turboci-admin-aside-link {
@apply w-full border-foreground-light/10 dark:border-r-foreground-dark/10 py-4 px-6;
@apply w-full border-b-0 xl:border-foreground-light/10 xl:dark:border-foreground-dark/10 py-4 px-6;
@apply text-foreground-light;
}

View File

@ -1,4 +1,6 @@
import { ToastStyles } from "@/twui/components/elements/Toast";
import { DATASQUIREL_LoggedInUser } from "@moduletrace/datasquirel/dist/package-shared/types";
import useAppInit from "../hooks/use-app-init";
export type User = DATASQUIREL_LoggedInUser & {};
@ -169,13 +171,18 @@ export type ServiceScriptObject = {
work_dir?: string;
};
export type URLQueryType = {};
export type URLQueryType = {
service_name?: string | null;
};
export type PagePropsType = {
config?: TCIGlobalConfig | null;
deployment?: TCIGlobalConfig | null;
query?: URLQueryType | null;
user: User;
pageUrl?: string | null;
deployment_id?: string | null;
service?: ParsedDeploymentServiceConfig | null;
children_services?: ParsedDeploymentServiceConfig[] | null;
};
export type APIReqObject = {
@ -208,6 +215,34 @@ export type TurboCISignupFormObject = {
confirmed_password?: string;
};
export type TurboCIAdminAppContextType = {
pageProps: PagePropsType;
export type TurboCIAdminAppContextType = ReturnType<typeof useAppInit>;
export const WebSocketEvents = [
"client:ping",
"server:ping",
"server:error",
"server:message",
"server:ready",
"server:success",
"server:update",
] as const;
export type WebSocketDataType = {
event: (typeof WebSocketEvents)[number];
message?: string;
};
export type WebSocketType = {
socket?: WebSocket;
data?: WebSocketDataType | null;
message?: string;
sendData?: (data: WebSocketDataType) => void;
};
export type ToastType = {
toastStyle?: (typeof ToastStyles)[number];
toastMessage?: string;
toastOpen: boolean;
closeDelay?: number;
};

View File

@ -49,14 +49,8 @@ export function setCookie(
res.setHeader("Set-Cookie", final_cookie_string);
}
export function getCookie(
req: http.IncomingMessage,
name: string,
): string | null {
const cookieHeader = req.headers.cookie;
if (!cookieHeader) return null;
const cookies = cookieHeader
export function getCookie(cookie_string: string, name: string): string | null {
const cookies = cookie_string
.split(";")
.reduce((acc: { [key: string]: string }, cookie: string) => {
const [key, val] = cookie.trim().split("=").map(decodeURIComponent);

View File

@ -8,17 +8,28 @@ import { EJSON } from "../exports/client-exports";
import grabCookieNames from "./grab-cookie-names";
type Params = {
req:
req?:
| NextApiRequest
| (IncomingMessage & { cookies: Partial<{ [key: string]: string }> });
bun_req?: Request;
};
export default async function userAuth({
req,
bun_req,
}: Params): Promise<APIResponseObject<User>> {
try {
const { auth_key_cookie_name, csrf_cookie_name } = grabCookieNames();
const key = getCookie(req, auth_key_cookie_name);
const cookie_string =
req?.headers.cookie || bun_req?.headers.get("cookie");
if (!cookie_string) {
return {
success: false,
msg: `Couldn't grab cookie string`,
};
}
const key = getCookie(cookie_string, auth_key_cookie_name);
if (!key) {
return {
@ -37,7 +48,7 @@ export default async function userAuth({
};
}
const csrf = getCookie(req, csrf_cookie_name);
const csrf = getCookie(cookie_string, csrf_cookie_name);
if (!csrf) {
return {

View File

@ -0,0 +1,11 @@
import type { ServerWebSocket } from "bun";
import datasquirel from "@moduletrace/datasquirel";
import { WebSocketData, WebSocketDataType } from "@/src/types";
const EJSON = datasquirel.client.utils.EJSON;
export default function sendData(
ws: ServerWebSocket<WebSocketData>,
data: WebSocketDataType,
) {
ws.send(String(EJSON.stringify(data)));
}

View File

@ -0,0 +1,18 @@
import type { ServerWebSocket } from "bun";
import { WebSocketData, WebSocketDataType } from "@/src/types";
import datasquirel from "@moduletrace/datasquirel";
const EJSON = datasquirel.client.utils.EJSON;
export default function sendError(
ws: ServerWebSocket<WebSocketData>,
message?: String,
) {
ws.send(
String(
EJSON.stringify({
event: "server:error",
message: message,
} as WebSocketDataType),
),
);
}

View File

@ -0,0 +1,18 @@
import type { ServerWebSocket } from "bun";
import { WebSocketData, WebSocketDataType } from "@/src/types";
import datasquirel from "@moduletrace/datasquirel";
const EJSON = datasquirel.client.utils.EJSON;
export default function sendMessage(
ws: ServerWebSocket<WebSocketData>,
message: String,
) {
ws.send(
String(
EJSON.stringify({
event: "server:message",
message: message,
} as WebSocketDataType),
),
);
}

View File

@ -0,0 +1,18 @@
import type { ServerWebSocket } from "bun";
import { WebSocketData, WebSocketDataType } from "@/src/types";
import datasquirel from "@moduletrace/datasquirel";
const EJSON = datasquirel.client.utils.EJSON;
export default function sendReady(
ws: ServerWebSocket<WebSocketData>,
message?: String,
) {
ws.send(
String(
EJSON.stringify({
event: "server:ready",
message: message,
} as WebSocketDataType),
),
);
}

View File

@ -0,0 +1,18 @@
import type { ServerWebSocket } from "bun";
import { WebSocketData, WebSocketDataType } from "@/src/types";
import datasquirel from "@moduletrace/datasquirel";
const EJSON = datasquirel.client.utils.EJSON;
export default function sendSuccess(
ws: ServerWebSocket<WebSocketData>,
message: String,
) {
ws.send(
String(
EJSON.stringify({
event: "server:success",
message: message,
} as WebSocketDataType),
),
);
}

View File

@ -0,0 +1,18 @@
import type { ServerWebSocket } from "bun";
import { WebSocketData, WebSocketDataType } from "@/src/types";
import datasquirel from "@moduletrace/datasquirel";
const EJSON = datasquirel.client.utils.EJSON;
export default function sendUpdate(
ws: ServerWebSocket<WebSocketData>,
message: String,
) {
ws.send(
String(
EJSON.stringify({
event: "server:update",
message: message,
} as WebSocketDataType),
),
);
}

View File

@ -0,0 +1,16 @@
import sendData from "../(utils)/send-data";
import sendError from "../(utils)/send-error";
import { WebSocketMessageParam } from "../socket-message";
export default async function socketClientPing({
ws,
data,
}: WebSocketMessageParam) {
try {
sendData(ws, {
event: "server:ping",
});
} catch (error: any) {
sendError(ws, "Client Ping Error! " + error.message);
}
}

View File

@ -1,5 +1,6 @@
import { WebSocketData } from "../types";
import socketInit from "./socket-init";
import socketMessage from "./socket-message";
const server = Bun.serve<WebSocketData>({
async fetch(req, server) {
@ -22,10 +23,17 @@ const server = Bun.serve<WebSocketData>({
websocket: {
async message(ws, message) {
if (typeof message == "string") {
await socketMessage({ ws, message });
}
},
async open(ws) {},
async close(ws, code, message) {},
async open(ws) {
const user = ws.data.user;
console.log(`Web Socket Opened by ${user.first_name}`);
},
async close(ws, code, message) {
const user = ws.data.user;
console.log(`Socket Closed by ${user.first_name}`);
},
idleTimeout: 600,
maxPayloadLength: 1024 * 1024 * 10,
},

View File

@ -1,5 +1,6 @@
import datasquirel from "@moduletrace/datasquirel";
import { User } from "../types";
import userAuth from "../utils/user-auth";
type Param = {
req: Request;
@ -7,7 +8,7 @@ type Param = {
};
type Return = {
user: User | null;
user?: User | null;
};
export default async function socketInit({
@ -25,15 +26,11 @@ export default async function socketInit({
user: null,
};
const user = datasquirel.user.auth.auth({
cookieString,
database: process.env.DSQL_DB_NAME || "",
skipFileCheck: true,
});
const { singleRes: user } = await userAuth({ bun_req: req });
if (debug) {
console.log("DEBUG:::socketInit:user", user);
}
return { user: user.payload as User | null };
return { user };
}

View File

@ -0,0 +1,46 @@
import { ServerWebSocket } from "bun";
import { WebSocketData, WebSocketDataType } from "../types";
import { EJSON } from "../exports/client-exports";
import socketClientPing from "./events/client-ping";
import debugLog from "@moduletrace/datasquirel/dist/package-shared/utils/logging/debug-log";
type Param = {
ws: ServerWebSocket<WebSocketData>;
message: string | Buffer;
};
export type WebSocketMessageParam = {
ws: ServerWebSocket<WebSocketData>;
message?: string | Buffer;
data?: WebSocketDataType;
};
export default async function socketMessage({ ws, message }: Param) {
const user = ws.data.user;
const data = EJSON.parse(message.toString()) as
| WebSocketDataType
| undefined;
const websocketMessageParams: WebSocketMessageParam = {
ws,
data,
message,
};
const userRef = `User ${user.first_name} ${user.last_name} [#${user.id}]`;
const label = "Web Socket Message";
switch (data?.event) {
case "client:ping":
debugLog({
log: `${userRef} Pinging Server ...`,
addTime: true,
label,
});
await socketClientPing(websocketMessageParams);
break;
default:
break;
}
}