diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 98e6e53..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -// This configuration only applies to the package manager root. -/** @type {import("eslint").Linter.Config} */ -module.exports = { - ignorePatterns: ["apps/**", "packages/**"], - extends: ["@pm2.web/eslint-config/base.js"], -}; diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7c16d8a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,34 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "cwd": "${workspaceFolder}/apps/dashboard", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev" + }, + { + "name": "Next.js: debug client-side", + "type": "msedge", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "cwd": "${workspaceFolder}/apps/dashboard", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/.bin/next", + "runtimeArgs": ["--inspect"], + "skipFiles": ["/**"], + "serverReadyAction": { + "action": "debugWithEdge", + "killOnServerStop": true, + "pattern": "- Local:.+(https?://.+)", + "uriFormat": "%s", + "webRoot": "${workspaceFolder}" + } + } + ] + } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 642d0c9..314fbd1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,18 @@ { - "cssvar.files": ["apps/dashboard/node_modules/@mantine/core/styles.css"], - "cssvar.extensions": ["css", "scss", "tsx", "jsx"] + "[typescript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } diff --git a/apps/backend/.eslintrc.js b/apps/backend/.eslintrc.js deleted file mode 100644 index da8914d..0000000 --- a/apps/backend/.eslintrc.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -module.exports = { - root: true, - extends: ["@pm2.web/eslint-config/base.js"], -}; diff --git a/apps/backend/README.md b/apps/backend/README.md index 4c98b18..b772954 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -4,7 +4,7 @@ The Backend is a simple Node.js application that uses the pm2 BUS API to communi ## Prerequisites -- Node.js v18 +- Node.js >= v20 - MongoDB Cluster - PM2 (installed globally) diff --git a/apps/backend/eslint.config.js b/apps/backend/eslint.config.js new file mode 100644 index 0000000..09c95dd --- /dev/null +++ b/apps/backend/eslint.config.js @@ -0,0 +1,4 @@ +import { config } from "@pm2.web/eslint-config/base"; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/apps/backend/handlers/captureLogs.ts b/apps/backend/handlers/capture-logs.ts similarity index 91% rename from apps/backend/handlers/captureLogs.ts rename to apps/backend/handlers/capture-logs.ts index 27df4d2..98e58b2 100644 --- a/apps/backend/handlers/captureLogs.ts +++ b/apps/backend/handlers/capture-logs.ts @@ -1,7 +1,9 @@ +import EventEmitter from "node:events"; + import pm2 from "pm2"; import { Packet, QueuedLog } from "../types/handler.js"; -import censorMessage from "../utils/censorMessage.js"; +import censorMessage from "../utils/censor-message.js"; class LogCapture { private queuedLogs: QueuedLog[] = []; @@ -9,7 +11,7 @@ class LogCapture { constructor() {} capture(): void { - pm2.launchBus((err, bus) => { + pm2.launchBus((_err, bus: EventEmitter) => { bus.on("log:err", (packet: Packet) => { this.queuedLogs.push({ id: packet.process.pm_id, diff --git a/apps/backend/handlers/connectDb.ts b/apps/backend/handlers/connect-db.ts similarity index 100% rename from apps/backend/handlers/connectDb.ts rename to apps/backend/handlers/connect-db.ts diff --git a/apps/backend/handlers/onChange.ts b/apps/backend/handlers/on-change.ts similarity index 90% rename from apps/backend/handlers/onChange.ts rename to apps/backend/handlers/on-change.ts index a3c7759..9e2cf89 100644 --- a/apps/backend/handlers/onChange.ts +++ b/apps/backend/handlers/on-change.ts @@ -1,11 +1,14 @@ -// mongoose changestream +/* eslint-disable @typescript-eslint/no-misused-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { processModel } from "@pm2.web/mongoose-models"; import mongoose from "mongoose"; import pm2 from "pm2"; -import processInfo from "../utils/processInfo.js"; +import processInfo from "../utils/process-info.js"; -const onChange = async (serverId: string) => { +const onChange = (serverId: string) => { console.log(`[STREAM] Listening for changes on server ${serverId}`); const filter = [ { diff --git a/apps/backend/handlers/updateData.ts b/apps/backend/handlers/update-data.ts similarity index 96% rename from apps/backend/handlers/updateData.ts rename to apps/backend/handlers/update-data.ts index f53151c..8bc49d7 100644 --- a/apps/backend/handlers/updateData.ts +++ b/apps/backend/handlers/update-data.ts @@ -2,8 +2,8 @@ import { processModel, serverModel, statModel } from "@pm2.web/mongoose-models"; import { ISettingModel } from "@pm2.web/typings"; import { QueuedLog, UpdateDataResponse } from "../types/handler.js"; -import processInfo from "../utils/processInfo.js"; -import serverInfo from "../utils/serverInfo.js"; +import processInfo from "../utils/process-info.js"; +import serverInfo from "../utils/server-info.js"; export default async function updateData( queuedLogs: QueuedLog[], @@ -17,7 +17,7 @@ export default async function updateData( if (currentServer) { currentServer.name = server.name; currentServer.heartbeat = Date.now(); - currentServer.save(); + await currentServer.save(); } else { // create server const newServer = new serverModel({ diff --git a/apps/backend/index.ts b/apps/backend/index.ts index 5ec9c0a..6aa0cf0 100644 --- a/apps/backend/index.ts +++ b/apps/backend/index.ts @@ -1,10 +1,10 @@ import * as dotenv from "dotenv"; -import LogCapture from "./handlers/captureLogs.js"; -import connectDb from "./handlers/connectDb.js"; -import onChange from "./handlers/onChange.js"; -import updateData from "./handlers/updateData.js"; -import { getCachedSettings } from "./utils/cachedSettings.js"; +import LogCapture from "./handlers/capture-logs.js"; +import connectDb from "./handlers/connect-db.js"; +import onChange from "./handlers/on-change.js"; +import updateData from "./handlers/update-data.js"; +import { getCachedSettings } from "./utils/cached-settings.js"; dotenv.config(); @@ -13,9 +13,10 @@ const logCapture = new LogCapture(); async function createInterval() { const { polling } = await getCachedSettings(); + // eslint-disable-next-line @typescript-eslint/no-misused-promises const interval = setInterval(async () => { - await updateData(logCapture.clear(), await getCachedSettings()); const settings = await getCachedSettings(); + await updateData(logCapture.clear(), settings); // if polling changed clear interval and create new one if (settings.polling.backend !== polling.backend) { clearInterval(interval); @@ -32,6 +33,6 @@ async function main() { } // eslint-disable-next-line unicorn/prefer-top-level-await -(async () => { +void (async () => { await main(); })(); diff --git a/apps/backend/package.json b/apps/backend/package.json index d96c14c..454b627 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -7,6 +7,7 @@ "name": "oxdev03" }, "private": true, + "type": "module", "repository": { "type": "git", "url": "https://github.com/oxdev03/pm2.web" @@ -32,15 +33,14 @@ "@pm2.web/typings": "*", "bcrypt": "^5.1.1", "bytes-iec": "^3.1.1", - "dotenv": "^16.4.5", - "pm2": "^5.4.2", - "systeminformation": "^5.23.5" + "dotenv": "^16.4.7", + "pm2": "^5.4.3", + "systeminformation": "^5.23.14" }, "devDependencies": { "@pm2.web/eslint-config": "*", "@pm2.web/typescript-config": "*", "@types/bcrypt": "^5.0.2", - "eslint": "^8.57.1", "typescript": "^5.6.2" } } diff --git a/apps/backend/types/handler.ts b/apps/backend/types/handler.ts index ccafa98..80aee5d 100644 --- a/apps/backend/types/handler.ts +++ b/apps/backend/types/handler.ts @@ -1,6 +1,6 @@ import { IServerModel } from "@pm2.web/typings"; -import { IProcessInfo } from "./info"; +import type { IProcessInfo } from "./info.ts"; interface QueuedLog { id: number; diff --git a/apps/backend/utils/cachedSettings.ts b/apps/backend/utils/cached-settings.ts similarity index 100% rename from apps/backend/utils/cachedSettings.ts rename to apps/backend/utils/cached-settings.ts diff --git a/apps/backend/utils/censorMessage.ts b/apps/backend/utils/censor-message.ts similarity index 100% rename from apps/backend/utils/censorMessage.ts rename to apps/backend/utils/censor-message.ts diff --git a/apps/backend/utils/processInfo.ts b/apps/backend/utils/process-info.ts similarity index 93% rename from apps/backend/utils/processInfo.ts rename to apps/backend/utils/process-info.ts index 911b8d4..fd5d04a 100644 --- a/apps/backend/utils/processInfo.ts +++ b/apps/backend/utils/process-info.ts @@ -2,8 +2,8 @@ import { IProcessType, PROCESS_TYPES } from "@pm2.web/typings"; import bytes from "bytes-iec"; import pm2 from "pm2"; -import { IProcessInfo } from "../types/info"; -import { Pm2ProcessDescription } from "../types/pm2"; +import type { IProcessInfo } from "../types/info.js"; +import type { Pm2ProcessDescription } from "../types/pm2.js"; const getProcessInfo = async (): Promise => { const pm2List = await new Promise( diff --git a/apps/backend/utils/serverInfo.ts b/apps/backend/utils/server-info.ts similarity index 83% rename from apps/backend/utils/serverInfo.ts rename to apps/backend/utils/server-info.ts index f6a926f..5b0e5a2 100644 --- a/apps/backend/utils/serverInfo.ts +++ b/apps/backend/utils/server-info.ts @@ -1,7 +1,7 @@ /* eslint-disable unicorn/no-await-expression-member */ import si from "systeminformation"; -import { IServerInfo } from "../types/info"; +import type { IServerInfo } from "../types/info.js"; export default async function getServerInfo(): Promise { const mem = await si.mem(); @@ -12,7 +12,7 @@ export default async function getServerInfo(): Promise { cpu: (await si.currentLoad())?.currentLoad ?? 0, memory: mem?.used ?? 0, memoryMax: mem?.total ?? 0, - uptime: ((await si.time())?.uptime || 0) * 1000, + uptime: si.time().uptime * 1000, }, heartbeatAt: Date.now(), }; diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index f15815a..b4d7708 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -1,3 +1,3 @@ -NEXTAUTH_SECRET=Generate using openssl rand -base64 32 or https://generate-secret.vercel.app/32 +AUTH_SECRET=Generate using openssl rand -base64 32 or https://generate-secret.vercel.app/32 DB_URI=mongodb+srv://connecturi NEXTAUTH_URL=http://localhost:3000 \ No newline at end of file diff --git a/apps/dashboard/.env.production b/apps/dashboard/.env.production index 5853771..d8e2b76 100644 --- a/apps/dashboard/.env.production +++ b/apps/dashboard/.env.production @@ -1,3 +1,3 @@ -NEXTAUTH_SECRET=6ed05dbc1250ca97807099afd120dabe +AUTH_SECRET=6ed05dbc1250ca97807099afd120dabe DB_URI=mongodb://127.0.0.1:20583/test NEXTAUTH_URL=http://localhost:3000 \ No newline at end of file diff --git a/apps/dashboard/.env.test b/apps/dashboard/.env.test index 5853771..d8e2b76 100644 --- a/apps/dashboard/.env.test +++ b/apps/dashboard/.env.test @@ -1,3 +1,3 @@ -NEXTAUTH_SECRET=6ed05dbc1250ca97807099afd120dabe +AUTH_SECRET=6ed05dbc1250ca97807099afd120dabe DB_URI=mongodb://127.0.0.1:20583/test NEXTAUTH_URL=http://localhost:3000 \ No newline at end of file diff --git a/apps/dashboard/.eslintrc.js b/apps/dashboard/.eslintrc.js deleted file mode 100644 index 19ecb99..0000000 --- a/apps/dashboard/.eslintrc.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @type {import('eslint').Linter.Config} - */ -module.exports = { - root: true, - extends: ["next", "plugin:@typescript-eslint/recommended", "plugin:unicorn/recommended", "prettier"], - plugins: ["simple-import-sort", "import"], - ignorePatterns: ["node_modules", "dist"], - rules: { - "simple-import-sort/imports": "error", - "simple-import-sort/exports": "error", - "import/first": "error", - "import/newline-after-import": "error", - "import/no-duplicates": "error", - "unicorn/prevent-abbreviations": "off", - "unicorn/catch-error-name": "off", - "unicorn/no-null": "off", - "unicorn/prefer-module": "off", - "unicorn/filename-case": [ - "error", - { - cases: { - kebabCase: true, - pascalCase: true, - }, - }, - ], - }, - parserOptions: { - babelOptions: { - presets: [require.resolve("next/babel")], - }, - }, -}; diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 4e49306..38b37f2 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -6,7 +6,7 @@ The Dashboard is a Next.js application built on the t3 stack, utilizing trpc for ### Prerequisites -- Node v18 +- Node >= v20 - MongoDB Cluster (required for Restart/Shutdown/Delete functionality) / MongoDB Atlas - Open Port 3000 or 80,443 (if you use a reverse proxy) diff --git a/apps/dashboard/pages/index.tsx b/apps/dashboard/app/_components/Overview.tsx similarity index 64% rename from apps/dashboard/pages/index.tsx rename to apps/dashboard/app/_components/Overview.tsx index 2857214..84e41a8 100644 --- a/apps/dashboard/pages/index.tsx +++ b/apps/dashboard/app/_components/Overview.tsx @@ -1,18 +1,15 @@ +"use client"; + import { AreaChart, DonutChart } from "@mantine/charts"; import { Flex, Paper, SimpleGrid } from "@mantine/core"; -import { ISetting } from "@pm2.web/typings"; import ms from "ms"; -import { InferGetServerSidePropsType } from "next"; -import Head from "next/head"; -import { SelectedProvider, useSelected } from "@/components/context/SelectedProvider"; +import { useSelected } from "@/components/context/SelectedProvider"; import DashboardLog from "@/components/dashboard/DashboardLog"; -import { Dashboard } from "@/components/layouts/Dashboard"; import { StatsRing } from "@/components/stats/StatsRing"; -import { getServerSideHelpers } from "@/server/helpers"; import classes from "@/styles/index.module.css"; +import { api } from "@/trpc/react"; import { formatBytes } from "@/utils/format"; -import { trpc } from "@/utils/trpc"; const statChartProps = { h: "120px", @@ -25,20 +22,20 @@ const statChartProps = { connectNulls: true, }; -function Home({ settings }: { settings: ISetting }) { - const { selectedServers, selectedProcesses } = useSelected(); - const { data } = trpc.server.getStats.useQuery( +export default function Overview() { + const { selectedServers, selectedProcesses, settings } = useSelected(); + const { data } = api.server.getStats.useQuery( { processIds: selectedProcesses.map((p) => p._id), serverIds: selectedServers.map((p) => p._id), - polling: settings.polling.backend / 1000, + polling: settings!.polling.backend / 1000, }, { - refetchInterval: settings.polling.frontend, + refetchInterval: settings!.polling.frontend, }, ); - const chartData = data?.stats?.map((e) => ({ ...e, date: new Date(e._id).toLocaleTimeString() })) || []; + const chartData = data?.stats?.map((e) => ({ ...e, date: new Date(e._id || 0).toLocaleTimeString() })) || []; const onlineCount = selectedProcesses.filter((p) => p.status == "online").length; const stoppedCount = selectedProcesses.filter((p) => p.status == "stopped").length; @@ -113,46 +110,7 @@ function Home({ settings }: { settings: ISetting }) { - p._id)} /> + p._id)} /> ); } - -export default function HomePage({}: InferGetServerSidePropsType) { - const dashboardQuery = trpc.server.getDashBoardData.useQuery(undefined, { - refetchInterval: 5000, - }); - const data = dashboardQuery.data!; - - if (dashboardQuery.status !== "success") { - return <>; - } - - return ( - <> - - pm2.web - - - - - - - - - - - ); -} - -export async function getServerSideProps() { - const helpers = await getServerSideHelpers(); - - await helpers.server.getDashBoardData.prefetch(); - - return { - props: { - trpcState: helpers.dehydrate(), - }, - }; -} diff --git a/apps/dashboard/app/api/auth/[...nextauth]/route.ts b/apps/dashboard/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..63cd05d --- /dev/null +++ b/apps/dashboard/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/server/auth"; + +export const { GET, POST } = handlers; diff --git a/apps/dashboard/app/api/trpc/[trpc]/route.ts b/apps/dashboard/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..0aab15d --- /dev/null +++ b/apps/dashboard/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,32 @@ +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { type NextRequest } from "next/server"; + +import { env } from "@/env.js"; +import { appRouter } from "@/server/api/root"; +import { createTRPCContext } from "@/server/api/trpc"; + +/** + * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when + * handling a HTTP request (e.g. when you make requests from Client Components). + */ +const createContext = async (req: NextRequest) => { + return createTRPCContext({ + headers: req.headers, + }); +}; + +const handler = (req: NextRequest) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req, + router: appRouter, + createContext: () => createContext(req), + onError: + env.NODE_ENV === "development" + ? ({ path, error }) => { + console.error(`❌ tRPC failed on ${path ?? ""}: ${error.message}`); + } + : undefined, + }); + +export { handler as GET, handler as POST }; diff --git a/apps/dashboard/app/layout.tsx b/apps/dashboard/app/layout.tsx new file mode 100644 index 0000000..8560397 --- /dev/null +++ b/apps/dashboard/app/layout.tsx @@ -0,0 +1,39 @@ +import "@mantine/core/styles.css"; +import "@mantine/notifications/styles.css"; +import "@mantine/charts/styles.css"; + +import { ColorSchemeScript, MantineProvider } from "@mantine/core"; +import { Notifications } from "@mantine/notifications"; +import { Metadata } from "next"; +import React from "react"; + +import { TRPCReactProvider } from "@/trpc/react"; + +import { theme } from "../theme"; + +export const metadata: Metadata = { + title: "pm2.web", + description: + "pm2.web - Easily monitor your processes, control them with various actions, view logs and set up access controls for users using the dashboard", + icons: [{ rel: "icon", url: "/logo.png" }], +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + + + + + + + + + + {children} + + + + + ); +} diff --git a/apps/dashboard/app/login/_components/AuthenticationForm.tsx b/apps/dashboard/app/login/_components/AuthenticationForm.tsx new file mode 100644 index 0000000..3f6290c --- /dev/null +++ b/apps/dashboard/app/login/_components/AuthenticationForm.tsx @@ -0,0 +1,186 @@ +"use client"; +import { + Alert, + Anchor, + Button, + Center, + Checkbox, + Divider, + Group, + Input, + Paper, + PasswordInput, + PinInput, + Stack, + Text, + TextInput, + Tooltip, + Transition, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { upperFirst, useToggle } from "@mantine/hooks"; +import { useRouter, useSearchParams } from "next/navigation"; +import { signIn } from "next-auth/react"; +import { useState } from "react"; + +import { GithubIcon } from "@/components/icons/github"; +import { GoogleIcon } from "@/components/icons/google"; +import { AuthErrorMessages, AuthErrors } from "@/utils/auth-errors"; + +export default function AuthenticationForm({ registrationCodeRequired }: { registrationCodeRequired: boolean }) { + const [type, toggle] = useToggle(["login", "register"]); + const [authLoading, setAuthLoading] = useState(false); + const form = useForm({ + initialValues: { + email: "", + name: "", + password: "", + terms: false, + registrationCode: "", + }, + + validate: { + email: (val) => (/^\S+@\S+$/.test(val) ? null : "Invalid email"), + password: (val) => (val.length <= 6 ? "Password should include at least 6 characters" : null), + registrationCode: (val) => + registrationCodeRequired && !val && type == "register" ? "Registration code is required" : null, + terms: (val) => (!val && type == "register" ? "You need to accept terms and conditions" : null), + }, + }); + const router = useRouter(); + const searchParams = useSearchParams(); + const [error, callbackUrl] = [searchParams.get("error"), searchParams.get("callbackUrl") || "/"]; + + return ( +
+
+ + + Welcome to pm2.web, {type} with + + + {type !== "register" && ( + <> + + {process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID && ( + + )} + {process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID && ( + + + + )} + + + +
+
+ ); +} diff --git a/apps/dashboard/app/login/page.tsx b/apps/dashboard/app/login/page.tsx new file mode 100644 index 0000000..e2ea9df --- /dev/null +++ b/apps/dashboard/app/login/page.tsx @@ -0,0 +1,13 @@ +import { api, HydrateClient } from "@/trpc/server"; + +import AuthenticationForm from "./_components/AuthenticationForm"; + +export default async function LoginPage() { + const registrationCodeRequired = await api.setting.registrationCodeRequired(); + + return ( + + + + ); +} diff --git a/apps/dashboard/app/page.tsx b/apps/dashboard/app/page.tsx new file mode 100644 index 0000000..f5c589a --- /dev/null +++ b/apps/dashboard/app/page.tsx @@ -0,0 +1,15 @@ +import { SelectedProvider } from "@/components/context/SelectedProvider"; +import { DashboardLayout } from "@/components/layouts/DashboardLayout"; +import { api } from "@/trpc/server"; + +import Home from "./_components/Overview"; + +export default function HomePage() { + void api.server.getDashBoardData.prefetch(); + + return ( + + + + ); +} diff --git a/apps/dashboard/app/process/_components/ProcessList.tsx b/apps/dashboard/app/process/_components/ProcessList.tsx new file mode 100644 index 0000000..c8afb9e --- /dev/null +++ b/apps/dashboard/app/process/_components/ProcessList.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { Flex } from "@mantine/core"; + +import { useSelected } from "@/components/context/SelectedProvider"; +import ProcessItem from "@/components/process/ProcessItem"; + +export default function ProcessList() { + const { selectedProcesses, settings } = useSelected(); + + return ( + + {selectedProcesses?.map((process) => )} + + ); +} diff --git a/apps/dashboard/app/process/page.tsx b/apps/dashboard/app/process/page.tsx new file mode 100644 index 0000000..6ec6aa0 --- /dev/null +++ b/apps/dashboard/app/process/page.tsx @@ -0,0 +1,15 @@ +import { SelectedProvider } from "@/components/context/SelectedProvider"; +import { DashboardLayout } from "@/components/layouts/DashboardLayout"; +import { api } from "@/trpc/server"; + +import ProcessList from "./_components/ProcessList"; + +export default function ProcessPage() { + void api.server.getDashBoardData.prefetch(); + + return ( + + + + ); +} diff --git a/apps/dashboard/app/settings/_components/Settings.tsx b/apps/dashboard/app/settings/_components/Settings.tsx new file mode 100644 index 0000000..6f71921 --- /dev/null +++ b/apps/dashboard/app/settings/_components/Settings.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Accordion, Badge, Grid, Overlay, Paper, ScrollArea, Title } from "@mantine/core"; +import { useSession } from "next-auth/react"; + +import DatabaseAction from "@/components/settings/DatabaseAction"; +import DeleteAccount from "@/components/settings/DeleteAccount"; +import UnlinkOAuth2 from "@/components/settings/UnlinkOAuth2"; +import UpdateConfiguration from "@/components/settings/UpdateConfiguration"; +import UpdatePassword from "@/components/settings/UpdatePassword"; +import { api } from "@/trpc/react"; + +export default function Settings() { + const { data: session } = useSession(); + const [settings, settingsQuery] = api.setting.getSettings.useSuspenseQuery(); + const hasPermission = session?.user?.acl?.owner || session?.user?.acl?.admin; + const isOAuth2 = !!session?.user?.oauth2?.provider; + + if (settingsQuery.isError) { + // TODO: add proper error handling + } + + return ( + + + + + Configuration + + + + + + + {!hasPermission && ( + + + Owner/Admin Permission required + + + )} + + + + + + + User Settings + + + + + {isOAuth2 && } + + + + + ); +} diff --git a/apps/dashboard/app/settings/page.tsx b/apps/dashboard/app/settings/page.tsx new file mode 100644 index 0000000..50913aa --- /dev/null +++ b/apps/dashboard/app/settings/page.tsx @@ -0,0 +1,14 @@ +import { DashboardLayout } from "@/components/layouts/DashboardLayout"; +import { api } from "@/trpc/server"; + +import Settings from "./_components/Settings"; + +export default function SettingsPage() { + void api.setting.getSettings.prefetch(); + + return ( + + + + ); +} diff --git a/apps/dashboard/app/user/_components/UserAdministration.tsx b/apps/dashboard/app/user/_components/UserAdministration.tsx new file mode 100644 index 0000000..f97c2f1 --- /dev/null +++ b/apps/dashboard/app/user/_components/UserAdministration.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { + Accordion, + Badge, + Box, + Button, + Divider, + Flex, + Grid, + Overlay, + Paper, + rem, + ScrollArea, + Title, + Transition, +} from "@mantine/core"; +import { IAclServer } from "@pm2.web/typings"; +import { IconCircleFilled, IconDeviceFloppy } from "@tabler/icons-react"; +import React, { useEffect, useState } from "react"; + +import { CustomMultiSelect } from "@/components/misc/MultiSelect/CustomMultiSelect"; +import UserManagement from "@/components/user/UserManagement"; +import { permissionData, PillComponent, SelectItemComponent } from "@/components/user/UserMultiSelectHelper"; +import classes from "@/styles/user.module.css"; +import { api } from "@/trpc/react"; +import { actionNotification } from "@/utils/notification"; +import { IPermissionConstants, Permission, PERMISSIONS } from "@/utils/permission"; + +export default function UserAdministration() { + const dashboardQuery = api.server.getDashBoardData.useQuery(true); + const usersQuery = api.user.getUsers.useQuery(); + const servers = dashboardQuery.data?.servers || []; + const users = usersQuery.data || []; + + const [selection, setSelection] = useState([]); + const [perms, setPerms] = useState( + servers.map((server) => ({ + server: server._id, + processes: server.processes.map((process) => ({ + process: process._id, + perms: 0, + })), + perms: 0, + })), + ); + + const updatePerms = api.user.setCustomPermission.useMutation({ + onMutate() { + actionNotification(`update-perms`, "Updating permissions", "Please wait...", "pending"); + }, + onError(error) { + actionNotification(`update-perms`, "Failed to update permissions", error.message, "error"); + }, + onSuccess(data) { + actionNotification(`update-perms`, "Permissions updated", data, "success"); + void usersQuery.refetch(); + }, + }); + + const updatePermsState = (server_id: string, process_id: string, new_perms: string[]) => { + const newPerms = [...perms]; + const serverIndex = newPerms.findIndex((x) => x.server == server_id); + if (serverIndex !== -1 && newPerms[serverIndex]) { + const server = newPerms[serverIndex]; + if (process_id) { + const processIndex = server.processes.findIndex((x) => x.process == process_id); + if (processIndex !== -1 && server.processes[processIndex]) { + server.processes[processIndex].perms = new Permission().add( + ...new_perms.map((x) => PERMISSIONS[x as keyof IPermissionConstants]), + ).value; + } + } else { + server.perms = new Permission().add( + ...new_perms.map((x) => PERMISSIONS[x as keyof IPermissionConstants]), + ).value; + // process should inherit server perms , if server perms is changed + server.processes = newPerms[serverIndex].processes.map((process) => ({ + ...process, + perms: new Permission().add(...new_perms.map((x) => PERMISSIONS[x as keyof IPermissionConstants])).value, + })); + } + } + setPerms(newPerms); + }; + + const getSelectedPerms = (server_id: string, process_id?: string) => { + const server = perms.find((x) => x.server == server_id); + if (server) { + if (process_id) { + const process = server.processes.find((x) => x.process == process_id); + if (process) { + return new Permission(process.perms).toArray(); + } + } else { + return new Permission(server.perms).toArray(); + } + } + return []; + }; + + useEffect(() => { + if (perms.length === 0) return; + const selectedUsers = users.filter((x) => selection.includes(x._id)); + const newPerms = [...perms]; + + for (const perm of newPerms) { + perm.perms = new Permission().add( + ...Permission.common( + ...selectedUsers.map((x) => x.acl.servers.find((y) => y.server == perm.server)?.perms ?? 0), + ), + ).value; + perm.processes = perm.processes.map((process) => ({ + ...process, + perms: new Permission().add( + ...Permission.common( + ...selectedUsers.map( + (x) => + x.acl.servers.find((y) => y.server == perm.server)?.processes.find((z) => z.process == process.process) + ?.perms ?? perm.perms, + ), + ), + ).value, + })); + } + + setPerms(newPerms); + }, [selection]); + + return ( + + void usersQuery.refetch()} + users={users} + selection={selection} + setSelection={setSelection} + /> + + + + + Custom Permissions + + + + + {servers.map((item) => ( + + + + + Date.now() - 1000 * 60 * 4 + ? "#12B886" + : "#FA5252", + marginTop: "2.5px", + }} + /> + {item.name} + + + updatePermsState(item._id, "", values)} + data={permissionData} + itemComponent={SelectItemComponent} + pillComponent={PillComponent} + placeholder="Select Permissions" + variant="filled" + radius={"md"} + size="sm" + w={{ + sm: "24rem", + }} + pl={{ + base: "3rem", + sm: "unset", + }} + /> + + + {item.processes?.map((process) => ( +
+ + + + + {process.name} + + updatePermsState(item._id, process._id, values)} + variant="filled" + radius={"sm"} + size="xs" + w="14rem" + /> + + + +
+ ))} +
+
+ ))} +
+ + {(styles) => ( + + + Select a User First + + + )} + +
+ + + +
+
+
+
+
+ ); +} diff --git a/apps/dashboard/app/user/page.tsx b/apps/dashboard/app/user/page.tsx new file mode 100644 index 0000000..7a8f96f --- /dev/null +++ b/apps/dashboard/app/user/page.tsx @@ -0,0 +1,15 @@ +import { DashboardLayout } from "@/components/layouts/DashboardLayout"; +import { api } from "@/trpc/server"; + +import UserAdministration from "./_components/UserAdministration"; + +export default function UserAdministrationPage() { + void api.server.getDashBoardData.prefetch(); + void api.user.getUsers.prefetch(); + + return ( + + + + ); +} diff --git a/apps/dashboard/components/context/AuthContext.tsx b/apps/dashboard/components/context/AuthContext.tsx new file mode 100644 index 0000000..f42b8e5 --- /dev/null +++ b/apps/dashboard/components/context/AuthContext.tsx @@ -0,0 +1,14 @@ +import { Session } from "next-auth"; +import { SessionProvider } from "next-auth/react"; + +import { auth } from "@/server/auth"; + +export interface AuthContextProps { + session?: Session | null; + children: React.ReactNode; +} + +export default async function AuthContext({ children, session }: AuthContextProps) { + const serverSession = session || (await auth()); + return {children}; +} diff --git a/apps/dashboard/components/context/SelectedProvider.tsx b/apps/dashboard/components/context/SelectedProvider.tsx index 46b941a..e28b327 100644 --- a/apps/dashboard/components/context/SelectedProvider.tsx +++ b/apps/dashboard/components/context/SelectedProvider.tsx @@ -1,7 +1,10 @@ -import { IProcess, IServer } from "@pm2.web/typings"; +"use client"; + +import { IProcess, IServer, ISetting } from "@pm2.web/typings"; import { useSession } from "next-auth/react"; import { createContext, useContext, useState } from "react"; +import { api } from "@/trpc/react"; import { SelectItem, StateSelectedItem } from "@/types/context"; import Access from "@/utils/access"; import { IPermissionConstants, PERMISSIONS } from "@/utils/permission"; @@ -12,6 +15,7 @@ interface SelectedContextType { selectedProcesses: IProcess[]; selectedServers: IServer[]; servers: IServer[]; + settings: ISetting | undefined; } const SelectedContext = createContext({ @@ -23,13 +27,21 @@ const SelectedContext = createContext({ selectedProcesses: [], selectedServers: [], servers: [], + settings: undefined, }); // pass null as initial value export function useSelected() { return useContext(SelectedContext); } -export function SelectedProvider({ children, servers }: { children: React.ReactNode; servers: IServer[] }) { +export function SelectedProvider({ children }: { children: React.ReactNode }) { + const [{ servers, settings }] = api.server.getDashBoardData.useSuspenseQuery(undefined, { + refetchInterval: (query) => { + const data = query.state.data; + const polling = data?.settings?.polling?.frontend || 0; + return Math.min(Math.max(polling, 4000), 10_000); + }, + }); const { data: session } = useSession(); const [selectedItem, setSelectedItem] = useState({ servers: [], @@ -84,7 +96,9 @@ export function SelectedProvider({ children, servers }: { children: React.ReactN ); return ( - + {children} ); diff --git a/apps/dashboard/components/dashboard/DashboardLog.tsx b/apps/dashboard/components/dashboard/DashboardLog.tsx index 2b5f2a7..f8486a6 100644 --- a/apps/dashboard/components/dashboard/DashboardLog.tsx +++ b/apps/dashboard/components/dashboard/DashboardLog.tsx @@ -2,7 +2,7 @@ import { Flex, Paper, ScrollArea, Text } from "@mantine/core"; import { IconList } from "@tabler/icons-react"; import { useRef } from "react"; -import { trpc } from "@/utils/trpc"; +import { api } from "@/trpc/react"; interface DashboardLogProps { refetchInterval: number; @@ -11,7 +11,7 @@ interface DashboardLogProps { export default function DashboardLog({ refetchInterval, processIds }: DashboardLogProps) { const scrollViewport = useRef(null); - const { data } = trpc.server.getLogs.useQuery( + const { data } = api.server.getLogs.useQuery( { processIds }, { refetchInterval: refetchInterval, diff --git a/apps/dashboard/components/layouts/Dashboard.tsx b/apps/dashboard/components/layouts/Dashboard.tsx deleted file mode 100644 index eb94ea1..0000000 --- a/apps/dashboard/components/layouts/Dashboard.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { AppShell } from "@mantine/core"; -import { ReactNode } from "react"; - -import { Head } from "../partials/Head"; -import { Nav } from "../partials/Nav"; -import classes from "./Dashboard.module.css"; - -export function Dashboard({ children }: { children: ReactNode }) { - return ( - <> - - -