Initial commit

This commit is contained in:
Josias Castro
2026-06-18 17:20:35 -03:00
commit 8480111728
14 changed files with 8069 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules/
dist/
# Local deployment configuration contains server credentials.
targets.json
# Legacy local scripts contain embedded credentials.
0001-backup_gt_nova.ps1
0002-deploy_gt_nova_v3.ps1
# Local Windows shortcuts.
*.lnk
+346
View File
@@ -0,0 +1,346 @@
param(
[Parameter(Mandatory = $true)]
[string]$Version,
[Parameter(Mandatory = $true)]
[string]$ZipPath,
[string]$ReportsZipPath = "",
[Parameter(Mandatory = $true)]
[string]$TargetId,
[string]$TargetsPath = ""
)
$ErrorActionPreference = "Stop"
function Write-Step {
param([string]$Message)
Write-Host "[INFO] $Message"
}
function Write-Success {
param([string]$Message)
Write-Host "[SUCCESS] $Message"
}
function Get-ToolPath {
param(
[Parameter(Mandatory = $true)]
[string]$FileName,
[string[]]$FallbackPaths = @()
)
$localPath = Join-Path $PSScriptRoot $FileName
if (Test-Path $localPath) { return $localPath }
foreach ($path in $FallbackPaths) {
if (Test-Path $path) { return $path }
}
$command = Get-Command $FileName -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
throw "No se encontro $FileName. Copialo junto a la app o instalalo con PuTTY."
}
function Get-ZipEntryNames {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead($Path)
try {
return @(
$zip.Entries |
Where-Object { -not [string]::IsNullOrWhiteSpace($_.FullName) } |
ForEach-Object { $_.FullName.Replace("\", "/") } |
Where-Object {
$_ -and
$_ -notmatch "^__MACOSX/" -and
$_ -notmatch "(^|/)\.DS_Store$"
}
)
}
finally {
$zip.Dispose()
}
}
function ConvertTo-ShellArg {
param(
[Parameter(Mandatory = $true)]
[string]$Value
)
$singleQuote = [string][char]39
return $singleQuote + $Value.Replace($singleQuote, "$singleQuote\$singleQuote$singleQuote") + $singleQuote
}
function Invoke-Plink {
param(
[Parameter(Mandatory = $true)]
[string]$Command
)
& $script:Plink -ssh "$($script:Target.user)@$($script:Target.host)" -P $script:Target.port -pw $script:Target.password -batch $Command 2>&1
}
if ([string]::IsNullOrWhiteSpace($TargetsPath)) {
$TargetsPath = Join-Path $PSScriptRoot "targets.json"
}
if (-not (Test-Path $TargetsPath)) {
throw "No se encontro el archivo de destinos: $TargetsPath"
}
if (-not (Test-Path $ZipPath)) {
throw "No se encontro el ZIP principal: $ZipPath"
}
$targetsConfig = Get-Content -Raw -Path $TargetsPath | ConvertFrom-Json
$targetMatches = @($targetsConfig.targets | Where-Object { $_.id -eq $TargetId })
if ($targetMatches.Count -eq 0) {
throw "No existe el destino seleccionado: $TargetId"
}
$script:Target = $targetMatches[0]
$script:Plink = Get-ToolPath -FileName "plink.exe" -FallbackPaths @(
"C:\Program Files\PuTTY\plink.exe",
"C:\Program Files (x86)\PuTTY\plink.exe"
)
$pscp = Get-ToolPath -FileName "pscp.exe" -FallbackPaths @(
"C:\Program Files\PuTTY\pscp.exe",
"C:\Program Files (x86)\PuTTY\pscp.exe"
)
$zipFileName = Split-Path -Leaf $ZipPath
$reportesFolderName = "reportes_subidos_jasper"
$hasReportsZipPath = -not [string]::IsNullOrWhiteSpace($ReportsZipPath)
if ($hasReportsZipPath -and -not (Test-Path $ReportsZipPath)) {
throw "No se encontro el ZIP de reportes: $ReportsZipPath"
}
Write-Step "Version indicada: $Version"
Write-Step "Destino: $($Target.name) ($($Target.host):$($Target.port))"
Write-Step "Validando estructura del ZIP principal..."
$zipEntryNames = Get-ZipEntryNames -Path $ZipPath
if ($zipEntryNames.Count -eq 0) {
throw "El ZIP principal esta vacio: $ZipPath"
}
$topLevelNames = @(
$zipEntryNames |
ForEach-Object { ($_ -split "/", 2)[0] } |
Where-Object { $_ } |
Sort-Object -Unique
)
$topLevelFiles = @(
$zipEntryNames |
Where-Object { ($_ -notmatch "/") -and (-not $_.EndsWith("/")) }
)
$zipRootPrefix = $null
if (($topLevelNames.Count -eq 1) -and ($topLevelFiles.Count -eq 0)) {
$zipRootPrefix = $topLevelNames[0]
Write-Step "El ZIP contiene una carpeta raiz ($zipRootPrefix). Se desplegara su contenido."
}
$deployEntryNames = $zipEntryNames
if ($zipRootPrefix) {
$rootPrefixPattern = "^" + [regex]::Escape($zipRootPrefix.TrimEnd("/")) + "/"
$deployEntryNames = @(
$zipEntryNames |
Where-Object { $_ -match $rootPrefixPattern } |
ForEach-Object { $_ -replace $rootPrefixPattern, "" } |
Where-Object { $_ }
)
}
$deployTopLevelFolders = @(
$deployEntryNames |
Where-Object { $_ -match "/" } |
ForEach-Object { ($_ -split "/", 2)[0] } |
Where-Object { $_ } |
Sort-Object -Unique
)
if ($deployTopLevelFolders.Count -le 1) {
throw "El contenido a desplegar debe tener mas de una carpeta en el nivel padre."
}
$hasReportesFolder = @(
$deployEntryNames |
Where-Object {
$_ -eq "$reportesFolderName/" -or
$_ -like "$reportesFolderName/*"
}
).Count -gt 0
$needsReportesZip = -not $hasReportesFolder
$reportesZipExtractMode = "direct"
$reportesZipName = ""
if ($needsReportesZip) {
if (-not $hasReportsZipPath) {
Write-Step "El ZIP no incluye $reportesFolderName y no se selecciono ZIP auxiliar. Se continua sin reportes."
}
else {
$reportesZipName = Split-Path -Leaf $ReportsZipPath
$reportesEntryNames = Get-ZipEntryNames -Path $ReportsZipPath
if ($reportesEntryNames.Count -eq 0) {
throw "El ZIP de reportes esta vacio: $ReportsZipPath"
}
$reportesZipHasFolder = @(
$reportesEntryNames |
Where-Object {
$_ -eq "$reportesFolderName/" -or
$_ -like "$reportesFolderName/*"
}
).Count -gt 0
if (-not $reportesZipHasFolder) {
$reportesZipExtractMode = "into-folder"
Write-Step "El ZIP de reportes no contiene carpeta raiz. Se extraera dentro de $reportesFolderName."
}
}
}
$remoteUploadPath = $Target.remoteUploadPath
$remoteAppPath = $Target.remoteAppPath
$backupDir = $Target.backupDir
$jasperstarterPath = "$remoteAppPath/_lib/libraries/grp/composer/vendor/geekcom/phpjasper/bin/jasperstarter/bin/jasperstarter"
$remoteTempPath = "$remoteUploadPath/gt_nova_deploy_tmp_$Version"
$remoteZipPath = "$remoteUploadPath/$zipFileName"
$remoteReportesZipPath = if ($reportesZipName) { "$remoteUploadPath/$reportesZipName" } else { "" }
$remoteExtractSource = if ($zipRootPrefix) { "$remoteTempPath/$zipRootPrefix" } else { $remoteTempPath }
$remoteAppArg = ConvertTo-ShellArg $remoteAppPath
$remoteTempArg = ConvertTo-ShellArg $remoteTempPath
$remoteZipArg = ConvertTo-ShellArg $remoteZipPath
$remoteExtractSourceArg = ConvertTo-ShellArg $remoteExtractSource
$remoteReportesFolderArg = ConvertTo-ShellArg "$remoteAppPath/$reportesFolderName"
$remoteReportesZipArg = if ($remoteReportesZipPath) { ConvertTo-ShellArg $remoteReportesZipPath } else { "" }
$jasperstarterArg = ConvertTo-ShellArg $jasperstarterPath
Write-Step "Probando conexion SSH..."
$connectionCheck = Invoke-Plink -Command "echo SSH_OK"
if ($connectionCheck -notmatch "SSH_OK") {
throw "No se pudo conectar por SSH. Salida: $connectionCheck"
}
Write-Success "Conexion SSH OK."
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$backupFileName = "backup_gt_nova_${Version}_$timestamp.tar.gz"
$backupCommand = "mkdir -p $(ConvertTo-ShellArg $backupDir) && tar -czf $(ConvertTo-ShellArg "$backupDir/$backupFileName") -C /var/www/html $(Split-Path -Leaf $remoteAppPath) && echo BACKUP_OK"
Write-Step "Creando backup remoto..."
$backupOutput = Invoke-Plink -Command $backupCommand
if ($backupOutput -notmatch "BACKUP_OK") {
throw "Error al crear backup remoto. Salida: $backupOutput"
}
Write-Success "Backup creado: $backupDir/$backupFileName"
Write-Step "Subiendo ZIP principal..."
& $pscp -P $Target.port -pw $Target.password $ZipPath "$($Target.user)@$($Target.host):$remoteUploadPath/" 2>&1 | ForEach-Object { Write-Host $_ }
if ($LASTEXITCODE -ne 0) {
throw "Error al subir el ZIP principal."
}
Write-Success "ZIP principal subido."
$shouldUploadReportsZip = $needsReportesZip -and $hasReportsZipPath
if ($shouldUploadReportsZip) {
Write-Step "Subiendo ZIP auxiliar de reportes..."
& $pscp -P $Target.port -pw $Target.password $ReportsZipPath "$($Target.user)@$($Target.host):$remoteUploadPath/" 2>&1 | ForEach-Object { Write-Host $_ }
if ($LASTEXITCODE -ne 0) {
throw "Error al subir el ZIP de reportes."
}
Write-Success "ZIP de reportes subido."
}
$cmdReportes = ":"
if ($shouldUploadReportsZip -and $reportesZipExtractMode -eq "direct") {
$cmdReportes = "sudo unzip -oq $remoteReportesZipArg -d $remoteAppArg"
}
elseif ($shouldUploadReportsZip -and $reportesZipExtractMode -eq "into-folder") {
$cmdReportes = @"
sudo mkdir -p $remoteReportesFolderArg
sudo unzip -oq $remoteReportesZipArg -d $remoteReportesFolderArg
"@
}
Write-Step "Descomprimiendo y reemplazando aplicacion..."
$deployCommand = @"
set -e
sudo rm -rf $remoteTempArg
sudo mkdir -p $remoteTempArg $remoteAppArg
sudo unzip -q $remoteZipArg -d $remoteTempArg
sudo find $remoteAppArg -mindepth 1 -maxdepth 1 -exec rm -rf {} +
sudo cp -a $remoteExtractSourceArg/. $remoteAppArg/
$cmdReportes
sudo rm -rf $remoteTempArg
echo UNZIP_OK
"@
$deployOutput = Invoke-Plink -Command $deployCommand
if ($deployOutput -notmatch "UNZIP_OK") {
throw "Error al descomprimir o reemplazar aplicacion. Salida: $deployOutput"
}
Write-Success "Aplicacion reemplazada."
$serviceRestartLines = @()
foreach ($service in @($Target.services)) {
if (-not [string]::IsNullOrWhiteSpace($service)) {
$serviceRestartLines += "sudo systemctl restart $(ConvertTo-ShellArg $service)"
}
}
$cleanupReportes = if ($shouldUploadReportsZip) { "sudo rm -f $remoteReportesZipArg" } else { ":" }
$fixJasperstarterPermissions = ":"
if ($Target.host -eq "4.246.183.98") {
$fixJasperstarterPermissions = @"
if [ -f $jasperstarterArg ]; then
sudo chmod 755 $jasperstarterArg
ls -l $jasperstarterArg
else
echo JASPERSTARTER_NOT_FOUND
exit 1
fi
"@
}
$postDeployCommand = @"
set -e
sudo chown -R www-data:www-data $remoteAppArg
sudo find $remoteAppArg -type d -exec chmod 755 {} \;
sudo find $remoteAppArg -type f -exec chmod 644 {} \;
$fixJasperstarterPermissions
$($serviceRestartLines -join "`n")
sudo rm -f $remoteZipArg
$cleanupReportes
echo DEPLOY_OK
"@
Write-Step "Aplicando permisos, reiniciando servicios y limpiando temporales..."
$postDeployOutput = Invoke-Plink -Command $postDeployCommand
if ($postDeployOutput -notmatch "DEPLOY_OK") {
throw "El deploy reemplazo archivos, pero fallo el post-deploy. Salida: $postDeployOutput"
}
if ($Target.host -eq "4.246.183.98") {
if ($postDeployOutput -notmatch "jasperstarter") {
throw "El deploy finalizo, pero no se pudo verificar el permiso de jasperstarter. Salida: $postDeployOutput"
}
Write-Success "Permiso 755 aplicado a jasperstarter."
}
Write-Success "Deploy finalizado correctamente para version $Version en $($Target.name)."
+14
View File
@@ -0,0 +1,14 @@
<?php
$str_apl = 'app_Login';
if(is_file("_lib/_app_data/" . $str_apl . '_ini.php'))
{
require("_lib/_app_data/" . $str_apl . '_ini.php');
$str_apl = $arr_data['friendly_url'];
}
else
{
$str_apl = $str_apl . '/';
}
header("Location: " . $str_apl);
?>
+6686
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "deploy-master",
"version": "1.0.0",
"description": "Interfaz simple para desplegar GT ARIES por SSH.",
"main": "src/main.js",
"scripts": {
"start": "electron .",
"dist": "electron-builder --win dir"
},
"devDependencies": {
"electron": "^30.5.1",
"electron-builder": "^24.13.3"
},
"build": {
"appId": "com.gt.deploymaster",
"productName": "Deploy Master",
"asar": false,
"files": [
"src/**/*",
"deploy.ps1",
"targets.json",
"plink.exe",
"pscp.exe",
"package.json"
],
"win": {
"target": "dir",
"signAndEditExecutable": false
}
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+84
View File
@@ -0,0 +1,84 @@
## **Guía paso a paso Despliegue GT ARIES / GT NOVA**
####
#### Preparación
Antes de comenzar, asegurarse de contar con el paquete de despliegue y los scripts necesarios.
El archivo comprimido que se utilizará para el despliegue debe estar en el mismo directorio donde se encuentran los scripts.
Este archivo debe renombrarse obligatoriamente a GT\_ARIES.zip, ya que los scripts esperan exactamente ese nombre para poder ejecutarse correctamente.
### Procedimiento
Paso 1. Instalar PuTTY (si no esta instalado)
Descargar e instalar el cliente SSH PuTTY en la máquina desde la cual se realizará el despliegue.
Paso 2. Verificar la presencia de plink
Dentro del mismo paquete de PuTTY se incluye Plink, que es la versión de línea de comandos del cliente SSH.
Copiar el archivo plink.exe dentro del directorio donde se encuentran los scripts de despliegue (.ps1).
Esto es necesario para que los scripts puedan ejecutar comandos remotos sobre el servidor.
Paso 3. Ejecutar el script de backup
Abrir una consola de Windows PowerShell.
Navegar hasta el directorio donde se encuentran los scripts y ejecutar:
.\\0001-backup\_gt\_nova.ps1
Este script realizará un respaldo previo del sistema en el servidor antes de iniciar el proceso de despliegue.
Paso 4. Ejecutar el script de despliegue
Una vez finalizado correctamente el backup, ejecutar el segundo script desde la misma consola:
.\\0002-deploy\_gt\_nova\_v2.ps1
Este script realizará el despliegue del paquete GT\_ARIES.zip en el servidor.
Paso 5. Verificar configuración de base de datos
Si durante el despliegue se instalaron nuevamente todas las librerías del sistema, será obligatorio reconfigurar la conexión con el servidor de base de datos.
Esto implica revisar y actualizar los parámetros de conexión (servidor, base de datos, usuario y credenciales) dentro de la configuración de la aplicación.
Resultado esperado
Luego de completar estos pasos, el sistema debería quedar desplegado en el servidor con el nuevo paquete y con la configuración de base de datos correctamente establecida.
+146
View File
@@ -0,0 +1,146 @@
const { app, BrowserWindow, dialog, ipcMain } = require("electron");
const path = require("path");
const fs = require("fs");
const { spawn } = require("child_process");
let mainWindow;
let deployProcess = null;
function getAppRoot() {
return app.getAppPath();
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 460,
height: 760,
minWidth: 420,
minHeight: 680,
backgroundColor: "#111820",
title: "Deploy Master",
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false
}
});
mainWindow.setMenuBarVisibility(false);
mainWindow.loadFile(path.join(__dirname, "renderer", "index.html"));
}
function readTargets() {
const targetsPath = path.join(getAppRoot(), "targets.json");
const content = fs.readFileSync(targetsPath, "utf8");
const config = JSON.parse(content);
return Array.isArray(config.targets)
? config.targets.map(target => ({
id: target.id,
name: target.name,
host: target.host,
port: target.port,
remoteAppPath: target.remoteAppPath
}))
: [];
}
function sendDeployEvent(payload) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("deploy:event", payload);
}
}
app.whenReady().then(createWindow);
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
ipcMain.handle("targets:list", () => readTargets());
ipcMain.handle("dialog:zip", async (_, options = {}) => {
const result = await dialog.showOpenDialog(mainWindow, {
title: options.title || "Seleccionar ZIP",
properties: ["openFile"],
filters: [{ name: "ZIP", extensions: ["zip"] }]
});
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
});
ipcMain.handle("deploy:start", async (_, payload) => {
if (deployProcess) {
throw new Error("Ya hay un deploy en ejecucion.");
}
const version = String(payload.version || "").trim();
const zipPath = String(payload.zipPath || "").trim();
const reportsZipPath = String(payload.reportsZipPath || "").trim();
const targetId = String(payload.targetId || "").trim();
if (!version) throw new Error("Completa el numero de version.");
if (!zipPath) throw new Error("Selecciona el archivo GT_ARIES.zip.");
if (!targetId) throw new Error("Selecciona un destino.");
const scriptPath = path.join(getAppRoot(), "deploy.ps1");
const targetsPath = path.join(getAppRoot(), "targets.json");
const args = [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
scriptPath,
"-Version",
version,
"-ZipPath",
zipPath,
"-TargetId",
targetId,
"-TargetsPath",
targetsPath
];
if (reportsZipPath) {
args.push("-ReportsZipPath", reportsZipPath);
}
sendDeployEvent({ type: "started" });
deployProcess = spawn("powershell.exe", args, {
cwd: getAppRoot(),
windowsHide: true
});
deployProcess.stdout.on("data", chunk => {
sendDeployEvent({ type: "stdout", text: chunk.toString() });
});
deployProcess.stderr.on("data", chunk => {
sendDeployEvent({ type: "stderr", text: chunk.toString() });
});
deployProcess.on("error", error => {
sendDeployEvent({ type: "error", text: error.message });
deployProcess = null;
});
deployProcess.on("close", code => {
sendDeployEvent({ type: "finished", code });
deployProcess = null;
});
return { ok: true };
});
ipcMain.handle("deploy:stop", async () => {
if (!deployProcess) return { ok: true };
deployProcess.kill();
deployProcess = null;
sendDeployEvent({ type: "finished", code: -1 });
return { ok: true };
});
+13
View File
@@ -0,0 +1,13 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("deployMaster", {
listTargets: () => ipcRenderer.invoke("targets:list"),
chooseZip: options => ipcRenderer.invoke("dialog:zip", options),
startDeploy: payload => ipcRenderer.invoke("deploy:start", payload),
stopDeploy: () => ipcRenderer.invoke("deploy:stop"),
onDeployEvent: callback => {
const listener = (_, payload) => callback(payload);
ipcRenderer.on("deploy:event", listener);
return () => ipcRenderer.removeListener("deploy:event", listener);
}
});
+97
View File
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deploy Master</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="shell">
<header class="titlebar">
<div class="brand">
<svg class="rocket" viewBox="0 0 24 24" aria-hidden="true">
<path d="M14.6 3.2c2.1-.9 4.4-1 6.2-.7.3 1.8.2 4.1-.7 6.2-1 2.4-2.9 4.6-5.9 6.7l-1.7-1.7-2.6 2.6-2.2-2.2 2.6-2.6-1.7-1.7c2.1-3 4.3-4.9 6.7-5.8ZM16 8.5a1.7 1.7 0 1 0 3.4 0A1.7 1.7 0 0 0 16 8.5ZM6.4 15.9l1.7 1.7c-.8 1.8-2.5 3.3-5.1 4 .7-2.6 2.2-4.3 4-5.1ZM4.6 9.7c1.2-2.3 2.7-3.9 4.8-4.8-.9 1.2-1.7 2.5-2.4 4L4.6 9.7Zm9.7 9.7.8-2.4c1.5-.7 2.8-1.5 4-2.4-.9 2.1-2.5 3.6-4.8 4.8Z"/>
</svg>
<div>
<h1>Deploy Master</h1>
<span>v1.0</span>
</div>
</div>
</header>
<section class="panel">
<div class="section-title">
<span>Deploy Configuration</span>
</div>
<label class="field">
<span>Version a subir</span>
<input id="version" type="text" placeholder="1.7.3" autocomplete="off">
</label>
<div class="field">
<span>GT_ARIES.zip</span>
<div class="file-row">
<input id="zip-path" type="text" placeholder="Seleccionar ZIP principal" readonly>
<button id="choose-zip" class="icon-button" type="button" title="Seleccionar GT_ARIES.zip">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H11l2 2h4.5A2.5 2.5 0 0 1 20 7.5v11A2.5 2.5 0 0 1 17.5 21h-11A2.5 2.5 0 0 1 4 18.5v-13Zm2.5-.5a.5.5 0 0 0-.5.5v13c0 .3.2.5.5.5h11c.3 0 .5-.2.5-.5v-11c0-.3-.2-.5-.5-.5h-5.3l-2-2H6.5Z"/>
</svg>
</button>
</div>
</div>
<div class="field">
<span>reportes_subidos_jasper.zip</span>
<div class="file-row">
<input id="reports-path" type="text" placeholder="Opcional" readonly>
<button id="choose-reports" class="icon-button" type="button" title="Seleccionar reportes">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H11l2 2h4.5A2.5 2.5 0 0 1 20 7.5v11A2.5 2.5 0 0 1 17.5 21h-11A2.5 2.5 0 0 1 4 18.5v-13Zm2.5-.5a.5.5 0 0 0-.5.5v13c0 .3.2.5.5.5h11c.3 0 .5-.2.5-.5v-11c0-.3-.2-.5-.5-.5h-5.3l-2-2H6.5Z"/>
</svg>
</button>
<button id="clear-reports" class="icon-button" type="button" title="Quitar reportes">x</button>
</div>
</div>
<label class="field">
<span>Destino SSH</span>
<select id="target"></select>
</label>
<div id="steps" class="steps">
<div class="step pending" data-step="validate">
<div class="step-dot"></div>
<div>Validar</div>
</div>
<div class="step pending" data-step="backup">
<div class="step-dot"></div>
<div>Backup</div>
</div>
<div class="step pending" data-step="upload">
<div class="step-dot"></div>
<div>Subir</div>
</div>
<div class="step pending" data-step="deploy">
<div class="step-dot"></div>
<div>Deploy</div>
</div>
</div>
<button id="run-deploy" class="primary-button" type="button">Ejecutar Deploy</button>
<button id="stop-deploy" class="secondary-button hidden" type="button">Detener</button>
</section>
<section class="console">
<div class="console-head">
<span>Consola de Logs</span>
<button id="clear-logs" class="clear-button" type="button" aria-label="Limpiar consola">x</button>
</div>
<pre id="logs"></pre>
</section>
</main>
<script src="renderer.js"></script>
</body>
</html>
+247
View File
@@ -0,0 +1,247 @@
const state = {
running: false,
logs: [],
stepStatus: {
validate: "pending",
backup: "pending",
upload: "pending",
deploy: "pending"
}
};
const versionEl = document.getElementById("version");
const zipPathEl = document.getElementById("zip-path");
const reportsPathEl = document.getElementById("reports-path");
const targetEl = document.getElementById("target");
const chooseZipButton = document.getElementById("choose-zip");
const chooseReportsButton = document.getElementById("choose-reports");
const clearReportsButton = document.getElementById("clear-reports");
const runButton = document.getElementById("run-deploy");
const stopButton = document.getElementById("stop-deploy");
const clearLogsButton = document.getElementById("clear-logs");
const logsEl = document.getElementById("logs");
document.addEventListener("DOMContentLoaded", init);
async function init() {
bindEvents();
await loadTargets();
resetSteps();
log("INFO", "Deploy Master listo.");
}
function bindEvents() {
chooseZipButton.addEventListener("click", async () => {
const path = await window.deployMaster.chooseZip({ title: "Seleccionar GT_ARIES.zip" });
if (path) zipPathEl.value = path;
});
chooseReportsButton.addEventListener("click", async () => {
const path = await window.deployMaster.chooseZip({ title: "Seleccionar reportes_subidos_jasper.zip" });
if (path) reportsPathEl.value = path;
});
clearReportsButton.addEventListener("click", () => {
reportsPathEl.value = "";
});
runButton.addEventListener("click", runDeploy);
stopButton.addEventListener("click", stopDeploy);
clearLogsButton.addEventListener("click", () => {
state.logs = [];
renderLogs();
});
window.deployMaster.onDeployEvent(handleDeployEvent);
}
async function loadTargets() {
try {
const targets = await window.deployMaster.listTargets();
targetEl.innerHTML = targets
.map(target => `<option value="${escapeHtml(target.id)}">${escapeHtml(target.name)} - ${escapeHtml(target.host)}:${escapeHtml(String(target.port))}</option>`)
.join("");
if (!targets.length) {
targetEl.innerHTML = '<option value="">Sin destinos configurados</option>';
log("WARN", "No hay destinos en targets.json.");
}
} catch (error) {
targetEl.innerHTML = '<option value="">Error leyendo destinos</option>';
log("ERROR", error.message || "No se pudieron cargar los destinos.");
}
}
async function runDeploy() {
if (state.running) return;
resetSteps();
const payload = {
version: versionEl.value,
zipPath: zipPathEl.value,
reportsZipPath: reportsPathEl.value,
targetId: targetEl.value
};
try {
setRunning(true);
log("INFO", "Deploy iniciado...");
setStep("validate", "running");
await window.deployMaster.startDeploy(payload);
} catch (error) {
setStep("validate", "error");
setRunning(false);
log("ERROR", error.message || "No se pudo iniciar el deploy.");
}
}
async function stopDeploy() {
await window.deployMaster.stopDeploy();
log("WARN", "Deploy detenido por el usuario.");
}
function handleDeployEvent(event) {
if (event.type === "stdout" || event.type === "stderr") {
appendProcessOutput(event.text, event.type === "stderr");
}
if (event.type === "error") {
setCurrentStepError();
log("ERROR", event.text);
}
if (event.type === "finished") {
if (event.code === 0) {
Object.keys(state.stepStatus).forEach(step => setStep(step, "done"));
log("SUCCESS", "Proceso finalizado correctamente.");
} else if (event.code !== -1) {
setCurrentStepError();
log("ERROR", `El proceso termino con codigo ${event.code}.`);
}
setRunning(false);
}
}
function appendProcessOutput(text, isError) {
const lines = text.split(/\r?\n/).filter(Boolean);
for (const line of lines) {
const level = detectLevel(line, isError);
log(level, line);
updateStepsFromLine(line);
}
}
function detectLevel(line, isError) {
if (isError) return "ERROR";
if (line.includes("[SUCCESS]")) return "SUCCESS";
if (line.includes("[WARN]")) return "WARN";
if (line.toLowerCase().includes("error")) return "ERROR";
return "INFO";
}
function updateStepsFromLine(line) {
if (line.includes("Validando estructura")) {
setStep("validate", "running");
}
if (line.includes("Conexion SSH OK")) {
setStep("validate", "done");
setStep("backup", "running");
}
if (line.includes("Backup creado")) {
setStep("backup", "done");
setStep("upload", "running");
}
if (line.includes("ZIP principal subido")) {
setStep("upload", "done");
setStep("deploy", "running");
}
if (line.includes("Aplicacion reemplazada")) {
setStep("deploy", "running");
}
if (line.includes("Deploy finalizado correctamente")) {
setStep("deploy", "done");
}
if (line.toLowerCase().includes("error") || line.includes("Exception")) {
setCurrentStepError();
}
}
function resetSteps() {
Object.keys(state.stepStatus).forEach(step => {
state.stepStatus[step] = "pending";
});
renderSteps();
}
function setStep(step, status) {
state.stepStatus[step] = status;
renderSteps();
}
function setCurrentStepError() {
const runningStep = Object.keys(state.stepStatus).find(step => state.stepStatus[step] === "running");
if (runningStep) setStep(runningStep, "error");
}
function renderSteps() {
document.querySelectorAll("[data-step]").forEach(element => {
const status = state.stepStatus[element.dataset.step] || "pending";
element.classList.remove("pending", "running", "done", "error");
element.classList.add(status);
});
}
function setRunning(isRunning) {
state.running = isRunning;
runButton.disabled = isRunning;
versionEl.disabled = isRunning;
chooseZipButton.disabled = isRunning;
chooseReportsButton.disabled = isRunning;
clearReportsButton.disabled = isRunning;
targetEl.disabled = isRunning;
stopButton.classList.toggle("hidden", !isRunning);
runButton.textContent = isRunning ? "Ejecutando..." : "Ejecutar Deploy";
}
function log(level, message) {
const normalizedLevel = level === "WARN" ? "WARNING" : level;
state.logs.push({ level: normalizedLevel, line: message });
renderLogs();
}
function renderLogs() {
logsEl.innerHTML = state.logs
.map(entry => {
const className = entry.level === "SUCCESS"
? "log-success"
: entry.level === "ERROR"
? "log-error"
: entry.level === "WARNING"
? "log-warning"
: "";
return `<span class="${className}">${escapeHtml(entry.line)}</span>`;
})
.join("\n");
logsEl.scrollTop = logsEl.scrollHeight;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
+377
View File
@@ -0,0 +1,377 @@
:root {
color-scheme: dark;
--bg: #111820;
--panel: #20303f;
--panel-2: #192633;
--line: #425569;
--text: #eef5ff;
--muted: #9aaabd;
--green: #36d787;
--blue: #3f9dff;
--error: #ff6b6b;
--warning: #ffd166;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 420px;
font-family: Inter, "Segoe UI", Arial, sans-serif;
background:
radial-gradient(circle at 20% 0%, rgba(73, 130, 190, 0.28), transparent 28%),
linear-gradient(180deg, #182230 0%, #111820 100%);
color: var(--text);
}
button,
input,
select {
font: inherit;
}
.shell {
min-height: 100vh;
padding-bottom: 10px;
}
.titlebar {
padding: 12px 18px;
background: linear-gradient(180deg, #253244 0%, #172232 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand h1 {
display: inline;
margin: 0;
font-size: 25px;
line-height: 1;
font-weight: 750;
letter-spacing: 0;
}
.brand span {
margin-left: 5px;
color: var(--muted);
font-size: 13px;
}
.rocket {
width: 30px;
height: 30px;
fill: #a9bdd2;
}
.panel {
margin: 14px 12px;
padding: 15px 17px 16px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 8px;
background:
linear-gradient(145deg, rgba(42, 60, 78, 0.92), rgba(25, 38, 51, 0.94)),
var(--panel);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 6px 18px rgba(0, 0, 0, 0.22);
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 15px;
color: #f5f8fc;
font-size: 19px;
}
.section-title::after {
content: "";
flex: 1;
height: 1px;
background: linear-gradient(90deg, #7b8da1, transparent);
}
.field {
display: grid;
gap: 7px;
margin-bottom: 13px;
color: #d9e6f2;
font-size: 13px;
}
.field > span {
color: #b5c5d7;
}
input,
select {
width: 100%;
min-height: 38px;
border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 7px;
color: #edf4fa;
background: rgba(30, 46, 61, 0.92);
outline: none;
}
input {
padding: 0 11px;
}
select {
padding: 0 10px;
cursor: pointer;
}
input:focus,
select:focus {
border-color: rgba(91, 178, 255, 0.85);
box-shadow: 0 0 0 3px rgba(63, 157, 255, 0.18);
}
.file-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 42px;
gap: 7px;
}
.file-row:has(#clear-reports) {
grid-template-columns: minmax(0, 1fr) 42px 42px;
}
.icon-button,
.clear-button {
display: grid;
place-items: center;
min-width: 38px;
min-height: 38px;
border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 7px;
color: #edf4fa;
background: rgba(30, 46, 61, 0.78);
cursor: pointer;
}
.icon-button svg {
width: 21px;
height: 21px;
fill: #d8e4ef;
}
.steps {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin: 16px 3px 18px;
}
.step {
display: grid;
justify-items: center;
gap: 7px;
min-height: 70px;
color: var(--muted);
text-align: center;
font-size: 13px;
line-height: 1.12;
position: relative;
}
.step::after {
content: "";
position: absolute;
top: 18px;
left: calc(50% + 21px);
width: calc(100% - 14px);
height: 3px;
background: var(--line);
border-radius: 999px;
}
.step:nth-child(4)::after {
display: none;
}
.step-dot {
display: grid;
place-items: center;
width: 37px;
height: 37px;
border-radius: 50%;
background: #5b6470;
color: rgba(255, 255, 255, 0.72);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.step-dot::before {
content: "";
width: 13px;
height: 7px;
border-left: 3px solid currentColor;
border-bottom: 3px solid currentColor;
transform: rotate(-45deg) translate(1px, -1px);
}
.step.pending .step-dot::before {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: currentColor;
opacity: 0.38;
transform: none;
}
.step.running,
.step.done {
color: #eefaf4;
}
.step.running .step-dot,
.step.done .step-dot {
background: linear-gradient(180deg, #46d98b, #23b86a);
color: #153927;
box-shadow: 0 0 14px rgba(54, 215, 135, 0.45);
}
.step.running .step-dot {
animation: pulse 900ms ease-in-out infinite;
}
.step.error .step-dot {
background: var(--error);
color: #391414;
}
.step.error .step-dot::before {
width: 16px;
height: 3px;
border: 0;
border-radius: 999px;
background: currentColor;
transform: rotate(45deg);
box-shadow: 0 0 0 0 currentColor;
}
.step.done::after,
.step.running::after {
background: var(--green);
box-shadow: 0 0 8px rgba(54, 215, 135, 0.28);
}
.primary-button,
.secondary-button {
width: 100%;
min-height: 45px;
border-radius: 7px;
color: white;
font-size: 18px;
font-weight: 650;
cursor: pointer;
}
.primary-button {
border: 1px solid rgba(180, 220, 255, 0.38);
background: linear-gradient(180deg, #5bb2ff 0%, #267de1 100%);
box-shadow: 0 0 12px rgba(63, 157, 255, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.26);
}
.secondary-button {
margin-top: 9px;
border: 1px solid rgba(255, 107, 107, 0.44);
background: rgba(86, 38, 44, 0.72);
}
.primary-button:disabled,
.secondary-button:disabled {
opacity: 0.58;
cursor: default;
}
.hidden {
display: none;
}
.console {
margin: 8px 0 0;
border-top: 1px solid rgba(255, 255, 255, 0.07);
background: #1b1f22;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.console-head {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 10px 0 14px;
color: #d4d7da;
font-size: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.console-head::before {
content: "";
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #aab0b5;
margin-right: 8px;
}
.console-head span {
flex: 1;
}
.clear-button {
width: 26px;
min-width: 26px;
min-height: 26px;
border: 0;
color: #b7bdc3;
background: transparent;
font-size: 20px;
line-height: 1;
}
#logs {
min-height: 190px;
max-height: 270px;
margin: 0;
padding: 10px 14px;
overflow: auto;
color: #d7d7d7;
font: 12px/1.35 Consolas, "Courier New", monospace;
white-space: pre-wrap;
}
.log-success {
color: #63dd8d;
}
.log-error {
color: #ff8585;
}
.log-warning {
color: var(--warning);
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"targets": [
{
"id": "example-target",
"name": "Example Target",
"host": "example.com",
"port": 22,
"user": "deploy-user",
"password": "change-me",
"remoteUploadPath": "/home/deploy-user",
"remoteAppPath": "/var/www/html/app",
"backupDir": "/home/deploy-user/backups",
"services": ["php8.1-fpm", "nginx"]
}
]
}