From e3467d125f69bfe95f1f6dee9147645ab151cbbf Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Sat, 8 Mar 2025 15:59:01 -0300 Subject: [PATCH 01/73] refactor: standardize cookie names using shared constants --- core/modules/WebServer/getReactIndex.ts | 2 +- core/modules/WebServer/index.ts | 7 ++++--- core/modules/WebServer/middlewares/ctxUtilsMw.ts | 2 +- panel/src/hooks/auth.ts | 9 ++++++++- panel/src/hooks/theme.ts | 3 ++- shared/consts.ts | 6 +++++- 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/core/modules/WebServer/getReactIndex.ts b/core/modules/WebServer/getReactIndex.ts index 496285185..fa04d83b7 100644 --- a/core/modules/WebServer/getReactIndex.ts +++ b/core/modules/WebServer/getReactIndex.ts @@ -161,7 +161,7 @@ export default async function getReactIndex(ctx: CtxWithVars | AuthedCtx) { } //Setting the theme class from the cookie - const themeCookie = ctx.cookies.get('txAdmin-theme'); + const themeCookie = ctx.cookies.get(consts.cookies.theme); if (themeCookie) { if (tmpDefaultThemes.includes(themeCookie)) { replacers.htmlClasses = themeCookie; diff --git a/core/modules/WebServer/index.ts b/core/modules/WebServer/index.ts index df4d1f0a1..c25f31350 100644 --- a/core/modules/WebServer/index.ts +++ b/core/modules/WebServer/index.ts @@ -27,6 +27,7 @@ import fatalError from '@lib/fatalError'; import { isProxy } from 'node:util/types'; import serveStaticMw from './middlewares/serveStaticMw'; import serveRuntimeMw from './middlewares/serveRuntimeMw'; +import consts from '@shared/consts'; const console = consoleFactory(modulename); const nanoid = customAlphabet(dict49, 32); @@ -54,8 +55,9 @@ export default class WebServer { //Generate cookie key & luaComToken const pathHash = crypto.createHash('shake256', { outputLength: 6 }) .update(txEnv.profilePath) - .digest('hex'); - this.sessionCookieName = `tx:${pathHash}`; + .digest('hex') + .padStart(12, '0'); + this.sessionCookieName = `${consts.cookies.session}:${pathHash}`; this.luaComToken = nanoid(); @@ -63,7 +65,6 @@ export default class WebServer { // Setting up Koa // =================== this.app = new Koa(); - this.app.keys = ['txAdmin' + nanoid()]; // Some people might want to enable it, but we are not guaranteeing XFF security // due to the many possible ways you can connect to koa. diff --git a/core/modules/WebServer/middlewares/ctxUtilsMw.ts b/core/modules/WebServer/middlewares/ctxUtilsMw.ts index ab97cf166..acd396c3f 100644 --- a/core/modules/WebServer/middlewares/ctxUtilsMw.ts +++ b/core/modules/WebServer/middlewares/ctxUtilsMw.ts @@ -161,7 +161,7 @@ export default async function ctxUtilsMw(ctx: CtxWithVars, next: Next) { //Setting up legacy theme let legacyTheme = ''; - const themeCookie = ctx.cookies.get('txAdmin-theme'); + const themeCookie = ctx.cookies.get(consts.cookies.theme); if (!themeCookie || themeCookie === 'dark' || !isWebInterface) { legacyTheme = 'theme--dark'; } else { diff --git a/panel/src/hooks/auth.ts b/panel/src/hooks/auth.ts index d3f85d944..b3fc33a85 100644 --- a/panel/src/hooks/auth.ts +++ b/panel/src/hooks/auth.ts @@ -12,6 +12,7 @@ import { redirectToLogin } from '@/lib/navigation'; import { LogoutReasonHash } from '@/pages/auth/Login'; import { mutate } from 'swr'; import { fetchWithTimeout } from './fetch'; +import consts from '@shared/consts'; /** @@ -103,9 +104,15 @@ export const useAuth = () => { redirectToLogin(LogoutReasonHash.LOGOUT); } else { console.error('Failed to logout:', data); + // Still wipe auth even if the request fails + setAuthData(false); + redirectToLogin(LogoutReasonHash.LOGOUT); } }).catch(error => { console.log('Error sending logout request:', error); + // Still wipe auth even if the request fails + setAuthData(false); + redirectToLogin(LogoutReasonHash.LOGOUT); }); return { @@ -116,7 +123,7 @@ export const useAuth = () => { }; //Effect to on logout, automagically close all dialogs/modals and reset globalState -export const logoutWatcher = atomEffect((get, set) => { +export const logoutWatcher: ReturnType = atomEffect((get, set) => { const isAuthenticated = get(isAuthenticatedAtom); if (isAuthenticated) return; diff --git a/panel/src/hooks/theme.ts b/panel/src/hooks/theme.ts index 9b73a1ce2..b1c904e9c 100644 --- a/panel/src/hooks/theme.ts +++ b/panel/src/hooks/theme.ts @@ -1,3 +1,4 @@ +import consts from '@shared/consts'; import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'; @@ -17,7 +18,7 @@ const initialAtomValue = availableCustomThemes.find((name) => root.classList.con * Helpers */ const setThemeCookieValue = (value: string) => { - document.cookie = `txAdmin-theme=${value};path=/;SameSite=Lax;max-age=31536000;`; + document.cookie = `${consts.cookies.theme}=${value};path=/;SameSite=Lax;max-age=31536000;`; } const parseTheme = (themeName: string) => { diff --git a/shared/consts.ts b/shared/consts.ts index b09b01559..fbd44af25 100644 --- a/shared/consts.ts +++ b/shared/consts.ts @@ -41,5 +41,9 @@ export default { regexValidIP: /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, actionIdAlphabet, nuiWebpipePath: 'https://monitor/WebPipe/', - regexCustomThemeName: /^[a-z0-9]+(-[a-z0-9]+)*$/ + regexCustomThemeName: /^[a-z0-9]+(-[a-z0-9]+)*$/, + cookies: { + theme: 'txa:theme', + session: 'txa:sess', + } } as const; From ad056f23b04fa852bb27dd7621d3aa4bbf2cd456 Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Sat, 8 Mar 2025 16:00:56 -0300 Subject: [PATCH 02/73] tweak(panel): handle removeChild error in dev mode by auto-reloading once --- panel/src/components/ErrorFallback.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/panel/src/components/ErrorFallback.tsx b/panel/src/components/ErrorFallback.tsx index 2fc8b94ee..20c644c61 100644 --- a/panel/src/components/ErrorFallback.tsx +++ b/panel/src/components/ErrorFallback.tsx @@ -8,6 +8,7 @@ import { FallbackProps } from "react-error-boundary"; import { FiAlertOctagon } from "react-icons/fi"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { useEffect } from "react"; //Used for global errors export function AppErrorFallback({ error }: FallbackProps) { @@ -49,6 +50,24 @@ type GenericErrorBoundaryCardProps = { } export function GenericErrorBoundaryCard(props: GenericErrorBoundaryCardProps) { + //Auto refresh the page if the error is related to removeChild - dev mode only + if (window.txConsts.showAdvanced) { + useEffect(() => { + if (props.error.message?.includes("Failed to execute 'removeChild' on 'Node'")) { + console.warn('Detected removeChild error, scheduling reload'); + // Use a flag in sessionStorage to prevent infinite reload loops + const storageKey = 'txa:last-error-reload'; + const lastReloadRaw = localStorage.getItem(storageKey); + const now = Date.now(); + const lastReload = lastReloadRaw ? parseInt(lastReloadRaw) : 0; + if (now - lastReload > 30_000) { + localStorage.setItem(storageKey, now.toString()); + setTimeout(() => window.location.reload(), 500); + } + } + }, [props.error]); + } + return ( From 72fdd0bbd9f2e66a9fecfb66ad3a906e7e91104c Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Sun, 9 Mar 2025 11:07:01 -0300 Subject: [PATCH 03/73] feat(panel): added theme-aware terminal colors and styling --- .../FXServerLogger/ConsoleTransformer.ts | 4 +- .../src/pages/LiveConsole/LiveConsolePage.tsx | 34 ++++---- .../pages/LiveConsole/liveConsoleColors.ts | 77 +++++++++++++++++++ .../src/pages/LiveConsole/liveConsoleUtils.ts | 10 +-- panel/src/pages/LiveConsole/xtermOptions.ts | 5 +- panel/src/pages/SystemLogPage.tsx | 20 ++++- 6 files changed, 120 insertions(+), 30 deletions(-) create mode 100644 panel/src/pages/LiveConsole/liveConsoleColors.ts diff --git a/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts b/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts index 188e92227..8672ee97d 100644 --- a/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts +++ b/core/modules/Logger/FXServerLogger/ConsoleTransformer.ts @@ -25,8 +25,8 @@ type StyleChannelConfig = { //Precalculating some styles const chalkToStr = (color: ChalkInstance) => color('\x00').split('\x00')[0]; -const precalcMarkerAdminCmd = chalkToStr(chalk.bgHex('#e6b863').black); -const precalcMarkerSystemCmd = chalkToStr(chalk.bgHex('#36383D').hex('#CCCCCC')); +const precalcMarkerAdminCmd = chalkToStr(chalk.bgAnsi256(180).black); +const precalcMarkerSystemCmd = chalkToStr(chalk.bgAnsi256(237).ansi256(252)); const precalcMarkerInfo = chalkToStr(chalk.bgBlueBright.black); const ANSI_RESET = '\x1B[0m'; const ANSI_ERASE_LINE = '\x1b[K'; diff --git a/panel/src/pages/LiveConsole/LiveConsolePage.tsx b/panel/src/pages/LiveConsole/LiveConsolePage.tsx index 791b7de3e..4afd88169 100644 --- a/panel/src/pages/LiveConsole/LiveConsolePage.tsx +++ b/panel/src/pages/LiveConsole/LiveConsolePage.tsx @@ -88,6 +88,7 @@ export default function LiveConsolePage() { prefix: defaultTermPrefix, }); const refreshPage = useContentRefresh(); + const isDarkTheme = useIsDarkMode(); //FIXME: maybe use atomWithStorage const consoleOptions: LiveConsoleOptions = useMemo(() => { @@ -105,7 +106,10 @@ export default function LiveConsolePage() { */ const jumpBottomBtnRef = useRef(null); const containerRef = useRef(null); - const term = useMemo(() => new Terminal(terminalOptions), []); + const term = useMemo(() => new Terminal({ + ...xtermOptions, + theme: isDarkTheme ? darkThemeColors : lightThemeColors, + }), []); const fitAddon = useMemo(() => new FitAddon(), []); const searchAddon = useMemo(() => new SearchAddon(), []); const termLinkHandler = (event: MouseEvent, uri: string) => { @@ -135,6 +139,14 @@ export default function LiveConsolePage() { } useEventListener('resize', debounce(100, refitTerminal)); + useEffect(() => { + if (term.element) { + term.options.theme = isDarkTheme ? darkThemeColors : lightThemeColors; + term.refresh(0, term.rows - 1); + refitTerminal(); + } + }, [term, isDarkTheme]); + useEffect(() => { if (containerRef.current && jumpBottomBtnRef.current && !term.element) { console.log('live console xterm init'); @@ -281,21 +293,17 @@ export default function LiveConsolePage() { } //Check if it's last line, and if the EOL was stripped - const prefixColor = isNewTs ? ANSI.WHITE : ANSI.GRAY; + const prefixColor = isNewTs ? ANSI.TS_STRONG : ANSI.TS_WEAK; const prefix = termPrefixRef.current.lastEol ? prefixColor + termPrefixRef.current.prefix : ''; - if (i < lines.length - 1) { - term.writeln(prefix + line, writeCallback); + const prefixedLine = prefix + line; + if (i < lines.length - 1 || wasEolStripped) { + term.writeln(prefixedLine, writeCallback); termPrefixRef.current.lastEol = true; } else { - if (wasEolStripped) { - term.writeln(prefix + line, writeCallback); - termPrefixRef.current.lastEol = true; - } else { - term.write(prefix + line, writeCallback); - termPrefixRef.current.lastEol = false; - } + term.write(prefixedLine, writeCallback); + termPrefixRef.current.lastEol = false; } } } @@ -388,7 +396,7 @@ export default function LiveConsolePage() { term.clear(); searchAddon.clearDecorations(); setShowSearchBar(false); - term.write(`${ANSI.YELLOW}[console cleared]${ANSI.RESET}\n`); + term.write(`${ANSI.ORANGE}[console cleared]${ANSI.RESET}\n`); } const toggleSearchBar = () => { setShowSearchBar(!showSearchBar); @@ -406,7 +414,7 @@ export default function LiveConsolePage() { return ( -
+
diff --git a/panel/src/pages/LiveConsole/liveConsoleColors.ts b/panel/src/pages/LiveConsole/liveConsoleColors.ts new file mode 100644 index 000000000..85c7ac9be --- /dev/null +++ b/panel/src/pages/LiveConsole/liveConsoleColors.ts @@ -0,0 +1,77 @@ +import type { ITheme } from "@xterm/xterm"; + + +//MARK: ANSI Codes +export const ANSI = { + ORANGE: '\x1B[1;38;5;202m', //console cleared message + TS_STRONG: '\x1B[1;37m', //bold white + TS_WEAK: '\x1B[2;37m', //faint white + RESET: '\x1B[0m', +} as const; + + +//MARK: Dark Theme +//From legacy systemLog.ejs, based on the ANSI-UP colors +const ansi16ColorsDark = { + black: '#000000', + brightBlack: '#555555', + red: '#D62341', + brightRed: '#FF5370', + green: '#9ECE58', + brightGreen: '#C3E88D', + yellow: '#FAED70', + brightYellow: '#FFCB6B', + blue: '#396FE2', + brightBlue: '#82AAFF', + magenta: '#BB80B3', + brightMagenta: '#C792EA', + cyan: '#2DDAFD', + brightCyan: '#89DDFF', + white: '#D0D0D0', + brightWhite: '#FFFFFF', +}; + +const baseColorsDark = { + background: '#222326', //card bg + foreground: '#F8F8F8', //primary +} + +export const darkThemeColors: ITheme = { + ...baseColorsDark, + ...ansi16ColorsDark, +}; + + +//MARK: Light Theme +//NOTE: Colors generated from scripts/dev/makeConsoleLightTheme.ts +const ansi16ColorsLight = { + black: "#FFFFFF", + brightBlack: "#AAAAAA", + red: "#AB0420", + brightRed: "#CC0829", + green: "#65A509", + brightGreen: "#74BA0E", + yellow: "#C8B60B", + brightYellow: "#CC880B", + blue: "#063EB5", + brightBlue: "#0D4ACC", + magenta: "#960D83", + brightMagenta: "#770FBB", + cyan: "#05A9CA", + brightCyan: "#0E95CC", + white: "#2F2F2F", + brightWhite: "#0D0D0D" +}; + +const ansi256LightHex = ["#FFFFFF", "#00003E", "#000058", "#000072", "#00008C", "#0000A6", "#003E00", "#003E3E", "#003E58", "#003E72", "#003E8C", "#003EA6", "#005800", "#00583E", "#005858", "#005872", "#00588C", "#0058A6", "#007200", "#00723E", "#007258", "#007272", "#00728C", "#0072A6", "#008C00", "#008C3E", "#008C58", "#008C72", "#008C8C", "#008CA6", "#00A600", "#00A63E", "#00A658", "#00A672", "#00A68C", "#00A6A6", "#3E0000", "#3E003E", "#3E0058", "#3E0072", "#3E008C", "#3E00A6", "#3E3E00", "#A0A0A0", "#131358", "#131372", "#13138C", "#1313A6", "#3E5800", "#135813", "#135858", "#134272", "#133B8C", "#1338A6", "#3E7200", "#137213", "#137242", "#137272", "#13638C", "#135CA6", "#3E8C00", "#138C13", "#138C3B", "#138C63", "#138C8C", "#1381A6", "#3EA600", "#13A613", "#13A638", "#13A65C", "#13A681", "#13A6A6", "#580000", "#58003E", "#580058", "#580072", "#58008C", "#5800A6", "#583E00", "#581313", "#581358", "#421372", "#3B138C", "#3813A6", "#585800", "#585813", "#787878", "#1B1B72", "#1B1B8C", "#1B1BA6", "#587200", "#427213", "#1B721B", "#1B7272", "#1B538C", "#1B49A6", "#588C00", "#3B8C13", "#1B8C1B", "#1B8C53", "#1B8C8C", "#1B77A6", "#58A600", "#38A613", "#1BA61B", "#1BA649", "#1BA677", "#1BA6A6", "#720000", "#72003E", "#720058", "#720072", "#72008C", "#7200A6", "#723E00", "#721313", "#721342", "#721372", "#64138C", "#5C13A6", "#725800", "#724213", "#721B1B", "#721B72", "#531B8C", "#491BA6", "#727200", "#727213", "#72721B", "#505050", "#23238C", "#2323A6", "#728C00", "#638C13", "#538C1B", "#238C23", "#238C8C", "#2364A6", "#72A601", "#5CA613", "#49A61B", "#23A623", "#23A664", "#23A6A6", "#8C0000", "#8C003E", "#8C0058", "#8C0072", "#8C008C", "#8C00A6", "#8C3E00", "#8C1313", "#8C133B", "#8C1364", "#8C138C", "#8113A6", "#8C5800", "#8C3B13", "#8C1B1B", "#8C1B53", "#8C1B8c", "#781BA6", "#8C7200", "#8C6413", "#8C531B", "#8C2323", "#8C238C", "#6423A6", "#8C8C00", "#8C8C13", "#8C8C1B", "#8C8C23", "#282828", "#2B2BA6", "#8CA600", "#81A613", "#77A61B", "#64A623", "#2BA62B", "#2BA6A6", "#A60000", "#A6003E", "#A60058", "#A60072", "#A6008C", "#A600A6", "#A63E00", "#A61313", "#A61338", "#A6135C", "#A61381", "#A613A6", "#A65800", "#A63813", "#A61B1B", "#A61B49", "#A61B78", "#A61BA6", "#A67200", "#A65C13", "#A6491B", "#A62323", "#A62364", "#A623A6", "#A68C00", "#A68113", "#A6781B", "#A66423", "#A62B2B", "#A62BA6", "#A6A600", "#A6A613", "#A6A61B", "#A6A623", "#A6A62B", "#0D0D0D", "#F7F7F7", "#EDEDED", "#E3E3E3", "#D9D9D9", "#CFCFCF", "#C5C5C5", "#BBBBBB", "#B1B1B1", "#A7A7A7", "#9D9D9D", "#939393", "#898989", "#7F7F7F", "#757575", "#6B6B6B", "#616161", "#575757", "#4D4D4D", "#434343", "#393939", "#2F2F2F", "#252525", "#1B1B1B", "#111111"]; + +const baseColorsLight = { + background: '#F0F1F4', //card bg + foreground: '#16171B', //primary +} + +export const lightThemeColors: ITheme = { + ...baseColorsLight, + ...ansi16ColorsLight, + extendedAnsi: ansi256LightHex, +}; diff --git a/panel/src/pages/LiveConsole/liveConsoleUtils.ts b/panel/src/pages/LiveConsole/liveConsoleUtils.ts index b2ed02f26..38a7bf974 100644 --- a/panel/src/pages/LiveConsole/liveConsoleUtils.ts +++ b/panel/src/pages/LiveConsole/liveConsoleUtils.ts @@ -1,14 +1,6 @@ import { copyToClipboard } from "@/lib/utils"; import { LiveConsoleOptions } from "./LiveConsolePage"; - - -//ANSII escape codes -export const ANSI = { - WHITE: '\x1B[0;37m', - GRAY: '\x1B[1;90m', - YELLOW: '\x1B[0;33m', - RESET: '\x1B[0m', -} as const; +import { ANSI } from "./liveConsoleColors"; //Yoinked from core/modules/Logger/FXServerLogger/index.ts diff --git a/panel/src/pages/LiveConsole/xtermOptions.ts b/panel/src/pages/LiveConsole/xtermOptions.ts index 01bc2536b..73be1d5d2 100644 --- a/panel/src/pages/LiveConsole/xtermOptions.ts +++ b/panel/src/pages/LiveConsole/xtermOptions.ts @@ -24,8 +24,7 @@ const baseTheme: ITheme = { brightWhite: '#FFFFFF', }; -const terminalOptions: ITerminalOptions | ITerminalInitOnlyOptions = { - theme: baseTheme, +export const xtermOptions: ITerminalOptions | ITerminalInitOnlyOptions = { convertEol: true, cursorBlink: true, cursorStyle: 'bar', @@ -53,4 +52,4 @@ const terminalOptions: ITerminalOptions | ITerminalInitOnlyOptions = { - light mode? - whether clicking on a saved command copies it to the input or executes it directly */ -export default terminalOptions; +export default xtermOptions; diff --git a/panel/src/pages/SystemLogPage.tsx b/panel/src/pages/SystemLogPage.tsx index 1eef3f677..710cc602f 100644 --- a/panel/src/pages/SystemLogPage.tsx +++ b/panel/src/pages/SystemLogPage.tsx @@ -13,10 +13,12 @@ import './LiveConsole/xtermOverrides.css'; import '@xterm/xterm/css/xterm.css'; import { openExternalLink } from '@/lib/navigation'; import { handleHotkeyEvent } from '@/lib/hotkeyEventListener'; -import terminalOptions from './LiveConsole/xtermOptions'; +import xtermOptions from './LiveConsole/xtermOptions'; import ScrollDownAddon from './LiveConsole/ScrollDownAddon'; import LiveConsoleSearchBar from './LiveConsole/LiveConsoleSearchBar'; import { useBackendApi } from '@/hooks/fetch'; +import { useIsDarkMode } from '@/hooks/theme'; +import { darkThemeColors, lightThemeColors } from './LiveConsole/liveConsoleColors'; //Helpers @@ -31,6 +33,7 @@ export default function SystemLogPage({ pageName }: SystemLogPageProps) { const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [showSearchBar, setShowSearchBar] = useState(false); + const isDarkTheme = useIsDarkMode(); const refreshPage = useContentRefresh(); const getLogsApi = useBackendApi<{ data: string }>({ method: 'GET', @@ -44,7 +47,10 @@ export default function SystemLogPage({ pageName }: SystemLogPageProps) { */ const jumpBottomBtnRef = useRef(null); const containerRef = useRef(null); - const term = useMemo(() => new Terminal(terminalOptions), []); + const term = useMemo(() => new Terminal({ + ...xtermOptions, + theme: isDarkTheme ? darkThemeColors : lightThemeColors, + }), []); const fitAddon = useMemo(() => new FitAddon(), []); const searchAddon = useMemo(() => new SearchAddon(), []); const termLinkHandler = (event: MouseEvent, uri: string) => { @@ -74,6 +80,14 @@ export default function SystemLogPage({ pageName }: SystemLogPageProps) { } useEventListener('resize', debounce(100, refitTerminal)); + useEffect(() => { + if (term.element) { + term.options.theme = isDarkTheme ? darkThemeColors : lightThemeColors; + term.refresh(0, term.rows - 1); + refitTerminal(); + } + }, [term, isDarkTheme]); + useEffect(() => { if (containerRef.current && jumpBottomBtnRef.current && !term.element) { console.log('xterm init'); @@ -194,7 +208,7 @@ export default function SystemLogPage({ pageName }: SystemLogPageProps) { } return ( -
+
Date: Sun, 9 Mar 2025 18:43:10 -0300 Subject: [PATCH 04/73] feat(panel): add live console persistent options popover --- docs/dev-notes.md | 1 - package-lock.json | 220 +++++++++++++++++- panel/package.json | 1 + panel/src/components/ui/popover.tsx | 33 +++ .../pages/LiveConsole/LiveConsoleHeader.tsx | 71 ++++-- .../src/pages/LiveConsole/LiveConsolePage.tsx | 103 +++----- .../src/pages/LiveConsole/OptionsPopover.tsx | 127 ++++++++++ .../src/pages/LiveConsole/liveConsoleHooks.ts | 118 +++++++++- .../src/pages/LiveConsole/liveConsoleUtils.ts | 31 ++- panel/src/pages/LiveConsole/xtermOptions.ts | 93 +++++--- panel/src/pages/Settings/utils.ts | 1 - 11 files changed, 657 insertions(+), 142 deletions(-) create mode 100644 panel/src/components/ui/popover.tsx create mode 100644 panel/src/pages/LiveConsole/OptionsPopover.tsx diff --git a/docs/dev-notes.md b/docs/dev-notes.md index d43378e74..78d97fc9b 100644 --- a/docs/dev-notes.md +++ b/docs/dev-notes.md @@ -354,7 +354,6 @@ https://tailwindcss.com/blog/automatic-class-sorting-with-prettier - [ ] if socket connects but no data received, add a warning to the console and wipe it after first write - [ ] persistent cls via ts offsets - [ ] improve the bufferization to allow just loading most recent "block" and loading prev blocks via button - - [ ] options dropdown? - [ ] console nav button to jump to server start or errors? - Or maybe filter just error lines (with margin) - Or maybe even detect all channels and allow you to filter them, show dropdown sorted by frequency diff --git a/package-lock.json b/package-lock.json index a7cae8e0e..6a6d6410b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3258,6 +3258,215 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-arrow": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", @@ -13020,16 +13229,16 @@ } }, "node_modules/react-remove-scroll": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz", - "integrity": "sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", + "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==", "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.1", + "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.2" + "use-sidecar": "^1.1.3" }, "engines": { "node": ">=10" @@ -16406,6 +16615,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.1", + "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.4", diff --git a/panel/package.json b/panel/package.json index 6fc68b226..eaece21f4 100644 --- a/panel/package.json +++ b/panel/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.1", + "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.4", diff --git a/panel/src/components/ui/popover.tsx b/panel/src/components/ui/popover.tsx new file mode 100644 index 000000000..29c7bd2a4 --- /dev/null +++ b/panel/src/components/ui/popover.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverAnchor = PopoverPrimitive.Anchor + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/panel/src/pages/LiveConsole/LiveConsoleHeader.tsx b/panel/src/pages/LiveConsole/LiveConsoleHeader.tsx index e71e57af6..c4f28b616 100644 --- a/panel/src/pages/LiveConsole/LiveConsoleHeader.tsx +++ b/panel/src/pages/LiveConsole/LiveConsoleHeader.tsx @@ -1,23 +1,58 @@ -export default function LiveConsoleHeader() { +import { Settings } from "lucide-react"; +import { Popover, PopoverTrigger } from "@/components/ui/popover"; +import type { LiveConsoleOptionsPopoverProps } from "./OptionsPopover"; +import LiveConsoleOptionsPopover from "./OptionsPopover"; +import { DynamicNewBadge } from "@/components/DynamicNewBadge"; +import { useState } from "react"; +import { useContentRefresh } from "@/hooks/pages"; + + +export default function LiveConsoleHeader(popoverProps: Omit) { + const [hasPendingRefresh, setHasPendingRefresh] = useState(false); + const refreshPage = useContentRefresh(); + return (
-
- - - - -

Live Console

+
+
+ + + + +

Live Console

+
+ +
+ { + if (!state && hasPendingRefresh) { + refreshPage(); + setHasPendingRefresh(false); + } + }}> + + + + + + +
) diff --git a/panel/src/pages/LiveConsole/LiveConsolePage.tsx b/panel/src/pages/LiveConsole/LiveConsolePage.tsx index 4afd88169..6892151a6 100644 --- a/panel/src/pages/LiveConsole/LiveConsolePage.tsx +++ b/panel/src/pages/LiveConsole/LiveConsolePage.tsx @@ -16,90 +16,47 @@ import LiveConsoleSearchBar from "./LiveConsoleSearchBar"; import LiveConsoleSaveSheet from "./LiveConsoleSaveSheet"; import ScrollDownAddon from "./ScrollDownAddon"; -import terminalOptions from "./xtermOptions"; +import xtermOptions from "./xtermOptions"; import './xtermOverrides.css'; import '@xterm/xterm/css/xterm.css'; import { getSocket } from '@/lib/utils'; import { openExternalLink } from '@/lib/navigation'; import { handleHotkeyEvent } from '@/lib/hotkeyEventListener'; import { txToast } from '@/components/TxToaster'; -import { ANSI, copyTermLine, extractTermLineTimestamp, formatTermTimestamp, getNumFontVariantsLoaded } from './liveConsoleUtils'; +import { copyTermLine, extractTermLineTimestamp, formatTermTimestamp, getEmptyTermTimestamp, getNumFontVariantsLoaded } from './liveConsoleUtils'; import { getTermLineEventData, getTermLineInitialData, getTermLineRtlData, registerTermLineMarker } from './liveConsoleMarkers'; - - -//Options -export type LiveConsoleOptions = { - timestampDisabled: boolean; - timestampForceHour12: boolean | undefined; - copyTimestamp: boolean; - copyTag: boolean; -} - -//Loading local storage configs -//FIXME: this is hacky, maybe use atomWithStorage -let timestampDisabled = false; -let timestampForceHour12: boolean | undefined = undefined; -try { - const localConfig = localStorage.getItem('liveConsoleTimestamp'); - if (localConfig === '24h') { - timestampForceHour12 = false; - } else if (localConfig === '12h') { - timestampForceHour12 = true; - } else if (localConfig === 'off') { - timestampDisabled = true; - } -} catch (error) { } -let copyTimestamp = false; -let copyTag = true; -try { - const localConfig = localStorage.getItem('liveConsoleCopyOpts'); - if (typeof localConfig === 'string') { - const parts = localConfig.split(','); - copyTimestamp = parts.includes('ts'); - copyTag = parts.includes('tag'); - } -} catch (error) { } +import { useIsDarkMode } from '@/hooks/theme'; +import { darkThemeColors, lightThemeColors, ANSI } from './liveConsoleColors'; +import { useTerminalOptions } from './liveConsoleHooks'; +import { DensityModes } from './xtermOptions'; //Consts const keyDebounceTime = 150; //ms -//FIXME: move to inside the component -const defaultTermPrefix = formatTermTimestamp( - Date.now(), - { - timestampDisabled, - timestampForceHour12, - copyTimestamp, - copyTag, - } -).replace(/\w/g, '-'); - //Main component export default function LiveConsolePage() { const [isSaveSheetOpen, setIsSaveSheetOpen] = useState(false); const [isConnected, setIsConnected] = useState(false); const [showSearchBar, setShowSearchBar] = useState(false); + + // Terminal settings state + const { options: savedOptions, updateOptions: updateTerminalOptions } = useTerminalOptions(); + const optionsRef = useRef({ ...savedOptions }); + const termInputRef = useRef(null); const termPrefixRef = useRef({ ts: 0, //so we can clear the console lastEol: true, - //FIXME: defaultTermPrefix depends on options, deal with it when options change - prefix: defaultTermPrefix, + prefix: getEmptyTermTimestamp(optionsRef.current.timestamp), }); const refreshPage = useContentRefresh(); const isDarkTheme = useIsDarkMode(); - //FIXME: maybe use atomWithStorage - const consoleOptions: LiveConsoleOptions = useMemo(() => { - return { - timestampDisabled, - timestampForceHour12, - copyTimestamp, - copyTag, - }; - }, []); - + //This is required to update the timestamp, because writeToTerminal is "cached" by the socket useEffect + useEffect(() => { + optionsRef.current = { ...savedOptions }; + }, [savedOptions]); /** * xterm stuff @@ -108,6 +65,7 @@ export default function LiveConsolePage() { const containerRef = useRef(null); const term = useMemo(() => new Terminal({ ...xtermOptions, + ...savedOptions, theme: isDarkTheme ? darkThemeColors : lightThemeColors, }), []); const fitAddon = useMemo(() => new FitAddon(), []); @@ -141,15 +99,22 @@ export default function LiveConsolePage() { useEffect(() => { if (term.element) { - term.options.theme = isDarkTheme ? darkThemeColors : lightThemeColors; - term.refresh(0, term.rows - 1); + const densitySettings = DensityModes[savedOptions.density]; + Object.assign(term.options, { + theme: isDarkTheme ? darkThemeColors : lightThemeColors, + scrollback: savedOptions.scrollback, + fontSize: densitySettings.fontSize, + lineHeight: densitySettings.lineHeight, + letterSpacing: densitySettings.letterSpacing, + }); refitTerminal(); + term.refresh(0, term.rows - 1); } - }, [term, isDarkTheme]); + }, [term, isDarkTheme, savedOptions]); useEffect(() => { if (containerRef.current && jumpBottomBtnRef.current && !term.element) { - console.log('live console xterm init'); + console.log('Live Console xterm.js init'); containerRef.current.innerHTML = ''; //due to HMR, the terminal element might still be there term.loadAddon(fitAddon); term.loadAddon(searchAddon); @@ -189,7 +154,8 @@ export default function LiveConsolePage() { copyTermLine( selection, term.element as any, - consoleOptions, + optionsRef.current.copyTimestamp, + optionsRef.current.copyChannel, termInputRef.current ).then((res) => { //undefined if no error @@ -269,10 +235,10 @@ export default function LiveConsolePage() { isNewTs = true; line = content; termPrefixRef.current.ts = ts; - termPrefixRef.current.prefix = formatTermTimestamp(ts, consoleOptions); + termPrefixRef.current.prefix = formatTermTimestamp(ts, optionsRef.current.timestamp); } } catch (error) { - termPrefixRef.current.prefix = defaultTermPrefix; + termPrefixRef.current.prefix = getEmptyTermTimestamp(optionsRef.current.timestamp); console.warn('Failed to parse timestamp from:', line, (error as any).message); } @@ -415,7 +381,10 @@ export default function LiveConsolePage() { return (
- +
{/* Connecting overlay */} diff --git a/panel/src/pages/LiveConsole/OptionsPopover.tsx b/panel/src/pages/LiveConsole/OptionsPopover.tsx new file mode 100644 index 000000000..c29c0bd92 --- /dev/null +++ b/panel/src/pages/LiveConsole/OptionsPopover.tsx @@ -0,0 +1,127 @@ +import { PopoverContent } from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ScrollbackSizes } from "./xtermOptions"; +import type { TerminalOptions, ScrollbackSize, DensityMode, TimestampMode } from "./xtermOptions"; +import { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; + +export type LiveConsoleOptionsPopoverProps = { + options: TerminalOptions; + setOptions: (options: Partial) => void; + setHasPendingRefresh: (hasPendingRefresh: boolean) => void; +} + +export default function LiveConsoleOptionsPopover({ + options, + setOptions, + setHasPendingRefresh, +}: LiveConsoleOptionsPopoverProps) { + return ( + +
+ {/* Header */} +
+

Live Console Settings

+

+ Customize the appearance and behavior of your live console. These settings will be saved. +

+
+ + {/* Display Settings */} +
+
Display Options
+
+
+ +
+ +
+
+ +
+
+ +
+ +
+
+
+ +
+ +
+ +
+
+
+
+ + + + {/* Copy Settings */} +
+
Copy Options
+
+
+ + setOptions({ copyTimestamp: checked })} + /> +
+
+ + setOptions({ copyChannel: checked })} + /> +
+
+
+
+
+ ) +} diff --git a/panel/src/pages/LiveConsole/liveConsoleHooks.ts b/panel/src/pages/LiveConsole/liveConsoleHooks.ts index f998541d6..87c6c9ec9 100644 --- a/panel/src/pages/LiveConsole/liveConsoleHooks.ts +++ b/panel/src/pages/LiveConsole/liveConsoleHooks.ts @@ -1,18 +1,132 @@ import { useAtom } from "jotai"; import { atomWithStorage } from "jotai/utils"; +import { terminalDefaultOptions, type TerminalOptions, ScrollbackSizes, DensityModes, TimestampModes } from "./xtermOptions"; + + +/** + * Storage Factory + */ +const createValidatedStorage = (validator: (value: unknown) => T, defaultValue: T) => { + return { + getItem: (key: string): T => { + const storedValue = localStorage.getItem(key); + if (!storedValue) return defaultValue; + try { + const parsedValue = JSON.parse(storedValue); + return validator(parsedValue); + } catch (error) { + return defaultValue; + } + }, + setItem: (key: string, value: T): void => { + const validatedValue = validator(value); + localStorage.setItem(key, JSON.stringify(validatedValue)); + }, + removeItem: (key: string): void => { + localStorage.removeItem(key); + }, + }; +}; + + +/** + * Validation + */ +const validateTerminalOptions = (options: unknown): TerminalOptions => { + const defaultOpts = terminalDefaultOptions; + + // If not an object or null, return defaults + if (!options || typeof options !== 'object') { + return defaultOpts; + } + + const typedOptions = options as Partial; + const validatedOptions: TerminalOptions = { + density: defaultOpts.density, + scrollback: defaultOpts.scrollback, + timestamp: defaultOpts.timestamp, + copyTimestamp: defaultOpts.copyTimestamp, + copyChannel: defaultOpts.copyChannel, + }; + + // Validate density + if ( + typeof typedOptions.density === 'string' && + Object.keys(DensityModes).includes(typedOptions.density) + ) { + validatedOptions.density = typedOptions.density; + } + + // Validate scrollback + if ( + typeof typedOptions.scrollback === 'number' && + Object.values(ScrollbackSizes).includes(typedOptions.scrollback) + ) { + validatedOptions.scrollback = typedOptions.scrollback; + } + + // Validate timestamp + if ( + typeof typedOptions.timestamp === 'string' && + Object.keys(TimestampModes).includes(typedOptions.timestamp) + ) { + validatedOptions.timestamp = typedOptions.timestamp; + } + + // Validate copyTimestamp & copyChannel + if (typeof typedOptions.copyTimestamp === 'boolean') { + validatedOptions.copyTimestamp = typedOptions.copyTimestamp; + } + if (typeof typedOptions.copyChannel === 'boolean') { + validatedOptions.copyChannel = typedOptions.copyChannel; + } + + return validatedOptions; +}; + +const validateStringArray = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string'); +}; /** * Atoms */ -const liveConsoleHistoryAtom = atomWithStorage('liveConsoleCommandHistory', []); -const liveConsoleBookmarksAtom = atomWithStorage('liveConsoleCommandBookmarks', []); +const terminalOptionsAtom = atomWithStorage( + 'liveConsoleOptions', + terminalDefaultOptions, + createValidatedStorage(validateTerminalOptions, terminalDefaultOptions) +); + +const liveConsoleHistoryAtom = atomWithStorage( + 'liveConsoleCommandHistory', + [], + createValidatedStorage(validateStringArray, []) +); + +const liveConsoleBookmarksAtom = atomWithStorage( + 'liveConsoleCommandBookmarks', + [], + createValidatedStorage(validateStringArray, []) +); + const historyMaxLength = 50; /** * Hooks */ +export const useTerminalOptions = () => { + const [options, setOptions] = useAtom(terminalOptionsAtom); + return { + options, + updateOptions: (newOptions: Partial) => { + setOptions(prev => ({ ...prev, ...newOptions })); + } + }; +}; + export const useLiveConsoleHistory = () => { const [history, setHistory] = useAtom(liveConsoleHistoryAtom); return { diff --git a/panel/src/pages/LiveConsole/liveConsoleUtils.ts b/panel/src/pages/LiveConsole/liveConsoleUtils.ts index 38a7bf974..b9c7309b5 100644 --- a/panel/src/pages/LiveConsole/liveConsoleUtils.ts +++ b/panel/src/pages/LiveConsole/liveConsoleUtils.ts @@ -1,5 +1,5 @@ import { copyToClipboard } from "@/lib/utils"; -import { LiveConsoleOptions } from "./LiveConsolePage"; +import { TimestampMode } from "./xtermOptions"; import { ANSI } from "./liveConsoleColors"; @@ -38,8 +38,8 @@ export const extractTermLineTimestamp = (line: string) => { /** * Formats a timestamp into a console prefix */ -export const formatTermTimestamp = (ts: number, opts: LiveConsoleOptions): string => { - if (opts.timestampDisabled) return ''; +export const formatTermTimestamp = (ts: number, timestampMode: TimestampMode): string => { + if (timestampMode === 'DISABLED') return ANSI.RESET; const time = new Date(ts * 1000); const str = time.toLocaleTimeString( 'en-US', //as en-gb uses 4 digits for the am/pm indicator @@ -47,7 +47,9 @@ export const formatTermTimestamp = (ts: number, opts: LiveConsoleOptions): strin hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: opts.timestampForceHour12 ?? window.txBrowserHour12, + hour12: timestampMode === 'FORCE12H' ? true : + timestampMode === 'FORCE24H' ? false : + window.txBrowserHour12, } ); @@ -56,18 +58,26 @@ export const formatTermTimestamp = (ts: number, opts: LiveConsoleOptions): strin } +export const getEmptyTermTimestamp = (timestampMode: TimestampMode) => { + return formatTermTimestamp(Date.now(), timestampMode).replace(/\w/g, '-'); +} + /** * Filters a string to be copied to the clipboard */ -export const filterTermLine = (selection: string, opts: LiveConsoleOptions) => { - if (opts.copyTimestamp && opts.copyTag) return selection; +export const filterTermLine = ( + selection: string, + copyTimestamp: boolean, + copyTag: boolean +) => { + if (copyTimestamp && copyTag) return selection; const lineRegex = /^(?\d{2}:\d{2}:\d{2}(?: [AP]M)? )?(?\[.{20}] )?(?.*)?/; const match = selection.match(lineRegex); if (!match) return selection; const { ts, tag, content } = match.groups ?? {}; let prefix = ''; - if (opts.copyTimestamp) prefix += ts ?? ''; - if (opts.copyTag) prefix += tag ?? ''; + if (copyTimestamp) prefix += ts ?? ''; + if (copyTag) prefix += tag ?? ''; return prefix + (content ?? '').trimEnd(); } @@ -78,12 +88,13 @@ export const filterTermLine = (selection: string, opts: LiveConsoleOptions) => { export const copyTermLine = async ( selection: string, divRef: HTMLDivElement, - opts: LiveConsoleOptions, + copyTimestamp: boolean, + copyTag: boolean, returnFocusTo: HTMLElement | null = null ) => { const strToCopy = selection .split(/\r?\n/) - .map(line => filterTermLine(line, opts)) + .map(line => filterTermLine(line, copyTimestamp, copyTag)) .join('\r\n') //assuming the user is on windows .replace(/(\r?\n)+$/, '\r\n'); //single one at the end, if any return copyToClipboard(strToCopy, divRef, returnFocusTo); diff --git a/panel/src/pages/LiveConsole/xtermOptions.ts b/panel/src/pages/LiveConsole/xtermOptions.ts index 73be1d5d2..232bce38b 100644 --- a/panel/src/pages/LiveConsole/xtermOptions.ts +++ b/panel/src/pages/LiveConsole/xtermOptions.ts @@ -1,27 +1,56 @@ -import type { ITerminalInitOnlyOptions, ITerminalOptions, ITheme } from "@xterm/xterm"; - -//From legacy systemLog.ejs, based on the ANSI-UP colors -//TODO: at component instantiation, grab those as variables from the CSS -// putting css variables here will not work (i think) -const baseTheme: ITheme = { - background: '#222326', //card bg - foreground: '#F8F8F8', - black: '#000000', - brightBlack: '#555555', - red: '#D62341', - brightRed: '#FF5370', - green: '#9ECE58', - brightGreen: '#C3E88D', - yellow: '#FAED70', - brightYellow: '#FFCB6B', - blue: '#396FE2', - brightBlue: '#82AAFF', - magenta: '#BB80B3', - brightMagenta: '#C792EA', - cyan: '#2DDAFD', - brightCyan: '#89DDFF', - white: '#D0D0D0', - brightWhite: '#FFFFFF', +import type { ITerminalInitOnlyOptions, ITerminalOptions } from "@xterm/xterm"; + +export const ScrollbackSizes = { + SMALL: 2500, //~250kb + MEDIUM: 5000,//default + LARGE: 10000, +} as const; + +export type ScrollbackSize = typeof ScrollbackSizes[keyof typeof ScrollbackSizes]; + +export const DensityModes = { + COMPACT: { + fontSize: 12, + lineHeight: 1.0, + letterSpacing: 0.5, + }, + COMFORTABLE: { + fontSize: 14, + lineHeight: 1.1, + letterSpacing: 0.8, + }, + SPACIOUS: { + fontSize: 16, + lineHeight: 1.1, + letterSpacing: 0.8, + }, +} as const; + +export type DensityMode = keyof typeof DensityModes; + +export const TimestampModes = { + DEFAULT: 'default', + FORCE12H: 'force12h', + FORCE24H: 'force24h', + DISABLED: 'disabled', +} as const; + +export type TimestampMode = keyof typeof TimestampModes; + +export type TerminalOptions = { + density: DensityMode; + scrollback: ScrollbackSize; + timestamp: TimestampMode; + copyTimestamp: boolean; + copyChannel: boolean; +} + +export const terminalDefaultOptions: TerminalOptions = { + density: 'COMFORTABLE', + scrollback: ScrollbackSizes.MEDIUM, + timestamp: 'DEFAULT', + copyTimestamp: false, + copyChannel: true, }; export const xtermOptions: ITerminalOptions | ITerminalInitOnlyOptions = { @@ -31,25 +60,13 @@ export const xtermOptions: ITerminalOptions | ITerminalInitOnlyOptions = { disableStdin: true, drawBoldTextInBrightColors: false, fontFamily: "JetBrains Mono Variable, monospace", - fontSize: 14, - lineHeight: 1.1, fontWeight: "300", fontWeightBold: "600", - letterSpacing: 0.8, - scrollback: 5000, - // scrollback: 2500, //more or less equivalent to the legacy 250kb limit allowProposedApi: true, allowTransparency: true, overviewRulerWidth: 15, + ...terminalDefaultOptions, + ...DensityModes[terminalDefaultOptions.density], }; -/* - NOTE: When implementing a stored options dropdown, add the following options: - - fontSize - - lineHeight - - scrollback - - RTL fixes - - light mode? - - whether clicking on a saved command copies it to the input or executes it directly -*/ export default xtermOptions; diff --git a/panel/src/pages/Settings/utils.ts b/panel/src/pages/Settings/utils.ts index 34afd1c7f..12c175b6a 100644 --- a/panel/src/pages/Settings/utils.ts +++ b/panel/src/pages/Settings/utils.ts @@ -1,4 +1,3 @@ -import { useId } from "react"; import { dequal } from 'dequal/lite'; import { GetConfigsResp, PartialTxConfigs, TxConfigs } from "@shared/otherTypes"; From 2d24d8bdc5e7edbc488b87a66bd27c98b7a97c8a Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Sun, 9 Mar 2025 18:47:46 -0300 Subject: [PATCH 05/73] chore: added missing dev script --- scripts/dev/makeConsoleLightTheme.ts | 230 +++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 scripts/dev/makeConsoleLightTheme.ts diff --git a/scripts/dev/makeConsoleLightTheme.ts b/scripts/dev/makeConsoleLightTheme.ts new file mode 100644 index 000000000..65b5dc279 --- /dev/null +++ b/scripts/dev/makeConsoleLightTheme.ts @@ -0,0 +1,230 @@ +/** + * This script makes the live console light theme colors + * NOTE: The debug part is meant to run in the browser console + */ +type RgbColor = { r: number, g: number, b: number }; + +//MARK: Dark Colors +//From legacy systemLog.ejs, based on the ANSI-UP colors +//Copy of the one in panel/src/pages/LiveConsole/liveConsoleColors.ts +const ansi16ColorsDark = { + black: '#000000', + brightBlack: '#555555', + red: '#D62341', + brightRed: '#FF5370', + green: '#9ECE58', + brightGreen: '#C3E88D', + yellow: '#FAED70', + brightYellow: '#FFCB6B', + blue: '#396FE2', + brightBlue: '#82AAFF', + magenta: '#BB80B3', + brightMagenta: '#C792EA', + cyan: '#2DDAFD', + brightCyan: '#89DDFF', + white: '#D0D0D0', + brightWhite: '#FFFFFF', +}; + + +//MARK: Utils +const rgbToHwb = ({ r, g, b }: RgbColor) => { + const rn = r / 255, gn = g / 255, bn = b / 255; + const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn); + let h = 0; + if (max !== min) { + if (max === rn) { + h = 60 * (((gn - bn) / (max - min)) % 6); + } else if (max === gn) { + h = 60 * (((bn - rn) / (max - min)) + 2); + } else { + h = 60 * (((rn - gn) / (max - min)) + 4); + } + if (h < 0) h += 360; + } + const w = min; + const bl = 1 - max; + return { h, w, b: bl }; +}; + +const hwbToRgb = (h: number, w: number, bl: number) => { + // Convert HWB to HSV first: + const v = 1 - bl; + const s = v === 0 ? 0 : 1 - w / v; + + // Then convert HSV to RGB + const c = v * s; + const hh = h / 60; + const x = c * (1 - Math.abs((hh % 2) - 1)); + let r1 = 0, g1 = 0, b1 = 0; + if (hh >= 0 && hh < 1) { + r1 = c; g1 = x; b1 = 0; + } else if (hh >= 1 && hh < 2) { + r1 = x; g1 = c; b1 = 0; + } else if (hh >= 2 && hh < 3) { + r1 = 0; g1 = c; b1 = x; + } else if (hh >= 3 && hh < 4) { + r1 = 0; g1 = x; b1 = c; + } else if (hh >= 4 && hh < 5) { + r1 = x; g1 = 0; b1 = c; + } else if (hh >= 5 && hh < 6) { + r1 = c; g1 = 0; b1 = x; + } + const m = v - c; + return { + r: Math.round((r1 + m) * 255), + g: Math.round((g1 + m) * 255), + b: Math.round((b1 + m) * 255), + }; +}; + +const rgbToHex = ({ r, g, b }: RgbColor) => { + const toHex = (n: number) => n.toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase(); +}; + +const hexToRgb = (hex: string) => { + const bigint = parseInt(hex.replace('#', ''), 16); + return { r: (bigint >> 16) & 255, g: (bigint >> 8) & 255, b: bigint & 255 }; +} + + +//MARK: Extended ANSI palette +const ansi256Dark: RgbColor[] = []; +// Color cube: 216 colors from index 16 to 231 +for (let r = 0; r < 6; r++) { + for (let g = 0; g < 6; g++) { + for (let b = 0; b < 6; b++) { + ansi256Dark.push({ + r: r === 0 ? 0 : r * 40 + 55, // slightly adjusted formula sometimes used + g: g === 0 ? 0 : g * 40 + 55, + b: b === 0 ? 0 : b * 40 + 55, + }); + } + } +} +// Grayscale ramp: 24 shades from index 232 to 255 +for (let i = 0; i < 24; i++) { + const gray = 8 + i * 10; + ansi256Dark.push({ r: gray, g: gray, b: gray }); +} + + +//MARK: Computing Colors +const invertHwb = ({ r, g, b }: RgbColor, ansi16 = false) => { + if (r === g && g === b) { + // For grayscale colors, directly invert the value + const newK = 255 - r; + const bgCardOffset = 13.3333333333; //255-((240+241+244)/3) + const clampedK = Math.round(Math.min(255, Math.max(bgCardOffset, newK))); + return { r: clampedK, g: clampedK, b: clampedK }; + } + let { h, w, b: bl } = rgbToHwb({ r, g, b }); + const factors = ansi16 + ? { w: 0.1, b: 0.2 } //timestamp, info marker, normal text, most ^n colors + : { w: 0.2, b: 0.35 }; //channel tags, other markers + const newW = w * factors.w; + const newB = bl + (1 - bl) * factors.b; + return hwbToRgb(h, newW, newB); +}; + + +const ansi256Light: RgbColor[] = ansi256Dark.map(color => invertHwb(color)); +const ansi256LightHex = ansi256Light.map(rgbToHex); +const ansi16ColorsLight = Object.fromEntries( + Object.entries(ansi16ColorsDark).map(([key, value]) => [key, rgbToHex(invertHwb(hexToRgb(value), true))]) +); + + +//MARK: Debug +const getConsoleStyle = (bg: string, fg = 'black') => `background-color: ${bg}; color: ${fg}; padding: 0.5em;`; +const bgResetStyle = 'background-color: white'; +// console.clear(); +const baseColorsDark = { + background: '#222326', //card bg + foreground: '#F8F8F8', //primary +} +const baseColorsLight = { + background: '#F0F1F4', //card bg + foreground: '#16171B', //primary +} + +//Debugging background colors +for (let index = 0; index < 240; index++) { + if (index % 10 !== 0) continue; + const before = rgbToHex(ansi256Dark[index]); + const after = rgbToHex(ansi256Light[index]); + const space = ' '.repeat(10); + const bgString = `%c${space}%c %c${space}`; + const fgString = `%clorem ipsum%c %clorem ipsum`; + console.log( + `${index+16}\tbg:${bgString}%c\tfg:${fgString}`, + getConsoleStyle(before), + bgResetStyle, + getConsoleStyle(after), + bgResetStyle, + + getConsoleStyle(baseColorsDark.background, before), + bgResetStyle, + getConsoleStyle(baseColorsLight.background, after), + {before, after} + ); +} + + +//MARK: Converting +// // Find the nearest ANSI 256 color for a given RGB +// const rgbToAnsi256 = ({ r, g, b }: RgbColor) => { +// let bestIndex = 0; +// let bestDistance = Infinity; +// for (let i = 0; i < ansi256Dark.length; i++) { +// const color = ansi256Dark[i]; +// const dr = r - color.r; +// const dg = g - color.g; +// const db = b - color.b; +// const distance = dr * dr + dg * dg + db * db; +// if (distance < bestDistance) { +// bestDistance = distance; +// bestIndex = i; +// } +// } +// return bestIndex; +// }; + +// const ogColorHex = '#E6B863'; +// const ogColorRgb = hexToRgb(ogColorHex); + +// //Find best match for background colors +// console.group('Matching Colors:'); +// for (let index = 0; index < 240; index++) { +// const before = rgbToHex(ansi256Dark[index]); +// const after = rgbToHex(ansi256Light[index]); +// console.log( +// `${index + 16}\t%c${ogColorHex}%c\t%c${before}%c\t%c${after}%c\t%clorem ipsum`, +// getConsoleStyle(ogColorHex), +// bgResetStyle, +// getConsoleStyle(before), +// bgResetStyle, +// getConsoleStyle(after), +// bgResetStyle, +// `background-color: ${baseColorsLight.background}; color: ${after}; padding: 0.5em;`, +// ); +// } +// console.groupEnd(); + +// const matchIndex = rgbToAnsi256(ogColorRgb); +// const matchColorDark = ansi256Dark[matchIndex]; +// const matchColorDarkHex = rgbToHex(matchColorDark); +// const matchColorLight = ansi256Light[matchIndex]; +// const matchColorLightHex = rgbToHex(matchColorLight); + +// console.group(`Converting ${ogColorHex} to ANSI 256`); +// console.log( +// `${matchIndex + 16}\t%c${ogColorHex}%c\t%c${matchColorDarkHex}%c\t%c${matchColorLightHex}`, +// getConsoleStyle(ogColorHex), +// bgResetStyle, +// getConsoleStyle(matchColorDarkHex), +// bgResetStyle, +// getConsoleStyle(matchColorLightHex), +// ); +// console.groupEnd(); From 1c4e446fa8be452d7e142c6a0f906833dee3494a Mon Sep 17 00:00:00 2001 From: mardev Date: Sun, 9 Mar 2025 03:56:40 +0100 Subject: [PATCH 06/73] tweak: healedPlayer -> playerHealed Revamped the link/text to the new event, instead of the current deprecated version --- docs/menu.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/menu.md b/docs/menu.md index 11f233946..bba163b84 100644 --- a/docs/menu.md +++ b/docs/menu.md @@ -113,5 +113,5 @@ You can find development instructions regarding the menu [here.](https://github. - **Q**: Why don't the 'Heal' options revive a player when using ESX/QBCore/etc? - **A**: Many frameworks independently handle a "dead" state for a player, meaning the menu is unable to reset this state in an resource agnostic form directly. To establish compatibility - with any framework, txAdmin will emit an [txAdmin:events:healedPlayer](https://github.com/tabarra/txAdmin/blob/master/docs/events.md#txadmineventshealedplayer-v48) + with any framework, txAdmin will emit an [txAdmin:events:playerHealed](https://github.com/tabarra/txAdmin/blob/master/docs/events.md#txadmineventsplayerhealed) for developers to handle. From 9b92cd5ee0f4784e522fd940e90b34e0d97da7f4 Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Sat, 15 Mar 2025 13:14:52 -0300 Subject: [PATCH 07/73] style: fixed padding on WarningBar --- panel/src/layout/WarningBar.tsx | 6 +-- panel/src/pages/TestingPage/TestingPage.tsx | 2 +- .../pages/TestingPage/TmpWarningBarState.tsx | 38 +++++++++---------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/panel/src/layout/WarningBar.tsx b/panel/src/layout/WarningBar.tsx index a8e6c3f92..4a0e9ecbe 100644 --- a/panel/src/layout/WarningBar.tsx +++ b/panel/src/layout/WarningBar.tsx @@ -58,12 +58,12 @@ export function InnerWarningBar({ titleIcon, title, description, isImportant, ca return (
-

+

{titleIcon} {title}

diff --git a/panel/src/pages/TestingPage/TestingPage.tsx b/panel/src/pages/TestingPage/TestingPage.tsx index 2a9f48a00..8f693872f 100644 --- a/panel/src/pages/TestingPage/TestingPage.tsx +++ b/panel/src/pages/TestingPage/TestingPage.tsx @@ -23,7 +23,6 @@ export default function TestingPage() { // }, []); return
- {/* */} {/* */} {/* */} @@ -39,5 +38,6 @@ export default function TestingPage() {
*/} {/* */} {/* */} + {/* */}
; } diff --git a/panel/src/pages/TestingPage/TmpWarningBarState.tsx b/panel/src/pages/TestingPage/TmpWarningBarState.tsx index 816bd241b..8411e5cb2 100644 --- a/panel/src/pages/TestingPage/TmpWarningBarState.tsx +++ b/panel/src/pages/TestingPage/TmpWarningBarState.tsx @@ -16,11 +16,8 @@ export default function TmpWarningBarState() { Warning Bar States -
-
-                        {JSON.stringify(offlineWarning, null, 2)}
-                    
-
+
+
@@ -28,21 +25,21 @@ export default function TmpWarningBarState() { Socket Off
-
- -
-                        {JSON.stringify(txUpdateData, null, 2)}
+                        {JSON.stringify(offlineWarning, null, 2)}
                     
-
+
+ +
+
-
- -
-                        {JSON.stringify(fxUpdateData, null, 2)}
+                        {JSON.stringify(txUpdateData, null, 2)}
                     
-
+
+ +
+
+
+                        {JSON.stringify(fxUpdateData, null, 2)}
+                    
From 83cdd5f9ac90f464ccb5f51cd7c889aeadff32bd Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Sat, 15 Mar 2025 16:26:05 -0300 Subject: [PATCH 08/73] feat(panel): add 3xl breakpoint and fixed dashboard layout overflow --- panel/src/components/BreakpointDebugger.tsx | 1 + panel/src/pages/Dashboard/DashboardPage.tsx | 2 +- panel/src/pages/Dashboard/PlayerDropCard.tsx | 2 +- panel/src/pages/Dashboard/ServerStatsCard.tsx | 2 +- panel/tailwind.config.cjs | 1 + 5 files changed, 5 insertions(+), 3 deletions(-) diff --git a/panel/src/components/BreakpointDebugger.tsx b/panel/src/components/BreakpointDebugger.tsx index 3eb366930..1d5ea9649 100644 --- a/panel/src/components/BreakpointDebugger.tsx +++ b/panel/src/components/BreakpointDebugger.tsx @@ -34,6 +34,7 @@ export default function BreakpointDebugger() {

lg

xl

2xl

+

3xl

; } diff --git a/panel/src/pages/Dashboard/DashboardPage.tsx b/panel/src/pages/Dashboard/DashboardPage.tsx index 34279dee1..5892e412a 100644 --- a/panel/src/pages/Dashboard/DashboardPage.tsx +++ b/panel/src/pages/Dashboard/DashboardPage.tsx @@ -41,7 +41,7 @@ function DashboardPageInner() { return (
-
+
diff --git a/panel/src/pages/Dashboard/PlayerDropCard.tsx b/panel/src/pages/Dashboard/PlayerDropCard.tsx index 9550513b1..bd3eaf082 100644 --- a/panel/src/pages/Dashboard/PlayerDropCard.tsx +++ b/panel/src/pages/Dashboard/PlayerDropCard.tsx @@ -203,7 +203,7 @@ export default function PlayerDropCard() { } return ( -
+

Player drop cause (last 6h)

diff --git a/panel/src/pages/Dashboard/ServerStatsCard.tsx b/panel/src/pages/Dashboard/ServerStatsCard.tsx index eaf206b74..5aeb48bea 100644 --- a/panel/src/pages/Dashboard/ServerStatsCard.tsx +++ b/panel/src/pages/Dashboard/ServerStatsCard.tsx @@ -127,7 +127,7 @@ export default function ServerStatsCard() { } return ( -
+

Server stats {titleNode} diff --git a/panel/tailwind.config.cjs b/panel/tailwind.config.cjs index 27eb5ff26..d517e2d79 100644 --- a/panel/tailwind.config.cjs +++ b/panel/tailwind.config.cjs @@ -62,6 +62,7 @@ module.exports = { screens: { xs: "480px", "2xl": "1400px", + "3xl": "1600px", }, fontFamily: { sans: ["var(--font-sans)", ...fontFamily.sans], From 11624cfd5afb26bc530d5b88d8dc4eada5f3e931 Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Sun, 16 Mar 2025 18:44:46 -0300 Subject: [PATCH 09/73] fix(core): sort actions query result --- core/modules/Database/dao/actions.ts | 3 ++- docs/dev-notes.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/core/modules/Database/dao/actions.ts b/core/modules/Database/dao/actions.ts index 093401f7b..47329871f 100644 --- a/core/modules/Database/dao/actions.ts +++ b/core/modules/Database/dao/actions.ts @@ -70,7 +70,8 @@ export default class ActionsDao { .filter(customFilter as (a: DatabaseActionType) => a is T) .filter(idsMatchFilter) .cloneDeep() - .value(); + .value() + .sort((a, b) => a.timestamp - b.timestamp); //FIXME: shouldn't be needed, remove after the autosort migration } catch (error) { const msg = `Failed to search for a registered action database with error: ${(error as Error).message}`; console.verbose.error(msg); diff --git a/docs/dev-notes.md b/docs/dev-notes.md index 78d97fc9b..daba11d7b 100644 --- a/docs/dev-notes.md +++ b/docs/dev-notes.md @@ -232,6 +232,7 @@ Legend: - [ref](/core/playerLogic/playerClasses.ts#L281) - [ ] create simple page to list top 100 players by playtime in the last 30d, 14d, 7d, yesterday, today - if storing in a linear UInt16Array, 100k players * 120d * 4bytes per date = 48mb +- [ ] on migration, sort all the actions and players, due to the search sorting bug From 35cafe493e1f953a9a920b06f632ca474df22785 Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Sun, 16 Mar 2025 18:54:06 -0300 Subject: [PATCH 10/73] tweak(core): only print db optimization if it happened --- core/modules/Database/dao/cleanup.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/core/modules/Database/dao/cleanup.ts b/core/modules/Database/dao/cleanup.ts index ec220ab5c..8cbad9864 100644 --- a/core/modules/Database/dao/cleanup.ts +++ b/core/modules/Database/dao/cleanup.ts @@ -2,6 +2,7 @@ import { DbInstance, SavePriority } from "../instance"; import consoleFactory from '@lib/console'; import { DatabasePlayerType, DatabaseWhitelistApprovalsType, DatabaseWhitelistRequestsType } from '../databaseTypes'; import { now } from '@lib/misc'; +import chalk from "chalk"; const console = consoleFactory('DatabaseDao'); @@ -100,7 +101,7 @@ export default class CleanupDao { //Optimize players //Players that have not joined the last 16 days, and have less than 2 hours of playtime - let playerRemoved; + let playerRemoved = 0; try { const sixteenDaysAgo = now() - (16 * oneDay); const filter = (p: DatabasePlayerType) => { @@ -114,7 +115,8 @@ export default class CleanupDao { //Optimize whitelistRequests + whitelistApprovals //Removing the ones older than 7 days - let wlRequestsRemoved, wlApprovalsRemoved; + let wlRequestsRemoved = 0; + let wlApprovalsRemoved = 0; const sevenDaysAgo = now() - (7 * oneDay); try { const wlRequestsFilter = (req: DatabaseWhitelistRequestsType) => { @@ -131,10 +133,19 @@ export default class CleanupDao { console.error(msg); } - this.db.writeFlag(SavePriority.LOW); - console.ok(`Internal Database optimized. This applies only for the txAdmin internal database, and does not affect your MySQL or framework (ESX/QBCore/etc) databases.`); - console.ok(`- ${playerRemoved} players that haven't connected in the past 16 days and had less than 2 hours of playtime.`); - console.ok(`- ${wlRequestsRemoved} whitelist requests older than a week.`); - console.ok(`- ${wlApprovalsRemoved} whitelist approvals older than a week.`); + //Skip if failed or nothing was removed + if (playerRemoved || wlRequestsRemoved || wlApprovalsRemoved) { + this.db.writeFlag(SavePriority.LOW); + console.ok(`Internal Database optimized. This applies only for the txAdmin internal database, and does not affect your MySQL or framework (ESX/QBCore/etc) databases.`); + } + if (playerRemoved) { + console.ok(chalk.dim(`- ${playerRemoved} players that haven't connected in the past 16 days and had less than 2 hours of playtime.`)); + } + if (wlRequestsRemoved) { + console.ok(chalk.dim(`- ${wlRequestsRemoved} whitelist requests older than a week.`)); + } + if (wlApprovalsRemoved) { + console.ok(chalk.dim(`- ${wlApprovalsRemoved} whitelist approvals older than a week.`)); + } } } From 5e8a6bb7b59993cc9a4e96b032bbf798dcdfaf59 Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Mon, 26 May 2025 20:11:13 -0300 Subject: [PATCH 11/73] tweak: updated copyright notice The changes reflect the acquisition of txAdmin by Cfx.re as [announced](https://forum.cfx.re/t/txadmin-officially-joins-cfx-re/5319010). --- LICENSE | 2 +- panel/src/layout/ServerSidebar/ServerSidebar.tsx | 8 -------- panel/src/pages/auth/AddMasterCallback.tsx | 2 +- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/LICENSE b/LICENSE index 8e98ecd5f..ac24dd71c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2025 André Tabarra +Copyright (c) 2019-2025 Take-Two Interactive Software, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/panel/src/layout/ServerSidebar/ServerSidebar.tsx b/panel/src/layout/ServerSidebar/ServerSidebar.tsx index c6e1448a3..be56cfcc1 100644 --- a/panel/src/layout/ServerSidebar/ServerSidebar.tsx +++ b/panel/src/layout/ServerSidebar/ServerSidebar.tsx @@ -64,14 +64,6 @@ export function ServerSidebar({ isSheet }: ServerSidebarProps) {  | fx: b{window.txConsts.fxsVersion} - - © 2019-{(new Date).getUTCFullYear()} Tabarra -

) : null} diff --git a/panel/src/pages/auth/AddMasterCallback.tsx b/panel/src/pages/auth/AddMasterCallback.tsx index 1e3286620..57c44df09 100644 --- a/panel/src/pages/auth/AddMasterCallback.tsx +++ b/panel/src/pages/auth/AddMasterCallback.tsx @@ -176,7 +176,7 @@ function RegisterForm({ fivemId, fivemName, profilePicture }: ApiAddMasterCallba htmlFor="terms" className="text-sm font-medium leading-4 peer-disabled:cursor-not-allowed peer-disabled:opacity-70" > - I have read and agree to the Creator PLA as well as the txAdmin License. + I have read and agree to the Creator PLA.
From 77f2cb7e83d19db20b8351c0fbc59d9bafd63c7d Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Mon, 26 May 2025 20:15:02 -0300 Subject: [PATCH 12/73] fix: controls for resources containing apostrophe (#1053) --- web/main/resources.ejs | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/web/main/resources.ejs b/web/main/resources.ejs index bb81af915..bf07db3a3 100644 --- a/web/main/resources.ejs +++ b/web/main/resources.ejs @@ -98,20 +98,29 @@ <% if (resource.status === 'started') { %> - > + data-resAction="ensure_res" + data-resName="<%= encodeURIComponent(resource.name) %>" + <%= disableActions %> + > Restart - > + data-resAction="ensure_res" + data-resName="<%= encodeURIComponent(resource.name) %>" + <%= disableActions %> + > Stop <% } else { %> - > + data-resAction="ensure_res" + data-resName="<%= encodeURIComponent(resource.name) %>" + <%= disableActions %> + > Start <% } %> @@ -328,6 +337,16 @@ //============================================== Utils function refreshResourceList() { + const resButtons = document.getElementsByClassName('btn-res-action'); + for (const btn of resButtons) { + btn.onclick = (e) => { + e.preventDefault(); + const resAction = e.currentTarget.dataset.resaction; + const resName = e.currentTarget.dataset.resname; + btnCommand(resAction, resName); + }; + } + if ($('#defResCheckbox').is(':checked')) { defaultResources.forEach(defRes => { $(`#res-${defRes}`).show(); From c6577254492715dee993f4f9fb7a333a6254bca4 Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Mon, 2 Jun 2025 01:15:16 -0300 Subject: [PATCH 13/73] tweak(panel): improve error handling for outdated browsers --- panel/src/pages/auth/errors.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/panel/src/pages/auth/errors.tsx b/panel/src/pages/auth/errors.tsx index 06ef5510b..b30caa516 100644 --- a/panel/src/pages/auth/errors.tsx +++ b/panel/src/pages/auth/errors.tsx @@ -122,6 +122,11 @@ export const processFetchError = (error: any) => { errorTitle: 'Network Error', errorMessage: 'If you closed txAdmin, please restart it and try again.', }; + } else if(error.message?.startsWith('AbortSignal.timeout')) { + return { + errorTitle: 'Browser Outdated', + errorMessage: 'The version of this browser is too old to use txAdmin. Please download a new version of Edge, Chrome, Firefox, etc. and try again.', + }; } else { return { errorTitle: 'Unknown Error', From 18ac96367b5f6459471f60e444f3aa0bb659f2b2 Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Mon, 2 Jun 2025 01:39:01 -0300 Subject: [PATCH 14/73] tweak(web): updated recipe index url --- web/standalone/setup.ejs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/standalone/setup.ejs b/web/standalone/setup.ejs index 19ffdbed9..ca9fef184 100644 --- a/web/standalone/setup.ejs +++ b/web/standalone/setup.ejs @@ -380,7 +380,7 @@ From 14624780b637989532f2554c74a6f7d3928fdfe2 Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:09:10 -0300 Subject: [PATCH 30/73] wip: small changes --- core/globalData.ts | 21 +++++++++++++++++++-- panel/src/components/DynamicAdvert.tsx | 2 +- panel/src/components/TxToaster.tsx | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/core/globalData.ts b/core/globalData.ts index e99619778..9a5b83fa3 100644 --- a/core/globalData.ts +++ b/core/globalData.ts @@ -482,8 +482,25 @@ let fxsVersionTag = fxsVersion.toString(); if (fxsVerParsed.branch && fxsVerParsed.branch !== 'master') { fxsVersionTag += '-ft'; } -if (isZapHosting) { - fxsVersionTag += '/ZAP'; + +let providerTag = ''; +const partnerPrefixes = { + 'gportal': 'GPor', + 'nitrado': 'Nitr', + 'nodecraft': 'NoCr', + 'shockbyte': 'ShBy', + 'xrealm': 'XRea', + 'zaphosting': 'ZapH', +} as { [key: string]: string }; +if (providerName) { + const cleanName = providerName.toLowerCase().replace(/[^a-z0-9]+/g, ''); + if (cleanName in partnerPrefixes) { + providerTag = partnerPrefixes[cleanName]; + } +} + +if (providerTag) { + fxsVersionTag += `/${providerTag}`; } else if (isPterodactyl) { fxsVersionTag += '/Ptero'; } else if (isWindows && fxsVerParsed.platform === 'windows') { diff --git a/panel/src/components/DynamicAdvert.tsx b/panel/src/components/DynamicAdvert.tsx index 10d63a187..9cbbc0c6f 100644 --- a/panel/src/components/DynamicAdvert.tsx +++ b/panel/src/components/DynamicAdvert.tsx @@ -21,7 +21,7 @@ const pickRandomPartner = (placement: AdPlacement): HostingPartner => { let partnerChosen = partners[Math.floor(Math.random() * partners.length)]; let isCustomer = false; if (window.txConsts.providerName) { - const providerKey = window.txConsts.providerName.toLowerCase().replace(/[^a-z0-9]+/g, '') + const providerKey = window.txConsts.providerName.toLowerCase().replace(/[^a-z0-9]+/g, ''); const partnerFound = partners.find(partner => partner.name === providerKey); if (partnerFound) { partnerChosen = partnerFound; diff --git a/panel/src/components/TxToaster.tsx b/panel/src/components/TxToaster.tsx index 07ec30b6f..89269a550 100644 --- a/panel/src/components/TxToaster.tsx +++ b/panel/src/components/TxToaster.tsx @@ -112,7 +112,7 @@ export const CustomToast = ({ t, type, data }: CustomToastProps) => { For support, visit  Date: Sat, 26 Jul 2025 00:34:29 -0300 Subject: [PATCH 31/73] feat(web/admins): added identifiers to admins table --- core/modules/AdminStore/index.js | 2 +- core/routes/adminManager/page.ts | 12 +++++-- web/main/adminManager.ejs | 55 +++++++++++++++----------------- 3 files changed, 36 insertions(+), 33 deletions(-) diff --git a/core/modules/AdminStore/index.js b/core/modules/AdminStore/index.js index ccf0ead1f..7fdce24f3 100644 --- a/core/modules/AdminStore/index.js +++ b/core/modules/AdminStore/index.js @@ -220,7 +220,7 @@ export default class AdminStore { return { name: user.name, master: user.master, - providers: Object.keys(user.providers), + providers: user.providers, permissions: user.permissions, }; }); diff --git a/core/routes/adminManager/page.ts b/core/routes/adminManager/page.ts index 3648d6453..ecc9bb42f 100644 --- a/core/routes/adminManager/page.ts +++ b/core/routes/adminManager/page.ts @@ -27,10 +27,18 @@ export default async function AdminManagerPage(ctx: AuthedCtx) { } const isSelf = ctx.admin.name.toLowerCase() === admin.name.toLowerCase(); + let identifiers: string[] = []; + if ('citizenfx' in admin.providers && typeof admin.providers.citizenfx?.identifier === 'string') { + identifiers.push(admin.providers.citizenfx.identifier); + } + + if ('discord' in admin.providers && typeof admin.providers.discord?.identifier === 'string') { + identifiers.push(admin.providers.discord.identifier); + } + return { - hasCitizenFX: (admin.providers.includes('citizenfx')), - hasDiscord: (admin.providers.includes('discord')), name: admin.name, + identifiers, perms: perms, isSelf, disableEdit: !ctx.admin.isMaster && admin.master, diff --git a/web/main/adminManager.ejs b/web/main/adminManager.ejs index 287aae055..2bc47bb5e 100644 --- a/web/main/adminManager.ejs +++ b/web/main/adminManager.ejs @@ -10,6 +10,9 @@ margin: auto; text-align: center; } + .table td { + vertical-align: middle; + } @@ -23,21 +26,11 @@ - - - - - - - - - -
-
+
@@ -54,7 +47,7 @@ Username - Auth + Identifiers Permissions Actions @@ -64,24 +57,26 @@ <%= admin.name %> - - - Password Authentication - - - - - - Cfx.re Authentication - - - - - - Discord Authentication - - - +
+ <% if (admin.identifiers.length > 0) { %> + <% for (const identifier of admin.identifiers) { %> + + <% if (identifier.startsWith('fivem:')) { %> + + + + <% } else if (identifier.startsWith('discord:')) { %> + + + + <% } %> + <%= identifier %> + + <% } %> + <% } else { %> + -- + <% } %> +
<%= admin.perms %> From 9e73fd494c34d0ba27a48d6c4a641320fda8d49b Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Sat, 26 Jul 2025 01:31:09 -0300 Subject: [PATCH 32/73] wip: small stuff --- core/modules/AdminStore/index.js | 7 +++++-- core/routes/advanced/actions.js | 2 +- core/routes/player/checkJoin.ts | 2 +- docs/dev-notes.md | 18 +++++++++++++----- docs/env-config.md | 29 +++++++++++++++++++---------- 5 files changed, 39 insertions(+), 19 deletions(-) diff --git a/core/modules/AdminStore/index.js b/core/modules/AdminStore/index.js index 7fdce24f3..5a88ccae6 100644 --- a/core/modules/AdminStore/index.js +++ b/core/modules/AdminStore/index.js @@ -13,6 +13,7 @@ const console = consoleFactory(modulename); //NOTE: The way I'm doing versioning right now is horrible but for now it's the best I can do //NOTE: I do not need to version every admin, just the file itself +//NOTE: The only reason every admin has a schema is because I did not want to change the format of the file const ADMIN_SCHEMA_VERSION = 1; @@ -46,7 +47,7 @@ export default class AdminStore { //FIXME: move to a separate file //TODO: maybe put in @shared so the frontend's UnauthorizedPage can use it //TODO: when migrating the admins page to react, definitely put this in @shared so the front rendering doesn't depend on the backend response - lessons learned from the settings page. - //FIXME: if not using enums, definitely use so other type of type safety + //FIXME: if not using enums, definitely use some other type of type safety //FIXME: maybe rename all_permissions to `administrator` (just like discord) or `super_admin` and rename the `Admins` page to `Users`. This fits better with how people use txAdmin as "mods" are not really admins this.registeredPermissions = { 'all_permissions': 'All Permissions', @@ -340,7 +341,7 @@ export default class AdminStore { restore(); } } catch (error) { - console.error(`Cannot check admins file integrity: ${error.message}`); + console.error(`Failed to check admins file integrity: ${error.message}`); } } @@ -614,6 +615,7 @@ export default class AdminStore { /** * Notify game server about admin changes + * FIXME: doesn't need to be async, just make sure it never throws */ async refreshOnlineAdmins() { //Refresh auth of all admins connected to socket.io @@ -621,6 +623,7 @@ export default class AdminStore { try { //Getting all admin identifiers + //FIXME: use getAdminsIdentifiers() instead const adminIDs = this.admins.reduce((ids, adm) => { const adminIDs = Object.keys(adm.providers).map((pName) => adm.providers[pName].identifier); return ids.concat(adminIDs); diff --git a/core/routes/advanced/actions.js b/core/routes/advanced/actions.js index 8d3e9fc83..1b5ecade1 100644 --- a/core/routes/advanced/actions.js +++ b/core/routes/advanced/actions.js @@ -154,7 +154,7 @@ export default async function AdvancedActions(ctx) { } } else if (action == 'printFxRunnerChildHistory') { - const message = JSON.stringify(txCore.fxRunner.history, null, 2) + const message = JSON.stringify(txCore.fxRunner.history, null, 2); return ctx.send({ type: 'success', message }); } else if (action == 'xxxxxx') { diff --git a/core/routes/player/checkJoin.ts b/core/routes/player/checkJoin.ts index 4eaa66a82..cd59aff60 100644 --- a/core/routes/player/checkJoin.ts +++ b/core/routes/player/checkJoin.ts @@ -262,7 +262,7 @@ async function checkAdminOnlyMode( }; //Check if fivem/discord ids are available - if (!validIdsObject.license && !validIdsObject.discord) { + if (!validIdsObject.fivem && !validIdsObject.discord) { return { allow: false, reason: rejectMessageTemplate( diff --git a/docs/dev-notes.md b/docs/dev-notes.md index 629176fd2..56e2e6077 100644 --- a/docs/dev-notes.md +++ b/docs/dev-notes.md @@ -4,6 +4,10 @@ Legend: - [!] -> Release Blocker - [?] -> Lower priority or pending investigation +## Before Updating: +- Search for all FIXME:REMOVE:NEXT:UPDATE +- Re-enable diagnostics report + ## Feat - [x] Option to delete player identifiers - Ref: https://github.com/tabarra/txAdmin/issues/751 @@ -96,6 +100,8 @@ Legend: - [ ] fix circular dependencies - search for `circular_dependency` - use `madge` (command at the bottom of file) +- [ ] migrate shadcn to use the radix monorepo vis its migration tool + - ref: https://x.com/shadcn/status/1932819652524355998 ## Previous bugs - [ ] use `ScanResourceRoot()` @@ -105,7 +111,7 @@ Legend: ## Pending Improvements - [ ] Settings Page: - - [ ] bake in the defaults, so so SwitchText's don't show fale initial value + - [ ] bake in the defaults, so so SwitchText's don't show false initial value - [ ] check for pending changes on the navigate-away buttons - [ ] use jsonForgivingParse for embed jsons and custom locale - [ ] use the standalone json editor page @@ -116,9 +122,9 @@ Legend: - try messing with the canvas size +- 0.5px - [ ] review page layout: - [ ] make it less card-y - - [ ] fix crashes table is not responsive - - [ ] fix scroll popping in/out - - [ ] switch from `useSWRImmutable` to `useSWR` + - [x] fix crashes table is not responsive + - [x] fix scroll popping in/out + - [x] switch from `useSWRImmutable` to `useSWR` - [ ] add drilldown interval buttons - Dashboard stuff: - [ ] add testing for getServerStatsData @@ -305,7 +311,8 @@ https://tailwindcss.com/blog/automatic-class-sorting-with-prettier - [ ] remove more pending DynamicNewBadge/DynamicNewItem (settings page as well) - [ ] reevaluate globals?.tmpSetHbDataTracking - [ ] fix socket.io multiple connections - start a single instance when page opens, commands to switch rooms -- [ ] switch tx to lua54 +- [ ] considering lua54 is enabled by default, use new options +- [ ] test `node_version '22'` - [ ] build: generate fxmanifest files list dynamically - node 22 use fs.glob @@ -621,6 +628,7 @@ https://uicolors.app/create https://www.tailwindshades.com/ https://contrast.tools/?tab=apca https://atmos.style/contrast-checker +https://tweakcn.com/editor/theme https://realtimecolors.com/ https://www.learnui.design/blog/color-in-ui-design-a-practical-framework.html https://www.refactoringui.com/previews/building-your-color-palette diff --git a/docs/env-config.md b/docs/env-config.md index 0b33c8388..672ef56f1 100644 --- a/docs/env-config.md +++ b/docs/env-config.md @@ -128,17 +128,26 @@ The specific way to set up those variables vary from system to system, and there ## Examples -Migrating a dev server using an old `start.bat`: -```diff - @echo off -+set TXHOST_DATA_PATH=E:\FiveM\txData-dev -+set TXHOST_TXA_PORT=40125 --FXServer.exe +set serverProfile "server2" +set txAdminPort "40125" -+FXServer.exe - pause +### Migrate old `start.bat` +Replace this: +```batch +@echo off +"E:/FiveM/13079//FXServer.exe" +set serverProfile "server2" +set txAdminPort "40125" +pause +``` +With this: +```batch +@echo off +set TXHOST_DATA_PATH=E:\FiveM\txData-dev +set TXHOST_TXA_PORT=40125 +"E:/FiveM/13079//FXServer.exe" +pause ``` +> [!NOTE] +> Replace `"E:/FiveM/13079//FXServer.exe"` with the path you see in your existing `*.bat`. + -Setting up a dev server on Windows with a `env.bat` file: +### Setting up a dev server on Windows with a `env.bat` file: ```batch @REM Deployer defaults set TXHOST_DEFAULT_CFXKEY=cfxk_11hIT156dX0F0ekFVsuda_fQ0ZYS @@ -154,7 +163,7 @@ set TXHOST_FXS_PORT=30125 set TXHOST_MAX_SLOTS=8 ``` -Setting a GSP configuration on Docker with a `.env` file: +### Setting a GSP configuration on Docker with a `.env` file: ```dotenv # So txAdmin suggests the right path during setup TXHOST_DATA_PATH=/home/container From a0f0a5a1c8e3e3489b74fa25808a4b4b3b1001fe Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Sat, 26 Jul 2025 01:31:51 -0300 Subject: [PATCH 33/73] feat(core): added advanced command `printFxResourcesBootLog` --- core/modules/FxMonitor/utils.ts | 11 +++++++++++ core/modules/FxResources.ts | 34 +++++++++++++++++++++++++++------ core/routes/advanced/actions.js | 17 +++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/core/modules/FxMonitor/utils.ts b/core/modules/FxMonitor/utils.ts index 1ea3277ad..3241bc0bf 100644 --- a/core/modules/FxMonitor/utils.ts +++ b/core/modules/FxMonitor/utils.ts @@ -71,6 +71,17 @@ export class Stopwatch { return Math.floor(elapsedMs / 1000); } } + + /** + * Returns the elapsed time in milliseconds or Infinity if not started. + */ + get elapsedMs() { + if (this.tsStart === null) { + return Infinity; + } else { + return Date.now() - this.tsStart; + } + } } diff --git a/core/modules/FxResources.ts b/core/modules/FxResources.ts index 4fc7b3bdf..e783261d5 100644 --- a/core/modules/FxResources.ts +++ b/core/modules/FxResources.ts @@ -1,4 +1,5 @@ const modulename = 'FxResources'; +import { cloneDeep } from 'lodash-es'; import consoleFactory from '@lib/console'; import { Stopwatch } from './FxMonitor/utils'; const console = consoleFactory(modulename); @@ -41,14 +42,19 @@ export default class FxResources { public resourceReport?: ResourceReportType; private resBooting: ResPendingStartState | null = null; private resBootLog: ResBootLogEntry[] = []; + private prevBootLog: ResBootLogEntry[] | null = null; /** * Reset boot state on server close */ handleServerClose() { - this.resBooting = null; + //Save the previous boot log + if (this.resBootLog.length > 0) { + this.prevBootLog = this.resBootLog; + } this.resBootLog = []; + this.resBooting = null; } @@ -67,11 +73,20 @@ export default class FxResources { } } else if (event === 'onResourceStart') { //Resource started - this.resBootLog.push({ - resource, - duration: this.resBooting?.time.elapsed ?? 0, - tsBooted: Date.now(), - }) + if (this.resBooting?.name === resource) { + this.resBootLog.push({ + resource, + duration: this.resBooting.time.elapsedMs ?? -1, + tsBooted: Date.now(), + }); + } else { + console.verbose.error(`Resource ${resource} started while ${this.resBooting?.name ?? 'unknown'} was booting`); + this.resBootLog.push({ + resource, + duration: -1, + tsBooted: Date.now(), + }); + } } } @@ -91,6 +106,13 @@ export default class FxResources { } } + /** + * Getter for the latest boot log + */ + public get latestBootLog() { + return cloneDeep(this.resBooting ? this.resBootLog : this.prevBootLog); + } + /** * Handle resource report. diff --git a/core/routes/advanced/actions.js b/core/routes/advanced/actions.js index 1b5ecade1..71ed92198 100644 --- a/core/routes/advanced/actions.js +++ b/core/routes/advanced/actions.js @@ -4,6 +4,7 @@ import bytes from 'bytes'; import got from '@lib/got'; import consoleFactory from '@lib/console'; import { SYM_SYSTEM_AUTHOR } from '@lib/symbols'; +import { msToShortishDuration } from '@lib/misc'; const console = consoleFactory(modulename); //Helper functions @@ -157,6 +158,22 @@ export default async function AdvancedActions(ctx) { const message = JSON.stringify(txCore.fxRunner.history, null, 2); return ctx.send({ type: 'success', message }); + } else if (action == 'printFxResourcesBootLog') { + const bootLog = txCore.fxResources.latestBootLog; + if (!bootLog) { + return ctx.send({ type: 'danger', message: 'No boot log found' }); + } else { + const maxResNameLength = Math.max(...bootLog.map((x) => x.resource.length)); + const lines = bootLog + .sort((a, b) => b.duration - a.duration) + .map((x) => { + const name = x.resource.padEnd(maxResNameLength); + const duration = msToShortishDuration(x.duration, { units: ['m', 's', 'ms'] }); + return `${name} - ${duration}`; + }); + return ctx.send({ type: 'success', message: lines.join('\n') }); + } + } else if (action == 'xxxxxx') { return ctx.send({ type: 'success', message: '😀👍' }); } From 657594d2c2cca25c85a0d6dffaa8ec507429b4c2 Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Sat, 26 Jul 2025 19:00:03 -0300 Subject: [PATCH 34/73] wip: small stuff --- core/modules/FxResources.ts | 4 +++- core/routes/advanced/get.js | 2 +- core/routes/cfgEditor/get.js | 2 +- core/routes/cfgEditor/save.js | 2 +- core/routes/deployer/actions.js | 10 +++++----- core/routes/deployer/status.js | 2 +- core/routes/deployer/stepper.js | 2 +- core/routes/fxserver/commands.ts | 31 +---------------------------- core/routes/fxserver/downloadLog.js | 2 +- core/routes/fxserver/schedule.ts | 1 - core/routes/intercom.ts | 1 - core/routes/resources.js | 2 +- core/routes/serverLog.js | 2 +- core/routes/serverLogPartial.js | 2 +- core/routes/setup/post.js | 16 +++++++-------- 15 files changed, 26 insertions(+), 55 deletions(-) diff --git a/core/modules/FxResources.ts b/core/modules/FxResources.ts index e783261d5..fdf2546c0 100644 --- a/core/modules/FxResources.ts +++ b/core/modules/FxResources.ts @@ -80,7 +80,9 @@ export default class FxResources { tsBooted: Date.now(), }); } else { - console.verbose.error(`Resource ${resource} started while ${this.resBooting?.name ?? 'unknown'} was booting`); + if (resource !== 'monitor') { + console.verbose.warn(`Resource ${resource} started while ${this.resBooting?.name ?? 'unknown'} was booting`); + } this.resBootLog.push({ resource, duration: -1, diff --git a/core/routes/advanced/get.js b/core/routes/advanced/get.js index 98c53b698..4d0cea6c7 100644 --- a/core/routes/advanced/get.js +++ b/core/routes/advanced/get.js @@ -5,7 +5,7 @@ const console = consoleFactory(modulename); /** * Returns the output page containing the server.cfg - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ export default async function AdvancedPage(ctx) { //Check permissions diff --git a/core/routes/cfgEditor/get.js b/core/routes/cfgEditor/get.js index 845d8ce03..a03770a3d 100644 --- a/core/routes/cfgEditor/get.js +++ b/core/routes/cfgEditor/get.js @@ -6,7 +6,7 @@ const console = consoleFactory(modulename); /** * Returns the output page containing the server.cfg - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ export default async function CFGEditorPage(ctx) { //Check permissions diff --git a/core/routes/cfgEditor/save.js b/core/routes/cfgEditor/save.js index 6e60a2c9a..5382a49e1 100644 --- a/core/routes/cfgEditor/save.js +++ b/core/routes/cfgEditor/save.js @@ -9,7 +9,7 @@ const isUndefined = (x) => (x === undefined); /** * Saves the server.cfg - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ export default async function CFGEditorSave(ctx) { //Sanity check diff --git a/core/routes/deployer/actions.js b/core/routes/deployer/actions.js index c84fa82d2..7ebc89d9b 100644 --- a/core/routes/deployer/actions.js +++ b/core/routes/deployer/actions.js @@ -16,7 +16,7 @@ const isUndefined = (x) => (x === undefined); /** * Handle all the server control actions - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ export default async function DeployerActions(ctx) { //Sanity check @@ -56,7 +56,7 @@ export default async function DeployerActions(ctx) { //================================================================ /** * Handle submition of user-edited recipe (record to deployer, starts the process) - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ async function handleConfirmRecipe(ctx) { //Sanity check @@ -79,7 +79,7 @@ async function handleConfirmRecipe(ctx) { //================================================================ /** * Handle submition of the input variables/parameters - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ async function handleSetVariables(ctx) { //Sanity check @@ -196,7 +196,7 @@ async function handleSetVariables(ctx) { //================================================================ /** * Handle the commit of a Recipe by receiving the user edited server.cfg - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ async function handleSaveConfig(ctx) { //Sanity check @@ -277,7 +277,7 @@ async function handleSaveConfig(ctx) { //================================================================ /** * Handle the cancellation of the deployer proguess - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ async function handleCancel(ctx) { txManager.deployer = null; diff --git a/core/routes/deployer/status.js b/core/routes/deployer/status.js index 74a83277c..d9720f8dd 100644 --- a/core/routes/deployer/status.js +++ b/core/routes/deployer/status.js @@ -5,7 +5,7 @@ const console = consoleFactory(modulename); /** * Returns the output page containing the live console - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ export default async function DeployerStatus(ctx) { //Check permissions diff --git a/core/routes/deployer/stepper.js b/core/routes/deployer/stepper.js index 2e0414bda..11ed2c4fe 100644 --- a/core/routes/deployer/stepper.js +++ b/core/routes/deployer/stepper.js @@ -8,7 +8,7 @@ const console = consoleFactory(modulename); /** * Returns the output page containing the deployer stepper page (all 3 stages) - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ export default async function DeployerStepper(ctx) { //Check permissions diff --git a/core/routes/fxserver/commands.ts b/core/routes/fxserver/commands.ts index 445f347fa..d4991858d 100644 --- a/core/routes/fxserver/commands.ts +++ b/core/routes/fxserver/commands.ts @@ -2,18 +2,11 @@ const modulename = 'WebServer:FXServerCommands'; import { AuthedCtx } from '@modules/WebServer/ctxTypes'; import consoleFactory from '@lib/console'; import { ApiToastResp } from '@shared/genericApiTypes'; -import { txEnv } from '@core/globalData'; const console = consoleFactory(modulename); -//Helper functions -const delay = async (ms: number) => { - return new Promise((resolve) => setTimeout(resolve, ms)); -}; - /** * Handle all the server commands - * @param {object} ctx */ export default async function FXServerCommands(ctx: AuthedCtx) { if ( @@ -47,29 +40,7 @@ export default async function FXServerCommands(ctx: AuthedCtx) { //============================================== - //DEBUG: Only available in the /advanced page - //FIXME: move to the advanced route, give button for profiling, saving mem snapshot, verbose, etc. - if (action == 'profile_monitor') { - if (!ensurePermission(ctx, 'all_permissions')) return false; - ctx.admin.logAction('Profiling txAdmin instance.'); - - const profileDuration = 5; - const savePath = `${txEnv.profilePath}/data/txProfile.bin`; - ExecuteCommand('profiler record start'); - await delay(profileDuration * 1000); - ExecuteCommand('profiler record stop'); - await delay(150); - ExecuteCommand(`profiler save "${savePath}"`); - await delay(150); - console.ok(`Profile saved to: ${savePath}`); - txCore.fxRunner.sendCommand('profiler', ['view', savePath], ctx.admin.name); - return ctx.send({ - type: 'success', - msg: 'Check your live console in a few seconds.', - }); - - //============================================== - } else if (action == 'admin_broadcast') { + if (action == 'admin_broadcast') { if (!ensurePermission(ctx, 'announcement')) return false; const message = (parameter ?? '').trim(); diff --git a/core/routes/fxserver/downloadLog.js b/core/routes/fxserver/downloadLog.js index 10db6574c..15c852554 100644 --- a/core/routes/fxserver/downloadLog.js +++ b/core/routes/fxserver/downloadLog.js @@ -6,7 +6,7 @@ const console = consoleFactory(modulename); /** * Returns the console log file - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ export default async function FXServerDownloadLog(ctx) { //Check permissions diff --git a/core/routes/fxserver/schedule.ts b/core/routes/fxserver/schedule.ts index dd93ea97d..8a3d14a58 100644 --- a/core/routes/fxserver/schedule.ts +++ b/core/routes/fxserver/schedule.ts @@ -7,7 +7,6 @@ const console = consoleFactory(modulename); /** * Handle all the server scheduler commands - * @param {object} ctx */ export default async function FXServerSchedule(ctx: AuthedCtx) { if ( diff --git a/core/routes/intercom.ts b/core/routes/intercom.ts index ff11415a2..d1184706e 100644 --- a/core/routes/intercom.ts +++ b/core/routes/intercom.ts @@ -8,7 +8,6 @@ const console = consoleFactory(modulename); /** * Intercommunications endpoint - * @param {object} ctx */ export default async function Intercom(ctx: InitializedCtx) { //Sanity check diff --git a/core/routes/resources.js b/core/routes/resources.js index 97afee328..98b34b9b7 100644 --- a/core/routes/resources.js +++ b/core/routes/resources.js @@ -44,7 +44,7 @@ const getResourceSubPath = (resPath) => { /** * Returns the resources list - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ export default async function Resources(ctx) { if (!txCore.fxRunner.child?.isAlive) { diff --git a/core/routes/serverLog.js b/core/routes/serverLog.js index 0e68c6215..d5efe0ace 100644 --- a/core/routes/serverLog.js +++ b/core/routes/serverLog.js @@ -5,7 +5,7 @@ const console = consoleFactory(modulename); /** * Returns the server log page - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ export default async function ServerLog(ctx) { //Check permissions diff --git a/core/routes/serverLogPartial.js b/core/routes/serverLogPartial.js index 92b91eedf..49fc6a58e 100644 --- a/core/routes/serverLogPartial.js +++ b/core/routes/serverLogPartial.js @@ -5,7 +5,7 @@ const console = consoleFactory(modulename); /** * Returns the output page containing the admin log. - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ export default async function ServerLogPartial(ctx) { //Check permissions diff --git a/core/routes/setup/post.js b/core/routes/setup/post.js index e310370c3..75e819dc4 100644 --- a/core/routes/setup/post.js +++ b/core/routes/setup/post.js @@ -48,7 +48,7 @@ const getPotentialServerDataFolders = (source) => { * Handle all the server control actions * FIXME: separate into validate.ts, saveDeployer.ts, and saveLocal.ts files * FIXME: or maybe postDeployer.ts, and postLocal.ts files - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ export default async function SetupPost(ctx) { //Sanity check @@ -101,7 +101,7 @@ export default async function SetupPost(ctx) { /** * Handle Validation of a remote recipe/template URL - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ async function handleValidateRecipeURL(ctx) { //Sanity check @@ -127,7 +127,7 @@ async function handleValidateRecipeURL(ctx) { /** * Handle Validation of a remote recipe/template URL - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ async function handleValidateLocalDeployPath(ctx) { //Sanity check @@ -148,7 +148,7 @@ async function handleValidateLocalDeployPath(ctx) { /** * Handle Validation of Local (existing) Server Data Folder - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ async function handleValidateLocalDataFolder(ctx) { //Sanity check @@ -210,7 +210,7 @@ async function handleValidateLocalDataFolder(ctx) { /** * Handle Validation of CFG File - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ async function handleValidateCFGFile(ctx) { //Sanity check @@ -243,7 +243,7 @@ async function handleValidateCFGFile(ctx) { /** * Handle Save settings for local server data imports * Actions: sets serverDataPath/cfgPath, starts the server, redirect to live console - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ async function handleSaveLocal(ctx) { //Sanity check @@ -322,7 +322,7 @@ async function handleSaveLocal(ctx) { /** * Handle Save settings for remote recipe importing * Actions: download recipe, starts deployer - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ async function handleSaveDeployerImport(ctx) { //Sanity check @@ -383,7 +383,7 @@ async function handleSaveDeployerImport(ctx) { /** * Handle Save settings for custom recipe * Actions: download recipe, starts deployer - * @param {object} ctx + * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx */ async function handleSaveDeployerCustom(ctx) { //Sanity check From a3d5c55ba186c9b9e66fd2e39c4b3fc73604712a Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Sun, 27 Jul 2025 13:21:38 -0300 Subject: [PATCH 35/73] refactor: rewrote the advanced cmds page --- core/boot/startReadyWatcher.ts | 2 +- core/lib/host/probeInternetTime.ts | 83 +++++++ core/lib/misc.ts | 31 +++ core/modules/FxRunner/index.ts | 7 +- core/modules/Logger/LoggerBase.ts | 2 +- core/modules/Logger/handlers/server.js | 4 +- core/modules/WebServer/router.ts | 3 +- core/routes/advanced/actions.js | 183 -------------- core/routes/advanced/get.js | 20 -- core/routes/advanced/groups/databaseCmds.ts | 23 ++ core/routes/advanced/groups/otherCmds.ts | 49 ++++ core/routes/advanced/groups/processCmds.ts | 67 +++++ core/routes/advanced/groups/serverCmds.ts | 80 ++++++ core/routes/advanced/groups/txAdminCmds.ts | 132 ++++++++++ core/routes/advanced/runCommand.ts | 78 ++++++ core/routes/index.ts | 3 +- panel/src/components/MarkdownProse.tsx | 5 +- panel/src/layout/MainRouter.tsx | 3 +- panel/src/lib/navigation.ts | 18 +- panel/src/lib/utils.ts | 40 +++ panel/src/pages/AdvancedPage.tsx | 255 ++++++++++++++++++++ shared/advancedCommands.ts | 111 +++++++++ web/main/advanced.ejs | 155 ------------ 23 files changed, 980 insertions(+), 374 deletions(-) create mode 100644 core/lib/host/probeInternetTime.ts delete mode 100644 core/routes/advanced/actions.js delete mode 100644 core/routes/advanced/get.js create mode 100644 core/routes/advanced/groups/databaseCmds.ts create mode 100644 core/routes/advanced/groups/otherCmds.ts create mode 100644 core/routes/advanced/groups/processCmds.ts create mode 100644 core/routes/advanced/groups/serverCmds.ts create mode 100644 core/routes/advanced/groups/txAdminCmds.ts create mode 100644 core/routes/advanced/runCommand.ts create mode 100644 panel/src/pages/AdvancedPage.tsx create mode 100644 shared/advancedCommands.ts delete mode 100644 web/main/advanced.ejs diff --git a/core/boot/startReadyWatcher.ts b/core/boot/startReadyWatcher.ts index 3a5183d70..3974e29db 100644 --- a/core/boot/startReadyWatcher.ts +++ b/core/boot/startReadyWatcher.ts @@ -13,7 +13,7 @@ import { chalkInversePad } from '@lib/misc'; const console = consoleFactory(); -const getPublicIp = async () => { +export const getPublicIp = async () => { const zIpValidator = z.string().ip(); const reqOptions = { timeout: { request: 2000 }, diff --git a/core/lib/host/probeInternetTime.ts b/core/lib/host/probeInternetTime.ts new file mode 100644 index 000000000..8496fc45d --- /dev/null +++ b/core/lib/host/probeInternetTime.ts @@ -0,0 +1,83 @@ +import got from "@lib/got"; +import { performance } from "node:perf_hooks"; + +type TimeProbe = { url: string; parse: (body: string) => number }; + +const timeProbes: TimeProbe[] = [ + { + url: "https://time.akamai.com/?ms", + parse: (b) => parseFloat(b.trim()) * 1000 + }, + { + url: "https://www.cloudflare.com/cdn-cgi/trace", + parse: (b) => { + const tsLine = b.split("\n").find((l) => l.startsWith("ts="))!; + const ts = parseFloat(tsLine.slice(3)); // seconds.frac + return Math.round(ts * 1000); + } + }, + { + url: "https://gettimeapi.dev/v1/time", + parse: (b) => { + const json = JSON.parse(b); + return Date.parse(json.date + 'T' + json.time + 'Z'); + } + } +]; + + +type ProbeSuccess = { + url: string; + success: true; + serverTime: number; + rtt: number; + offset: number; +}; +type ProbeFailure = { + url: string; + success: false; + error: string; +} +type ProbeResult = ProbeSuccess | ProbeFailure; + +const runProbe = async ({ url, parse }: TimeProbe): Promise => { + const timeoutMs = 5000; + try { + const t0 = performance.now(); + const res = await got(url, { + retry: { limit: 0 }, + timeout: { request: timeoutMs }, + }); + const t1 = performance.now(); + const serverTime = parse(res.body); + const rtt = t1 - t0; + const offset = serverTime + rtt / 2 - Date.now(); + return { url, success: true, serverTime, rtt, offset }; + } catch (error) { + return { url, success: false, error: (error as Error)?.message ?? 'unknown error' }; + } +} + + +type RunAllProbesResult = { + date: Date | null; + avgTimeMs: number | null; + avgOffsetMs: number | null; + avgRttMs: number | null; + results: ProbeResult[]; +} + +export default async function probeInternetTime(): Promise { + const results = await Promise.all(timeProbes.map(runProbe)); + const successResults = results.filter((r) => r.success) as ProbeSuccess[]; + const avgTimeMs = successResults.reduce((acc, r) => acc + r.serverTime, 0) / successResults.length; + const avgOffsetMs = successResults.reduce((acc, r) => acc + r.offset, 0) / successResults.length; + const avgRttMs = successResults.reduce((acc, r) => acc + r.rtt, 0) / successResults.length; + return { + date: avgTimeMs ? new Date(avgTimeMs) : null, + avgTimeMs: Math.floor(avgTimeMs), + avgOffsetMs: Math.floor(avgOffsetMs), + avgRttMs: Math.floor(avgRttMs), + results, + }; +} diff --git a/core/lib/misc.ts b/core/lib/misc.ts index d9f1867e9..dfd6d54e3 100644 --- a/core/lib/misc.ts +++ b/core/lib/misc.ts @@ -299,3 +299,34 @@ export const deepFreeze = >(obj: T) => { * Returns a chalk.inverse of a string with a 1ch padding */ export const chalkInversePad = (str: string) => chalk.inverse(` ${str} `); + + +/** + * Parses a string to a safe and finite integer, or returns null if it's not a valid integer. + */ +export const parseFiniteIntString = (str: string) => { + const parsed = parseInt(str); + if (Number.isNaN(parsed) || !Number.isSafeInteger(parsed) || !Number.isFinite(parsed)) { + return null; + } + return parsed; +} + +/** + * Returns a markdown code inline string + */ +export const mdCodeInline = (msg: string) => `\`${msg}\``; + + +/** + * Returns a markdown code block string + */ +export const mdCodeBlock = (msg: string, lang: string | false = '', maxLen: number | false = false) => { + const blockLang = lang ? lang : ''; + let blockMsg = msg; + if (maxLen && blockMsg.length > maxLen) { + blockMsg = blockMsg.substring(0, maxLen - 3) + '...'; + } + const escapedMessage = blockMsg.replaceAll(/`/g, '\\\`'); + return `\`\`\`${blockLang}\n${escapedMessage}\n\`\`\``; +}; diff --git a/core/modules/FxRunner/index.ts b/core/modules/FxRunner/index.ts index 4a2c6e945..fd4530cc3 100644 --- a/core/modules/FxRunner/index.ts +++ b/core/modules/FxRunner/index.ts @@ -369,7 +369,7 @@ export default class FxRunner { * Useful for when we change txAdmin settings and want it to reflect on the server. * This will also fire the `txAdmin:event:configChanged` */ - private async updateMutableConvars() { + public async updateMutableConvars() { console.log('Updating FXServer ConVars.'); try { await setupCustomLocaleFile(); @@ -377,11 +377,12 @@ export default class FxRunner { for (const [set, convar, value] of convarList) { this.sendCommand(set, [convar, value], SYM_SYSTEM_AUTHOR); } - return this.sendEvent('configChanged'); + this.sendEvent('configChanged'); + return convarList; } catch (error) { console.verbose.error('Error updating FXServer ConVars'); console.verbose.dir(error); - return false; + return null; } } diff --git a/core/modules/Logger/LoggerBase.ts b/core/modules/Logger/LoggerBase.ts index b70f100bc..de594b6a1 100644 --- a/core/modules/Logger/LoggerBase.ts +++ b/core/modules/Logger/LoggerBase.ts @@ -17,7 +17,7 @@ export class LoggerBase { lrStream: rfs.RotatingFileStream; lrErrors = 0; public activeFilePath: string; - private lrLastError: string | undefined; + public lrLastError: string | undefined; private basePath: string; private logNameRegex: RegExp; diff --git a/core/modules/Logger/handlers/server.js b/core/modules/Logger/handlers/server.js index affa82ff1..a1c6576f0 100644 --- a/core/modules/Logger/handlers/server.js +++ b/core/modules/Logger/handlers/server.js @@ -106,10 +106,10 @@ export default class ServerLogger extends LoggerBase { /*** * Returns the recent fxserver buffer containing HTML markers, and not XSS escaped. * The size of this buffer is usually above 64kb, never above 128kb. - * @param {Number} lastN + * @param {number|undefined} lastN * @returns the recent buffer, optionally only the last N elements */ - getRecentBuffer(lastN) { + getRecentBuffer(lastN = undefined) { return (lastN) ? this.recentBuffer.slice(-lastN) : this.recentBuffer; } diff --git a/core/modules/WebServer/router.ts b/core/modules/WebServer/router.ts index c9b2fd99d..e561d57c5 100644 --- a/core/modules/WebServer/router.ts +++ b/core/modules/WebServer/router.ts @@ -28,7 +28,6 @@ export default () => { //Rendered Pages router.get('/legacy/adminManager', webAuthMw, routes.adminManager_page); - router.get('/legacy/advanced', webAuthMw, routes.advanced_page); router.get('/legacy/cfgEditor', webAuthMw, routes.cfgEditor_page); router.get('/legacy/masterActions', webAuthMw, routes.masterActions_page); router.get('/legacy/resources', webAuthMw, routes.resources); @@ -83,7 +82,7 @@ export default () => { //Diagnostic routes router.get('/diagnostics/getDiagnostics', apiAuthMw, routes.diagnostics_getDiagnostics); router.post('/diagnostics/sendReport', apiAuthMw, routes.diagnostics_sendReport); - router.post('/advanced', apiAuthMw, routes.advanced_actions); + router.post('/advanced/run', apiAuthMw, routes.advanced_runCommand); //Data routes router.get('/serverLog/partial', apiAuthMw, routes.serverLogPartial); diff --git a/core/routes/advanced/actions.js b/core/routes/advanced/actions.js deleted file mode 100644 index 71ed92198..000000000 --- a/core/routes/advanced/actions.js +++ /dev/null @@ -1,183 +0,0 @@ -const modulename = 'WebServer:AdvancedActions'; -import v8 from 'node:v8'; -import bytes from 'bytes'; -import got from '@lib/got'; -import consoleFactory from '@lib/console'; -import { SYM_SYSTEM_AUTHOR } from '@lib/symbols'; -import { msToShortishDuration } from '@lib/misc'; -const console = consoleFactory(modulename); - -//Helper functions -const isUndefined = (x) => (x === undefined); - - -/** - * Endpoint for running advanced commands - basically, should not ever be used - */ -export default async function AdvancedActions(ctx) { - //Sanity check - if ( - isUndefined(ctx.request.body.action) - || isUndefined(ctx.request.body.parameter) - ) { - console.warn('Invalid request!'); - return ctx.send({ type: 'danger', message: 'Invalid request :(' }); - } - const action = ctx.request.body.action; - const parameter = ctx.request.body.parameter; - - - //Check permissions - if (!ctx.admin.testPermission('all_permissions', modulename)) { - return ctx.send({ - type: 'danger', - message: 'You don\'t have permission to execute this action.', - }); - } - - //Action: Change Verbosity - if (action == 'change_verbosity') { - console.setVerbose(parameter == 'true'); - //temp disabled because the verbosity convar is not being set by this method - return ctx.send({ refresh: true }); - } else if (action == 'perform_magic') { - const message = JSON.stringify(txCore.fxPlayerlist.getPlayerList(), null, 2); - return ctx.send({ type: 'success', message }); - } else if (action == 'show_db') { - const dbo = txCore.database.getDboRef(); - console.dir(dbo); - return ctx.send({ type: 'success', message: JSON.stringify(dbo, null, 2) }); - } else if (action == 'show_log') { - return ctx.send({ type: 'success', message: JSON.stringify(txCore.logger.server.getRecentBuffer(), null, 2) }); - } else if (action == 'memory') { - let memory; - try { - const usage = process.memoryUsage(); - Object.keys(usage).forEach((prop) => { - usage[prop] = bytes(usage[prop]); - }); - memory = JSON.stringify(usage, null, 2); - } catch (error) { - memory = 'error'; - } - return ctx.send({ type: 'success', message: memory }); - } else if (action == 'freeze') { - console.warn('Freezing process for 50 seconds.'); - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * 1000); - } else if (action == 'updateMutableConvars') { - txCore.fxRunner.updateMutableConvars(); - return ctx.send({ refresh: true }); - } else if (action == 'reauthLast10Players') { - // force refresh the admin status of the last 10 players to join - const lastPlayers = txCore.fxPlayerlist.getPlayerList().map((p) => p.netid).slice(-10); - txCore.fxRunner.sendEvent('adminsUpdated', lastPlayers); - return ctx.send({ type: 'success', message: `refreshed: ${JSON.stringify(lastPlayers)}` }); - } else if (action == 'getLoggerErrors') { - const outData = { - admin: txCore.logger.admin.lrLastError, - fxserver: txCore.logger.fxserver.lrLastError, - server: txCore.logger.server.lrLastError, - }; - return ctx.send({ type: 'success', message: JSON.stringify(outData, null, 2) }); - } else if (action == 'testSrcAddress') { - const url = 'https://api.myip.com'; - const respDefault = await got(url).json(); - const respReset = await got(url, { localAddress: undefined }).json(); - const outData = { - url, - respDefault, - respReset, - }; - return ctx.send({ type: 'success', message: JSON.stringify(outData, null, 2) }); - } else if (action == 'getProcessEnv') { - return ctx.send({ type: 'success', message: JSON.stringify(process.env, null, 2) }); - } else if (action == 'snap') { - setTimeout(() => { - // if (Citizen && Citizen.snap) Citizen.snap(); - const snapFile = v8.writeHeapSnapshot(); - console.warn(`Heap snapshot written to: ${snapFile}`); - }, 50); - return ctx.send({ type: 'success', message: 'terminal' }); - } else if (action === 'gc') { - if (typeof global.gc === 'function') { - global.gc(); - return ctx.send({ type: 'success', message: 'done' }); - } else { - return ctx.send({ type: 'danger', message: 'GC is not exposed' }); - } - } else if (action === 'safeEnsureMonitor') { - const setCmdResult = txCore.fxRunner.sendCommand( - 'set', - [ - 'txAdmin-luaComToken', - txCore.webServer.luaComToken, - ], - SYM_SYSTEM_AUTHOR - ); - if (!setCmdResult) { - return ctx.send({ type: 'danger', message: 'Failed to reset luaComToken.' }); - } - const ensureCmdResult = txCore.fxRunner.sendCommand( - 'ensure', - ['monitor'], - SYM_SYSTEM_AUTHOR - ); - if (ensureCmdResult) { - return ctx.send({ type: 'success', message: 'done' }); - } else { - return ctx.send({ type: 'danger', message: 'Failed to ensure monitor.' }); - } - } else if (action.startsWith('playerDrop')) { - const reason = action.split(' ', 2)[1]; - const category = txCore.metrics.playerDrop.handlePlayerDrop(reason); - return ctx.send({ type: 'success', message: category }); - - } else if (action.startsWith('set')) { - // set general.language "pt" - // set general.language "en" - // set server.onesync "on" - // set server.onesync "legacy" - try { - const [_, scopeKey, valueJson] = action.split(' ', 3); - if (!scopeKey || !valueJson) throw new Error(`Invalid set command: ${action}`); - const [scope, key] = scopeKey.split('.'); - if (!scope || !key) throw new Error(`Invalid set command: ${action}`); - const configUpdate = { [scope]: { [key]: JSON.parse(valueJson) } }; - const storedKeysChanges = txCore.configStore.saveConfigs(configUpdate, ctx.admin.name); - const outParts = [ - 'Keys Updated: ' + JSON.stringify(storedKeysChanges ?? 'not set', null, 2), - '-'.repeat(16), - 'Stored:' + JSON.stringify(txCore.configStore.getStoredConfig(), null, 2), - ]; - return ctx.send({ type: 'success', message: outParts.join('\n') }); - } catch (error) { - return ctx.send({ type: 'danger', message: error.message }); - } - - } else if (action == 'printFxRunnerChildHistory') { - const message = JSON.stringify(txCore.fxRunner.history, null, 2); - return ctx.send({ type: 'success', message }); - - } else if (action == 'printFxResourcesBootLog') { - const bootLog = txCore.fxResources.latestBootLog; - if (!bootLog) { - return ctx.send({ type: 'danger', message: 'No boot log found' }); - } else { - const maxResNameLength = Math.max(...bootLog.map((x) => x.resource.length)); - const lines = bootLog - .sort((a, b) => b.duration - a.duration) - .map((x) => { - const name = x.resource.padEnd(maxResNameLength); - const duration = msToShortishDuration(x.duration, { units: ['m', 's', 'ms'] }); - return `${name} - ${duration}`; - }); - return ctx.send({ type: 'success', message: lines.join('\n') }); - } - - } else if (action == 'xxxxxx') { - return ctx.send({ type: 'success', message: '😀👍' }); - } - - //Catch all - return ctx.send({ type: 'danger', message: 'Unknown action :(' }); -}; diff --git a/core/routes/advanced/get.js b/core/routes/advanced/get.js deleted file mode 100644 index 4d0cea6c7..000000000 --- a/core/routes/advanced/get.js +++ /dev/null @@ -1,20 +0,0 @@ -const modulename = 'WebServer:AdvancedPage'; -import consoleFactory from '@lib/console'; -const console = consoleFactory(modulename); - - -/** - * Returns the output page containing the server.cfg - * @param {import('@modules/WebServer/ctxTypes').AuthedCtx} ctx - */ -export default async function AdvancedPage(ctx) { - //Check permissions - if (!ctx.admin.hasPermission('all_permisisons')) { - return ctx.utils.render('main/message', {message: 'You don\'t have permission to view this page.'}); - } - - return ctx.utils.render('main/advanced', { - headerTitle: 'Advanced', - verbosityEnabled: console.isVerbose, - }); -}; diff --git a/core/routes/advanced/groups/databaseCmds.ts b/core/routes/advanced/groups/databaseCmds.ts new file mode 100644 index 000000000..6ecdbf5e4 --- /dev/null +++ b/core/routes/advanced/groups/databaseCmds.ts @@ -0,0 +1,23 @@ +import type { AdvancedCommandHandler } from "../runCommand"; + + +const dbComparePlayers: AdvancedCommandHandler = async (ctx, args) => { + return { + type: 'md', + data: `TODO: implement`, + } +} + + +const dbPurgeId: AdvancedCommandHandler = async (ctx, args) => { + return { + type: 'md', + data: `TODO: implement`, + } +} + + +export default { + // dbComparePlayers, + // dbPurgeId, +} diff --git a/core/routes/advanced/groups/otherCmds.ts b/core/routes/advanced/groups/otherCmds.ts new file mode 100644 index 000000000..4eae470c5 --- /dev/null +++ b/core/routes/advanced/groups/otherCmds.ts @@ -0,0 +1,49 @@ +import { getPublicIp } from "@core/boot/startReadyWatcher"; +import type { AdvancedCommandHandler } from "../runCommand"; +import probeInternetTime from "@lib/host/probeInternetTime"; +import { mdCodeBlock } from "@lib/misc"; + + +const printPublicIp: AdvancedCommandHandler = async (ctx, args) => { + const ip = await getPublicIp() || 'not found'; + return { + type: 'md', + data: `Public IP: ${ip}`, + } +} + + +const printClockInfo: AdvancedCommandHandler = async (ctx, args) => { + const probeResult = await probeInternetTime(); + const { date, avgTimeMs, avgOffsetMs, avgRttMs, results } = probeResult; + const internetTime = date ? new Date(date).toLocaleString() : 'not found'; + const currentTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const drift = avgOffsetMs ? `${avgOffsetMs}ms` : 'not found'; + const rtt = avgRttMs ? `${avgRttMs}ms` : 'not found'; + + return { + type: 'md', + data: [ + '## Clock Info:', + `- Local time: ${new Date().toLocaleString()}`, + `- Internet time: ${internetTime}`, + `- Timezone: ${currentTimezone}`, + `- Drift: ${drift}`, + `- RTT: ${rtt}`, + '## Raw Probe Data:', + ...results.map(r => { + const j = JSON.stringify({ ...r, url: undefined, success: undefined }, null, 2) + return [ + `### ${r.url}`, + mdCodeBlock(j, 'json'), + ].join('\n'); + }), + ].join('\n'), + } +} + + +export default { + printPublicIp, + printClockInfo, +} diff --git a/core/routes/advanced/groups/processCmds.ts b/core/routes/advanced/groups/processCmds.ts new file mode 100644 index 000000000..b55ad26c5 --- /dev/null +++ b/core/routes/advanced/groups/processCmds.ts @@ -0,0 +1,67 @@ +import v8 from 'node:v8'; +import type { AdvancedCommandHandler } from "../runCommand"; +import { parseFiniteIntString } from "@lib/misc"; +import bytes from "bytes"; + +const printProcessEnv: AdvancedCommandHandler = (ctx, args) => { + return { + type: 'json', + data: JSON.stringify(process.env, null, 2), + } +} + + +const printProcessMemoryUsage: AdvancedCommandHandler = (ctx, args) => { + let outLines: string[] = []; + for (const [key, value] of Object.entries(process.memoryUsage())) { + outLines.push(`- ${key}: ${bytes(value)!}`); + } + return { + type: 'md', + data: [ + '## Process Memory:', + ...outLines, + ].join('\n'), + } +} + + +const freezeProcess: AdvancedCommandHandler = (ctx, args) => { + const secs = parseFiniteIntString(args) ?? 50; + if (secs < 1) { + return { + type: 'md', + data: `Invalid argument: ${args}`, + } + } + + //Scheduling the freeze after the response is sent to the client + setTimeout(() => { + console.warn(`Admin ${ctx.admin.name} requested freezing process for ${secs} seconds.`); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, secs * 1000); + }, 250); //grace period + return { + type: 'md', + data: `Freezing process for ${secs} seconds...`, + } +} + + +const saveHeapSnapshot: AdvancedCommandHandler = (ctx, args) => { + setTimeout(() => { + const snapFile = v8.writeHeapSnapshot(); + console.warn(`Heap snapshot written to: ${snapFile}`); + }, 250); //grace period + return { + type: 'md', + data: `Saving heap snapshot, check the terminal for more details.\nThis may take a while and has high chance of crashing the server.`, + } +} + + +export default { + printProcessEnv, + printProcessMemoryUsage, + freezeProcess, + saveHeapSnapshot, +} diff --git a/core/routes/advanced/groups/serverCmds.ts b/core/routes/advanced/groups/serverCmds.ts new file mode 100644 index 000000000..77ecc19a7 --- /dev/null +++ b/core/routes/advanced/groups/serverCmds.ts @@ -0,0 +1,80 @@ +import { SYM_SYSTEM_AUTHOR } from "@lib/symbols"; +import type { AdvancedCommandHandler } from "../runCommand"; +import { mdCodeBlock, parseFiniteIntString } from "@lib/misc"; + + +const forceUpdateMutableConvars: AdvancedCommandHandler = async (ctx, args) => { + const convarList = await txCore.fxRunner.updateMutableConvars(); + if (!convarList) { + return { + type: 'md', + data: `Failed to update mutable convars.\nCheck the terminal for more details.`, + } + } + const lines = convarList.map(([set, convar, value]) => `${set} ${convar} ${value}`); + return { + type: 'md', + data: [ + '## Convars Updated:', + mdCodeBlock(lines.join('\n'), 'txt'), + ].join('\n'), + } +} + + +const safelyRestartMonitorResource: AdvancedCommandHandler = (ctx, args) => { + const setCmdResult = txCore.fxRunner.sendCommand( + 'set', + [ + 'txAdmin-luaComToken', + txCore.webServer.luaComToken, + ], + SYM_SYSTEM_AUTHOR + ); + if (!setCmdResult) { + return { + type: 'md', + data: 'Failed to reset luaComToken.\nCheck the terminal for more details.', + } + } + const ensureCmdResult = txCore.fxRunner.sendCommand( + 'ensure', + ['monitor'], + SYM_SYSTEM_AUTHOR + ); + if (ensureCmdResult) { + return { + type: 'md', + data: 'Monitor restarted.', + } + } else { + return { + type: 'md', + data: 'Failed to restart monitor.\nCheck the terminal for more details.', + } + } +} + + +const forceReauthRecentPlayers: AdvancedCommandHandler = async (ctx, args) => { + const numPlayers = parseFiniteIntString(args) ?? 10; + if (numPlayers < 1) { + return { + type: 'md', + data: `Invalid argument: ${args}`, + } + } + const netIds = txCore.fxPlayerlist.getPlayerList().map((p) => p.netid).slice(-numPlayers); + txCore.fxRunner.sendEvent('adminsUpdated', netIds); + return { + type: 'md', + data: `Refreshed players with NetIDs: ${netIds.join(', ')}`, + } +} + + +export default { + forceUpdateMutableConvars, + safelyRestartMonitorResource, + forceReauthRecentPlayers, +} diff --git a/core/routes/advanced/groups/txAdminCmds.ts b/core/routes/advanced/groups/txAdminCmds.ts new file mode 100644 index 000000000..e679e0e1d --- /dev/null +++ b/core/routes/advanced/groups/txAdminCmds.ts @@ -0,0 +1,132 @@ +import { mdCodeBlock, msToShortishDuration } from "@lib/misc"; +import type { AdvancedCommandHandler } from "../runCommand"; + + +const setVerbosity: AdvancedCommandHandler = (ctx, args) => { + if (args === 'true') { + console.setVerbose(true); + } else if (args === 'false') { + console.setVerbose(false); + } else if (!args) { + return { + type: 'md', + data: `The current verbosity is ${console.isVerbose}.\nYou need to pass a boolean value to set it.`, + } + } else { + return { + type: 'md', + data: `Invalid argument: ${args}`, + } + } + + return { + type: 'md', + data: `Console verbosity set to ${console.isVerbose}`, + } +} + + +const setConfig: AdvancedCommandHandler = (ctx, args) => { + const [scopeKey, valueString] = args.split(/\s+/, 2); + if (!scopeKey || !valueString) throw new Error(`Invalid set command: ${args}`); + const [scope, key] = scopeKey.split('.'); + if (!scope || !key) throw new Error(`Invalid scope.key: ${scopeKey}`); + + const configUpdate: any = {}; + try { + configUpdate[scope] = { [key]: JSON.parse(valueString) }; + } catch (error) { + console.dir(error); + return { + type: 'md', + data: `Failed to parse the JSON value: ${valueString}\nIf you are trying to set a string, you must wrap it in quotes.`, + } + } + const { raw: keysUpdated } = txCore.configStore.saveConfigs(configUpdate, ctx.admin.name); + const outParts = [ + '## Keys Updated:', + mdCodeBlock(JSON.stringify(keysUpdated ?? 'not set', null, 2), 'json'), + '## Stored:', + mdCodeBlock(JSON.stringify(txCore.configStore.getStoredConfig(), null, 2), 'json'), + ]; + + return { + type: 'md', + data: outParts.join('\n'), + } +} + + +const printFullPlayerList: AdvancedCommandHandler = (ctx, args) => { + return { + type: 'json', + data: JSON.stringify(txCore.fxPlayerlist.getPlayerList(), null, 2), + } +} + + +const printFullServerLogBuffer: AdvancedCommandHandler = (ctx, args) => { + return { + type: 'json', + data: JSON.stringify(txCore.logger.server.getRecentBuffer(), null, 2), + } +} + + +const printFxRunnerChildHistory: AdvancedCommandHandler = (ctx, args) => { + return { + type: 'json', + data: JSON.stringify(txCore.fxRunner.history, null, 2), + } +} + + +const printLoggerErrors: AdvancedCommandHandler = (ctx, args) => { + const outData = { + admin: txCore.logger.admin.lrLastError, + fxserver: txCore.logger.fxserver.lrLastError, + server: txCore.logger.server.lrLastError, + }; + return { + type: 'json', + data: JSON.stringify(outData, null, 2), + } +} + + +const printFxResourcesBootLog: AdvancedCommandHandler = (ctx, args) => { + const bootLog = txCore.fxResources.latestBootLog; + if (!bootLog) { + return { + type: 'md', + data: 'Server has not booted yet.', + } + } else { + const maxResNameLength = Math.max(...bootLog.map((x) => x.resource.length)); + const lines = bootLog + .sort((a, b) => b.duration - a.duration) + .map((x) => { + const name = x.resource.padEnd(maxResNameLength); + const duration = msToShortishDuration(x.duration, { units: ['m', 's', 'ms'] }); + return `${name} ${duration}`; + }); + return { + type: 'md', + data: [ + '## Latest Boot Log:', + mdCodeBlock(lines.join('\n'), 'txt'), + ].join('\n'), + } + } +} + + +export default { + verbose: setVerbosity, + set: setConfig, + printFullPlayerList, + printFullServerLogBuffer, + printFxRunnerChildHistory, + printLoggerErrors, + printFxResourcesBootLog, +} diff --git a/core/routes/advanced/runCommand.ts b/core/routes/advanced/runCommand.ts new file mode 100644 index 000000000..e4548469e --- /dev/null +++ b/core/routes/advanced/runCommand.ts @@ -0,0 +1,78 @@ +const modulename = 'WebServer:Advanced:Run'; +import { AuthedCtx } from '@modules/WebServer/ctxTypes'; +import consoleFactory from '@lib/console'; +import { z } from 'zod'; +import type { GenericApiErrorResp } from '@shared/genericApiTypes'; +import txAdminCmds from './groups/txAdminCmds'; +import serverCmds from './groups/serverCmds'; +import processCmds from './groups/processCmds'; +import otherCmds from './groups/otherCmds'; +import databaseCmds from './groups/databaseCmds'; +const console = consoleFactory(modulename); + + +//Req validation & types +const bodySchema = z.object({ + cmd: z.string(), +}); +export type RunAdvancedCommandReq = z.infer; +export type RunAdvancedCommandRespSuccess = { + type: 'md' | 'json'; + data: string; +} +export type RunAdvancedCommandResp = RunAdvancedCommandRespSuccess | GenericApiErrorResp; + + +//NOTE: leaving the args splitting to the handler +export type AdvancedCommandHandler = (ctx: AuthedCtx, args: string) => (Promise | RunAdvancedCommandRespSuccess); +const handlers: Record = { + ...txAdminCmds, + ...databaseCmds, + ...serverCmds, + ...processCmds, + ...otherCmds, +} + + +/** + * Runs an advanced command + */ +export default async function RunAdvancedCommand(ctx: AuthedCtx) { + const sendTypedResp = (data: RunAdvancedCommandResp) => ctx.send(data); + + //Check permissions + if (!ctx.admin.testPermission('all_permissions', modulename)) { + return sendTypedResp({ + error: 'You do not have permission to change the settings.' + }); + } + + //Validating input + const schemaRes = bodySchema.safeParse(ctx.request.body); + if (!schemaRes.success) { + return sendTypedResp({ + error: `Invalid request body: ${schemaRes.error.message}`, + }); + } + const cmdStr = schemaRes.data.cmd; + + //Parsing the command + const [, cmd, rawArgs] = cmdStr.match(/^(?\w+)(?:\s+(?.*))?$/) || []; + if (!cmd || !(cmd in handlers) || !handlers[cmd]) { + return sendTypedResp({ + error: `Invalid command: ${cmdStr}`, + }); + } + + //Running the command + const args = rawArgs?.trim() ?? ''; + try { + const result = await handlers[cmd](ctx, args); + ctx.admin.logAction(`Ran advanced command: ${cmd} ${args}`); + return sendTypedResp(result); + } catch (error) { + return sendTypedResp({ + error: `Error running command: ${(error as any)?.message ?? 'Unknown error'}`, + }); + } +}; diff --git a/core/routes/index.ts b/core/routes/index.ts index 4968cbb83..0016917f0 100644 --- a/core/routes/index.ts +++ b/core/routes/index.ts @@ -63,8 +63,7 @@ export { default as whitelist_page } from './whitelist/page'; export { default as whitelist_list } from './whitelist/list'; export { default as whitelist_actions } from './whitelist/actions'; -export { default as advanced_page } from './advanced/get'; -export { default as advanced_actions } from './advanced/actions'; +export { default as advanced_runCommand } from './advanced/runCommand'; //FIXME: reorganizar TODAS rotas de logs, incluindo listagem e download export { default as serverLog } from './serverLog.js'; diff --git a/panel/src/components/MarkdownProse.tsx b/panel/src/components/MarkdownProse.tsx index da46a8b18..4d452c320 100644 --- a/panel/src/components/MarkdownProse.tsx +++ b/panel/src/components/MarkdownProse.tsx @@ -19,8 +19,10 @@ type MarkdownProseProps = { isSmall?: boolean; isTitle?: boolean; isToast?: boolean; + className?: string; }; -export default function MarkdownProse({ md, isSmall, isTitle, isToast }: MarkdownProseProps) { +export default function MarkdownProse({ md, isSmall, isTitle, isToast, className }: MarkdownProseProps) { + //FIXME: the \n replacer should ignore code blocks return ( {stripIndent(md.replace(/\n/g, ' \n'))} diff --git a/panel/src/layout/MainRouter.tsx b/panel/src/layout/MainRouter.tsx index 7e66bbc63..cc1d7042c 100644 --- a/panel/src/layout/MainRouter.tsx +++ b/panel/src/layout/MainRouter.tsx @@ -20,6 +20,7 @@ import PlayerDropsPage from "@/pages/PlayerDropsPage/PlayerDropsPage"; import SettingsPage from "@/pages/Settings/SettingsPage"; import UnauthorizedPage from "@/pages/UnauthorizedPage"; import DiagnosticsPage from "@/pages/Diagnostics/DiagnosticsPage"; +import AdvancedPage from "@/pages/AdvancedPage"; type RouteType = { @@ -131,7 +132,7 @@ const allRoutes: RouteType[] = [ path: '/advanced', title: 'Advanced', permission: 'all_permissions', - Page: