fantasy-manager/pages/admin.tsx
Lukas Schaefer 98ac04951e
Fix colors for plugin table in admin page (#953)
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
2026-06-12 11:58:39 +00:00

980 lines
28 KiB
TypeScript

import Menu from "../components/Menu";
import { GetServerSideProps, GetServerSidePropsContext } from "next";
import Head from "next/head.js";
import db from "../Modules/database";
import { analytics, data, plugins } from "#type/database";
import React, { useContext, useEffect, useState } from "react";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Filler,
Legend,
} from "chart.js";
import { Line } from "react-chartjs-2";
import {
Button,
Checkbox,
FormControlLabel,
FormGroup,
InputLabel,
MenuItem,
Select,
Slider,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
useTheme,
} from "@mui/material";
import { compareSemanticVersions } from "../Modules/semantic";
import store from "#/types/store";
import { NotifyContext } from "#Modules/context";
import { getServerSession } from "next-auth";
import { authOptions } from "#/pages/api/auth/[...nextauth]";
import { useRouter } from "next/router";
import Link from "#components/Link";
import { MUIThemeCodetoJSONString } from "#components/theme";
import { sql } from "kysely";
interface settingsType {
name: string;
shortName: string;
variant: string;
options?: string[];
}
export const settings: settingsType[] = [
{
name: "Minimum Time Before Updating during games",
shortName: "MinTimeGame",
variant: "number",
},
{
name: "Maximum Time Before Updating during games",
shortName: "MaxTimeGame",
variant: "number",
},
{
name: "Minimum Time Before Updating during no games",
shortName: "MinTimeTransfer",
variant: "number",
},
{
name: "Maximum Time Before Updating during no games",
shortName: "MaxTimeTransfer",
variant: "number",
},
{
name: "Days of inactivity until a league is archived",
shortName: "ArchiveInactiveLeague",
variant: "number",
},
{
name: "Days of inactivity until a user is deleted",
shortName: "DeleteInactiveUser",
variant: "number",
},
{
name: "Enable Signups with passwords",
shortName: "EnablePasswordSignup",
variant: "boolean",
},
];
interface LeaguePluginsProps {
plugins: plugins[];
pluginData: (store | "error")[];
version: string;
}
function LeaguePlugins({ plugins, pluginData, version }: LeaguePluginsProps) {
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Description</TableCell>
<TableCell>Link</TableCell>
<TableCell>Enabled</TableCell>
<TableCell>Installed</TableCell>
<TableCell>Settings</TableCell>
</TableRow>
</TableHead>
<TableBody>
{plugins.map((plugin, idx) => (
<LeaguePlugin
key={idx}
data={plugin}
store={pluginData[idx]}
version={version}
/>
))}
</TableBody>
</Table>
</TableContainer>
);
}
interface LeaguePluginProps {
data: plugins;
store: store | "error";
version: string;
}
function LeaguePlugin({ data, store, version }: LeaguePluginProps) {
const [deleted, setDeleted] = useState(false);
const notify = useContext(NotifyContext);
const [enabled, setEnabled] = useState(data.enabled);
const [settings, setSettings] = useState<{ [Key: string]: string }>(
JSON.parse(data.settings),
);
const checkboxChange = (_: unknown, checked: boolean) => {
setEnabled(checked);
};
const changeSettings = (value: string, name: string) => {
setSettings((prev) => {
prev = { ...prev, [name]: value };
return prev;
});
};
// Used to save the preferences for the plugin
const save = async () => {
const res = await fetch("/api/admin/plugins", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url: data.url,
enabled,
settings: JSON.stringify(settings),
}),
});
notify(await res.text(), res.ok ? "success" : "error");
};
// Used to delete the plugin
const deletePlugin = async () => {
const res = await fetch(
`/api/admin/plugins?url=${encodeURIComponent(data.url)}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
},
);
notify(await res.text(), res.ok ? "success" : "error");
if (res.ok) {
setDeleted(true);
}
};
let installedText = <></>;
// Gets the text for the installed column
if (data.installed) {
if (store === "error") {
installedText = (
<Typography color="secondary">
Installed, but failed to Check for Update
</Typography>
);
} else if (store.version !== data.version) {
if (
store.min_version &&
compareSemanticVersions(store.min_version, version) === -1
) {
installedText = (
<Typography color="warning">
Installed but unsupported(Update Fantasy Manger to Update)
</Typography>
);
} else {
installedText = (
<Typography color="warning">Installed but out of Date</Typography>
);
}
} else {
installedText = <Typography color="success">Installed</Typography>;
}
} else {
if (store === "error") {
installedText = (
<Typography color="secondary">
Uninstalled and not accessible
</Typography>
);
} else if (
store.min_version &&
compareSemanticVersions(store.min_version, version) === -1
) {
installedText = (
<Typography color="warning">
Unsupported (Update Fantasy Manger to Install)
</Typography>
);
} else {
installedText = (
<Typography color="error">
Not Installed(Restart Server to Install)
</Typography>
);
}
}
if (deleted) return <></>;
return (
<TableRow>
<TableCell>{data.name}</TableCell>
<TableCell>
{store !== "error" && store.description
? store.description
: "No Description"}
</TableCell>
<TableCell>{data.url}</TableCell>
<TableCell>
{enabled ? (
<Typography color="success">Enabled</Typography>
) : (
<Typography color="error">Disabled</Typography>
)}
</TableCell>
<TableCell>{installedText}</TableCell>
<TableCell>
<FormGroup>
<FormControlLabel
control={
<Checkbox checked={Boolean(enabled)} onChange={checkboxChange} />
}
label="Enabled"
/>
{store !== "error" &&
/* Creates a form with all the inputs needed for this plugin*/
store.input &&
store.input.map((input) => {
return (
<TextField
key={input.name}
label={input.name}
helperText={input.description}
value={
Object.keys(settings).includes(input.name)
? settings[input.name]
: ""
}
onChange={(e) => {
changeSettings(e.target.value, input.name);
}}
></TextField>
);
})}
<Button variant="outlined" color="success" onClick={save}>
Save
</Button>
<br />
<Button variant="outlined" color="error" onClick={deletePlugin}>
Delete
</Button>
</FormGroup>
</TableCell>
</TableRow>
);
}
function Analytics({
analytics,
firstDay,
}: {
analytics: analytics[];
firstDay: number;
}) {
const [graphLength, setGraphLength] = useState(
analytics.length > 28 ? 28 : analytics.length,
);
const [graphPrecision, setGraphPrecision] = useState(30);
const [analyticsData, setAnalyticsData] = useState(analytics);
const theme = useTheme();
const dark = theme.palette.mode === "dark";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Filler,
Legend,
);
const options = {
maintainAspectRatio: false,
responsive: true,
normalized: true,
scales: {
x: {
title: {
display: true,
text: "Date",
},
},
y: {
stacked: true,
title: {
display: true,
text: "Users",
},
},
},
};
// List of all the versions
const versions: string[] = Object.keys(
JSON.parse(analyticsData[analyticsData.length - 1]?.versionTotal || "{}"),
);
const leagueList: string[] = Object.keys(
JSON.parse(analyticsData[analyticsData.length - 1]?.leagueTotal || "{}"),
);
const locales: string[] = Object.keys(
JSON.parse(analyticsData[analyticsData.length - 1]?.localeTotal || "{}"),
);
// Calculates colors for things
const calculateColor = (idx: number, length: number) => {
length = length + 1;
return ((length - idx) * 360) / length - 120;
};
// Calculates the hue for an id for the version number
const calculateVersionColor = (idx: number) => {
return calculateColor(idx, 14);
};
versions.sort((a, b) => compareSemanticVersions(a, b));
const slicedAnalytics = analyticsData.slice(graphLength * -1);
const amountBetween = Math.ceil(slicedAnalytics.length / graphPrecision);
let remainder = (slicedAnalytics.length % amountBetween) - 1;
if (remainder === -1) remainder = amountBetween - 1;
const condensedAnalytics =
slicedAnalytics.length > graphPrecision
? slicedAnalytics.filter((_, idx) => idx % amountBetween === remainder)
: slicedAnalytics;
const labels = condensedAnalytics.map((e) => {
const date = new Date(e.day * 3600 * 24 * 1000);
return date.toDateString();
});
const condensed_versions: Set<string> = new Set();
const condensedAnalyticsParsed = condensedAnalytics.map((e) => {
const versionTotal = JSON.parse(e.versionTotal);
Object.keys(versionTotal).forEach((key) => {
if (versionTotal[key] > 0) condensed_versions.add(key);
});
return {
day: e.day,
versionActive: JSON.parse(e.versionActive),
versionTotal,
leagueActive: JSON.parse(e.leagueActive),
leagueTotal: JSON.parse(e.leagueTotal),
localeActive: JSON.parse(e.localeActive),
localeTotal: JSON.parse(e.localeTotal),
themeActive: JSON.parse(e.themeActive),
themeTotal: JSON.parse(e.themeTotal),
};
});
const condensedVersions = Array.from(condensed_versions);
const versionData = {
labels,
datasets: [
...condensedVersions.map((version, idx) => {
return {
fill: true,
label: version + " Active",
data: condensedAnalyticsParsed.map(
(e) => e.versionActive[version] ?? 0,
),
borderColor: `hsla(${calculateVersionColor(idx)}, 100%, 50%, 1)`,
backgroundColor: `hsla(${calculateVersionColor(idx)}, 100%, 50%, 1)`,
};
}),
...condensedVersions.map((version, idx) => {
return {
fill: false,
label: version + " Inactive",
data: condensedAnalytics.map(
(e) =>
(JSON.parse(e.versionTotal)[version] ?? 0) -
(JSON.parse(e.versionActive)[version] ?? 0),
),
borderColor: `hsla(${calculateVersionColor(idx)}, 100%, 50%, 1)`,
backgroundColor: `hsla(${calculateVersionColor(idx)}, 100%, 50%, 0)`,
};
}),
],
};
// Data for the league graph
const leagueData = {
labels,
datasets: [
...leagueList.map((league, idx) => {
return {
fill: true,
label: league + " Active",
data: condensedAnalyticsParsed.map(
(e) => e.leagueActive[league] ?? 0,
),
borderColor: `hsla(${calculateColor(
idx,
league.length,
)}, 100%, 50%, 1)`,
backgroundColor: `hsla(${calculateColor(
idx,
league.length,
)}, 100%, 50%, 1)`,
};
}),
...leagueList.map((league, idx) => {
return {
fill: true,
label: league + " Inactive",
data: condensedAnalyticsParsed.map(
(e) => (e.leagueTotal[league] ?? 0) - (e.leagueActive[league] ?? 0),
),
borderColor: `hsla(${calculateColor(
idx,
league.length,
)}, 100%, 50%, 1)`,
backgroundColor: `hsla(${calculateColor(
idx,
league.length,
)}, 100%, 50%, 0)`,
};
}),
],
};
// Gets the locale data
const localeData = {
labels,
datasets: [
...locales.map((locale, idx) => {
return {
fill: true,
label: locale + " Active",
data: condensedAnalyticsParsed.map(
(e) => e.localeActive[locale] ?? 0,
),
borderColor: `hsla(${calculateColor(
idx,
locale.length,
)}, 100%, 50%, 1)`,
backgroundColor: `hsla(${calculateColor(
idx,
locale.length,
)}, 100%, 50%, 1)`,
};
}),
...locales.map((locale, idx) => {
return {
fill: true,
label: locale + " Inactive",
data: condensedAnalyticsParsed.map(
(e) => (e.localeTotal[locale] ?? 0) - (e.localeActive[locale] ?? 0),
),
borderColor: `hsla(${calculateColor(
idx,
locale.length,
)}, 100%, 50%, 1)`,
backgroundColor: `hsla(${calculateColor(
idx,
locale.length,
)}, 100%, 50%, 0)`,
};
}),
],
};
// Gets the theme data
const darkColor = dark ? 30 : 0;
const lightColor = dark ? 100 : 80;
// const custom
const themeData = {
labels,
datasets: [
{
fill: true,
label: "Dark Active",
data: condensedAnalyticsParsed.map((e) => e.themeActive.dark ?? 0),
borderColor: `hsla(0, 0%, ${darkColor}%, 1)`,
backgroundColor: `hsla(0, 0%, ${darkColor}%, 1)`,
},
{
fill: true,
label: "Light Active",
data: condensedAnalyticsParsed.map((e) => e.themeActive.light ?? 0),
borderColor: `hsla(120, 0%, ${lightColor}%, 1)`,
backgroundColor: `hsla(120, 0%, ${lightColor}%, 1)`,
},
{
fill: true,
label: "Custom Active",
data: condensedAnalyticsParsed.map((e) => e.themeActive.custom ?? 0),
borderColor: theme.palette.secondary.main,
backgroundColor: theme.palette.secondary.main,
},
{
fill: true,
label: "Dark Inactive",
data: condensedAnalyticsParsed.map(
(e) => (e.themeTotal.dark ?? 0) - (e.themeActive.dark ?? 0),
),
borderColor: `hsla(0, 0%, ${darkColor}%, 1)`,
backgroundColor: `hsla(0, 0%, ${darkColor}%, 0)`,
},
{
fill: true,
label: "Light Inactive",
data: condensedAnalyticsParsed.map(
(e) => (e.themeTotal.light ?? 0) - (e.themeActive.light ?? 0),
),
borderColor: `hsla(120, 0%, ${lightColor}%, 1)`,
backgroundColor: `hsla(120, 0%, ${lightColor}%, 0)`,
},
{
fill: true,
label: "Custom Inactive",
data: condensedAnalyticsParsed.map(
(e) => (e.themeTotal.custom ?? 0) - (e.themeActive.custom ?? 0),
),
borderColor: theme.palette.secondary.main,
backgroundColor: `hsla(20, 100%, 50%, 0)`,
},
],
};
// Loads analytics data
useEffect(() => {
let canceled = false;
setTimeout(async () => {
if (canceled || analyticsData.length == 0) {
return;
}
const loadUntilDay =
analyticsData[analyticsData.length - 1].day - graphLength - 10;
let firstLoadedDay = analyticsData[0].day;
while (loadUntilDay < firstLoadedDay && firstDay < firstLoadedDay) {
firstLoadedDay -= 50;
const result = await fetch(
`/api/admin/analytics?day=${firstLoadedDay}`,
);
const data: analytics[] = await result.json();
if (canceled) {
return;
}
setAnalyticsData((prev) => {
if (prev.length == analyticsData.length) {
return [...data, ...prev];
}
return prev;
});
}
}, 100);
return () => {
canceled = true;
};
}, [graphLength, analyticsData, firstDay]);
// Handles when the graph slider changes
function graphLengthChange(e: Event, value: number | number[]) {
if (typeof value === "number") {
setGraphLength(value);
}
}
function graphPrecisionChange(e: Event, value: number | number[]) {
if (typeof value === "number") {
setGraphPrecision(value);
}
}
return (
<>
<h3>Version Data</h3>
<p>
This graph shows how many users are using each (server) version. Active
users are defined as users that are active on that day.
</p>
<div style={{ height: "min(max(50vh, 50vw), 80vh)", width: "95%" }}>
<Line options={options} data={versionData} />
</div>
<h3>League Type Data</h3>
<p>
This graph shows how many users are using each league type. Note that
users are counted based on how many leagues they are in.
</p>
<div style={{ height: "min(max(50vh, 50vw), 80vh)", width: "95%" }}>
<Line options={options} data={leagueData} />
</div>
<h3>Locale Data</h3>
<p>This graph shows how many users are using which languages.</p>
<div style={{ height: "min(max(50vh, 50vw), 80vh)", width: "95%" }}>
<Line options={options} data={localeData} />
</div>
<h3>Theme Data</h3>
<p>This graph shows how many users are using dark vs light theme.</p>
<div style={{ height: "min(max(50vh, 50vw), 80vh)", width: "95%" }}>
<Line options={options} data={themeData} />
</div>
<Typography id="graph-length" gutterBottom>
Graph Data Length: {graphLength} Days
</Typography>
<div style={{ width: "95%", margin: "2%" }}>
<Slider
value={graphLength}
min={1}
step={1}
max={analyticsData[analyticsData.length - 1]?.day - firstDay}
onChange={graphLengthChange}
valueLabelDisplay="auto"
/>
</div>
<Typography id="graph-precision" gutterBottom>
Maximum points on the Graph: {graphPrecision}
</Typography>
<div style={{ width: "95%", margin: "2%" }}>
<Slider
value={graphPrecision}
min={1}
step={1}
max={analyticsData[analyticsData.length - 1]?.day - firstDay}
onChange={graphPrecisionChange}
valueLabelDisplay="auto"
/>
</div>
</>
);
}
interface configProps extends settingsType {
default_value: string;
}
function Config({
shortName,
name,
default_value,
variant,
options,
}: configProps) {
const [value, setValue] = useState(
variant === "boolean"
? default_value === "1"
? true
: false
: default_value,
);
const notify = useContext(NotifyContext);
const save = async () => {
notify("Saving");
let value2 = value;
if (variant === "textarea") {
setValue(String(value).replace(/\n/g, "").replaceAll(" ", ""));
value2 = String(value).replace(/\n/g, "").replaceAll(" ", "");
}
const res = await fetch("/api/admin/config", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: shortName,
value: value2,
}),
});
notify(await res.text(), res.ok ? "success" : "error");
};
switch (variant) {
case "number":
return (
<>
<TextField
value={value}
onChange={(e) => setValue(e.target.value)}
helperText={name}
type="number"
></TextField>
<Button variant="outlined" color="success" onClick={save}>
Save
</Button>
<br />
</>
);
case "select":
return (
<>
<InputLabel htmlFor={shortName}>{name}</InputLabel>
<Select
value={String(value)}
onChange={(e) => setValue(e.target.value)}
id={shortName}
>
{options?.map((e) => (
<MenuItem key={e} value={e}>
{e}
</MenuItem>
))}
</Select>
<Button variant="outlined" color="success" onClick={save}>
Save
</Button>
<br />
</>
);
case "boolean":
return (
<>
<FormControlLabel
control={
<Checkbox
checked={Boolean(value)}
onChange={(e) => setValue(e.target.checked)}
/>
}
label={name}
/>
<Button variant="outlined" color="success" onClick={save}>
Save
</Button>
<br />
</>
);
case "textarea":
// Automatically parses when pasted in
if (String(value).includes("import")) {
setValue(MUIThemeCodetoJSONString(String(value)));
}
return (
<>
<TextField
value={value}
onChange={(e) => setValue(e.target.value)}
helperText={name}
multiline
fullWidth
></TextField>
<Button variant="outlined" color="success" onClick={save}>
Save
</Button>
<br />
</>
);
default:
return <></>;
}
}
interface props {
analytics: analytics[];
firstDay: number | null;
plugins: plugins[];
pluginData: (store | "error")[];
version: string;
config: data[];
}
export default function Home({
analytics,
firstDay,
plugins,
pluginData,
version,
config,
}: props) {
const [newPlugin, setNewPlugin] = useState("");
const router = useRouter();
const notify = useContext(NotifyContext);
// Used to install a plugin
async function installPlugin() {
const res = await fetch("/api/admin/plugins", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url: newPlugin,
enabled: false,
settings: "{}",
}),
});
notify(await res.text(), res.ok ? "success" : "error");
router.push("/admin");
}
return (
<>
<Head>
<title>Admin Panel</title>
</Head>
<Menu />
<h1>Admin Panel</h1>
<h2>League Plugins</h2>
<Typography variant="body1">
For a league to be useable it needs to be installed and enabled. Leagues
will be installed on a server restart.
</Typography>
<LeaguePlugins
plugins={plugins}
pluginData={pluginData}
version={version}
/>
<br />
<TextField
value={newPlugin}
onChange={(e) => setNewPlugin(e.target.value)}
fullWidth
label="Plugin Url"
placeholder="https://raw.githubusercontent.com/"
/>
<br />
<Button variant="outlined" color="success" onClick={installPlugin}>
Install New Plugin
</Button>
<h2>Settings</h2>
<h3>Information Update Settings</h3>
<p>
Data is automatically updated following these settings. There is a min
and a max time for during the game and one for the transfer times. The
data is updated when the maximum time is exceeded or a request is
recieved asking for data from the server when the minimum time is
exceeded.
</p>
{settings.map((setting) => (
<Config
key={setting.shortName}
default_value={
(
config.filter(
(e) => e.value1 === "config" + setting.shortName,
)[0] ?? { value2: "" }
).value2
}
{...setting}
/>
))}
<h3>Picture Downloading</h3>
<p>
This is the setting for how player pictures should be downloaded. No
means they are never downloaded, needed means they are downloaded when
needed, new&needed means they are downloaded when needed and when a new
picture is found, and yes means every picture is downloaded on startup
and downloaded when discovered.
</p>
<Config
default_value={
(
config.filter((e) => e.value1 === "configDownloadPicture")[0] ?? {
value2: "",
}
).value2
}
name={"Picture Downloading"}
shortName="DownloadPicture"
variant="select"
options={["no", "needed", "new&needed", "yes"]}
/>
<h3>Custom Theme</h3>
<p>
In the textbox below you can paste a custom theme. To create one go to{" "}
<Link
href="https://zenoo.github.io/mui-theme-creator/"
rel="noopener noreferrer"
target="_blank"
>
MUI Theme Creator.
</Link>{" "}
When you are done editing your theme copy it and paste it below, it will
be automatically formatted to the correct format. The first textarea is
the dark theme while the second one is the light theme. If you want to
use the default just enter `{"{}"}` as the value.
</p>
{["Dark", "Light"].map((theme) => (
<Config
default_value={
(
config.filter((e) => e.value1 === "configTheme" + theme)[0] ?? {
value2: "{}",
}
).value2
}
key={theme}
name={`${theme} Theme`}
shortName={`Theme${theme}`}
variant="textarea"
/>
))}
<h2>Analytics</h2>
{firstDay !== null && (
<Analytics analytics={analytics} firstDay={firstDay} />
)}
{firstDay === null && <p>No Analytics Data Exists</p>}
</>
);
}
export const getServerSideProps: GetServerSideProps = async (
ctx: GetServerSidePropsContext,
) => {
const user = await getServerSession(ctx.req, ctx.res, authOptions);
// Makes sure the user is logged in
if (!user) {
return {
redirect: {
destination: `/api/auth/signin?callbackUrl=${encodeURIComponent(
ctx.resolvedUrl,
)}`,
permanent: false,
},
};
}
if (user.user.admin) {
// Used to find the amount of historical data to get
const analytics = await db
.selectFrom("analytics")
.selectAll()
.where((eb) =>
eb(
"day",
">",
eb
.selectFrom("analytics")
.select(eb.fn.max("day").as("max_day"))
.where(sql`day % 50`, "=", 0),
),
)
.execute();
const firstDay =
(
await db
.selectFrom("analytics")
.select("day")
.orderBy("day", "asc")
.executeTakeFirst()
)?.day ?? 0;
const plugins = await db.selectFrom("plugins").selectAll().execute();
const pluginData: (store | "error")[] = await Promise.all(
plugins.map(async (plugin) => {
const request = await fetch(plugin.url).catch(() => "error");
if (!(request instanceof Response)) {
return "error";
} else {
return await request.json().catch(() => "error");
}
}),
);
const version = (await import("#/package.json")).default.version;
const config = await db
.selectFrom("data")
.selectAll()
.where("value1", "like", "config%")
.execute();
return {
props: {
analytics,
firstDay,
plugins,
pluginData,
version,
config,
},
};
}
return {
notFound: true,
};
};