This commit is contained in:
Benjamin Toby 2026-03-10 08:56:20 +00:00
parent f26227c7a8
commit 921375c53d
22 changed files with 614 additions and 30 deletions

View File

@ -0,0 +1,113 @@
import Border from "@/twui/components/elements/Border";
import Loading from "@/twui/components/elements/Loading";
import LucideIcon from "@/twui/components/elements/lucide-icon";
import useStatus from "@/twui/components/hooks/useStatus";
import Button from "@/twui/components/layout/Button";
import Center from "@/twui/components/layout/Center";
import Row from "@/twui/components/layout/Row";
import Span from "@/twui/components/layout/Span";
import Stack from "@/twui/components/layout/Stack";
import {
ComponentProps,
DetailedHTMLProps,
Fragment,
IframeHTMLAttributes,
ReactNode,
} from "react";
import { twMerge } from "tailwind-merge";
type Props = Omit<
DetailedHTMLProps<
IframeHTMLAttributes<HTMLIFrameElement>,
HTMLIFrameElement
>,
"title"
> & {
url: string;
wrapperProps?: ComponentProps<typeof Border>;
title?: string | ReactNode;
};
export default function TtydIframe({
url,
wrapperProps,
title,
...props
}: Props) {
const { loading, setLoading } = useStatus();
return (
<Border
{...wrapperProps}
className={twMerge("p-0", wrapperProps?.className)}
>
<Stack className="gap-0">
<Row className="p-4 w-full justify-between">
<Row>
{title ? (
<Fragment>
<Span size="small" variant="faded">
{title}
</Span>
<LucideIcon
name="ChevronRight"
size={15}
opacity={0.3}
/>
</Fragment>
) : null}
<Span size="small" variant="faded">
<a
href={url}
target="_blank"
className="dotted-link"
>
{url}
</a>
</Span>
</Row>
<Row>
<Button
title="Open Full Screen"
variant="ghost"
className="p-1!"
onClick={() => {
window.open(url, "__blank");
}}
>
<LucideIcon name="ArrowUpRight" size={20} />
</Button>
<Button
title="Refresh Iframe"
variant="ghost"
className="p-1!"
loading={loading}
onClick={() => {
setLoading(true);
setTimeout(() => {
setLoading(false);
}, 2000);
}}
loadingProps={{ size: "smaller" }}
>
<LucideIcon name="RotateCcw" size={18} />
</Button>
</Row>
</Row>
<hr />
{loading ? (
<Center className="w-full p-10 h-[400px]">
<Loading />
</Center>
) : (
<iframe
{...props}
src={url}
className={twMerge("w-full h-[400px]", props.className)}
/>
)}
</Stack>
</Border>
);
}

View File

@ -1,15 +1,18 @@
import Stack from "@/twui/components/layout/Stack";
import { useContext, useEffect, useRef } from "react";
import { RefObject, useContext, useEffect, useRef, useState } from "react";
import { AppContext } from "@/src/pages/_app";
import {
NormalizedServerObject,
ParsedDeploymentServiceConfig,
TtydInfoObject,
WebSocketDataType,
} from "@/src/types";
import useIntersectionObserver from "@/twui/components/hooks/useIntersectionObserver";
import Center from "@/twui/components/layout/Center";
import Loading from "@/twui/components/elements/Loading";
import useWebSocketEventHandler from "@/twui/components/hooks/useWebSocketEventHandler";
import _ from "lodash";
import Loading from "@/twui/components/elements/Loading";
import Center from "@/twui/components/layout/Center";
import TtydIframe from "@/src/components/general/ttyd-iframe";
import useIntersectionObserver from "@/twui/components/hooks/useIntersectionObserver";
type Props = {
service: ParsedDeploymentServiceConfig;
@ -19,23 +22,97 @@ type Props = {
export default function ServiceClusterServerViews({ service, server }: Props) {
const { pageProps, ws } = useContext(AppContext);
const viewRef = useRef<HTMLDivElement>(undefined);
const { data } = useWebSocketEventHandler<WebSocketDataType>();
const { isIntersecting } = useIntersectionObserver({ elementRef: viewRef });
const [ttydLogs, setTtydLogs] = useState<TtydInfoObject>();
const WsReqSentRef = useRef(false);
useEffect(() => {
if (!ws?.socket) {
if (!ws?.socket || WsReqSentRef.current) {
return;
}
ws.sendData({
event: "client:ping",
event: "client:service-server-logs",
server,
service,
service: _.omit(service, ["servers"]),
});
WsReqSentRef.current = true;
}, [ws]);
useEffect(() => {
console.log("data", data);
if (ttydLogs) return;
if (
data?.event == "server:service-server-logs" &&
data?.ttyd &&
data.server?.private_ip == server.private_ip
) {
setTimeout(() => {
setTtydLogs(data.ttyd);
}, 2000);
}
}, [data]);
return <Stack className="gap-0 w-full"></Stack>;
console.log("isIntersecting", isIntersecting);
console.log("ttydLogs", ttydLogs);
useEffect(() => {
if (!ttydLogs?.port) return;
if (!isIntersecting) {
ws.sendData({
event: "client:kill-port",
server,
service: _.omit(service, ["servers"]),
port: ttydLogs.port,
});
}
}, [isIntersecting]);
const dev_logs_url = ttydLogs?.port
? `http://localhost:${ttydLogs.port}`
: undefined;
const title = (
<>
{service.service_name} service <code>{server.private_ip}</code> Logs
</>
);
return (
<Stack className="gap-0 w-full" componentRef={viewRef as any}>
{isIntersecting && ttydLogs?.url && ttydLogs.port ? (
<Stack className="gap-0">
{/* {dev_logs_url ? (
<TtydIframe
url={dev_logs_url}
title={title}
wrapperProps={{
className: "border-none",
}}
/>
) : null}
<hr /> */}
<TtydIframe
url={ttydLogs?.url}
title={title}
wrapperProps={{
className: "border-none",
}}
/>
</Stack>
) : (
<Center className="p-10 h-[400px]">
<Loading />
</Center>
)}
<hr />
</Stack>
);
}

View File

@ -36,6 +36,9 @@ export default async function defaultAdminProps({
const deployment = grabTurboCiConfig();
const deployment_id = readFileSync(TURBOCI_DEPLOYMENT_ID_FILE, "utf-8");
const host = process.env.HOST || null;
const ws_url = `${host?.replace(/^http/, "ws")}/ws`;
const service = query.service_name
? deployment.services.find(
(srv) => srv.service_name == query.service_name,
@ -98,6 +101,8 @@ export default async function defaultAdminProps({
deployment_id,
service,
children_services,
ws_url,
host,
};
let finalProps = _.merge(props, propsFnProps, defaultPageProps);

View File

@ -0,0 +1,72 @@
import { AppData } from "@/src/data/app-data";
import {
NormalizedServerObject,
ParsedDeploymentServiceConfig,
PrivateServerTtydParadigms,
TtydInfoObject,
User,
} from "@/src/types";
import grabDirNames from "@/src/utils/grab-dir-names";
import getNextAvailablePort from "@/src/utils/grab-next-available-port";
import grabSSHPrefix from "@/src/utils/grab-ssh-prefix";
import grabTtydCmd from "@/src/utils/grab-ttyd-cmd";
import grabConnectedWebsocketUserdata from "@/src/websocket/(utils)/grab-connected-websocket-user-data";
import { exec } from "child_process";
const { TURBOCI_SSH_KEY_FILE } = grabDirNames();
type Params = {
user: User;
service?: Omit<ParsedDeploymentServiceConfig, "servers">;
server?: NormalizedServerObject;
paradigm: (typeof PrivateServerTtydParadigms)[number]["name"];
};
export default async function grabTtydServerInfo({
server,
service,
user,
paradigm,
}: Params): Promise<TtydInfoObject> {
const first_log = service?.logs?.[0];
const final_first_log =
typeof first_log == "string" ? first_log : first_log?.cmd;
if (paradigm == "logs" && !final_first_log) {
throw new Error(`Service Doesn't have logs.`);
}
const available_port = getNextAvailablePort();
let url = `${process.env.HOST}/ttyd/${available_port}`;
let cmd = ``;
if (paradigm == "logs") {
cmd += ` ${grabSSHPrefix()}`;
} else {
cmd += ` ssh -i ${TURBOCI_SSH_KEY_FILE}`;
}
cmd += ` root@${server?.private_ip}`;
if (final_first_log) {
cmd += ` ${final_first_log}`;
}
const ttyd_cmd = grabTtydCmd({
cmd,
port: available_port,
});
const ttyd_exec = exec(ttyd_cmd.cmd);
await Bun.sleep(2000);
const connected_user_data = grabConnectedWebsocketUserdata({ user });
connected_user_data.child_processes.push(ttyd_exec);
connected_user_data.ports.push(available_port);
return { port: available_port, url };
}

View File

@ -2,9 +2,11 @@ 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";
import useWebSocketEventHandler from "@/twui/components/hooks/useWebSocketEventHandler";
import { date } from "zod";
export default function useAppInit(pageProps: PagePropsType) {
const wsURL = process.env.NEXT_PUBLIC_WEBSOCKET_URL || "";
const wsURL = pageProps.ws_url || "";
const { user } = pageProps;
@ -22,6 +24,32 @@ export default function useAppInit(pageProps: PagePropsType) {
const ws = { socket, sendData };
const { data } = useWebSocketEventHandler<WebSocketDataType>();
useEffect(() => {
if (data?.event == "server:error") {
setToast({
toastOpen: true,
toastMessage: data.message,
toastStyle: "error",
});
}
if (data?.event == "server:update") {
setToast({
toastOpen: true,
toastMessage: data.message,
toastStyle: "normal",
});
}
if (data?.event == "server:success") {
setToast({
toastOpen: true,
toastMessage: data.message,
toastStyle: "success",
});
}
}, [data]);
return {
socket,
sendData,

View File

@ -5,6 +5,8 @@ import Stack from "@/twui/components/layout/Stack";
import { PropsWithChildren } from "react";
import { AdminAsideLinks } from "./(data)/links";
import Header from "./header";
import { twMerge } from "tailwind-merge";
import Spacer from "@/twui/components/layout/Spacer";
type Props = PropsWithChildren & {};
@ -25,8 +27,17 @@ export default function Layout({ children }: Props) {
}}
/>
</Stack>
<Stack className="grid-cell col-span-6 xl:col-span-5 gap-0">
<Stack
className={twMerge(
"grid-cell col-span-6 xl:col-span-5 gap-0",
"overflow-auto pb-[200px]",
)}
>
{children}
<div
className="h-[400px] w-full block"
style={{ height: "400px" }}
/>
</Stack>
</div>
</Main>

View File

@ -3,6 +3,7 @@ import type { AppProps } from "next/app";
import { createContext } from "react";
import { PagePropsType, TurboCIAdminAppContextType } from "../types";
import useAppInit from "../hooks/use-app-init";
import Toast from "@/twui/components/elements/Toast";
export const AppContext = createContext<TurboCIAdminAppContextType>(
{} as TurboCIAdminAppContextType,
@ -11,9 +12,21 @@ export const AppContext = createContext<TurboCIAdminAppContextType>(
export default function App({ Component, pageProps }: AppProps<PagePropsType>) {
const init = useAppInit(pageProps);
const { toast, setToast } = init;
return (
<AppContext.Provider value={{ ...init }}>
<Component {...pageProps} />
<Toast
open={toast.toastOpen}
closeDispatch={(open) => {
setToast((prev) => ({ ...prev, toastOpen: false }));
}}
color={toast.toastStyle}
closeDelay={toast.closeDelay}
>
{toast.toastMessage}
</Toast>
</AppContext.Provider>
);
}

View File

@ -12,7 +12,7 @@ app.prepare().then(() => {
return;
}
const full_href = `${process.env.NEXT_PUBLIC_HOST}${req.url}`;
const full_href = `${process.env.HOST}${req.url}`;
const url = new URL(full_href);

View File

@ -119,3 +119,7 @@ code {
hr {
@apply border-foreground-light/10 dark:border-foreground-dark/10;
}
.dotted-link {
@apply border-b border-dashed border-foreground-light/40 dark:border-foreground-dark/40;
}

View File

@ -1,6 +1,8 @@
import { ToastStyles } from "@/twui/components/elements/Toast";
import { DATASQUIREL_LoggedInUser } from "@moduletrace/datasquirel/dist/package-shared/types";
import useAppInit from "../hooks/use-app-init";
import { ServerWebSocket } from "bun";
import { ChildProcess } from "child_process";
export type User = DATASQUIREL_LoggedInUser & {};
@ -119,8 +121,15 @@ export type TCIConfigServiceConfig = {
* Commoands to Run on first run
*/
init?: string[];
logs?: TCIConfigServiceConfigLog[];
};
export type TCIConfigServiceConfigLog =
| string
| {
cmd: string;
};
export type TCIConfigServiceHealthcheck = {
cmd: string;
test: string;
@ -183,6 +192,8 @@ export type PagePropsType = {
deployment_id?: string | null;
service?: ParsedDeploymentServiceConfig | null;
children_services?: ParsedDeploymentServiceConfig[] | null;
ws_url?: string | null;
host?: string | null;
};
export type APIReqObject = {
@ -219,6 +230,8 @@ export type TurboCIAdminAppContextType = ReturnType<typeof useAppInit>;
export const WebSocketEvents = [
"client:ping",
"client:service-server-logs",
"client:kill-port",
"server:ping",
"server:error",
@ -226,13 +239,23 @@ export const WebSocketEvents = [
"server:ready",
"server:success",
"server:update",
"server:service-server-logs",
"server:killed-port",
] as const;
export type WebSocketDataType = {
event: (typeof WebSocketEvents)[number];
message?: string;
service?: ParsedDeploymentServiceConfig;
service?: Omit<ParsedDeploymentServiceConfig, "servers">;
server?: NormalizedServerObject;
ttyd?: TtydInfoObject;
port?: string | number;
};
export type WebSocketMessageParam = {
ws: ServerWebSocket<WebSocketData>;
message?: string | Buffer;
data?: WebSocketDataType;
};
export type WebSocketType = {
@ -248,3 +271,23 @@ export type ToastType = {
toastOpen: boolean;
closeDelay?: number;
};
export type TtydInfoObject = {
url: string;
port: number;
};
export const PrivateServerTtydParadigms = [
{
name: "logs",
},
{
name: "terminal",
},
] as const;
export type WebSocketConnectedUserData = {
user: User;
child_processes: ChildProcess[];
ports: (string | number)[];
};

View File

@ -4,14 +4,19 @@ type Params = {
cmd: string;
cwd?: string;
flags?: string[];
port: number;
};
export default function grabTtydCmd({ cmd: ttydCmd, cwd, flags }: Params) {
const port = 8080;
export default function grabTtydCmd({
cmd: ttydCmd,
cwd,
flags,
port,
}: Params) {
let cmd = ``;
cmd += `${AppData["TerminalBinName"]}`;
cmd += ` --writable --max-clients 1`;
cmd += ` --writable`;
// cmd += ` --max-clients 1`;
cmd += ` --client-option 'theme={"background":"#0c0e11"}'`;
cmd += ` --client-option fontSize=14`;

View File

@ -0,0 +1,17 @@
import { User } from "@/src/types";
type Params = {
user: User;
};
export default function grabConnectedWebsocketUserdataIndex({ user }: Params) {
const user_index = global.WEBSOCKET_CONNECTED_USERS_DATA.findIndex(
(u) => u.user.id == user.id,
);
if (typeof user_index == "number" && user_index >= 0) {
return user_index;
}
return undefined;
}

View File

@ -0,0 +1,21 @@
import { User } from "@/src/types";
import grabConnectedWebsocketUserdataIndex from "./grab-connected-websocket-user-data-index";
type Params = {
user: User;
};
export default function grabConnectedWebsocketUserdata({ user }: Params) {
const connected_user_data_index = grabConnectedWebsocketUserdataIndex({
user,
});
if (typeof connected_user_data_index !== "number") {
throw new Error(`User Connection Data not found!`);
}
const connected_user_data =
global.WEBSOCKET_CONNECTED_USERS_DATA[connected_user_data_index];
return connected_user_data;
}

View File

@ -0,0 +1,20 @@
import { AppData } from "@/src/data/app-data";
import { execSync, ExecSyncOptions } from "child_process";
type Params = {
exec_options?: ExecSyncOptions;
};
export default async function killAllTtydPorts(params?: Params) {
const start_port = AppData["DynamicPortStart"];
const end_port = start_port + 100;
const kill_ports_cmd = `ss -tlnp | awk 'NR>1 {split($4, a, ":"); port=a[length(a)]; if (port >= ${start_port} && port <= ${end_port}) print $6}' | grep -oP 'pid=\\K[0-9]+' | xargs -r kill -9`;
execSync(kill_ports_cmd, {
...params?.exec_options,
});
}
// sudo ss -tlnp | awk 'NR>1 {split($4, a, ":"); port=a[length(a)]; if (port >= 4700 && port <= 4800) print $6}' | grep -oP 'pid=\K[0-9]+' | xargs -r sudo kill -9
// ss -tlnp | awk 'NR>1 {split($4, a, ":"); port=a[length(a)]; if (port >= 4700 && port <= 4800) print $6}' | grep -oP 'pid=\K[0-9]+' | xargs -r sudo kill -9

View File

@ -0,0 +1,14 @@
import { execSync, ExecSyncOptions } from "child_process";
type Params = {
port: string | number;
exec_options?: ExecSyncOptions;
};
export default async function killPort({ port, exec_options }: Params) {
const kill_ports_cmd = `ss -tlnp | awk -v p=${port} 'NR>1 {split($4,a,":"); if(a[length(a)]==p) print $6}' | grep -oP 'pid=\K[0-9]+' | xargs -r kill -9`;
execSync(kill_ports_cmd, {
...exec_options,
});
}

View File

@ -0,0 +1,26 @@
import { WebSocketMessageParam } from "@/src/types";
import sendData from "../(utils)/send-data";
import sendError from "../(utils)/send-error";
import grabConnectedWebsocketUserdata from "../(utils)/grab-connected-websocket-user-data";
export default async function socketClientKillPort({
ws,
data,
}: WebSocketMessageParam) {
try {
const user = ws.data.user;
const service = data?.service;
const server = data?.server;
const port = data?.port;
const connected_user_data = grabConnectedWebsocketUserdata({ user });
console.log("connected_user_data", connected_user_data);
sendData(ws, {
event: "server:killed-port",
});
} catch (error: any) {
sendError(ws, "Service Server Logs Error! " + error.message);
}
}

View File

@ -1,12 +1,18 @@
import { WebSocketMessageParam } from "@/src/types";
import sendData from "../(utils)/send-data";
import sendError from "../(utils)/send-error";
import { WebSocketMessageParam } from "../socket-message";
import grabConnectedWebsocketUserdata from "../(utils)/grab-connected-websocket-user-data";
export default async function socketClientPing({
ws,
data,
}: WebSocketMessageParam) {
try {
const user = ws.data.user;
const connected_user_data = grabConnectedWebsocketUserdata({ user });
console.log("connected_user_data", connected_user_data);
sendData(ws, {
event: "server:ping",
});

View File

@ -0,0 +1,32 @@
import { WebSocketMessageParam } from "@/src/types";
import sendData from "../(utils)/send-data";
import sendError from "../(utils)/send-error";
import grabTtydServerInfo from "@/src/functions/ttyd/grab-ttyd-service-info";
export default async function socketClientServiceServerLogs({
ws,
data,
}: WebSocketMessageParam) {
try {
const user = ws.data.user;
const service = data?.service;
const server = data?.server;
const ttyd = await grabTtydServerInfo({
server,
service,
user,
paradigm: "logs",
});
console.log("ttyd", ttyd);
sendData(ws, {
event: "server:service-server-logs",
ttyd,
server,
});
} catch (error: any) {
sendError(ws, "Service Server Logs Error! " + error.message);
}
}

View File

@ -1,6 +1,14 @@
import { WebSocketData } from "../types";
import { WebSocketConnectedUserData, WebSocketData } from "../types";
import socketClose from "./socket-close";
import socketInit from "./socket-init";
import socketMessage from "./socket-message";
import socketOpen from "./socket-open";
declare global {
var WEBSOCKET_CONNECTED_USERS_DATA: WebSocketConnectedUserData[];
}
global.WEBSOCKET_CONNECTED_USERS_DATA = [];
const server = Bun.serve<WebSocketData>({
async fetch(req, server) {
@ -27,12 +35,10 @@ const server = Bun.serve<WebSocketData>({
}
},
async open(ws) {
const user = ws.data.user;
console.log(`Web Socket Opened by ${user.first_name}`);
await socketOpen({ ws });
},
async close(ws, code, message) {
const user = ws.data.user;
console.log(`Socket Closed by ${user.first_name}`);
await socketClose({ ws });
},
idleTimeout: 600,
maxPayloadLength: 1024 * 1024 * 10,

View File

@ -0,0 +1,33 @@
import { ServerWebSocket } from "bun";
import { WebSocketData } from "../types";
import killAllTtydPorts from "./(utils)/kill-all-ttyd-ports";
type Param = {
ws: ServerWebSocket<WebSocketData>;
};
export default async function socketClose({ ws }: Param) {
const user = ws.data.user;
console.log(`Web Socket Closed by ${user.first_name}`);
const existing_connected_user_data =
global.WEBSOCKET_CONNECTED_USERS_DATA.find((u) => u.user.id == user.id);
if (existing_connected_user_data) {
for (
let i = 0;
i < existing_connected_user_data.child_processes.length;
i++
) {
const child_process =
existing_connected_user_data.child_processes[i];
child_process.kill();
}
existing_connected_user_data.child_processes = [];
}
killAllTtydPorts({
exec_options: { stdio: "inherit" },
});
}

View File

@ -1,20 +1,20 @@
import { ServerWebSocket } from "bun";
import { WebSocketData, WebSocketDataType } from "../types";
import {
WebSocketData,
WebSocketDataType,
WebSocketMessageParam,
} 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";
import socketClientServiceServerLogs from "./events/client-service-server-logs";
import socketClientKillPort from "./events/client-kill-port";
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
@ -39,6 +39,22 @@ export default async function socketMessage({ ws, message }: Param) {
});
await socketClientPing(websocketMessageParams);
break;
case "client:service-server-logs":
debugLog({
log: `${userRef} Getting Service Server Logs ...`,
addTime: true,
label,
});
await socketClientServiceServerLogs(websocketMessageParams);
break;
case "client:kill-port":
debugLog({
log: `${userRef} Killing Port ${data.port} ...`,
addTime: true,
label,
});
await socketClientKillPort(websocketMessageParams);
break;
default:
break;

View File

@ -0,0 +1,22 @@
import { ServerWebSocket } from "bun";
import { WebSocketData } from "../types";
type Param = {
ws: ServerWebSocket<WebSocketData>;
};
export default async function socketOpen({ ws }: Param) {
const user = ws.data.user;
console.log(`Web Socket Opened by ${user.first_name}`);
const existing_connected_user_data =
global.WEBSOCKET_CONNECTED_USERS_DATA.find((u) => u.user.id == user.id);
if (!existing_connected_user_data) {
global.WEBSOCKET_CONNECTED_USERS_DATA.push({
user,
child_processes: [],
ports: [],
});
}
}