Skip to content

Commit

Permalink
Merge branch 'SignalRApi' into next
Browse files Browse the repository at this point in the history
  • Loading branch information
Cyberboss committed Nov 5, 2023
2 parents bc7c9ed + 3382415 commit 038cb59
Show file tree
Hide file tree
Showing 7 changed files with 309 additions and 24 deletions.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "tgstation-server-control-panel",
"version": "4.25.7",
"tgs_api_version": "9.12.0",
"version": "4.26.0",
"tgs_api_version": "9.13.0",
"private": true,
"homepage": "https://tgstation.github.io/tgstation-server-control-panel",
"repository": "github:tgstation/tgstation-server-control-panel",
Expand Down Expand Up @@ -30,6 +30,7 @@
"@fortawesome/react-fontawesome": "^0.1.16",
"@loadable/component": "^5.14.1",
"@mapbox/react-click-to-select": "^2.2.1",
"@microsoft/signalr": "^7.0.12",
"@octokit/plugin-paginate-rest": "^2.17.0",
"@octokit/plugin-retry": "^3.0.9",
"@octokit/plugin-throttling": "^3.4.1",
Expand Down
16 changes: 10 additions & 6 deletions src/ApiClient/ServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export default new (class ServerClient extends ApiClient<IEvents> {
// user and kick them to the login page. Snowflake behaviour: Acts as a failed login for the login endpoint
case 401: {
const request = error.config;
if ((request.url === "/" || request.url === "") && request.method === "post") {
if (request.url === "/" || request.url === "") {
return Promise.resolve(error.response);
}

Expand Down Expand Up @@ -288,6 +288,14 @@ export default new (class ServerClient extends ApiClient<IEvents> {
public autoLogin = true;
private loggingIn = false;

public get defaultHeaders() {
return {
Accept: "application/json",
Api: `Tgstation.Server.Api/` + API_VERSION,
"Webpanel-Version": VERSION
};
}

public async initApi(): Promise<void> {
console.log("Initializing API client");
console.time("APIInit");
Expand All @@ -296,11 +304,7 @@ export default new (class ServerClient extends ApiClient<IEvents> {
//Yes this is only initialized once even if the configOption changes, this doesn't
baseURL: configOptions.apipath.value as string,
withCredentials: false,
headers: {
Accept: "application/json",
Api: `Tgstation.Server.Api/` + API_VERSION,
"Webpanel-Version": VERSION
},
headers: this.defaultHeaders,
//Global errors are handled via the catch clause and endpoint specific response codes are handled normally
validateStatus: status => {
return !ServerClient.globalHandledCodes.includes(status);
Expand Down
4 changes: 3 additions & 1 deletion src/ApiClient/models/InternalComms/InternalError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ export enum ErrorCode {
BAD_YML = "error.bad_yml",
BAD_TGS_YML_VERSION = "error.bad_tgs_yml_version",

BAD_CHANNELS_JSON = "error.bad_channels_json"
BAD_CHANNELS_JSON = "error.bad_channels_json",

BAD_HUB_CONNECTION = "error.bad_hub_connection"
}

type errorMessage = {
Expand Down
149 changes: 135 additions & 14 deletions src/ApiClient/util/JobsController.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { satisfies as SemverSatisfies } from "semver";
import * as signalR from "@microsoft/signalr";
import { gte as SemVerGte, satisfies as SemverSatisfies } from "semver";
import { TypedEmitter } from "tiny-typed-emitter";

import { resolvePermissionSet } from "../../utils/misc";
Expand Down Expand Up @@ -34,9 +35,13 @@ interface IEvents {
export default new (class JobsController extends TypedEmitter<IEvents> {
private fastmodecount = 0;
public set fastmode(cycles: number) {
if (this.connection) {
return;
}

console.log(`JobsController going in fastmode for ${cycles} cycles`);
this.fastmodecount = cycles;
this.restartLoop();
void this.restartLoop();
}

private currentLoop: Date = new Date(0);
Expand All @@ -45,16 +50,28 @@ export default new (class JobsController extends TypedEmitter<IEvents> {
private enableJobProgressWorkaround?: boolean;

public errors: InternalError[] = [];
public nextRetry: Date | null;
public jobs = new Map<number, TGSJobResponse>();
public jobsByInstance = new Map<number, Map<number, TGSJobResponse>>();
private jobCallback = new Map<number, Set<(job: TGSJobResponse) => unknown>>();
private lastSeenJob = -1;

private connection: signalR.HubConnection | null;

public reset(clearJobs = true) {
if (clearJobs) {
this.jobs = new Map<number, TGSJobResponse>();
this.jobsByInstance = new Map<number, Map<number, TGSJobResponse>>();

if (this.connection) {
void this.restartLoop();
}
}

if (this.connection) {
return;
}

this.reloadAccessibleInstances()
.then(this.restartLoop)
.catch(e => {
Expand All @@ -65,6 +82,8 @@ export default new (class JobsController extends TypedEmitter<IEvents> {
public constructor() {
super();

this.connection = null;
this.nextRetry = null;
this.loop = this.loop.bind(this);
this.reset = this.reset.bind(this);
this.restartLoop = this.restartLoop.bind(this);
Expand Down Expand Up @@ -142,24 +161,124 @@ export default new (class JobsController extends TypedEmitter<IEvents> {
}
}

public restartLoop() {
//we use an actual date object here because it could help prevent really weird timing
// issues as two different date objects cannot be equal
// despite the date being
const initDate = new Date(Date.now());
this.currentLoop = initDate;
window.setTimeout(() => {
this.loop(initDate).catch(e =>
this.errors.push(new InternalError(ErrorCode.APP_FAIL, { jsError: Error(e) }))
);
}, 0);
public async restartLoop(): Promise<void> {
const serverInfo = await ServerClient.getServerInfo();
let jobHubSupported = false;
if (serverInfo.code === StatusCode.OK) {
jobHubSupported = SemVerGte(serverInfo.payload.apiVersion, "9.13.0");
}

if (!jobHubSupported) {
//we use an actual date object here because it could help prevent really weird timing
// issues as two different date objects cannot be equal
// despite the date being
const initDate = new Date(Date.now());
this.currentLoop = initDate;
window.setTimeout(() => {
this.loop(initDate).catch(e =>
this.errors.push(new InternalError(ErrorCode.APP_FAIL, { jsError: Error(e) }))
);
}, 0);

return;
}

if (this.connection) {
await this.connection.stop();
}

this.nextRetry = null;

let apiPath = configOptions.apipath.value as string;
if (!apiPath.endsWith("/")) {
apiPath = apiPath + "/";
}

this.connection = new signalR.HubConnectionBuilder()
.withUrl(`${apiPath}hubs/jobs`, {
accessTokenFactory: async () => {
const token = await ServerClient.wait4Token();
return token.bearer;
},
transport: signalR.HttpTransportType.ServerSentEvents,
headers: ServerClient.defaultHeaders
})
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (retryContext: signalR.RetryContext) => {
const nextRetryMs = Math.min(2 ** retryContext.previousRetryCount, 30) * 1000;
const retryDate = new Date();
retryDate.setSeconds(retryDate.getMilliseconds() + nextRetryMs);
this.nextRetry = retryDate;
this.emit("jobsLoaded");
return nextRetryMs;
}
})
.build();

this.connection.on("ReceiveJobUpdate", (job: JobResponse) => {
this.registerJob(job, job.instanceId);
this.emit("jobsLoaded");
});

this.connection.onclose(error => {
this.errors = [];
if (error) {
this.errors.push(
new InternalError(ErrorCode.BAD_HUB_CONNECTION, { jsError: error })
);
} else {
this.errors.push(new InternalError(ErrorCode.BAD_HUB_CONNECTION, { void: true }));
}
this.emit("jobsLoaded");
});

this.connection.onreconnected(() => {
this.nextRetry = null;

// at this point we need to manually load all the jobs we have registered in case they've completed in the hub and are no longer receiving updates
const forcedRefresh = async () => {
await this.reloadAccessibleInstances(false);
await this.loop((this.currentLoop = new Date()));
};

void forcedRefresh();
});

this.connection.onreconnecting(() => {
this.errors = [];
this.errors.push(new InternalError(ErrorCode.BAD_HUB_CONNECTION, { void: true }));
this.emit("jobsLoaded");
});

// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
this.connection.start().catch(error => {
this.errors = [];
if (error instanceof Error) {
this.errors.push(
new InternalError(ErrorCode.BAD_HUB_CONNECTION, { jsError: error })
);
} else {
this.errors.push(new InternalError(ErrorCode.BAD_HUB_CONNECTION, { void: true }));
}
this.emit("jobsLoaded");
this.connection = null;
});
}

private _registerJob(job: TGSJobResponse, instanceid?: number): void;
// noinspection JSUnusedLocalSymbols
private _registerJob(job: JobResponse, instanceid: number): void;
private _registerJob(_job: JobResponse | TGSJobResponse, instanceid?: number) {
const job = _job as TGSJobResponse;
if (this.jobs.has(job.id) && this.jobs.get(job.id)!.stoppedAt) {
console.warn(
`Receieved job update for ${job.id} after it completed! Incoming job was${
job.stoppedAt ? "" : " not"
} completed.`
);
return;
}

if (instanceid) job.instanceid = instanceid;
const instanceSet = this.jobsByInstance.get(job.instanceid) ?? new Map();
this.jobsByInstance.set(job.instanceid, instanceSet);
Expand All @@ -172,7 +291,9 @@ export default new (class JobsController extends TypedEmitter<IEvents> {
public registerJob(_job: JobResponse | TGSJobResponse, instanceid?: number) {
// @ts-expect-error The signature is the same
this._registerJob(_job, instanceid);
this.restartLoop();
if (!this.connection) {
void this.restartLoop();
}
}

private async loop(loopid: Date) {
Expand Down
37 changes: 36 additions & 1 deletion src/components/utils/JobsList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { faTimes } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { ReactNode } from "react";
import { Button } from "react-bootstrap";
import { Button, Card } from "react-bootstrap";
import { OverlayInjectedProps } from "react-bootstrap/Overlay";
import OverlayTrigger from "react-bootstrap/OverlayTrigger";
import Tooltip from "react-bootstrap/Tooltip";
Expand All @@ -25,6 +25,7 @@ interface IProps {
interface IState {
jobs: Map<number, Map<number, TGSJobResponse>>;
errors: InternalError<ErrorCode>[];
nextRetrySeconds: number | null;
ownerrors: Array<InternalError<ErrorCode> | undefined>;
loading: boolean;
instances: Map<number, InstanceResponse>;
Expand All @@ -47,6 +48,7 @@ export default class JobsList extends React.Component<IProps, IState> {
this.state = {
jobs: JobsController.jobsByInstance,
errors: [],
nextRetrySeconds: null,
ownerrors: [],
loading: true,
instances: new Map<number, InstanceResponse>()
Expand Down Expand Up @@ -75,10 +77,30 @@ export default class JobsList extends React.Component<IProps, IState> {
JobsController.removeListener("jobsLoaded", this.handleUpdate);
}

private currentTimeout?: NodeJS.Timeout | null;

public handleUpdate(): void {
if (this.currentTimeout) {
clearTimeout(this.currentTimeout);
this.currentTimeout = null;
}

let nextRetrySeconds;
if (JobsController.nextRetry) {
if (JobsController.nextRetry.getSeconds() > new Date().getSeconds()) {
nextRetrySeconds = JobsController.nextRetry.getSeconds() - new Date().getSeconds();
} else {
nextRetrySeconds = 0;
}
this.currentTimeout = setTimeout(() => this.handleUpdate(), 1000);
} else {
nextRetrySeconds = null;
}

this.setState({
jobs: JobsController.jobsByInstance,
errors: JobsController.errors,
nextRetrySeconds,
loading: false,
instances: JobsController.accessibleInstances
});
Expand Down Expand Up @@ -188,6 +210,19 @@ export default class JobsList extends React.Component<IProps, IState> {
</div>
);
})}
{this.state.nextRetrySeconds !== null ? (
<Card>
{this.state.nextRetrySeconds == 0 ? (
<FormattedMessage id="view.instance.jobs.reconnect_now"></FormattedMessage>
) : (
<FormattedMessage
id="view.instance.jobs.reconnect_in"
values={{
seconds: this.state.nextRetrySeconds
}}></FormattedMessage>
)}
</Card>
) : null}
{Array.from(this.state.jobs)
.sort((a, b) => a[0] - b[0])
.map(([instanceid, jobMap]) => {
Expand Down
3 changes: 3 additions & 0 deletions src/translations/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"error.bad_yml": "Malformed .yml.",
"error.bad_channels_json": "Malformed channels JSON.",
"error.no_byond_version": "The target BYOND version does not exist on the server.",
"error.bad_hub_connection": "Lost connection to the SignalR hub.",
"generic.save": "Save",
"generic.savetab": "Save Tab",
"generic.saveall": "Save All",
Expand Down Expand Up @@ -263,6 +264,8 @@
"view.instance.jobs.jobtotal": "{amount} jobs",
"view.instance.jobs.error": "An error occured",
"view.instance.jobs.clearfinished": "Clear finished jobs",
"view.instance.jobs.reconnect_in": "Attempting reconnect in {seconds}s...",
"view.instance.jobs.reconnect_now": "Attempting reconnection...",
"view.instance.moving": "[MOVING INSTANCE...]",
"view.instance.config.instancesettings": "Instance Settings",
"view.instance.config.instanceusers": "Instance Users",
Expand Down
Loading

0 comments on commit 038cb59

Please sign in to comment.