diff --git a/.vscode/launch.json b/.vscode/launch.json index ddfe6996..0ab66647 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,6 +13,10 @@ "url": "http://localhost:8080", "webRoot": "${workspaceFolder}", "pathMappings": [ + { + "url": "webpack://tgstation-server-control-panel/src/components/views/Admin", + "path": "${workspaceFolder}/src/components/views/Admin" + }, { "url": "webpack://tgstation-server-control-panel/src/components/views/Instance", "path": "${workspaceFolder}/src/components/views/Instance" diff --git a/package.json b/package.json index 0b63f03c..21709caf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tgstation-server-control-panel", "version": "4.21.3", - "tgs_api_version": "9.10.2", + "tgs_api_version": "9.11.0", "private": true, "homepage": "https://tgstation.github.io/tgstation-server-control-panel", "repository": "github:tgstation/tgstation-server-control-panel", @@ -124,7 +124,8 @@ "patch": "yarn patch-package" }, "schema_gen": { - "type": "version" + "type": "url", + "value": "http://localhost:5000/swagger/v1/swagger.json" }, "schema_gen_example_version": { "desc": "Fetches a tgs api version from github using the tag name. References the \"tgs_api_version\" field in package.json.", diff --git a/src/ApiClient/AdminClient.ts b/src/ApiClient/AdminClient.ts index 133d26f4..9fdf3599 100644 --- a/src/ApiClient/AdminClient.ts +++ b/src/ApiClient/AdminClient.ts @@ -3,13 +3,14 @@ import type { AdministrationResponse, ErrorMessageResponse, LogFileResponse, - PaginatedLogFileResponse + PaginatedLogFileResponse, + ServerUpdateResponse } from "./generatedcode/generated"; import { DownloadedLog } from "./models/DownloadedLog"; import InternalError, { ErrorCode, GenericErrors } from "./models/InternalComms/InternalError"; import InternalStatus, { StatusCode } from "./models/InternalComms/InternalStatus"; import ServerClient from "./ServerClient"; -import TransferClient, { DownloadErrors } from "./TransferClient"; +import TransferClient, { DownloadErrors, UploadErrors } from "./TransferClient"; import configOptions from "./util/config"; interface IEvents { @@ -28,7 +29,8 @@ export type UpdateErrors = | ErrorCode.ADMIN_WATCHDOG_UNAVAIL | ErrorCode.ADMIN_VERSION_NOT_FOUND | ErrorCode.ADMIN_GITHUB_RATE - | ErrorCode.ADMIN_GITHUB_ERROR; + | ErrorCode.ADMIN_GITHUB_ERROR + | UploadErrors; export type LogsErrors = GenericErrors | ErrorCode.ADMIN_LOGS_IO_ERROR; @@ -190,7 +192,9 @@ export default new (class AdminClient extends ApiClient { } } - public async updateServer(newVersion: string): Promise> { + public async updateServer( + newVersion: string + ): Promise> { await ServerClient.wait4Init(); let response; @@ -209,7 +213,7 @@ export default new (class AdminClient extends ApiClient { case 202: { return new InternalStatus({ code: StatusCode.OK, - payload: null + payload: response.data as ServerUpdateResponse }); } case 410: { @@ -236,7 +240,7 @@ export default new (class AdminClient extends ApiClient { } case 424: { const errorMessage = response.data as ErrorMessageResponse; - return new InternalStatus({ + return new InternalStatus({ code: StatusCode.ERROR, error: new InternalError( ErrorCode.ADMIN_GITHUB_RATE, @@ -247,7 +251,7 @@ export default new (class AdminClient extends ApiClient { } case 429: { const errorMessage = response.data as ErrorMessageResponse; - return new InternalStatus({ + return new InternalStatus({ code: StatusCode.ERROR, error: new InternalError( ErrorCode.ADMIN_GITHUB_ERROR, @@ -257,7 +261,99 @@ export default new (class AdminClient extends ApiClient { }); } default: { - return new InternalStatus({ + return new InternalStatus({ + code: StatusCode.ERROR, + error: new InternalError( + ErrorCode.UNHANDLED_RESPONSE, + { axiosResponse: response }, + response + ) + }); + } + } + } + + public async uploadVersion( + newVersion: string, + file: ArrayBuffer + ): Promise> { + await ServerClient.wait4Init(); + + let response; + try { + response = await ServerClient.apiClient!.administration.administrationControllerUpdate({ + newVersion, + uploadZip: true + }); + } catch (stat) { + return new InternalStatus({ + code: StatusCode.ERROR, + error: stat as InternalError + }); + } + + switch (response.status) { + case 202: { + const payload = response.data as ServerUpdateResponse; + const upload = await TransferClient.Upload(payload.fileTicket, file); + if (upload.code === StatusCode.OK) { + return new InternalStatus({ + code: StatusCode.OK, + payload + }); + } + + return new InternalStatus({ + code: StatusCode.ERROR, + error: upload.error + }); + } + case 410: { + const errorMessage = response.data as ErrorMessageResponse; + return new InternalStatus({ + code: StatusCode.ERROR, + error: new InternalError( + ErrorCode.ADMIN_VERSION_NOT_FOUND, + { errorMessage }, + response + ) + }); + } + case 422: { + const errorMessage = response.data as ErrorMessageResponse; + return new InternalStatus({ + code: StatusCode.ERROR, + error: new InternalError( + ErrorCode.ADMIN_WATCHDOG_UNAVAIL, + { errorMessage }, + response + ) + }); + } + case 424: { + const errorMessage = response.data as ErrorMessageResponse; + return new InternalStatus({ + code: StatusCode.ERROR, + error: new InternalError( + ErrorCode.ADMIN_GITHUB_RATE, + { errorMessage }, + response + ) + }); + } + case 429: { + const errorMessage = response.data as ErrorMessageResponse; + return new InternalStatus({ + code: StatusCode.ERROR, + error: new InternalError( + ErrorCode.ADMIN_GITHUB_ERROR, + { errorMessage }, + response + ) + }); + } + default: { + return new InternalStatus({ code: StatusCode.ERROR, error: new InternalError( ErrorCode.UNHANDLED_RESPONSE, diff --git a/src/components/views/Admin/Update.tsx b/src/components/views/Admin/Update.tsx index f2b21301..49261ee9 100644 --- a/src/components/views/Admin/Update.tsx +++ b/src/components/views/Admin/Update.tsx @@ -1,3 +1,5 @@ +import { faUpload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import React, { ChangeEvent, ReactNode } from "react"; import Button from "react-bootstrap/Button"; import Col from "react-bootstrap/Col"; @@ -8,14 +10,20 @@ import Tooltip from "react-bootstrap/Tooltip"; import { FormattedMessage } from "react-intl"; import ReactMarkdown from "react-markdown"; import { RouteComponentProps, withRouter } from "react-router-dom"; +import { SemVer } from "semver"; -import AdminClient from "../../../ApiClient/AdminClient"; +import AdminClient, { UpdateErrors } from "../../../ApiClient/AdminClient"; +import { + AdministrationRights, + ServerUpdateResponse +} from "../../../ApiClient/generatedcode/generated"; import InternalError, { ErrorCode } from "../../../ApiClient/models/InternalComms/InternalError"; -import { StatusCode } from "../../../ApiClient/models/InternalComms/InternalStatus"; +import InternalStatus, { StatusCode } from "../../../ApiClient/models/InternalComms/InternalStatus"; import ServerClient from "../../../ApiClient/ServerClient"; import UserClient from "../../../ApiClient/UserClient"; import { GeneralContext } from "../../../contexts/GeneralContext"; import GithubClient, { TGSVersion } from "../../../utils/GithubClient"; +import { hasAdminRight, resolvePermissionSet } from "../../../utils/misc"; import { AppRoutes } from "../../../utils/routes"; import ErrorAlert from "../../utils/ErrorAlert"; import { DebugJsonViewer } from "../../utils/JsonViewer"; @@ -59,10 +67,8 @@ class Update extends React.Component { } public async componentDidMount(): Promise { - const tasks = []; - tasks.push(this.loadVersions()); + await this.loadVersions(); - await Promise.all(tasks); this.setState({ loading: false }); @@ -85,6 +91,15 @@ class Update extends React.Component { } private async loadVersions(): Promise { + if ( + !hasAdminRight( + resolvePermissionSet(this.context.user), + AdministrationRights.ChangeVersion + ) + ) { + return; + } + const adminInfo = await AdminClient.getAdminInfo(); switch (adminInfo.code) { @@ -171,6 +186,53 @@ class Update extends React.Component { } } + private async uploadVersion(): Promise { + const inputPromise = new Promise(resolve => { + const input = document.createElement("input"); + input.type = "file"; + input.onchange = e => { + const files = (e.target as HTMLInputElement)?.files; + if (files) resolve(files[0]); + else resolve(null); + }; + input.click(); + }); + + const localFile = await inputPromise; + if (!localFile) return; + + if (!localFile.name.toLowerCase().endsWith(".zip")) { + alert("Invalid zipfile!"); + return; + } + + // https://stackoverflow.com/questions/423376/how-to-get-the-file-name-from-a-full-path-using-javascript + const fileData = await localFile.arrayBuffer(); + + const targetVersionStr = prompt("Enter the TGS version semver:"); + if (!targetVersionStr) return; + + const targetVersionSemver = new SemVer(targetVersionStr); + + // reformat it for them in case they fucked up a little + const targetVersion = `${targetVersionSemver.major}.${targetVersionSemver.minor}.${targetVersionSemver.patch}`; + + if (targetVersion != targetVersionStr) { + alert("Invalid semver!"); + return; + } + + if ( + !confirm( + `JUST WHAT DO YOU THINK YOU'RE DOING!? This is your only and final warning: Uploading a TGS Version .zip that is improperly formatted or that does not match the version you just entered (${targetVersion}) can brick your installation! Think carefully before pressing OK to continue.` + ) + ) { + return; + } + + await this.serverUpdated(AdminClient.uploadVersion(targetVersion, fileData)); + } + private async updateServer(): Promise { if (!this.state.selectedOption) { console.error("Attempted to update server to a no version"); @@ -179,32 +241,41 @@ class Update extends React.Component { }); return; } - const response = await AdminClient.updateServer(this.state.selectedOption); + + await this.serverUpdated(AdminClient.updateServer(this.state.selectedOption)); + } + + private async serverUpdated( + request: Promise> + ): Promise { + const response = await request; switch (response.code) { case StatusCode.ERROR: { this.addError(response.error); - break; + return; } case StatusCode.OK: { - ServerClient.autoLogin = false; - // i need that timer to be async - // eslint-disable-next-line @typescript-eslint/no-misused-promises - window.setInterval(async () => { - const response = await UserClient.getCurrentUser(true); - switch (response.code) { - //we wait until we get an error which means either it rebooted and our creds are bullshit, or we rebooted and the api is different - //in both cases, we should reboot - case StatusCode.ERROR: { - window.location.reload(); - } - } - }, 2000); - this.setState({ - updating: true - }); + break; } } + + ServerClient.autoLogin = false; + // i need that timer to be async + // eslint-disable-next-line @typescript-eslint/no-misused-promises + window.setInterval(async () => { + const response = await UserClient.getCurrentUser(true); + switch (response.code) { + //we wait until we get an error which means either it rebooted and our creds are bullshit, or we rebooted and the api is different + //in both cases, we should reboot + case StatusCode.ERROR: { + window.location.reload(); + } + } + }, 2000); + this.setState({ + updating: true + }); } public render(): ReactNode { @@ -220,6 +291,10 @@ class Update extends React.Component { }); }; + const permissionSet = resolvePermissionSet(this.context.user); + const canChangeVersion = hasAdminRight(permissionSet, AdministrationRights.ChangeVersion); + const canUploadVersion = hasAdminRight(permissionSet, AdministrationRights.UploadVersion); + const selectedVersionMarkdown = this.state.selectedVersion?.body .replaceAll("\r", "") .replaceAll("\n", "\n\n"); @@ -280,60 +355,92 @@ class Update extends React.Component {

- - {this.state.versions.map((version, index) => { - return ( - - - - - - {version.version} - {version.current ? ( - - ) : ( - "" - )} - {index == 0 ? ( - - ) : ( - "" - )} - - - ); - })} - -
- - + })} + +
+ + + ) : ( +

+ +

+ )} +
+ + + + } + show={canUploadVersion ? false : undefined}> + {({ ref, ...triggerHandler }) => ( + + )} + )} diff --git a/src/components/views/Instance/Edit/ChatBots.tsx b/src/components/views/Instance/Edit/ChatBots.tsx index 8e04f139..6dcac032 100644 --- a/src/components/views/Instance/Edit/ChatBots.tsx +++ b/src/components/views/Instance/Edit/ChatBots.tsx @@ -1115,6 +1115,11 @@ class ChatBots extends React.Component { type: FieldType.Boolean as FieldType.Boolean, name: "fields.instance.chat.channel.updates", tooltip: "fields.instance.chat.channel.updates.tip" + }, + isSystemChannel: { + type: FieldType.Boolean as FieldType.Boolean, + name: "fields.instance.chat.channel.system", + tooltip: "fields.instance.chat.channel.system.tip" } }; @@ -1169,6 +1174,7 @@ class ChatBots extends React.Component { (fieldsCommon.isAdminChannel as InputFormField).defaultValue = channel.isAdminChannel; (fieldsCommon.isUpdatesChannel as InputFormField).defaultValue = channel.isUpdatesChannel; (fieldsCommon.isWatchdogChannel as InputFormField).defaultValue = channel.isWatchdogChannel; + (fieldsCommon.isSystemChannel as InputFormField).defaultValue = channel.isSystemChannel; (fieldsCommon.tag as InputFormField).defaultValue = channel.tag; return ( diff --git a/src/components/views/Instance/Edit/Server.tsx b/src/components/views/Instance/Edit/Server.tsx index 4d3a6cd0..e3e4f487 100644 --- a/src/components/views/Instance/Edit/Server.tsx +++ b/src/components/views/Instance/Edit/Server.tsx @@ -214,20 +214,20 @@ export default function Server(): JSX.Element { DreamDaemonRights.SetTopicTimeout ) }, - heartbeatSeconds: { + healthCheckSeconds: { type: FieldType.Number as FieldType.Number, - name: "fields.instance.watchdog.heartbeat", - defaultValue: watchdogSettings.heartbeatSeconds, + name: "fields.instance.watchdog.healthcheck", + defaultValue: watchdogSettings.healthCheckSeconds, min: 0, disabled: !hasDreamDaemonRight( instanceEditContext.instancePermissionSet, - DreamDaemonRights.SetHeartbeatInterval + DreamDaemonRights.SetHealthCheckInterval ) }, - dumpOnHeartbeatRestart: { + dumpOnHealthCheckRestart: { type: FieldType.Boolean as FieldType.Boolean, - name: "fields.instance.watchdog.dumpOnHeartbeatRestart", - defaultValue: watchdogSettings.dumpOnHeartbeatRestart, + name: "fields.instance.watchdog.dumpOnHealthCheckRestart", + defaultValue: watchdogSettings.dumpOnHealthCheckRestart, disabled: !hasDreamDaemonRight( instanceEditContext.instancePermissionSet, DreamDaemonRights.CreateDump @@ -250,6 +250,15 @@ export default function Server(): JSX.Element { instanceEditContext.instancePermissionSet, DreamDaemonRights.SetAdditionalParameters ) + }, + mapThreads: { + type: FieldType.Number as FieldType.Number, + name: "fields.instance.watchdog.mapthreads", + defaultValue: watchdogSettings.mapThreads, + disabled: !hasDreamDaemonRight( + instanceEditContext.instancePermissionSet, + DreamDaemonRights.SetMapThreads + ) } }; diff --git a/src/components/views/Instance/InstanceEdit.tsx b/src/components/views/Instance/InstanceEdit.tsx index e6c4d0fc..1041b138 100644 --- a/src/components/views/Instance/InstanceEdit.tsx +++ b/src/components/views/Instance/InstanceEdit.tsx @@ -64,7 +64,7 @@ const minimumServerPerms = DreamDaemonRights.Shutdown | DreamDaemonRights.Start | DreamDaemonRights.SetStartupTimeout | - DreamDaemonRights.SetHeartbeatInterval | + DreamDaemonRights.SetHealthCheckInterval | DreamDaemonRights.CreateDump | DreamDaemonRights.SetTopicTimeout | DreamDaemonRights.SetAdditionalParameters | diff --git a/src/translations/locales/en.json b/src/translations/locales/en.json index a505e3a6..d218ed6a 100644 --- a/src/translations/locales/en.json +++ b/src/translations/locales/en.json @@ -96,7 +96,7 @@ "generic.close": "Close", "generic.clone": "Clone", "generic.goback": "Go Back", - "generic.accessdenied": "This user does not have access to this page. ", + "generic.accessdenied": "This user does not have access to this page.", "generic.persist": "Persist", "generic.continue": "Continue", "generic.view": "View", @@ -174,6 +174,9 @@ "view.admin.update.releasenotes": "Release Notes", "view.admin.update.wait": "Please take the time to read the release notes before proceeding", "view.admin.update.showall": "Show all versions", + "view.admin.update.selectversion.deny": "You do not have permission to update to a GitHub version", + "view.admin.update.upload": "Upload Update Package", + "view.admin.update.upload.deny": "You do not have permission to update using uploaded packages", "view.admin.logs.button": "TGS Logs", "view.user.list.cantlist": "This user does not have the permission to list users, only the current user is listed/editable.", "view.user.edit.cantedit": "This user does not have the permission to edit users.", @@ -424,6 +427,8 @@ "perms.admin.downloadlogs.desc": "Ability to view and download all TGS logs", "perms.admin.editownoauthconnections": "Edit own external identity providers", "perms.admin.editownoauthconnections.desc": "Ability to edit their own identity providers(oauth)", + "perms.admin.uploadversion": "Upload Version .zip", + "perms.admin.uploadversion.desc": "Ability to update the server with an uploaded .zip update package.", "perms.instance": "Instance Manager Permissions", "perms.instance.read": "Read Accessible Instances", "perms.instance.read.desc": "Ability to list and view instances the user is allowed access to. WARNING: Users who know the instance ID can still use the API to edit it using other permissions even if they lack this one.", @@ -543,8 +548,8 @@ "perms.dreamdaemon.start.desc": "Allows launching DreamDaemon from a shutdown state.", "perms.dreamdaemon.setstartuptimeout": "Set Startup Timeout", "perms.dreamdaemon.setstartuptimeout.desc": "Allows changing the idle/DMAPI detection timeout for starting new servers before it's considered a failure. This also applies to DreamDaemon instances used during deployments.", - "perms.dreamdaemon.setheartbeatinterval": "Set Heartbeat Interval", - "perms.dreamdaemon.setheartbeatinterval.desc": "Allows changing the interval at which heartbeat Topics are sent.", + "perms.dreamdaemon.sethealthcheckinterval": "Set Health Check Interval", + "perms.dreamdaemon.sethealthcheckinterval.desc": "Allows changing the interval at which health check Topics are sent.", "perms.dreamdaemon.createdump": "Create Process Dump", "perms.dreamdaemon.createdump.desc": "Allows creating dump files of DreamDaemon while it is running.", "perms.dreamdaemon.settopictimeout": "Set Topic Timeout", @@ -555,6 +560,8 @@ "perms.dreamdaemon.setvisibility.desc": "Allows changing the DreamDaemon visibility setting.", "perms.dreamdaemon.setprofiler": "Set Profiler", "perms.dreamdaemon.setprofiler.desc": "Allows setting the `-profile` command line option.", + "perms.dreamdaemon.setmapthreads": "Set Map Threads", + "perms.dreamdaemon.setmapthreads.desc": "Allows setting the `-map-threads` command line option.", "perms.chatbots": "Chat Bots", "perms.chatbots.writeenabled": "Set Enabled", "perms.chatbots.writeenabled.desc": "Allows activation and deactivation of chat bots.", @@ -670,6 +677,8 @@ "fields.instance.chat.channel.tag.tip": "A string associated with this channel in the DMAPI", "fields.instance.chat.channel.updates": "Deployments Channel", "fields.instance.chat.channel.updates.tip": "This channel will receive TGS deployment messages", + "fields.instance.chat.channel.system": "System Channel", + "fields.instance.chat.channel.system.tip": "This channel will receive TGS system messages (Restarts and updates)", "fields.instance.chat.channel.watchdog": "Watchdog Channel", "fields.instance.chat.channel.watchdog.tip": "This channel will receive live updates as to the state of the active DreamDaemon process", "fields.instance.chat.create.channel": "Add Channel", @@ -728,9 +737,10 @@ "fields.instance.watchdog.port": "Network port", "fields.instance.watchdog.timeout.startup": "Startup timeout (seconds)", "fields.instance.watchdog.timeout.topic": "Topic timeout (milliseconds)", - "fields.instance.watchdog.heartbeat": "Heartbeat timeout (seconds)", - "fields.instance.watchdog.dumpOnHeartbeatRestart": "Create process dump on heartbeat fail restart", + "fields.instance.watchdog.healthcheck": "Health Check Timeout (seconds)", + "fields.instance.watchdog.dumpOnHealthCheckRestart": "Create process dump on health check fail restart", "fields.instance.watchdog.additionalparams": "Additional command line parameters", + "fields.instance.watchdog.mapthreads": "Map Threads Count (0 for default)", "fields.instance.repository.origincheckoutsha": "Origin SHA", "fields.instance.repository.origincheckoutsha.desc": "SHA of the origin commit", "fields.instance.repository.checkoutsha": "Checkout SHA", diff --git a/src/utils/icolibrary.ts b/src/utils/icolibrary.ts index a22b4362..8c031150 100644 --- a/src/utils/icolibrary.ts +++ b/src/utils/icolibrary.ts @@ -14,7 +14,8 @@ import { faFolderPlus, faHashtag, faMinus, - faUnlock + faUnlock, + faUpload } from "@fortawesome/free-solid-svg-icons"; import { faAngleRight } from "@fortawesome/free-solid-svg-icons/faAngleRight"; import { faCheck } from "@fortawesome/free-solid-svg-icons/faCheck"; @@ -96,6 +97,7 @@ export default function (): void { faExclamationTriangle, faClipboard, faArrowLeft, - faAngleRight + faAngleRight, + faUpload ); } diff --git a/src/utils/routes.ts b/src/utils/routes.ts index 2addd63a..94bb2d5e 100644 --- a/src/utils/routes.ts +++ b/src/utils/routes.ts @@ -238,7 +238,9 @@ const AppRoutes = asElementTypesAppRoute({ loose: true, navbarLoose: true, - isAuthorized: adminRight(AdministrationRights.ChangeVersion), + isAuthorized: adminRight( + AdministrationRights.ChangeVersion | AdministrationRights.UploadVersion + ), visibleNavbar: true, homeIcon: undefined,