From cb128944551101585591a9ac5cf252358a54b05d Mon Sep 17 00:00:00 2001 From: Naiel <109038805+naielv@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:39:13 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20agente=20de=20Windows=20y?= =?UTF-8?q?=20soporte=20para=20control=20remoto=20de=20ordenadores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/windows-agent-release.yml | 46 +++ .gitignore | 11 +- README.md | 75 +++++ python_sdk/__init__.py | 3 + python_sdk/telesec_couchdb.py | 293 ++++++++++++++++++++ python_sdk/windows_agent.py | 176 ++++++++++++ requirements.txt | 4 +- src/page/aulas.js | 214 ++++++++++++++ 8 files changed, 820 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/windows-agent-release.yml create mode 100644 python_sdk/__init__.py create mode 100644 python_sdk/telesec_couchdb.py create mode 100644 python_sdk/windows_agent.py diff --git a/.github/workflows/windows-agent-release.yml b/.github/workflows/windows-agent-release.yml new file mode 100644 index 0000000..cdabf1e --- /dev/null +++ b/.github/workflows/windows-agent-release.yml @@ -0,0 +1,46 @@ +name: Build Windows Agent (Release) + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-windows-agent: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + shell: bash + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Build hidden EXE with PyInstaller + shell: bash + run: | + pyinstaller --noconfirm --clean --onefile --noconsole --name telesec-windows-agent python_sdk/windows_agent.py + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: telesec-windows-agent + path: dist/telesec-windows-agent.exe + + - name: Upload asset to GitHub Release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: dist/telesec-windows-agent.exe diff --git a/.gitignore b/.gitignore index 26bc107..f0cae31 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,13 @@ dist/* radata/* node_modules/* .DS_Store -._* \ No newline at end of file +._* +# Python +__pycache__/* +*.pyc +*.pyo +*.pyd +*.egg-info/* +*.egg +.venv/* +venv/* \ No newline at end of file diff --git a/README.md b/README.md index 1c0314b..856fb71 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,77 @@ # TeleSec Nuevo programa de datos + +## Python SDK (CouchDB directo) + +Se añadió un SDK Python en `python_sdk/` para acceder directamente a CouchDB (sin replicación local), compatible con el formato de cifrado de `TS_encrypt`: + +- Formato: `RSA{...}` +- Algoritmo: `CryptoJS.AES.encrypt(payload, secret)` (modo passphrase/OpenSSL) + +### Instalación + +```bash +pip install -r requirements.txt +``` + +### Uso rápido + +```python +from python_sdk import TeleSecCouchDB + +db = TeleSecCouchDB( + server_url="https://tu-couchdb", + dbname="telesec", + username="usuario", + password="clave", + secret="SECRET123", +) + +# Guardar cifrado (como TS_encrypt) +db.put("personas", "abc123", {"nombre": "Ana"}, encrypt=True) + +# Leer y descifrar +obj = db.get("personas", "abc123", decrypt=True) + +# Listar una tabla +rows = db.list("personas", decrypt=True) +for row in rows: + print(row.id, row.data) +``` + +API principal: + +- `TeleSecCouchDB.put(table, item_id, data, encrypt=True)` +- `TeleSecCouchDB.get(table, item_id, decrypt=True)` +- `TeleSecCouchDB.list(table, decrypt=True)` +- `TeleSecCouchDB.delete(table, item_id)` +- `ts_encrypt(value, secret)` / `ts_decrypt(value, secret)` + +## Agente Windows (Gest-Aula > Ordenadores) + +Se añadió soporte para control de ordenadores del aula: + +- Tabla: `aulas_ordenadores` +- Campos reportados por agente: `Hostname`, `UsuarioActual`, `AppActualEjecutable`, `AppActualTitulo`, `LastSeenAt` +- Control remoto: `ShutdownBeforeDate` (programado desde web a `hora_servidor + 2 minutos`) + +### Ejecutar agente en Windows + +```bash +python -m python_sdk.windows_agent \ + --server "https://tu-couchdb" \ + --db "telesec" \ + --user "usuario" \ + --password "clave" \ + --secret "SECRET123" +``` + +Opciones útiles: + +- `--once`: una sola iteración +- `--interval 15`: intervalo (segundos) +- `--dry-run`: no apaga realmente, solo simula + +### Hora de servidor (sin depender del reloj local) + +El frontend y el agente usan la hora del servidor (cabecera HTTP `Date` de CouchDB) para comparar `ShutdownBeforeDate`. diff --git a/python_sdk/__init__.py b/python_sdk/__init__.py new file mode 100644 index 0000000..c74db67 --- /dev/null +++ b/python_sdk/__init__.py @@ -0,0 +1,3 @@ +from .telesec_couchdb import TeleSecCouchDB, ts_encrypt, ts_decrypt + +__all__ = ["TeleSecCouchDB", "ts_encrypt", "ts_decrypt"] diff --git a/python_sdk/telesec_couchdb.py b/python_sdk/telesec_couchdb.py new file mode 100644 index 0000000..4199638 --- /dev/null +++ b/python_sdk/telesec_couchdb.py @@ -0,0 +1,293 @@ +import base64 +import email.utils +import hashlib +import json +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional +from urllib.parse import quote + +import requests +from Crypto.Cipher import AES + + +class TeleSecCryptoError(Exception): + pass + + +class TeleSecCouchDBError(Exception): + pass + + +def _pkcs7_pad(data: bytes, block_size: int = 16) -> bytes: + pad_len = block_size - (len(data) % block_size) + return data + bytes([pad_len]) * pad_len + + +def _pkcs7_unpad(data: bytes, block_size: int = 16) -> bytes: + if not data or len(data) % block_size != 0: + raise TeleSecCryptoError("Invalid padded data length") + pad_len = data[-1] + if pad_len < 1 or pad_len > block_size: + raise TeleSecCryptoError("Invalid PKCS7 padding") + if data[-pad_len:] != bytes([pad_len]) * pad_len: + raise TeleSecCryptoError("Invalid PKCS7 padding bytes") + return data[:-pad_len] + + +def _evp_bytes_to_key(passphrase: bytes, salt: bytes, key_len: int, iv_len: int) -> tuple[bytes, bytes]: + d = b"" + prev = b"" + while len(d) < key_len + iv_len: + prev = hashlib.md5(prev + passphrase + salt).digest() + d += prev + return d[:key_len], d[key_len : key_len + iv_len] + + +def _json_dumps_like_js(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def ts_encrypt(input_value: Any, secret: str) -> str: + """ + Compatible with JS: CryptoJS.AES.encrypt(payload, secret).toString() + wrapped as RSA{}. + """ + if secret is None or secret == "": + if isinstance(input_value, str): + return input_value + return _json_dumps_like_js(input_value) + + payload = input_value + if not isinstance(input_value, str): + try: + payload = _json_dumps_like_js(input_value) + except Exception: + payload = str(input_value) + + payload_bytes = payload.encode("utf-8") + salt = os.urandom(8) + key, iv = _evp_bytes_to_key(secret.encode("utf-8"), salt, 32, 16) + cipher = AES.new(key, AES.MODE_CBC, iv=iv) + encrypted = cipher.encrypt(_pkcs7_pad(payload_bytes, 16)) + openssl_blob = b"Salted__" + salt + encrypted + b64 = base64.b64encode(openssl_blob).decode("utf-8") + return f"RSA{{{b64}}}" + + +def ts_decrypt(input_value: Any, secret: str) -> Any: + """ + Compatible with JS TS_decrypt behavior: + - If not string: return as-is. + - If RSA{...}: decrypt AES(CryptoJS passphrase mode), parse JSON when possible. + - If plain string JSON: parse JSON. + - Else: return raw string. + """ + if not isinstance(input_value, str): + return input_value + + is_wrapped = input_value.startswith("RSA{") and input_value.endswith("}") + if is_wrapped: + if not secret: + raise TeleSecCryptoError("Secret is required to decrypt RSA payload") + b64 = input_value[4:-1] + try: + raw = base64.b64decode(b64) + except Exception as exc: + raise TeleSecCryptoError("Invalid base64 payload") from exc + + if len(raw) < 16 or not raw.startswith(b"Salted__"): + raise TeleSecCryptoError("Unsupported encrypted payload format") + + salt = raw[8:16] + ciphertext = raw[16:] + key, iv = _evp_bytes_to_key(secret.encode("utf-8"), salt, 32, 16) + cipher = AES.new(key, AES.MODE_CBC, iv=iv) + decrypted = cipher.decrypt(ciphertext) + decrypted = _pkcs7_unpad(decrypted, 16) + + try: + text = decrypted.decode("utf-8") + except UnicodeDecodeError: + text = decrypted.decode("latin-1") + + try: + return json.loads(text) + except Exception: + return text + + try: + return json.loads(input_value) + except Exception: + return input_value + + +@dataclass +class TeleSecDoc: + id: str + data: Any + raw: Dict[str, Any] + + +class TeleSecCouchDB: + """ + Direct CouchDB client for TeleSec docs (_id = ":"). + No local replication layer. + """ + + def __init__( + self, + server_url: str, + dbname: str, + secret: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + timeout: int = 30, + session: Optional[requests.Session] = None, + ) -> None: + self.server_url = server_url.rstrip("/") + self.dbname = dbname + self.secret = secret or "" + self.timeout = timeout + self.base_url = f"{self.server_url}/{quote(self.dbname, safe='')}" + self.session = session or requests.Session() + self.session.headers.update({"Accept": "application/json"}) + if username is not None: + self.session.auth = (username, password or "") + + def _iso_now(self) -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + def _doc_id(self, table: str, item_id: str) -> str: + return f"{table}:{item_id}" + + def _request(self, method: str, path: str = "", **kwargs) -> requests.Response: + url = self.base_url if not path else f"{self.base_url}/{path.lstrip('/')}" + kwargs.setdefault("timeout", self.timeout) + res = self.session.request(method=method, url=url, **kwargs) + return res + + def get_server_datetime(self) -> datetime: + """ + Returns server datetime using HTTP Date header from CouchDB. + Avoids reliance on local machine clock. + """ + candidates = [ + ("HEAD", self.base_url), + ("GET", self.base_url), + ("HEAD", self.server_url), + ("GET", self.server_url), + ] + for method, url in candidates: + try: + res = self.session.request(method=method, url=url, timeout=self.timeout) + date_header = res.headers.get("Date") + if not date_header: + continue + dt = email.utils.parsedate_to_datetime(date_header) + if dt is None: + continue + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except Exception: + continue + raise TeleSecCouchDBError("Unable to retrieve server time from CouchDB Date header") + + def iso_from_server_plus_minutes(self, minutes: int = 0) -> str: + now = self.get_server_datetime() + target = now.timestamp() + (minutes * 60) + out = datetime.fromtimestamp(target, tz=timezone.utc) + return out.isoformat(timespec="milliseconds").replace("+00:00", "Z") + + def check_connection(self) -> Dict[str, Any]: + res = self._request("GET") + if res.status_code >= 400: + raise TeleSecCouchDBError(f"CouchDB connection failed: {res.status_code} {res.text}") + return res.json() + + def get_raw(self, doc_id: str) -> Optional[Dict[str, Any]]: + res = self._request("GET", quote(doc_id, safe="")) + if res.status_code == 404: + return None + if res.status_code >= 400: + raise TeleSecCouchDBError(f"GET doc failed: {res.status_code} {res.text}") + return res.json() + + def put_raw(self, doc: Dict[str, Any]) -> Dict[str, Any]: + if "_id" not in doc: + raise ValueError("Document must include _id") + res = self._request( + "PUT", + quote(doc["_id"], safe=""), + headers={"Content-Type": "application/json"}, + data=_json_dumps_like_js(doc).encode("utf-8"), + ) + if res.status_code >= 400: + raise TeleSecCouchDBError(f"PUT doc failed: {res.status_code} {res.text}") + return res.json() + + def delete_raw(self, doc_id: str) -> bool: + doc = self.get_raw(doc_id) + if not doc: + return False + res = self._request("DELETE", f"{quote(doc_id, safe='')}?rev={quote(doc['_rev'], safe='')}") + if res.status_code >= 400: + raise TeleSecCouchDBError(f"DELETE doc failed: {res.status_code} {res.text}") + return True + + def put(self, table: str, item_id: str, data: Any, encrypt: bool = True) -> Dict[str, Any]: + doc_id = self._doc_id(table, item_id) + + if data is None: + self.delete_raw(doc_id) + return {"ok": True, "id": doc_id, "deleted": True} + + existing = self.get_raw(doc_id) + doc: Dict[str, Any] = existing if existing else {"_id": doc_id} + + to_store = data + is_encrypted_string = isinstance(data, str) and data.startswith("RSA{") and data.endswith("}") + if encrypt and self.secret and not is_encrypted_string: + to_store = ts_encrypt(data, self.secret) + + doc["data"] = to_store + doc["table"] = table + doc["ts"] = self._iso_now() + + return self.put_raw(doc) + + def get(self, table: str, item_id: str, decrypt: bool = True) -> Optional[Any]: + doc_id = self._doc_id(table, item_id) + doc = self.get_raw(doc_id) + if not doc: + return None + value = doc.get("data") + if decrypt: + return ts_decrypt(value, self.secret) + return value + + def delete(self, table: str, item_id: str) -> bool: + return self.delete_raw(self._doc_id(table, item_id)) + + def list(self, table: str, decrypt: bool = True) -> List[TeleSecDoc]: + params = { + "include_docs": "true", + "startkey": f'"{table}:"', + "endkey": f'"{table}:\uffff"', + } + res = self._request("GET", "_all_docs", params=params) + if res.status_code >= 400: + raise TeleSecCouchDBError(f"LIST docs failed: {res.status_code} {res.text}") + + rows = res.json().get("rows", []) + out: List[TeleSecDoc] = [] + for row in rows: + doc = row.get("doc") or {} + item_id = row.get("id", "").split(":", 1)[1] if ":" in row.get("id", "") else row.get("id", "") + value = doc.get("data") + if decrypt: + value = ts_decrypt(value, self.secret) + out.append(TeleSecDoc(id=item_id, data=value, raw=doc)) + return out diff --git a/python_sdk/windows_agent.py b/python_sdk/windows_agent.py new file mode 100644 index 0000000..21054d6 --- /dev/null +++ b/python_sdk/windows_agent.py @@ -0,0 +1,176 @@ +import argparse +import ctypes +import os +import socket +import subprocess +import sys +import time +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +import psutil + +try: + from .telesec_couchdb import TeleSecCouchDB, TeleSecCouchDBError, ts_decrypt +except ImportError: + from telesec_couchdb import TeleSecCouchDB, TeleSecCouchDBError, ts_decrypt + + +def utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def parse_iso(value: str) -> Optional[datetime]: + if not value: + return None + try: + v = value.strip().replace("Z", "+00:00") + dt = datetime.fromisoformat(v) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except Exception: + return None + + +def get_current_username() -> str: + try: + return psutil.Process().username() or os.getlogin() + except Exception: + try: + return os.getlogin() + except Exception: + return os.environ.get("USERNAME", "") + + +def _window_title(hwnd: int) -> str: + buf_len = ctypes.windll.user32.GetWindowTextLengthW(hwnd) + if buf_len <= 0: + return "" + buf = ctypes.create_unicode_buffer(buf_len + 1) + ctypes.windll.user32.GetWindowTextW(hwnd, buf, buf_len + 1) + return buf.value or "" + + +def get_active_app() -> Dict[str, str]: + exe = "" + title = "" + try: + hwnd = ctypes.windll.user32.GetForegroundWindow() + if hwnd: + title = _window_title(hwnd) + pid = ctypes.c_ulong() + ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + if pid.value: + try: + proc = psutil.Process(pid.value) + exe = proc.name() or "" + except Exception: + exe = "" + except Exception: + pass + return {"exe": exe, "title": title} + + +def build_payload(machine_id: str) -> Dict[str, Any]: + app = get_active_app() + return { + "Hostname": machine_id, + "UsuarioActual": get_current_username(), + "AppActualEjecutable": app.get("exe", ""), + "AppActualTitulo": app.get("title", ""), + # campo local diagnóstico (no se usa para decisión remota) + "AgentLocalSeenAt": utcnow_iso(), + } + + +def should_shutdown(data: Dict[str, Any], server_now: datetime) -> bool: + target = parse_iso(str(data.get("ShutdownBeforeDate", "") or "")) + if not target: + return False + return server_now >= target + + +def execute_shutdown(dry_run: bool = False) -> None: + if dry_run: + print("[DRY-RUN] Ejecutaría: shutdown /s /t 0 /f") + return + subprocess.run(["shutdown", "/s", "/t", "0", "/f"], check=False) + + +def run_once(client: TeleSecCouchDB, machine_id: str, dry_run: bool = False) -> None: + server_now = client.get_server_datetime() + server_now_iso = server_now.isoformat(timespec="milliseconds").replace("+00:00", "Z") + + raw = client.get(table="aulas_ordenadores", item_id=machine_id, decrypt=False) + current: Dict[str, Any] = {} + if raw is not None: + current = ts_decrypt(raw, client.secret) + if not isinstance(current, dict): + current = {} + + update = build_payload(machine_id) + update["LastSeenAt"] = server_now_iso + + for key in ["ShutdownBeforeDate", "ShutdownRequestedAt", "ShutdownRequestedBy"]: + if key in current: + update[key] = current.get(key) + + client.put(table="aulas_ordenadores", item_id=machine_id, data=update, encrypt=True) + + if should_shutdown(update, server_now): + print(f"[{server_now_iso}] ShutdownBeforeDate alcanzado. Apagando {machine_id}...") + execute_shutdown(dry_run=dry_run) + else: + print( + f"[{server_now_iso}] Reportado {machine_id} user={update.get('UsuarioActual','')} " + f"exe={update.get('AppActualEjecutable','')} title={update.get('AppActualTitulo','')}" + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="TeleSec Windows Agent") + parser.add_argument("--server", required=True, help="CouchDB server URL, ej. https://couch.example") + parser.add_argument("--db", default="telesec", help="Database name") + parser.add_argument("--user", default="", help="CouchDB username") + parser.add_argument("--password", default="", help="CouchDB password") + parser.add_argument("--secret", required=True, help="TeleSec secret para cifrado") + parser.add_argument("--machine-id", default="", help="ID de máquina (default: hostname)") + parser.add_argument("--interval", type=int, default=15, help="Intervalo en segundos") + parser.add_argument("--once", action="store_true", help="Ejecutar una sola iteración") + parser.add_argument("--dry-run", action="store_true", help="No apagar realmente, solo log") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + machine_id = (args.machine_id or socket.gethostname() or "unknown-host").strip() + + client = TeleSecCouchDB( + server_url=args.server, + dbname=args.db, + secret=args.secret, + username=args.user or None, + password=args.password or None, + ) + + try: + client.check_connection() + except TeleSecCouchDBError as exc: + print(f"Error de conexión CouchDB: {exc}", file=sys.stderr) + return 2 + + if args.once: + run_once(client=client, machine_id=machine_id, dry_run=args.dry_run) + return 0 + + while True: + try: + run_once(client=client, machine_id=machine_id, dry_run=args.dry_run) + except Exception as exc: + print(f"Error en iteración agente: {exc}", file=sys.stderr) + time.sleep(max(5, args.interval)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/requirements.txt b/requirements.txt index 663bd1f..5204e63 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ -requests \ No newline at end of file +requests +pycryptodome +psutil \ No newline at end of file diff --git a/src/page/aulas.js b/src/page/aulas.js index 88a67cc..0da0eec 100644 --- a/src/page/aulas.js +++ b/src/page/aulas.js @@ -48,6 +48,9 @@ PAGES.aulas = { Ver comandas + Control de ordenadores
Datos de hoy @@ -451,6 +454,211 @@ Cargando... { + if (typeof raw !== 'string') { + resolve(raw || {}); + return; + } + TS_decrypt( + raw, + SECRET, + (data) => { + resolve(data || {}); + }, + table, + id + ); + }); + }, + __getServerNow: async function () { + try { + var couchUrl = (localStorage.getItem('TELESEC_COUCH_URL') || '').replace(/\/$/, ''); + var couchUser = localStorage.getItem('TELESEC_COUCH_USER') || ''; + var couchPass = localStorage.getItem('TELESEC_COUCH_PASS') || ''; + var couchDb = localStorage.getItem('TELESEC_COUCH_DBNAME') || 'telesec'; + + if (couchUrl) { + var target = couchUrl + '/' + encodeURIComponent(couchDb); + var headers = {}; + if (couchUser) { + headers['Authorization'] = 'Basic ' + btoa(couchUser + ':' + couchPass); + } + var res = await fetch(target, { method: 'HEAD', headers: headers }); + var dateHeader = res.headers.get('Date'); + if (dateHeader) { + var dt = new Date(dateHeader); + if (!isNaN(dt.getTime())) return dt; + } + } + } catch (e) { + console.warn('No se pudo obtener hora desde CouchDB', e); + } + + try { + var wres = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC'); + if (wres.ok) { + var wjson = await wres.json(); + if (wjson && wjson.utc_datetime) { + var wdt = new Date(wjson.utc_datetime); + if (!isNaN(wdt.getTime())) return wdt; + } + } + } catch (e2) { + console.warn('No se pudo obtener hora desde worldtimeapi', e2); + } + + return new Date(); + }, + __scheduleShutdown: async function (machineId) { + try { + document.getElementById('actionStatus').style.display = 'block'; + var serverNow = await PAGES.aulas.__getServerNow(); + var shutdownAt = new Date(serverNow.getTime() + 2 * 60 * 1000).toISOString(); + var raw = await DB.get('aulas_ordenadores', machineId); + var data = await PAGES.aulas.__decryptIfNeeded('aulas_ordenadores', machineId, raw); + data = data || {}; + data.Hostname = data.Hostname || machineId; + data.ShutdownBeforeDate = shutdownAt; + data.ShutdownRequestedAt = serverNow.toISOString(); + data.ShutdownRequestedBy = SUB_LOGGED_IN_ID || ''; + await DB.put('aulas_ordenadores', machineId, data); + toastr.warning('Apagado programado antes de: ' + shutdownAt); + } catch (e) { + console.warn('Error programando apagado remoto', e); + toastr.error('No se pudo programar el apagado remoto'); + } finally { + document.getElementById('actionStatus').style.display = 'none'; + } + }, + __cancelShutdown: async function (machineId) { + try { + document.getElementById('actionStatus').style.display = 'block'; + var raw = await DB.get('aulas_ordenadores', machineId); + var data = await PAGES.aulas.__decryptIfNeeded('aulas_ordenadores', machineId, raw); + data = data || {}; + data.Hostname = data.Hostname || machineId; + data.ShutdownBeforeDate = ''; + data.ShutdownRequestedAt = ''; + data.ShutdownRequestedBy = ''; + await DB.put('aulas_ordenadores', machineId, data); + toastr.success('Apagado remoto cancelado'); + } catch (e) { + console.warn('Error cancelando apagado remoto', e); + toastr.error('No se pudo cancelar el apagado remoto'); + } finally { + document.getElementById('actionStatus').style.display = 'none'; + } + }, + _ordenadores: function () { + container.innerHTML = html` + ← Volver a Gestión de Aulas +

Control de ordenadores

+

+ Estado enviado por el agente Windows. El apagado remoto se programa con hora de servidor. +

+
+ `; + + TS_IndexElement( + 'aulas,ordenadores', + [ + { key: 'Hostname', type: 'raw', default: '', label: 'Hostname' }, + { key: 'UsuarioActual', type: 'raw', default: '', label: 'Usuario actual' }, + { key: 'AppActualEjecutable', type: 'raw', default: '', label: 'App actual (exe)' }, + { key: 'AppActualTitulo', type: 'raw', default: '', label: 'App actual (título)' }, + { key: 'LastSeenAt', type: 'raw', default: '', label: 'Último visto (server)' }, + { + key: 'ShutdownBeforeDate', + type: 'template', + label: 'Apagado remoto', + template: (data, td) => { + var text = document.createElement('div'); + text.style.marginBottom = '6px'; + text.innerText = data.ShutdownBeforeDate + ? '⏻ Antes de: ' + data.ShutdownBeforeDate + : 'Sin apagado programado'; + td.appendChild(text); + + var btnOn = document.createElement('button'); + btnOn.className = 'rojo'; + btnOn.innerText = 'Programar +2m'; + btnOn.onclick = async (event) => { + event.preventDefault(); + event.stopPropagation(); + await PAGES.aulas.__scheduleShutdown(data._key); + return false; + }; + td.appendChild(btnOn); + + if (data.ShutdownBeforeDate) { + td.appendChild(document.createElement('br')); + var btnCancel = document.createElement('button'); + btnCancel.className = 'btn5'; + btnCancel.innerText = 'Cancelar'; + btnCancel.onclick = async (event) => { + event.preventDefault(); + event.stopPropagation(); + await PAGES.aulas.__cancelShutdown(data._key); + return false; + }; + td.appendChild(btnCancel); + } + }, + }, + ], + 'aulas_ordenadores', + document.querySelector('#cont') + ); + }, + _ordenadores__edit: function (mid) { + var field_host = safeuuid(); + var field_user = safeuuid(); + var field_exe = safeuuid(); + var field_title = safeuuid(); + var field_seen = safeuuid(); + var field_shutdown = safeuuid(); + var btn_schedule = safeuuid(); + var btn_cancel = safeuuid(); + + container.innerHTML = html` + ← Volver a ordenadores +

Ordenador ${mid}

+
+ Estado +

+

+

+

+

+

+ + +
+ `; + + async function loadData() { + var raw = await DB.get('aulas_ordenadores', mid); + var data = await PAGES.aulas.__decryptIfNeeded('aulas_ordenadores', mid, raw); + data = data || {}; + document.getElementById(field_host).value = data.Hostname || mid; + document.getElementById(field_user).value = data.UsuarioActual || ''; + document.getElementById(field_exe).value = data.AppActualEjecutable || ''; + document.getElementById(field_title).value = data.AppActualTitulo || ''; + document.getElementById(field_seen).value = data.LastSeenAt || ''; + document.getElementById(field_shutdown).value = data.ShutdownBeforeDate || ''; + } + + loadData(); + document.getElementById(btn_schedule).onclick = async () => { + await PAGES.aulas.__scheduleShutdown(mid); + await loadData(); + }; + document.getElementById(btn_cancel).onclick = async () => { + await PAGES.aulas.__cancelShutdown(mid); + await loadData(); + }; + }, edit: function (fsection) { if (!checkRole('aulas')) { setUrlHash('index'); @@ -467,6 +675,9 @@ Cargando...