GraphictoriaWeb/web/resources/js/components/Deployer.js

634 lines
19 KiB
JavaScript

/*
Copyright © XlXi 2022
*/
import { Component, createRef } from 'react';
import classNames from 'classnames/bind';
import axios from 'axios';
import { buildGenericApiUrl } from '../util/HTTP.js';
import Loader from './Loader';
axios.defaults.withCredentials = true;
class DeploymentUploadModal extends Component {
constructor(props) {
super(props);
this.state = {
deployments: []
};
this.updateInterval = null;
this.ModalRef = createRef();
this.Modal = null;
}
componentDidMount() {
this.Modal = new Bootstrap.Modal(
this.ModalRef.current,
{
backdrop: 'static',
keyboard: false
}
);
this.Modal.show();
this.ModalRef.current.addEventListener('hidden.bs.modal', (event) => {
this.props.setModal(null);
})
this.setState({ deployments: this.props.deployments });
this.updateInterval = setInterval(function() {
let deployments = this.state.deployments;
deployments.map((component, index) => {
axios.get(buildGenericApiUrl('api', `admin/v1/deploy?version=${ component.version }`))
.then(res => {
deployments[index] = { ...component, ...res.data };
// XlXi: -_-
//deployment['status'] = res.data['status'];
//deployment['message'] = res.data['message'];
//deployment['progress'] = res.data['progress'];
});
});
this.setState({ deployments: deployments });
}.bind(this), 2000);
}
componentWillUnmount() {
this.Modal.dispose();
clearInterval(this.updateInterval);
}
render() {
return (
<div ref={this.ModalRef} className="modal fade">
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Deployment Status</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div className="modal-body">
{
this.state.deployments.map((deployment, index) => (
<>
<h5 className="mb-0">Deploying { capitalizeFirstLetter(deployment.key) } { deployment.version }</h5>
<p className="text-muted mb-2">{ deployment.message }</p>
<div className="progress">
<div
className={classNames({
'progress-bar': true,
'progress-bar-striped': true,
'progress-bar-animated': true,
'bg-primary': ( deployment.status == 'Loading' ),
'bg-success': ( deployment.status == 'Success' ),
'bg-danger': ( deployment.status == 'Error' )
})}
style={ {width: `${deployment.progress * 100}%`} }
></div>
</div>
{ index != this.state.deployments.length-1 ? <hr /> : null }
</>
))
}
</div>
</div>
</div>
</div>
);
}
}
class DeploymentCard extends Component {
constructor(props) {
super(props);
}
render() {
return (
<div className="card mb-2">
<div className="card-header d-flex">
<span>{ this.props.name }</span>
<button className="ms-auto btn-close" onClick={ ()=>this.props.removeDeployment(this.props.index) }></button>
</div>
<div className="card-body">
{ this.props.children }
</div>
</div>
);
}
}
class RevertDeploymentCard extends Component {
constructor(props) {
super(props);
this.state = {
loading: true,
deployments: []
};
}
componentDidMount() {
let deployType = 'Unknown';
switch(this.props.index)
{
case 'client':
deployType = 'WindowsPlayer'
break;
case 'studio':
deployType = 'Studio'
break;
}
let deployHistoryRegex = new RegExp(`New ${deployType} version-[a-zA-Z0-9]{16} at \\d{1,2}\\/\\d{1,2}\\/\\d{4} \\d{1,2}:\\d{1,2}:\\d{1,2} (AM|PM), file version: (\\d+(, )?){4}\\.{3}Done!`, 'g');
axios.get(buildGenericApiUrl('setup', 'DeployHistory.txt'))
.then(res => {
let deployments = res.data.split(/\r?\n/).reverse();
deployments = deployments.filter((deployment) => deployHistoryRegex.test(deployment)).slice(0, 30);
let realDeployments = [];
deployments.map((deployment) => {
let newDeployment = {
type: deployType,
hash: deployment.match(/version-[a-zA-Z0-9]{16}/)[0],
version: deployment.match(/file version: (\d+(, )?){4}/)[0].replace('file version: ', ''),
date: deployment.match(/at \d{1,2}\/\d{1,2}\/\d{4} \d{1,2}:\d{1,2}:\d{1,2} (AM|PM)/)[0].replace('at ', '')
};
realDeployments.push(newDeployment);
});
this.setState({ loading: false, deployments: realDeployments });
});
}
render() {
return (
<DeploymentCard name={ this.props.name } index={ this.props.index } removeDeployment={ this.props.removeDeployment }>
<h5 className="mb-0">Revert Deployment</h5>
<p className="text-muted">Select a previous deployment below to roll back the { this.props.index } version.</p>
<select className="form-select mt-2" id="vb-revert-deployment" disabled={ this.state.loading }>
<option selected>{ this.state.loading ? 'Loading...' : 'None Selected' }</option>
{
this.state.deployments.map((deployment, index) => (
<option value={ deployment.hash } key={ index }>[{ deployment.type } { deployment.version }] [{ deployment.date }] { deployment.hash }</option>
))
}
</select>
</DeploymentCard>
);
}
}
class PushDeploymentCard extends Component {
constructor(props) {
super(props);
this.state = {
drag: false,
showRequiredFiles: false,
files: [],
options: []
};
this.dragDropBox = createRef();
this.dragCounter = 0;
this.neededFiles = [
'content-fonts.zip',
'content-music.zip',
'content-particles.zip',
'content-sky.zip',
'content-sounds.zip',
'content-terrain.zip',
'content-textures.zip',
'content-textures2.zip',
'content-textures3.zip',
'shaders.zip',
'redist.zip',
'Libraries.zip'
];
this.handleDrag = this.handleDrag.bind(this);
this.handleDragIn = this.handleDragIn.bind(this);
this.handleDragOut = this.handleDragOut.bind(this);
this.handleDrop = this.handleDrop.bind(this);
this.handleDropUi = this.handleDropUi.bind(this);
this.fileExists = this.fileExists.bind(this);
this.removeFile = this.removeFile.bind(this);
this.setOptions = this.setOptions.bind(this);
}
componentDidMount() {
switch(this.props.index)
{
case 'client':
this.neededFiles = this.neededFiles.concat([
'PlayerPdb.zip',
'VirtuBrick.zip',
'VirtuBrickPlayerLauncher.exe'
]);
break;
case 'studio':
this.neededFiles = this.neededFiles.concat([
'BuiltInPlugins.zip',
'imageformats.zip',
'content-scripts.zip',
'StudioPdb.zip',
'VirtuBrickStudio.zip',
'VirtuBrickStudioLauncherBeta.exe'
]);
break;
}
this.setState({ showRequiredFiles: true });
let ddb = this.dragDropBox.current
ddb.addEventListener('dragenter', this.handleDragIn)
ddb.addEventListener('dragleave', this.handleDragOut)
ddb.addEventListener('dragover', this.handleDrag)
ddb.addEventListener('drop', this.handleDrop)
}
componentWillUnmount() {
let ddb = this.dragDropBox.current
ddb.removeEventListener('dragenter', this.handleDragIn)
ddb.removeEventListener('dragleave', this.handleDragOut)
ddb.removeEventListener('dragover', this.handleDrag)
ddb.removeEventListener('drop', this.handleDrop)
}
handleDrag(evt) {
evt.preventDefault();
evt.stopPropagation();
}
handleDragIn(evt) {
evt.preventDefault();
evt.stopPropagation();
this.dragCounter++;
if (evt.dataTransfer.items && evt.dataTransfer.items.length > 0) {
this.setState({drag: true});
}
}
handleDragOut(evt) {
evt.preventDefault();
evt.stopPropagation();
this.dragCounter--;
if (this.dragCounter === 0) {
this.setState({drag: false});
}
}
handleDrop(evt) {
evt.preventDefault();
evt.stopPropagation();
this.setState({drag: false});
if (evt.dataTransfer.files && evt.dataTransfer.files.length > 0) {
this.handleDropUi(evt.dataTransfer.files);
evt.dataTransfer.clearData();
this.dragCounter = 0;
}
}
handleDropUi(files) {
let fileList = this.state.files;
for (var i = 0; i < files.length; i++) {
let file = files[i];
if (!file)
continue;
if(this.fileExists(file.name))
continue;
fileList.push(file);
}
this.setState({files: fileList});
this.props.updateDeploymentFiles(fileList);
}
fileExists(fileName) {
return this.state.files.some(file => file.name.toLowerCase() === fileName.toLowerCase());
}
removeFile(fileName) {
this.setState(prevState => ({
files: prevState.files.filter((file) => file.name.toLowerCase() !== fileName.toLowerCase())
}));
this.props.updateDeploymentFiles(this.state.files);
}
setOptions(key, value) {
let options = this.state.options;
if(value != '')
options[key] = value;
else
delete options[key];
this.setState({ options: options });
this.props.updateDeploymentOptions(options)
}
render() {
return (
<DeploymentCard name={ this.props.name } index={ this.props.index } removeDeployment={ this.props.removeDeployment }>
<h5 className="mb-0">Deployment Files</h5>
<p className="text-muted">Drag-and-Drop the necessary files into the box below. Any unneeded files will be discarded when uploading.</p>
<div className="card bg-secondary mt-3 p-3" ref={ this.dragDropBox }>
<div>
{/* XlXi: Reusing game cards here because they were already exactly what I wanted. */}
{
this.state.files.length == 0
?
<p className="text-muted">Drop files here.</p>
:
this.state.files.map((file, index) => {
let fileType = /(?:\.([^.]+))?$/.exec(file.name)[1].toLowerCase();
let fileIconClasses = {
'm-auto': true,
'fs-1': true,
'fa-regular': true
};
switch(fileType)
{
case 'exe':
fileIconClasses['fa-browser'] = true;
break;
case 'zip':
fileIconClasses['fa-file-zipper'] = true;
break;
default:
fileIconClasses['fa-file'] = true;
break;
}
return (
<div className="virtubrick-item-card virtubrick-game-card">
<div className="card m-2" data-bs-toggle="tooltip" data-bs-placement="top" title={ file.name }>
<div className="bg-light d-flex p-3">
<i className={classNames(fileIconClasses)}></i>
</div>
<div className="p-2">
<p className="text-truncate">{ file.name }</p>
<button className="btn btn-sm btn-danger mt-1 w-100" onClick={ ()=>this.removeFile(file.name) }>Remove</button>
</div>
</div>
</div>
);
})
}
</div>
{
this.state.showRequiredFiles
?
<>
<hr />
<h5>Needed Files</h5>
<div className="small">
{
this.neededFiles.map((fileName) => {
let fileExists = this.fileExists(fileName);
return (
<p className={classNames({
'text-success': fileExists,
'text-danger': !fileExists
})}>{ fileName }</p>
);
})
}
</div>
</>
:
null
}
</div>
{
this.props.index == 'client'
?
<>
<h5 className="mb-0 mt-3">Optional Configuration</h5>
<p className="text-muted mb-3">Only change if you've updated the security settings on the client/rcc. Shutting down game servers will delay deployment by 10 minutes.</p>
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch" id="vb-shut-down-servers" />
<label className="form-check-label" htmlFor="vb-shut-down-servers">Shut down game servers.</label>
</div>
<label htmlFor="vb-rcc-security-key" className="form-label mt-2">Update RCC Security Key</label>
<input type="text" id="vb-rcc-security-key" className="form-control" placeholder="New RCC Security Key" onChange={ changeEvent => this.setOptions('rccAccessKey', changeEvent.target.value) } />
<label htmlFor="vb-rcc-security-key" className="form-label mt-2">Update Version Compatibility Salt</label>
<input type="text" id="vb-rcc-security-key" className="form-control" placeholder="New Version Compatibility Salt" onChange={ changeEvent => this.setOptions('versionCompatiblityFuzzyKey', changeEvent.target.value) } />
</>
:
null
}
</DeploymentCard>
);
}
}
// https://stackoverflow.com/questions/1026069/how-do-i-make-the-first-letter-of-a-string-uppercase-in-javascript
function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
class Deployer extends Component {
constructor(props) {
super(props);
this.state = {
showModal: false,
deployType: 'deploy',
showTypeSwitchError: false,
deploying: false,
deployments: []
};
this.visibleModal = null;
this.updateDeploymentFiles = this.updateDeploymentFiles.bind(this);
this.deploymentExists = this.deploymentExists.bind(this);
this.getDeployment = this.getDeployment.bind(this);
this.createDeployment = this.createDeployment.bind(this);
this.removeDeployment = this.removeDeployment.bind(this);
this.trySetDeployType = this.trySetDeployType.bind(this);
this.uploadDeploymentFiles = this.uploadDeploymentFiles.bind(this);
this.uploadDeployments = this.uploadDeployments.bind(this);
this.setModal = this.setModal.bind(this);
}
updateDeploymentFiles(key, files) {
if(this.state.deploying) // XlXi: Prevent messing with the component when we're deploying.
return;
let deployment = this.getDeployment(key);
deployment['files'] = files;
}
updateDeploymentOptions(key, options) {
if(this.state.deploying) // XlXi: Prevent messing with the component when we're deploying.
return;
let deployment = this.getDeployment(key);
deployment['extraOptions'] = options;
}
deploymentExists(key) {
return this.state.deployments.some(deployment => deployment.key === key);
}
getDeployment(key) {
return this.state.deployments.find((deployment) => deployment.key === key);
}
createDeployment(key) {
if(this.state.deploying) // XlXi: Prevent messing with the component when we're deploying.
return;
let newElement = {
key: key,
name: capitalizeFirstLetter(this.state.deployType) + ' ' + capitalizeFirstLetter(key),
files: [],
extraOptions: []
};
this.setState(prevState => ({
deployments: [...prevState.deployments, newElement]
}));
}
removeDeployment(key) {
if(this.state.deploying) // XlXi: Prevent messing with the component when we're deploying.
return;
this.setState(prevState => ({
deployments: prevState.deployments.filter((deployment) => deployment.key !== key)
}));
}
trySetDeployType(evt, type) {
if(this.state.deploying) // XlXi: Prevent messing with the component when we're deploying.
return;
if(!evt.target.checked)
return;
if(this.state.deployType != type && this.state.deployments.length != 0)
{
this.setState({ showTypeSwitchError: true });
return;
}
this.setState({ deployType: type, showTypeSwitchError: false });
}
uploadDeploymentFiles(key, version) {
let deployment = this.getDeployment(key);
let bodyFormData = new FormData();
deployment.files.map((file) => {
bodyFormData.append('file[]', file);
});
Object.keys(deployment.extraOptions).forEach(function(key, index) {
bodyFormData.append(key, deployment.extraOptions[key]);
});
axios.post(
buildGenericApiUrl('api', `admin/v1/deploy/${ deployment.version }`),
bodyFormData
);
}
uploadDeployments() {
if(this.state.deploying) // XlXi: Prevent multiple deployments of the same files.
return;
this.setState({ deploying: true });
let requests = 0;
this.state.deployments.map((component, index) => {
axios.get(buildGenericApiUrl('api', `admin/v1/deploy?type=deploy&app=${ component.key }`))
.then(res => {
requests++;
this.state.deployments[index] = {...component, ...res.data};
if(requests == this.state.deployments.length)
this.setModal(<DeploymentUploadModal setModal={ this.setModal } deployments={ this.state.deployments } />);
this.uploadDeploymentFiles(component.key, component.version);
});
});
//this.setState({ deployments: [] });
}
setModal(modal = null) {
this.visibleModal = modal;
if(modal) {
this.setState({'showModal': true});
} else {
this.setState({'showModal': false});
}
}
render() {
return (
<>
<h5>Deployment Options</h5>
<div className="d-block">
<div className="btn-group mb-1">
<input type="radio" className="btn-check" name="vb-deployment-type" id="vb-deployment-deploy" autoComplete="off" onChange={ (e)=>this.trySetDeployType(e, 'deploy') } checked={ this.state.deployType == 'deploy' } />
<label className="btn btn-sm btn-outline-primary" htmlFor="vb-deployment-deploy">Deploy</label>
<input type="radio" className="btn-check" name="vb-deployment-type" id="vb-deployment-revert" autoComplete="off" onChange={ (e)=>this.trySetDeployType(e, 'revert') } checked={ this.state.deployType == 'revert' } />
<label className="btn btn-sm btn-outline-primary" htmlFor="vb-deployment-revert">Revert</label>
</div>
<br />
<button className="btn btn-sm btn-success" onClick={ ()=>this.createDeployment('client') } disabled={ this.deploymentExists('client') }>Deploy Client</button>
<button className="btn btn-sm btn-primary" onClick={ ()=>this.createDeployment('studio') } disabled={ this.deploymentExists('studio') }>Deploy Studio</button>
</div>
<hr />
{
this.state.showTypeSwitchError ?
<div className="alert alert-danger virtubrick-alert virtubrick-error-popup">Remove your { this.state.deployType == 'deploy' ? 'deployments' : 'reversions' } to change the uploader type.</div>
:
null
}
<h5>{ this.state.deployType == 'deploy' ? 'Staged Deployments' : 'Revert Deployments' }</h5>
{
this.state.deployments.length == 0 ?
<p className="text-muted">No deployments are selected.</p>
:
this.state.deployments.map((component) => {
// XlXi: surely theres a better way to do this.....
switch(this.state.deployType)
{
case 'deploy':
return <PushDeploymentCard name={ component.name } index={ component.key } key={ component.key } removeDeployment={ this.removeDeployment } updateDeploymentFiles={ (files)=>this.updateDeploymentFiles(component.key, files) } updateDeploymentOptions={ (options)=>this.updateDeploymentOptions(component.key, options) } />
case 'revert':
return <RevertDeploymentCard name={ component.name } index={ component.key } key={ component.key } removeDeployment={ this.removeDeployment } />
}
})
}
<hr />
<button className="btn btn-primary" onClick={ this.uploadDeployments } disabled={ this.state.deploying || this.state.deployments.length == 0 }>{ this.state.deployType == 'deploy' ? 'Deploy' : 'Revert Deployment' }</button>
{ this.state.showModal ? this.visibleModal : null }
</>
);
}
}
export default Deployer;