Compare commits
20 Commits
73a8c4ff2b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5342fb489d | |||
| c42814f963 | |||
| 0d1de7f7e2 | |||
| b34433a71e | |||
| 492d844523 | |||
| 8237e38164 | |||
| e04be61952 | |||
| 1b7e4f36e9 | |||
| d8cc6e9613 | |||
| aa04270550 | |||
| 2b47faf66a | |||
| 97db600b1f | |||
| f9bf5f4824 | |||
| 1db2f11c19 | |||
| 511b370a2e | |||
| 3d3ef3f002 | |||
| abaf43b8d6 | |||
| aa6d4e84c0 | |||
| f483058c2c | |||
| 656293b74c |
@@ -1,6 +1,9 @@
|
||||
# Ignorar los directorios de dependencias
|
||||
node_modules/
|
||||
|
||||
# Ignorar los volumenes respaldados
|
||||
docker-volumes*
|
||||
|
||||
# Ignorar las carpetas de bases de datos
|
||||
.db/
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# Dockerfile.dev
|
||||
FROM node:23-slim
|
||||
|
||||
# Definir variables de entorno con valores predeterminados
|
||||
ARG NODE_ENV=development
|
||||
ARG PORT=3000
|
||||
|
||||
# Definir las variables de entorno dentro del contenedor
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
ENV PORT=${PORT}
|
||||
|
||||
# Crea directorio de trabajo
|
||||
WORKDIR /app
|
||||
|
||||
# Copia archivos de configuración primero para aprovechar el cache
|
||||
COPY package*.json ./
|
||||
|
||||
# Instala dependencias (incluye devDependencies)
|
||||
RUN npm install
|
||||
|
||||
# Copia el resto de la app
|
||||
COPY . .
|
||||
|
||||
# Expone el puerto
|
||||
EXPOSE ${PORT}
|
||||
|
||||
# Usa nodemon para hot reload si lo tenés
|
||||
CMD ["npx", "nodemon", "src/index.js"]
|
||||
@@ -1,31 +0,0 @@
|
||||
# Dockerfile.prod
|
||||
FROM node:23-slim
|
||||
|
||||
# Definir variables de entorno con valores predeterminados
|
||||
ARG NODE_ENV=production
|
||||
ARG PORT=8080
|
||||
|
||||
# Definir las variables de entorno dentro del contenedor
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
ENV PORT=${PORT}
|
||||
|
||||
# Crea directorio de trabajo
|
||||
WORKDIR /app
|
||||
|
||||
# Copia solo archivos necesarios para prod
|
||||
COPY package*.json ./
|
||||
|
||||
# Instala solo dependencias de producción
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copia el resto de la app
|
||||
COPY . .
|
||||
|
||||
# Expone el puerto
|
||||
EXPOSE ${PORT}
|
||||
|
||||
# Ejecutar el servidor con nodemon en desarrollo, o con node en producción
|
||||
CMD ["npm", "start"]
|
||||
|
||||
# # Corre la app normalmente
|
||||
# CMD ["node", "src/index.js"]
|
||||
@@ -1,110 +0,0 @@
|
||||
|
||||
# Proyecto Node.js con Docker
|
||||
|
||||
Este proyecto es una aplicación Node.js containerizada usando Docker y Docker Compose, con entornos separados para desarrollo y producción. A continuación, se explica cómo configurar y ejecutar la aplicación en ambos entornos.
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
.
|
||||
├── Dockerfile.dev # Dockerfile para entorno de desarrollo
|
||||
├── Dockerfile.prod # Dockerfile para entorno de producción
|
||||
├── docker-compose.dev.yml # Archivo de configuración para entorno de desarrollo
|
||||
├── docker-compose.prod.yml # Archivo de configuración para entorno de producción
|
||||
├── package.json # Dependencias del proyecto Node.js
|
||||
├── src/ # Código fuente de la aplicación
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
Antes de comenzar, asegúrate de tener instalado:
|
||||
|
||||
- [Docker](https://www.docker.com/get-started)
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
## Configuración
|
||||
|
||||
### 1. Instalar dependencias
|
||||
|
||||
Primero, asegúrate de tener todas las dependencias necesarias para tu proyecto. Si aún no tienes el archivo `package.json`, ejecuta:
|
||||
|
||||
```bash
|
||||
npm init -y
|
||||
```
|
||||
|
||||
Luego, instala las dependencias necesarias para tu aplicación:
|
||||
|
||||
```bash
|
||||
npm install express
|
||||
npm install --save-dev nodemon
|
||||
```
|
||||
|
||||
### 2. Estructura de los Dockerfiles
|
||||
|
||||
- **`Dockerfile.dev`**: Configuración para el entorno de desarrollo.
|
||||
- **`Dockerfile.prod`**: Configuración para el entorno de producción.
|
||||
|
||||
### 3. Configuración de Docker Compose
|
||||
|
||||
- **`docker-compose.dev.yml`**: Configuración para el entorno de desarrollo. Utiliza `nodemon` para recargar la aplicación automáticamente.
|
||||
- **`docker-compose.prod.yml`**: Configuración para el entorno de producción. Instala solo las dependencias necesarias para producción.
|
||||
|
||||
## Uso
|
||||
|
||||
### 1. Entorno de Desarrollo
|
||||
|
||||
Para levantar el entorno de desarrollo y comenzar a trabajar, ejecuta:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
Esto hará lo siguiente:
|
||||
- Construirá la imagen usando el `Dockerfile.dev`.
|
||||
- Levantará el contenedor en el puerto `3000`.
|
||||
- Con `nodemon` instalado, cualquier cambio que realices en el código será automáticamente reflejado en el contenedor.
|
||||
|
||||
Puedes acceder a la aplicación en tu navegador en `http://localhost:3000`.
|
||||
|
||||
### 2. Entorno de Producción
|
||||
|
||||
Para ejecutar el entorno de producción (sin dependencias de desarrollo), ejecuta:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up --build
|
||||
```
|
||||
|
||||
Esto:
|
||||
- Construirá la imagen usando el `Dockerfile.prod`.
|
||||
- Levantará el contenedor en el puerto `3000`.
|
||||
|
||||
En este entorno no se instalarán dependencias de desarrollo y es más ligero para producción.
|
||||
|
||||
### 3. Detener los contenedores
|
||||
|
||||
Si necesitas detener los contenedores en ejecución, puedes usar:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Para eliminar también las imágenes, ejecuta:
|
||||
|
||||
```bash
|
||||
docker compose down --rmi all
|
||||
```
|
||||
|
||||
## Notas adicionales
|
||||
|
||||
- Si necesitas acceder al contenedor para depurar o inspeccionar archivos, puedes hacerlo con el siguiente comando:
|
||||
|
||||
```bash
|
||||
docker exec -it <container_id> sh
|
||||
```
|
||||
|
||||
- Si tienes alguna duda o problema, no dudes en contactarnos o abrir un issue en el repositorio.
|
||||
|
||||
---
|
||||
|
||||
¡Disfruta trabajando con tu aplicación Node.js containerizada!
|
||||
@@ -0,0 +1,633 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import List, Dict, Tuple, Optional, Set
|
||||
|
||||
PROJECT_ROOT = pathlib.Path.cwd()
|
||||
COMPOSE_BASE = PROJECT_ROOT / "compose.yaml"
|
||||
COMPOSE_DEV = PROJECT_ROOT / "compose.dev.yaml"
|
||||
COMPOSE_PROD = PROJECT_ROOT / "compose.prod.yaml"
|
||||
COMPOSE_NPM = PROJECT_ROOT / "compose.npm.yaml"
|
||||
COMPOSE_DBVR = PROJECT_ROOT / "compose.dbeaver.yaml"
|
||||
|
||||
GLOBAL_DEFAULT_PROJECT = "suitecoffee" # proyecto global (NPM/DBeaver)
|
||||
|
||||
# ---------- Shell utils ----------
|
||||
|
||||
def run(cmd: List[str], check=True, capture_output=True, text=True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, check=check, capture_output=capture_output, text=text)
|
||||
|
||||
def which(program: str) -> bool:
|
||||
from shutil import which as _which
|
||||
return _which(program) is not None
|
||||
|
||||
# ---------- Docker volume discovery ----------
|
||||
|
||||
def docker_volume_ls_json(filters: List[str]) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Devuelve objetos de 'docker volume ls' (formato json por entrada).
|
||||
Soporta filtros como '--filter label=...'.
|
||||
"""
|
||||
cmd = ["docker", "volume", "ls", "--format", "{{json .}}"]
|
||||
for f in filters:
|
||||
cmd += ["--filter", f]
|
||||
try:
|
||||
cp = run(cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
return []
|
||||
out = []
|
||||
for line in cp.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return out
|
||||
|
||||
def docker_volume_ls_names(filters: List[str]) -> List[str]:
|
||||
"""Devuelve solo los nombres (Name) con filtros aplicados."""
|
||||
rows = docker_volume_ls_json(filters)
|
||||
names = []
|
||||
for v in rows:
|
||||
name = v.get("Name")
|
||||
if name:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
def list_by_label_project(project: str) -> List[Dict[str, str]]:
|
||||
return docker_volume_ls_json([f"label=com.docker.compose.project={project}"])
|
||||
|
||||
def list_by_name_prefix(prefix: str) -> List[Dict[str, str]]:
|
||||
vols = docker_volume_ls_json([])
|
||||
keep = []
|
||||
for v in vols:
|
||||
name = v.get("Name")
|
||||
if not name:
|
||||
continue
|
||||
if name.startswith(prefix + "_") or name.startswith(prefix + "-") or name == prefix:
|
||||
keep.append(v)
|
||||
return keep
|
||||
|
||||
def normalize_project_name(p: str) -> str:
|
||||
return (p or "").replace(" ", "_")
|
||||
|
||||
# ---------- Compose config parsing ----------
|
||||
|
||||
def compose_config_json(files: List[pathlib.Path]) -> Optional[dict]:
|
||||
if not files or not all(p.exists() for p in files):
|
||||
return None
|
||||
cmd = ["docker", "compose"]
|
||||
for f in files:
|
||||
cmd += ["-f", str(f)]
|
||||
cmd += ["config", "--format", "json"]
|
||||
try:
|
||||
cp = run(cmd)
|
||||
return json.loads(cp.stdout or "{}")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def extract_short_volume_names_from_config(cfg: dict) -> Set[str]:
|
||||
"""
|
||||
Extrae short names de volúmenes usados en services[].volumes (type: volume)
|
||||
y las claves del toplevel 'volumes'.
|
||||
"""
|
||||
names: Set[str] = set()
|
||||
if not cfg:
|
||||
return names
|
||||
|
||||
# services[].volumes
|
||||
services = cfg.get("services") or {}
|
||||
for svc in services.values():
|
||||
vols = svc.get("volumes") or []
|
||||
for m in vols:
|
||||
# en JSON canonical, cada mount es un dict con 'type', 'source', 'target', ...
|
||||
if isinstance(m, dict) and m.get("type") == "volume":
|
||||
src = m.get("source")
|
||||
if isinstance(src, str) and src:
|
||||
names.add(src)
|
||||
|
||||
# top-level volumes (claves)
|
||||
top_vols = cfg.get("volumes") or {}
|
||||
if isinstance(top_vols, dict):
|
||||
for k in top_vols.keys():
|
||||
if isinstance(k, str) and k:
|
||||
names.add(k)
|
||||
|
||||
return names
|
||||
|
||||
def docker_compose_name_from(files: List[pathlib.Path]) -> Optional[str]:
|
||||
cfg = compose_config_json(files)
|
||||
if cfg and isinstance(cfg, dict):
|
||||
name = cfg.get("name")
|
||||
if name:
|
||||
return name
|
||||
return None
|
||||
|
||||
def read_compose_project_from_env(env_path: pathlib.Path) -> Optional[str]:
|
||||
try:
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("COMPOSE_PROJECT_NAME="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def base_folder_slug() -> str:
|
||||
return PROJECT_ROOT.name.lower().replace(" ", "_")
|
||||
|
||||
def candidates_for_env(env: str) -> List[str]:
|
||||
cand: List[str] = []
|
||||
if env == "development":
|
||||
n1 = docker_compose_name_from([COMPOSE_BASE, COMPOSE_DEV])
|
||||
n2 = read_compose_project_from_env(PROJECT_ROOT / ".env.development")
|
||||
n3 = f"{base_folder_slug()}_dev"
|
||||
n4 = f"{base_folder_slug()}-dev"
|
||||
cand.extend([n1, n2, n3, n4, base_folder_slug()])
|
||||
elif env == "production":
|
||||
n1 = docker_compose_name_from([COMPOSE_BASE, COMPOSE_PROD])
|
||||
n2 = read_compose_project_from_env(PROJECT_ROOT / ".env.production")
|
||||
n3 = f"{base_folder_slug()}_prod"
|
||||
n4 = f"{base_folder_slug()}-prod"
|
||||
cand.extend([n1, n2, n3, n4, base_folder_slug()])
|
||||
# dedup preservando orden
|
||||
seen = set(); ordered = []
|
||||
for x in cand:
|
||||
if x and x not in seen:
|
||||
seen.add(x); ordered.append(x)
|
||||
return ordered
|
||||
|
||||
def candidates_for_global() -> List[str]:
|
||||
cand: List[str] = []
|
||||
# nombres desde compose globales
|
||||
if COMPOSE_NPM.exists():
|
||||
n = docker_compose_name_from([COMPOSE_NPM])
|
||||
if n: cand.append(n)
|
||||
if COMPOSE_DBVR.exists():
|
||||
n = docker_compose_name_from([COMPOSE_DBVR])
|
||||
if n and n not in cand: cand.append(n)
|
||||
# fallback esperados
|
||||
if GLOBAL_DEFAULT_PROJECT not in cand: cand.append(GLOBAL_DEFAULT_PROJECT)
|
||||
bf = base_folder_slug()
|
||||
if bf not in cand: cand.append(bf)
|
||||
return cand
|
||||
|
||||
# ---------- Nueva detección por grupo: COMPOSE + labels ----------
|
||||
|
||||
def detect_group_volumes_with_compose(filesets: List[List[pathlib.Path]],
|
||||
project_candidates: List[str]) -> Tuple[Optional[str], str, List[str]]:
|
||||
"""
|
||||
filesets: lista de listas de archivos compose a considerar (dev=[base,dev], prod=[base,prod],
|
||||
global=[[npm], [dbeaver]] -> dos sets para unir shortnames).
|
||||
Devuelve (project_seleccionado, metodo, [nombres_de_volumen]).
|
||||
"""
|
||||
# 1) Unir shortnames de todos los filesets
|
||||
shortnames: Set[str] = set()
|
||||
for files in filesets:
|
||||
cfg = compose_config_json(files)
|
||||
shortnames |= extract_short_volume_names_from_config(cfg)
|
||||
|
||||
# 2) Si hay shortnames, probar a buscar por (project,label.volume)
|
||||
if shortnames:
|
||||
for proj in project_candidates:
|
||||
# Buscar cada shortname con ambos labels
|
||||
found: List[str] = []
|
||||
for sn in sorted(shortnames):
|
||||
names = docker_volume_ls_names([
|
||||
f"label=com.docker.compose.project={proj}",
|
||||
f"label=com.docker.compose.volume={sn}"
|
||||
])
|
||||
if names:
|
||||
found.extend(names)
|
||||
# dedup preservando orden
|
||||
if found:
|
||||
seen = set(); ordered = []
|
||||
for n in found:
|
||||
if n not in seen:
|
||||
seen.add(n); ordered.append(n)
|
||||
return proj, f"compose+labels:{proj}", ordered
|
||||
|
||||
# 3) Fallback: probar cualquier volumen del proyecto (label) o por prefijo
|
||||
for proj in project_candidates:
|
||||
method, rows = discover_volumes_for_project(proj)
|
||||
if rows:
|
||||
return proj, f"fallback:{method}", [r.get("Name") for r in rows if r.get("Name")]
|
||||
|
||||
# 4) Nada
|
||||
first = project_candidates[0] if project_candidates else None
|
||||
return first, "none", []
|
||||
|
||||
def discover_volumes_for_project(project_raw: str) -> Tuple[str, List[Dict[str, str]]]:
|
||||
"""
|
||||
Método previo de respaldo: por label de proyecto y prefijo (para CLI y fallback).
|
||||
"""
|
||||
project_norm = normalize_project_name(project_raw)
|
||||
project_lower = project_norm.lower()
|
||||
|
||||
vols = list_by_label_project(project_norm)
|
||||
if vols:
|
||||
return f"label:{project_norm}", vols
|
||||
|
||||
vols2 = list_by_label_project(project_lower)
|
||||
if vols2:
|
||||
return f"label:{project_lower}", vols2
|
||||
|
||||
by_name = list_by_name_prefix(project_norm)
|
||||
if by_name:
|
||||
return f"name-prefix:{project_norm}", by_name
|
||||
|
||||
by_name2 = list_by_name_prefix(project_lower)
|
||||
if by_name2:
|
||||
return f"name-prefix:{project_lower}", by_name2
|
||||
|
||||
return "none", []
|
||||
|
||||
# ---------- Backup helpers ----------
|
||||
|
||||
def ensure_alpine_image():
|
||||
try:
|
||||
run(["docker", "image", "inspect", "alpine:latest"])
|
||||
except subprocess.CalledProcessError:
|
||||
print("Pulling alpine:latest ...")
|
||||
run(["docker", "pull", "alpine:latest"], check=True, capture_output=False)
|
||||
|
||||
def build_archive_name(project: str, volume_name: str, ts: str) -> str:
|
||||
"""
|
||||
Construye el nombre del .tar.gz evitando duplicar el prefijo del proyecto.
|
||||
- Si volume_name ya empieza con '<project>_' o '<project>-', se usa tal cual.
|
||||
- Si no, se antepone '<project>_'.
|
||||
Resultado: <project>_<shortname>-<ts>.tar.gz
|
||||
"""
|
||||
proj_token = project.lower().replace(" ", "_")
|
||||
v_lower = volume_name.lower()
|
||||
if v_lower.startswith(proj_token + "_") or v_lower.startswith(proj_token + "-"):
|
||||
base = volume_name
|
||||
else:
|
||||
base = f"{proj_token}_{volume_name}"
|
||||
return f"{base}-{ts}.tar.gz"
|
||||
|
||||
def backup_volume(volume_name: str, out_dir: pathlib.Path, archive_name: str, dry_run: bool = False) -> int:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_dir_abs = out_dir.resolve()
|
||||
out_path = out_dir_abs / archive_name
|
||||
docker_cmd = [
|
||||
"docker", "run", "--rm",
|
||||
"-v", f"{volume_name}:/volume:ro",
|
||||
"-v", f"{str(out_dir_abs)}:/backup",
|
||||
# "--user", f"{os.getuid()}:{os.getgid()}",
|
||||
"alpine:latest",
|
||||
"sh", "-lc",
|
||||
f"tar czf /backup/{shlex.quote(out_path.name)} -C /volume ."
|
||||
]
|
||||
if dry_run:
|
||||
print("[DRY RUN] Would run:", " ".join(shlex.quote(c) for c in docker_cmd))
|
||||
return 0
|
||||
cp = subprocess.run(docker_cmd)
|
||||
return cp.returncode
|
||||
|
||||
def backup_explicit(volume_names: List[str], ts: str, output_dir: Optional[str], dry_run: bool, prefix_project: Optional[str]) -> int:
|
||||
"""
|
||||
Respalda exactamente los volúmenes indicados.
|
||||
- Directorio por defecto: ./docker-volumes-<ts>
|
||||
- Nombre de archivo: build_archive_name(prefix_project, volume_name, ts)
|
||||
"""
|
||||
out_dir = pathlib.Path(output_dir) if output_dir else (PROJECT_ROOT / f"docker-volumes-{ts}")
|
||||
if not dry_run:
|
||||
ensure_alpine_image()
|
||||
|
||||
failures = []
|
||||
for vname in volume_names:
|
||||
if not vname:
|
||||
continue
|
||||
archive = build_archive_name(prefix_project or "", vname, ts)
|
||||
print(f"Backing up volume: {vname} -> {archive}")
|
||||
rc = backup_volume(vname, out_dir, archive, dry_run=dry_run)
|
||||
if rc != 0:
|
||||
print(f" ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
|
||||
failures.append(vname)
|
||||
if failures:
|
||||
print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
|
||||
return 1
|
||||
else:
|
||||
print("\nAll done. Archives written to:", str(out_dir.resolve()))
|
||||
return 0
|
||||
|
||||
def backup_group(project_name: str, ts: str, output_dir: Optional[str] = None,
|
||||
dry_run: bool = False, excludes: Optional[List[str]] = None) -> int:
|
||||
"""
|
||||
Fallback legacy (label/prefix). Mantiene coherencia con nombres y directorio por defecto.
|
||||
"""
|
||||
method, rows = discover_volumes_for_project(project_name)
|
||||
|
||||
print_header(f"Proyecto '{project_name}': {len(rows)} volumen(es) detectado(s) (método: {method})")
|
||||
for v in rows:
|
||||
print(" -", v.get("Name"))
|
||||
|
||||
if not rows:
|
||||
warn("No hay volúmenes para respaldar.")
|
||||
return 0
|
||||
|
||||
vols = [v.get("Name") for v in rows if v.get("Name")]
|
||||
if excludes:
|
||||
excl = set(excludes)
|
||||
vols = [n for n in vols if n not in excl]
|
||||
if not vols:
|
||||
warn("Tras aplicar exclusiones, no quedó nada por respaldar.")
|
||||
return 0
|
||||
|
||||
out_dir = pathlib.Path(output_dir) if output_dir else (PROJECT_ROOT / f"docker-volumes-{ts}")
|
||||
if not dry_run:
|
||||
ensure_alpine_image()
|
||||
|
||||
failures = []
|
||||
for vname in vols:
|
||||
archive = build_archive_name(project_name, vname, ts)
|
||||
print(f"Backing up volume: {vname} -> {archive}")
|
||||
rc = backup_volume(vname, out_dir, archive, dry_run=dry_run)
|
||||
if rc != 0:
|
||||
print(f" ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
|
||||
failures.append(vname)
|
||||
|
||||
if failures:
|
||||
print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
|
||||
return 1
|
||||
else:
|
||||
print("\nAll done. Archives written to:", str(out_dir.resolve()))
|
||||
return 0
|
||||
|
||||
# ---------- UI helpers ----------
|
||||
|
||||
def yes_no(prompt: str, default="n") -> bool:
|
||||
default = default.lower()
|
||||
hint = "[Y/n]" if default == "y" else "[y/N]"
|
||||
while True:
|
||||
resp = input(f"{prompt} {hint} ").strip().lower()
|
||||
if not resp:
|
||||
return default == "y"
|
||||
if resp in ("y","yes","s","si","sí"):
|
||||
return True
|
||||
if resp in ("n","no"):
|
||||
return False
|
||||
print("Respuesta no reconocida. Por favor, responde con 'y' o 'n'.")
|
||||
|
||||
def print_header(title: str):
|
||||
print("\n" + "=" * 60)
|
||||
print(title)
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
def info(msg): print(f"• {msg}")
|
||||
def ok(msg): print(f"✓ {msg}")
|
||||
def warn(msg): print(f"! {msg}")
|
||||
def fail(msg):
|
||||
print(f"✗ {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
# ---------- Menú interactivo ----------
|
||||
|
||||
def interactive_menu():
|
||||
if not which("docker"):
|
||||
fail("ERROR: 'docker' no está en el PATH.")
|
||||
try:
|
||||
run(["docker", "version"], check=True, capture_output=True)
|
||||
except subprocess.CalledProcessError:
|
||||
fail("ERROR: No se puede hablar con el daemon de Docker. ¿Está corriendo? ¿Tu usuario está en el grupo 'docker'?")
|
||||
|
||||
# DEV
|
||||
dev_candidates = candidates_for_env("development") if COMPOSE_BASE.exists() and COMPOSE_DEV.exists() else []
|
||||
dev_proj, dev_method, dev_names = detect_group_volumes_with_compose(
|
||||
filesets=[[COMPOSE_BASE, COMPOSE_DEV]] if dev_candidates else [],
|
||||
project_candidates=dev_candidates
|
||||
)
|
||||
|
||||
# PROD
|
||||
prod_candidates = candidates_for_env("production") if COMPOSE_BASE.exists() and COMPOSE_PROD.exists() else []
|
||||
prod_proj, prod_method, prod_names = detect_group_volumes_with_compose(
|
||||
filesets=[[COMPOSE_BASE, COMPOSE_PROD]] if prod_candidates else [],
|
||||
project_candidates=prod_candidates
|
||||
)
|
||||
|
||||
# GLOBAL = NPM + DBEAVER (unir shortnames de ambos)
|
||||
global_candidates = candidates_for_global()
|
||||
global_filesets = []
|
||||
if COMPOSE_NPM.exists():
|
||||
global_filesets.append([COMPOSE_NPM])
|
||||
if COMPOSE_DBVR.exists():
|
||||
global_filesets.append([COMPOSE_DBVR])
|
||||
glob_proj, glob_method, glob_names = detect_group_volumes_with_compose(
|
||||
filesets=global_filesets,
|
||||
project_candidates=global_candidates
|
||||
)
|
||||
|
||||
# Resumen
|
||||
print_header("Resumen de volúmenes detectados")
|
||||
if dev_proj:
|
||||
info(f"DESARROLLO ({dev_proj}): {len(dev_names)} volumen(es) (método: {dev_method})")
|
||||
else:
|
||||
info("DESARROLLO: archivos compose no encontrados.")
|
||||
if prod_proj:
|
||||
info(f"PRODUCCIÓN ({prod_proj}): {len(prod_names)} volumen(es) (método: {prod_method})")
|
||||
else:
|
||||
info("PRODUCCIÓN: archivos compose no encontrados.")
|
||||
if glob_proj:
|
||||
info(f"GLOBALES ({glob_proj}): {len(glob_names)} volumen(es) (método: {glob_method})")
|
||||
else:
|
||||
info("GLOBALES: no se detectaron archivos compose globales.")
|
||||
print()
|
||||
|
||||
# Menú
|
||||
options = {}
|
||||
key = 1
|
||||
if dev_proj:
|
||||
print(f" {key}) Respaldar volúmenes de DESARROLLO ({dev_proj})")
|
||||
options[str(key)] = ("backup_explicit", dev_proj, dev_names); key += 1
|
||||
if prod_proj:
|
||||
print(f" {key}) Respaldar volúmenes de PRODUCCIÓN ({prod_proj})")
|
||||
options[str(key)] = ("backup_explicit", prod_proj, prod_names); key += 1
|
||||
if glob_proj:
|
||||
print(f" {key}) Respaldar volúmenes GLOBALES ({glob_proj})")
|
||||
options[str(key)] = ("backup_explicit", glob_proj, glob_names); key += 1
|
||||
|
||||
# TODOS: unión deduplicada por nombre (respalda 1 vez cada volumen)
|
||||
groups = []
|
||||
if dev_proj: groups.append( (dev_proj, dev_names) )
|
||||
if prod_proj: groups.append( (prod_proj, prod_names) )
|
||||
if glob_proj: groups.append( (glob_proj, glob_names) )
|
||||
|
||||
if len(groups) >= 2:
|
||||
print(f" {key}) Respaldar TODOS los grupos detectados")
|
||||
options[str(key)] = ("backup_all_explicit", groups); key += 1
|
||||
|
||||
print(f" {key}) Salir")
|
||||
exit_key = str(key)
|
||||
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
while True:
|
||||
choice = input("> ").strip()
|
||||
if choice == exit_key:
|
||||
ok("Saliendo.")
|
||||
sys.exit(0)
|
||||
|
||||
if choice not in options:
|
||||
print("Opción inválida.")
|
||||
continue
|
||||
|
||||
action = options[choice][0]
|
||||
dry = yes_no("¿Dry-run (no escribir archivos)?", default="n")
|
||||
outd = input(f"Directorio de salida (vacío = ./docker-volumes-{ts}): ").strip() or None
|
||||
excl_input = input("Excluir volúmenes (nombres separados por coma, vacío = ninguno): ").strip()
|
||||
excludes = set(e.strip() for e in excl_input.split(",") if e.strip()) if excl_input else set()
|
||||
|
||||
if action == "backup_explicit":
|
||||
_, proj, names = options[choice]
|
||||
names = [n for n in names if n not in excludes]
|
||||
if not names:
|
||||
warn("No hay volúmenes para respaldar.")
|
||||
sys.exit(0)
|
||||
rc = backup_explicit(names, ts, output_dir=outd, dry_run=dry, prefix_project=proj)
|
||||
sys.exit(rc)
|
||||
|
||||
elif action == "backup_all_explicit":
|
||||
_, groups_payload = options[choice]
|
||||
vol_to_proj: Dict[str, str] = {}
|
||||
for proj, names in groups_payload:
|
||||
for n in names:
|
||||
if n not in excludes and n not in vol_to_proj:
|
||||
vol_to_proj[n] = proj
|
||||
if not vol_to_proj:
|
||||
warn("No hay volúmenes para respaldar.")
|
||||
sys.exit(0)
|
||||
if not dry:
|
||||
ensure_alpine_image()
|
||||
out_dir = pathlib.Path(outd) if outd else (PROJECT_ROOT / f"docker-volumes-{ts}")
|
||||
failures = []
|
||||
for vname, proj in vol_to_proj.items():
|
||||
archive = build_archive_name(proj, vname, ts)
|
||||
print(f"Backing up volume: {vname} -> {archive}")
|
||||
rc = backup_volume(vname, out_dir, archive, dry_run=dry)
|
||||
if rc != 0:
|
||||
print(f" ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
|
||||
failures.append(vname)
|
||||
if failures:
|
||||
print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\nAll done. Archives written to:", str(out_dir.resolve()))
|
||||
sys.exit(0)
|
||||
|
||||
# ---------- CLI legacy (se mantiene) ----------
|
||||
|
||||
def detect_project_name(args_project: Optional[str]) -> str:
|
||||
if args_project:
|
||||
return args_project
|
||||
env_name = os.environ.get("COMPOSE_PROJECT_NAME")
|
||||
if env_name:
|
||||
return env_name
|
||||
return PROJECT_ROOT.name.replace(" ", "_")
|
||||
|
||||
def cli_main():
|
||||
parser = argparse.ArgumentParser(description="Export (compress) every Docker volume of a Docker Compose project.")
|
||||
parser.add_argument("-p", "--project", help="Compose project or prefix (see --discovery).")
|
||||
parser.add_argument("-o", "--output", help="Output directory (default: ./docker-volumes-<timestamp>).")
|
||||
parser.add_argument("--exclude", nargs="*", default=[], help="Volume names to exclude (space-separated).")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without doing it.")
|
||||
parser.add_argument("--timestamp", default=datetime.datetime.now().strftime("%Y%m%d-%H%M%S"),
|
||||
help="Timestamp to embed into filenames (default: current time).")
|
||||
parser.add_argument("--discovery", choices=["auto","label","name"], default="auto",
|
||||
help="How to discover volumes: 'label' (strict), 'name' (prefix), or 'auto' (default).")
|
||||
parser.add_argument("--list-only", action="store_true", help="Only list volumes that would be backed up and exit.")
|
||||
parser.add_argument("--menu", action="store_true", help="Launch interactive menu instead of CLI behavior.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.menu or not args.project:
|
||||
interactive_menu()
|
||||
return
|
||||
|
||||
if not which("docker"):
|
||||
print("ERROR: 'docker' not on PATH.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
project_raw = detect_project_name(args.project)
|
||||
project_norm = normalize_project_name(project_raw)
|
||||
project_lower = project_norm.lower()
|
||||
ts = args.timestamp
|
||||
out_dir = pathlib.Path(args.output) if args.output else (PROJECT_ROOT / f"docker-volumes-{ts}")
|
||||
|
||||
try:
|
||||
run(["docker", "version"], check=True, capture_output=True)
|
||||
except subprocess.CalledProcessError:
|
||||
print("ERROR: Docker daemon not reachable.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Descubrimiento legacy por label/prefijo (se mantiene para compatibilidad)
|
||||
selected = []
|
||||
method_used = None
|
||||
vols = list_by_label_project(project_norm)
|
||||
if vols:
|
||||
selected = vols; method_used = f"label:{project_norm}"
|
||||
else:
|
||||
vols2 = list_by_label_project(project_lower)
|
||||
if vols2:
|
||||
selected = vols2; method_used = f"label:{project_lower}"
|
||||
if not selected:
|
||||
by_name = list_by_name_prefix(project_norm)
|
||||
if by_name:
|
||||
selected = by_name; method_used = f"name-prefix:{project_norm}"
|
||||
else:
|
||||
by_name2 = list_by_name_prefix(project_lower)
|
||||
if by_name2:
|
||||
selected = by_name2; method_used = f"name-prefix:{project_lower}"
|
||||
|
||||
if not selected:
|
||||
print(f"No volumes found for project/prefix '{project_raw}'.")
|
||||
sys.exit(0)
|
||||
|
||||
exclude_set = set(args.exclude or [])
|
||||
names = [v.get("Name") for v in selected if v.get("Name") not in exclude_set]
|
||||
|
||||
print(f"Discovery method: {method_used}")
|
||||
print(f"Volumes discovered: {len(names)}")
|
||||
for n in names:
|
||||
print(" -", n)
|
||||
|
||||
if args.list_only:
|
||||
return
|
||||
|
||||
if not args.dry_run:
|
||||
ensure_alpine_image()
|
||||
|
||||
failures = []
|
||||
for vname in names:
|
||||
archive = build_archive_name(project_lower, vname, ts)
|
||||
print(f"Backing up volume: {vname} -> {archive}")
|
||||
rc = backup_volume(vname, out_dir, archive, dry_run=args.dry_run)
|
||||
if rc != 0:
|
||||
print(f" ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
|
||||
failures.append(vname)
|
||||
|
||||
if failures:
|
||||
print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\nAll done. Archives written to:", str(out_dir.resolve()))
|
||||
|
||||
# ---------- Entry point ----------
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 1:
|
||||
interactive_menu()
|
||||
else:
|
||||
cli_main()
|
||||
@@ -0,0 +1,73 @@
|
||||
# docker-compose.overrride.yml
|
||||
# Docker Comose para entorno de desarrollo o development.
|
||||
|
||||
|
||||
services:
|
||||
|
||||
app:
|
||||
image: node:20-bookworm
|
||||
expose:
|
||||
- ${APP_LOCAL_PORT}
|
||||
working_dir: /app
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
volumes:
|
||||
- ./services/app:/app:rw
|
||||
- ./services/app/node_modules:/app/node_modules
|
||||
env_file:
|
||||
- ./services/app/.env.development
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
networks:
|
||||
net:
|
||||
aliases: [dev-app]
|
||||
command: npm run dev
|
||||
|
||||
auth:
|
||||
image: node:20-bookworm
|
||||
expose:
|
||||
- ${AUTH_LOCAL_PORT}
|
||||
working_dir: /app
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
volumes:
|
||||
- ./services/auth:/app:rw
|
||||
- ./services/auth/node_modules:/app/node_modules
|
||||
env_file:
|
||||
- ./services/auth/.env.development
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
command: npm run dev
|
||||
networks:
|
||||
net:
|
||||
aliases: [dev-auth]
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASS}
|
||||
volumes:
|
||||
- suitecoffee-db:/var/lib/postgresql/data
|
||||
networks:
|
||||
net:
|
||||
aliases: [dev-db]
|
||||
|
||||
tenants:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: ${TENANTS_DB_NAME}
|
||||
POSTGRES_USER: ${TENANTS_DB_USER}
|
||||
POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
|
||||
volumes:
|
||||
- tenants-db:/var/lib/postgresql/data
|
||||
networks:
|
||||
net:
|
||||
aliases: [dev-tenants]
|
||||
|
||||
volumes:
|
||||
tenants-db:
|
||||
suitecoffee-db:
|
||||
|
||||
networks:
|
||||
net:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,71 @@
|
||||
# compose.prod.yml
|
||||
# Docker Comose para entorno de producción o production.
|
||||
|
||||
|
||||
services:
|
||||
|
||||
app:
|
||||
build:
|
||||
context: ./services/app
|
||||
dockerfile: Dockerfile.production
|
||||
expose:
|
||||
- ${APP_LOCAL_PORT}
|
||||
volumes:
|
||||
- ./services/app:/app
|
||||
env_file:
|
||||
- ./services/app/.env.production
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
networks:
|
||||
net:
|
||||
aliases: [prod-app]
|
||||
command: npm run start
|
||||
|
||||
auth:
|
||||
build:
|
||||
context: ./services/auth
|
||||
dockerfile: Dockerfile.production
|
||||
expose:
|
||||
- ${AUTH_LOCAL_PORT}
|
||||
volumes:
|
||||
- ./services/auth:/app
|
||||
env_file:
|
||||
- ./services/auth/.env.production
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
command: npm run start
|
||||
networks:
|
||||
net:
|
||||
aliases: [prod-auth]
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASS}
|
||||
volumes:
|
||||
- suitecoffee-db:/var/lib/postgresql/data
|
||||
networks:
|
||||
net:
|
||||
aliases: [prod-db]
|
||||
|
||||
tenants:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: ${TENANTS_DB_NAME}
|
||||
POSTGRES_USER: ${TENANTS_DB_USER}
|
||||
POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
|
||||
volumes:
|
||||
- tenants-db:/var/lib/postgresql/data
|
||||
networks:
|
||||
net:
|
||||
aliases: [prod-tenants]
|
||||
|
||||
volumes:
|
||||
tenants-db:
|
||||
suitecoffee-db:
|
||||
|
||||
networks:
|
||||
net:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,61 @@
|
||||
# compose.tools.yaml
|
||||
name: suitecoffee_tools
|
||||
|
||||
services:
|
||||
|
||||
dbeaver:
|
||||
image: dbeaver/cloudbeaver:latest
|
||||
profiles: [dbeaver]
|
||||
ports:
|
||||
- 8978:8978
|
||||
environment:
|
||||
TZ: America/Montevideo
|
||||
volumes:
|
||||
- dbeaver_logs:/opt/cloudbeaver/logs
|
||||
- dbeaver_workspace:/opt/cloudbeaver/workspace
|
||||
networks:
|
||||
suitecoffee_prod_net: {}
|
||||
suitecoffee_dev_net: {}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8978 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
npm:
|
||||
image: jc21/nginx-proxy-manager:latest
|
||||
profiles: [npm]
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80" # HTTP público
|
||||
- "81:81" # UI de administración
|
||||
environment:
|
||||
TZ: America/Montevideo
|
||||
volumes:
|
||||
- npm_data:/data
|
||||
- npm_letsencrypt:/etc/letsencrypt
|
||||
networks:
|
||||
suitecoffee_prod_net: {}
|
||||
suitecoffee_dev_net: {}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:81 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
|
||||
networks:
|
||||
suitecoffee_dev_net:
|
||||
external: true
|
||||
suitecoffee_prod_net:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
npm_data:
|
||||
npm_letsencrypt:
|
||||
|
||||
dbeaver_logs:
|
||||
dbeaver_workspace:
|
||||
@@ -0,0 +1,55 @@
|
||||
# compose.yml
|
||||
# Comose base
|
||||
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
|
||||
|
||||
services:
|
||||
|
||||
# app:
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# tenants:
|
||||
# condition: service_healthy
|
||||
# healthcheck:
|
||||
# test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
|
||||
# interval: 10s
|
||||
# timeout: 3s
|
||||
# retries: 10
|
||||
# start_period: 20s
|
||||
# restart: unless-stopped
|
||||
|
||||
# auth:
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# healthcheck:
|
||||
# test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
|
||||
# interval: 10s
|
||||
# timeout: 3s
|
||||
# retries: 10
|
||||
# start_period: 15s
|
||||
# restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
TZ: America/Montevideo
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
tenants:
|
||||
image: postgres:16
|
||||
environment:
|
||||
TZ: America/Montevideo
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${TENANTS_DB_USER} -d ${TENANTS_DB_NAME}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
@@ -1,73 +0,0 @@
|
||||
-- Crear la base de datos solo si no existe
|
||||
CREATE DATABASE IF NOT EXISTS `suitecoffee`;
|
||||
|
||||
USE `suitecoffee`;
|
||||
|
||||
-- Crear tabla de categorías
|
||||
CREATE TABLE IF NOT EXISTS categorias (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
nombre VARCHAR(100) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Crear tabla de productos
|
||||
CREATE TABLE IF NOT EXISTS productos (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
nombre VARCHAR(100) NOT NULL,
|
||||
precio DECIMAL(10,2) NOT NULL,
|
||||
categoria_id INT,
|
||||
FOREIGN KEY (categoria_id) REFERENCES categorias(id)
|
||||
);
|
||||
|
||||
-- Crear tabla de mesas
|
||||
CREATE TABLE IF NOT EXISTS mesas (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
numero INT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Crear tabla de comandas con productos en JSON
|
||||
CREATE TABLE IF NOT EXISTS comandas (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
mesa_id INT NOT NULL,
|
||||
productos JSON NOT NULL, -- Array de productos con cantidad y precio
|
||||
fecha DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
total DECIMAL(10,2),
|
||||
FOREIGN KEY (mesa_id) REFERENCES mesas(id)
|
||||
);
|
||||
|
||||
-- Insertar categoría 'Café' en la tabla categorias
|
||||
INSERT INTO categorias (nombre) VALUES ('cafe');
|
||||
|
||||
-- Insertar mesa '1, 2 y 3' en la tabla mesas
|
||||
INSERT INTO mesas (numero)
|
||||
VALUES
|
||||
(1),
|
||||
(2),
|
||||
(3);
|
||||
|
||||
-- Insertar cappuccino en la tabla productos, asociándolo con la categoría 'Café'
|
||||
INSERT INTO productos (nombre, precio, categoria_id)
|
||||
VALUES
|
||||
('Cappuccino', 200.00, (SELECT id FROM categorias WHERE nombre = 'Café')),
|
||||
('Latte', 200.00, (SELECT id FROM categorias WHERE nombre = 'Café')),
|
||||
('Espresso', 120.00, (SELECT id FROM categorias WHERE nombre = 'Café'));
|
||||
('Frappe', 290.00, (SELECT id FROM categorias WHERE nombre = 'Café'));
|
||||
|
||||
-- Insertar una comanda en la tabla comandas para la mesa 1
|
||||
INSERT INTO comandas (mesa_id, productos, total)
|
||||
VALUES
|
||||
(
|
||||
2, -- mesa_id
|
||||
JSON_ARRAY(
|
||||
JSON_OBJECT('producto_id', (SELECT id FROM productos WHERE nombre = 'Expresso'), 'cantidad', 2, 'precio_unitario', 111.00),
|
||||
JSON_OBJECT('producto_id', (SELECT id FROM productos WHERE nombre = 'Latte'), 'cantidad', 1, 'precio_unitario', 666.00)
|
||||
),
|
||||
208457935.00 -- total (2 Cappuccinos * 200 + 1 Latte * 220)
|
||||
),
|
||||
(
|
||||
3, -- mesa_id
|
||||
JSON_ARRAY(
|
||||
JSON_OBJECT('producto_id', (SELECT id FROM productos WHERE nombre = 'Cappuccino'), 'cantidad', 2, 'precio_unitario', 444.00),
|
||||
JSON_OBJECT('producto_id', (SELECT id FROM productos WHERE nombre = 'Frappe'), 'cantidad', 4, 'precio_unitario', 222.00)
|
||||
),
|
||||
93826.00 -- total (2 Cappuccinos * 200 + 1 Latte * 220)
|
||||
);
|
||||
@@ -1,34 +0,0 @@
|
||||
# docker-compose.dev.yml
|
||||
|
||||
services:
|
||||
suitecoffee-app:
|
||||
container_name: suitecoffee-app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
volumes:
|
||||
- .:/app
|
||||
ports:
|
||||
- "${PORT}:${PORT}" # Usa la variable de entorno PORT
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- PORT=${PORT}
|
||||
command: npm run dev # Usa nodemon para desarrollo
|
||||
restart: unless-stopped
|
||||
|
||||
suitecoffee-db:
|
||||
container_name: suitecoffee-db
|
||||
image: mysql:latest
|
||||
env_file:
|
||||
- .env.${NODE_ENV}
|
||||
environment:
|
||||
MYSQL_USER: $DB_USER
|
||||
MYSQL_PASSWORD: $DB_PASS
|
||||
MYSQL_ROOT_PASSWORD: $DB_ROOT_PASSWORD
|
||||
MYSQL_DATABASE: $DB_NAME
|
||||
volumes:
|
||||
- ./db/dev-db:/var/lib/mysql
|
||||
- ./db/init:/docker-entrypoint-initdb.d
|
||||
ports:
|
||||
- "$DB_LOCAL_PORT:$DB_DOCKER_PORT"
|
||||
restart: unless-stopped
|
||||
@@ -1,32 +0,0 @@
|
||||
# docker-compose.prod.yml
|
||||
|
||||
services:
|
||||
suitecoffee-app:
|
||||
container_name: suitecoffee-app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.prod
|
||||
ports:
|
||||
- "${PORT}:${PORT}" # Usa la variable de entorno PORT
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=${PORT}
|
||||
command: npm start # Usa el comando de inicio en producción
|
||||
restart: unless-stopped
|
||||
|
||||
suitecoffee-db:
|
||||
container_name: suitecoffee-db
|
||||
image: mysql:latest
|
||||
env_file:
|
||||
- .env.${NODE_ENV}
|
||||
environment:
|
||||
MYSQL_USER: $DB_USER
|
||||
MYSQL_PASSWORD: $DB_PASS
|
||||
MYSQL_ROOT_PASSWORD: $DB_ROOT_PASSWORD
|
||||
MYSQL_DATABASE: $DB_NAME
|
||||
volumes:
|
||||
- ./db/app-db/mysql_prod:/var/lib/mysql
|
||||
- ./db/init:/docker-entrypoint-initdb.d
|
||||
ports:
|
||||
- "$DB_LOCAL_PORT:$DB_DOCKER_PORT"
|
||||
restart: unless-stopped
|
||||
@@ -1,250 +0,0 @@
|
||||
// index.js
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const mysql = require('mysql2/promise');
|
||||
const cors = require('cors');
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// Cargar las variables de entorno dependiendo del entorno
|
||||
const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development';
|
||||
dotenv.config({ path: envFile });
|
||||
|
||||
// Configuración de conexión MySQL
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST || 'db',
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_DOCKER_PORT || 3306
|
||||
};
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
|
||||
// Servir archivos estáticos de la carpeta 'src'
|
||||
app.use(express.static(path.join(__dirname, 'src')));
|
||||
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// --------------------------------- RENDERIZADO --------------------------------------
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
// Ruta principal
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'src', 'index.html'));
|
||||
});
|
||||
|
||||
// Ruta para comandas
|
||||
app.get('/comandas', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'src', 'pages', 'comandas.html'));
|
||||
});
|
||||
// Ruta para dashboard
|
||||
app.get('/lectura', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'src', 'pages', 'lectura.html'));
|
||||
});
|
||||
// Ruta para dashboard
|
||||
app.get('/carga', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'src', 'pages', 'carga.html'));
|
||||
});
|
||||
|
||||
// Ruta para obtener las tablas de la base de datos
|
||||
app.get('/tablas', async (req, res) => {
|
||||
let connection;
|
||||
try {
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
const [rows] = await connection.execute(
|
||||
`SHOW TABLES FROM \`${dbConfig.database}\``
|
||||
);
|
||||
const tablas = rows.map(row => Object.values(row)[0]);
|
||||
res.json({ tablas });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error al conectar o consultar la base de datos:', error);
|
||||
res.status(500).json({ error: 'Error interno al consultar la base de datos' });
|
||||
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// ----------------------------------- LECTURAS ---------------------------------------
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
// Obtener mesas
|
||||
app.get('/api/obtenerMesas', async (req, res) => {
|
||||
let connection;
|
||||
try {
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
const [results] = await connection.query('SELECT * FROM mesas');
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error('Error al obtener mesas:', error);
|
||||
res.status(500).json({ error: 'Error al obtener mesas' });
|
||||
} finally {
|
||||
if (connection) await connection.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener productos
|
||||
app.get('/api/obtenerProductos', async (req, res) => {
|
||||
let connection;
|
||||
try {
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
const [results] = await connection.query('SELECT * FROM productos');
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error('Error al obtener productos:', error);
|
||||
res.status(500).json({ error: 'Error al obtener productos' });
|
||||
} finally {
|
||||
if (connection) await connection.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener categorías
|
||||
app.get('/api/obtenerCategorias', async (req, res) => {
|
||||
let connection;
|
||||
try {
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
const [results] = await connection.query('SELECT * FROM categorias');
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error('Error al obtener categorías:', error);
|
||||
res.status(500).json({ error: 'Error al obtener categorías' });
|
||||
} finally {
|
||||
if (connection) await connection.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener comandas
|
||||
app.get('/api/obtenerComandas', async (req, res) => {
|
||||
let connection;
|
||||
try {
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
const [results] = await connection.execute('SELECT * FROM comandas');
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error('Error al obtener comandas:', error);
|
||||
res.status(500).json({ error: 'Error al obtener comandas' });
|
||||
} finally {
|
||||
if (connection) await connection.end();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// ------------------------------------ CARGAS ----------------------------------------
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
// Cargar nueva mesa
|
||||
app.post('/api/cargarMesas', async (req, res) => {
|
||||
const { numero } = req.body;
|
||||
if (!numero) return res.status(400).json({ error: 'Falta el número de mesa' });
|
||||
|
||||
let connection;
|
||||
try {
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
await connection.execute('INSERT INTO mesas (numero) VALUES (?)', [numero]);
|
||||
res.status(201).json({ mensaje: 'Mesa cargada correctamente' });
|
||||
} catch (error) {
|
||||
console.error('Error al cargar mesa:', error);
|
||||
res.status(500).json({ error: 'Error al cargar mesa' });
|
||||
} finally {
|
||||
if (connection) await connection.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Cargar nuevo producto
|
||||
app.post('/api/cargarProductos', async (req, res) => {
|
||||
const { nombre, precio, categoria_id } = req.body;
|
||||
if (!nombre || !precio || !categoria_id) {
|
||||
return res.status(400).json({ error: 'Faltan datos para cargar el producto' });
|
||||
}
|
||||
|
||||
let connection;
|
||||
try {
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
await connection.execute(
|
||||
'INSERT INTO productos (nombre, precio, categoria_id) VALUES (?, ?, ?)',
|
||||
[nombre, precio, categoria_id]
|
||||
);
|
||||
res.status(201).json({ mensaje: 'Producto cargado correctamente' });
|
||||
} catch (error) {
|
||||
console.error('Error al cargar producto:', error);
|
||||
res.status(500).json({ error: 'Error al cargar producto' });
|
||||
} finally {
|
||||
if (connection) await connection.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Cargar nueva categoría
|
||||
app.post('/api/cargarCategorias', async (req, res) => {
|
||||
const { nombre } = req.body;
|
||||
if (!nombre) return res.status(400).json({ error: 'Falta el nombre de la categoría' });
|
||||
|
||||
let connection;
|
||||
try {
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
await connection.execute('INSERT INTO categorias (nombre) VALUES (?)', [nombre]);
|
||||
res.status(201).json({ mensaje: 'Categoría cargada correctamente' });
|
||||
} catch (error) {
|
||||
console.error('Error al cargar categoría:', error);
|
||||
res.status(500).json({ error: 'Error al cargar categoría' });
|
||||
} finally {
|
||||
if (connection) await connection.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Cargar nueva comanda
|
||||
app.post('/api/cargarComandas', async (req, res) => {
|
||||
const { mesa_id, productos, total } = req.body;
|
||||
if (!mesa_id || !productos || !Array.isArray(productos) || total == null) {
|
||||
return res.status(400).json({ error: 'Datos inválidos para cargar comanda' });
|
||||
}
|
||||
let connection;
|
||||
try {
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
await connection.execute(
|
||||
'INSERT INTO comandas (mesa_id, productos, total, fecha) VALUES (?, ?, ?, NOW())',
|
||||
[mesa_id, JSON.stringify(productos), total]
|
||||
);
|
||||
res.status(201).json({ mensaje: 'Comanda cargada correctamente' });
|
||||
} catch (error) {
|
||||
console.error('Error al cargar comanda:', error);
|
||||
res.status(500).json({ error: 'Error al cargar comanda' });
|
||||
} finally {
|
||||
if (connection) await connection.end();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// --------------------------------- VERIFICACIONES -----------------------------------
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
async function verificarConexion() {
|
||||
try {
|
||||
const connection = await mysql.createConnection(dbConfig);
|
||||
const [rows] = await connection.execute('SELECT NOW() AS hora');
|
||||
console.log('Conexión con la base de datos fue exitosa.');
|
||||
console.log('Fecha y hora actual de la base de datos:', rows[0].hora);
|
||||
await connection.end();
|
||||
} catch (error) {
|
||||
console.error('Error al conectar con la base de datos al iniciar:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Iniciar servidor
|
||||
app.listen(port, () => {
|
||||
console.log(`Servidor corriendo en http://localhost:${port}`);
|
||||
console.log('Estableciendo conexión...');
|
||||
verificarConexion();
|
||||
});
|
||||
+14
-16
@@ -1,23 +1,21 @@
|
||||
{
|
||||
"name": "suitecoffee",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js"
|
||||
"description": "Software para gestión de cafeterías",
|
||||
"keywords": [
|
||||
"coffee",
|
||||
"suite",
|
||||
"suitecoffee"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"mysql2": "^3.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.9"
|
||||
"author": "Mateo Saldain",
|
||||
"type": "module",
|
||||
"main": "suitecoffee.py",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
restore_compose_volumes.py
|
||||
--------------------------
|
||||
Restaura volúmenes desde backups generados por backup_compose_volumes.py.
|
||||
|
||||
- Busca carpetas ./docker-volumes-<timestamp>
|
||||
- Lee .tar.gz (nombres: <volume_name>-<YYYYMMDD-HHMMSS>.tar.gz)
|
||||
- Dos modos:
|
||||
1) Tradicional (sin labels)
|
||||
2) Reconocido por Compose (aplica labels com.docker.compose.* para evitar el warning)
|
||||
|
||||
Además:
|
||||
- Si un volumen existe y está en uso, ofrece detener y eliminar contenedores que lo usan
|
||||
para poder recrearlo con labels correctos (solo en modo 2).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import shlex
|
||||
import pathlib
|
||||
import subprocess
|
||||
from typing import List, Tuple, Dict, Optional
|
||||
|
||||
PROJECT_ROOT = pathlib.Path.cwd()
|
||||
BACKUP_DIR_PATTERN = re.compile(r"^docker-volumes-\d{8}-\d{6}$")
|
||||
ARCHIVE_PATTERN = re.compile(r"^(?P<basename>.+)-(?P<ts>\d{8}-\d{6})\.tar\.gz$")
|
||||
|
||||
# ---------- utils ----------
|
||||
|
||||
def run(cmd: List[str], check: bool = False, capture_output: bool = True, text: bool = True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, check=check, capture_output=capture_output, text=text)
|
||||
|
||||
def which(prog: str) -> bool:
|
||||
from shutil import which as _w
|
||||
return _w(prog) is not None
|
||||
|
||||
def fail(msg: str):
|
||||
print(f"✗ {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
def ok(msg: str):
|
||||
print(f"✓ {msg}")
|
||||
|
||||
def info(msg: str):
|
||||
print(f"• {msg}")
|
||||
|
||||
def warn(msg: str):
|
||||
print(f"! {msg}")
|
||||
|
||||
def yes_no(prompt: str, default: str = "n") -> bool:
|
||||
default = default.lower()
|
||||
hint = "[Y/n]" if default == "y" else "[y/N]"
|
||||
while True:
|
||||
resp = input(f"{prompt} {hint} ").strip().lower()
|
||||
if not resp:
|
||||
return default == "y"
|
||||
if resp in ("y","yes","s","si","sí"):
|
||||
return True
|
||||
if resp in ("n","no"):
|
||||
return False
|
||||
print("Respuesta no reconocida. Responde 'y' o 'n'.")
|
||||
|
||||
# ---------- docker helpers ----------
|
||||
|
||||
def ensure_alpine_image():
|
||||
try:
|
||||
run(["docker", "image", "inspect", "alpine:latest"], check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
info("Descargando alpine:latest ...")
|
||||
run(["docker", "pull", "alpine:latest"], check=True, capture_output=False, text=True)
|
||||
|
||||
def volume_exists(name: str) -> bool:
|
||||
try:
|
||||
run(["docker", "volume", "inspect", name], check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def inspect_volume_labels(name: str) -> Dict[str, str]:
|
||||
try:
|
||||
cp = run(["docker", "volume", "inspect", name, "--format", "{{json .Labels}}"], check=True)
|
||||
return json.loads(cp.stdout or "null") or {}
|
||||
except subprocess.CalledProcessError:
|
||||
return {}
|
||||
|
||||
def containers_using_volume(name: str) -> List[str]:
|
||||
# docker ps soporta --filter volume=<name>
|
||||
try:
|
||||
cp = run(["docker", "ps", "-a", "--filter", f"volume={name}", "-q"], check=True)
|
||||
return [l.strip() for l in cp.stdout.splitlines() if l.strip()]
|
||||
except subprocess.CalledProcessError:
|
||||
return []
|
||||
|
||||
def stop_containers(ids: List[str]) -> None:
|
||||
if not ids:
|
||||
return
|
||||
info("Deteniendo contenedores que usan el volumen...")
|
||||
run(["docker", "stop"] + ids, check=False, capture_output=False)
|
||||
|
||||
def remove_containers(ids: List[str]) -> None:
|
||||
if not ids:
|
||||
return
|
||||
info("Eliminando contenedores detenidos que usan el volumen...")
|
||||
run(["docker", "rm"] + ids, check=False, capture_output=False)
|
||||
|
||||
def remove_volume(name: str) -> bool:
|
||||
try:
|
||||
run(["docker", "volume", "rm", "-f", name], check=True, capture_output=False)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
warn(f"No se pudo eliminar volumen '{name}': {e}")
|
||||
return False
|
||||
|
||||
def create_volume(name: str, labels: Optional[Dict[str,str]] = None) -> bool:
|
||||
cmd = ["docker", "volume", "create"]
|
||||
if labels:
|
||||
for k, v in labels.items():
|
||||
cmd += ["--label", f"{k}={v}"]
|
||||
cmd.append(name)
|
||||
try:
|
||||
run(cmd, check=True, capture_output=False)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
warn(f"Fallo creando volumen '{name}': {e}")
|
||||
return False
|
||||
|
||||
def restore_into_volume(volume_name: str, backup_dir: pathlib.Path, archive_file: pathlib.Path) -> int:
|
||||
bdir_abs = backup_dir.resolve()
|
||||
docker_cmd = [
|
||||
"docker", "run", "--rm",
|
||||
"-v", f"{volume_name}:/volume",
|
||||
"-v", f"{str(bdir_abs)}:/backup",
|
||||
"alpine:latest",
|
||||
"sh", "-lc",
|
||||
f"tar xzf /backup/{shlex.quote(archive_file.name)} -C /volume"
|
||||
]
|
||||
proc = subprocess.run(docker_cmd)
|
||||
return proc.returncode
|
||||
|
||||
# ---------- parsing helpers ----------
|
||||
|
||||
def find_backup_dirs(root: pathlib.Path) -> List[pathlib.Path]:
|
||||
dirs = [p for p in root.iterdir() if p.is_dir() and BACKUP_DIR_PATTERN.match(p.name)]
|
||||
dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return dirs
|
||||
|
||||
def find_archives(dirpath: pathlib.Path) -> List[pathlib.Path]:
|
||||
files = [p for p in dirpath.iterdir() if p.is_file() and p.name.endswith(".tar.gz")]
|
||||
files.sort(key=lambda p: p.name)
|
||||
return files
|
||||
|
||||
def parse_archive_basename(archive_name: str) -> Optional[str]:
|
||||
m = ARCHIVE_PATTERN.match(archive_name)
|
||||
if not m:
|
||||
return None
|
||||
return m.group("basename")
|
||||
|
||||
# ---------- compose label helpers ----------
|
||||
|
||||
def derive_labels_auto(volume_name: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
project = parte antes del primer '_' o '-'
|
||||
short = resto luego del separador
|
||||
"""
|
||||
for sep in ("_", "-"):
|
||||
if sep in volume_name:
|
||||
idx = volume_name.find(sep)
|
||||
return volume_name[:idx], volume_name[idx+1:]
|
||||
return None, None
|
||||
|
||||
def derive_labels_with_fixed_project(volume_name: str, project: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Usa project fijo. Si volume_name empieza con '<project>_' o '<project>-', recorta.
|
||||
"""
|
||||
p = project
|
||||
if volume_name.startswith(p + "_"):
|
||||
return p, volume_name[len(p) + 1:]
|
||||
if volume_name.startswith(p + "-"):
|
||||
return p, volume_name[len(p) + 1:]
|
||||
return p, volume_name
|
||||
|
||||
def labels_match_compose(name: str, project: str, short: str) -> bool:
|
||||
labels = inspect_volume_labels(name)
|
||||
return (
|
||||
labels.get("com.docker.compose.project") == project and
|
||||
labels.get("com.docker.compose.volume") == short
|
||||
)
|
||||
|
||||
# ---------- UI flows ----------
|
||||
|
||||
def pick_backup_dir(dirs: List[pathlib.Path]) -> Optional[pathlib.Path]:
|
||||
if not dirs:
|
||||
warn("No se encontraron carpetas de backup 'docker-volumes-<timestamp>'.")
|
||||
return None
|
||||
print("\nCarpetas de backup encontradas:")
|
||||
for i, d in enumerate(dirs, 1):
|
||||
print(f" {i}) {d.name}")
|
||||
while True:
|
||||
sel = input("> Elige una carpeta (número) o Enter para cancelar: ").strip()
|
||||
if not sel:
|
||||
return None
|
||||
if sel.isdigit() and 1 <= int(sel) <= len(dirs):
|
||||
return dirs[int(sel) - 1]
|
||||
print("Opción inválida.")
|
||||
|
||||
def pick_archives(files: List[pathlib.Path]) -> List[pathlib.Path]:
|
||||
if not files:
|
||||
warn("No hay archivos .tar.gz en esa carpeta.")
|
||||
return []
|
||||
print("\nBackups disponibles:")
|
||||
for i, f in enumerate(files, 1):
|
||||
base = parse_archive_basename(f.name) or f.name
|
||||
print(f" {i}) {f.name} -> volumen: {base}")
|
||||
print("\nOpciones:")
|
||||
print(" a) Restaurar TODOS")
|
||||
print(" s) Seleccionar algunos (ej: 1,3,5)")
|
||||
while True:
|
||||
sel = input("> Elige 'a' o 's': ").strip().lower()
|
||||
if sel == "a":
|
||||
return files
|
||||
if sel == "s":
|
||||
picks = input("> Números separados por coma: ").strip()
|
||||
idxs = []
|
||||
try:
|
||||
for tok in picks.split(","):
|
||||
tok = tok.strip()
|
||||
if tok:
|
||||
idx = int(tok)
|
||||
idxs.append(idx - 1)
|
||||
chosen = [files[i] for i in sorted(set(i for i in idxs if 0 <= i < len(files)))]
|
||||
if chosen:
|
||||
return chosen
|
||||
except Exception:
|
||||
pass
|
||||
print("Selección inválida.")
|
||||
else:
|
||||
print("Opción inválida.")
|
||||
|
||||
def pick_restore_mode() -> str:
|
||||
print("\nModo de restauración:")
|
||||
print(" 1) Tradicional (sin labels)")
|
||||
print(" 2) Reconocido por Compose (aplica labels para evitar el warning)")
|
||||
while True:
|
||||
sel = input("> Elige 1 o 2: ").strip()
|
||||
if sel in ("1", "2"):
|
||||
return sel
|
||||
print("Opción inválida.")
|
||||
|
||||
def confirm_overwrite(volume_name: str) -> bool:
|
||||
return yes_no(f"El volumen '{volume_name}' ya existe. ¿Sobrescribir (recrear)?", default="n")
|
||||
|
||||
# ---------- restore flows ----------
|
||||
|
||||
def restore_traditional(backup_dir: pathlib.Path, archives: List[pathlib.Path]):
|
||||
ensure_alpine_image()
|
||||
print("\n=== Restauración TRADICIONAL ===\n")
|
||||
for arch in archives:
|
||||
vname = parse_archive_basename(arch.name)
|
||||
if not vname:
|
||||
warn(f"Nombre de backup no reconocible: {arch.name}, se omite.")
|
||||
continue
|
||||
info(f"Volumen: {vname}")
|
||||
|
||||
# Tradicional: no cambiamos labels; si existe, restauramos sobre volumen nuevo (recreándolo)
|
||||
if volume_exists(vname):
|
||||
# Intentar eliminar: si está en uso, ofrecer detener/remover contenedores
|
||||
if not confirm_overwrite(vname):
|
||||
info(" → Omitido (ya existe).")
|
||||
continue
|
||||
ids = containers_using_volume(vname)
|
||||
if ids:
|
||||
info(f"Contenedores que usan '{vname}': {', '.join(ids)}")
|
||||
if yes_no("¿Detener y eliminar esos contenedores para continuar?", default="y"):
|
||||
stop_containers(ids)
|
||||
remove_containers(ids)
|
||||
else:
|
||||
warn(" → No se puede recrear el volumen en uso. Omitido.")
|
||||
continue
|
||||
if not remove_volume(vname):
|
||||
warn(" → No se pudo eliminar el volumen. Omitido.")
|
||||
continue
|
||||
|
||||
if not create_volume(vname):
|
||||
warn(" → No se pudo crear el volumen, se omite.")
|
||||
continue
|
||||
rc = restore_into_volume(vname, backup_dir, arch)
|
||||
if rc == 0:
|
||||
ok(" Restaurado.")
|
||||
else:
|
||||
warn(f" Falló la restauración (rc={rc}).")
|
||||
|
||||
def restore_with_compose_labels(backup_dir: pathlib.Path, archives: List[pathlib.Path]):
|
||||
"""
|
||||
Restaura creando volúmenes con labels de Compose para que NO aparezca el warning:
|
||||
"volume ... already exists but was not created by Docker Compose..."
|
||||
"""
|
||||
ensure_alpine_image()
|
||||
print("\n=== Restauración RECONOCIDA POR COMPOSE (con labels) ===\n")
|
||||
print("Estrategia de etiquetado:")
|
||||
print(" 1) Auto (project = prefijo de <vol> antes de '_' o '-', short = resto)")
|
||||
print(" 2) Fijar un 'project' para todos (p. ej. suitecoffee, suitecoffee_dev, suitecoffee_prod)")
|
||||
mode = ""
|
||||
while mode not in ("1", "2"):
|
||||
mode = input("> Elige 1 o 2: ").strip()
|
||||
|
||||
fixed_project = None
|
||||
if mode == "2":
|
||||
fixed_project = input("> Indica el 'project' de Compose (exacto): ").strip()
|
||||
if not fixed_project:
|
||||
warn("Project vacío, cancelado.")
|
||||
return
|
||||
|
||||
# Previsualización de etiquetas
|
||||
preview = []
|
||||
for arch in archives:
|
||||
vname = parse_archive_basename(arch.name)
|
||||
if not vname:
|
||||
continue
|
||||
if mode == "1":
|
||||
proj, short = derive_labels_auto(vname)
|
||||
else:
|
||||
proj, short = derive_labels_with_fixed_project(vname, fixed_project)
|
||||
preview.append((arch, vname, proj, short))
|
||||
|
||||
print("\nVista previa de etiquetas (project / volume):")
|
||||
for _, vname, proj, short in preview:
|
||||
if proj and short:
|
||||
print(f" {vname} → project='{proj}', volume='{short}'")
|
||||
else:
|
||||
print(f" {vname} → (no derivado; se pedirá manualmente)")
|
||||
|
||||
if not yes_no("\n¿Confirmar restauración con estas etiquetas?", default="y"):
|
||||
warn("Cancelado por el usuario.")
|
||||
return
|
||||
|
||||
# Restaurar con labels
|
||||
for arch, vname, proj, short in preview:
|
||||
# completar manual si falta
|
||||
if not proj or not short:
|
||||
print(f"\nDefinir etiquetas para: {vname}")
|
||||
proj = input(" project = ").strip()
|
||||
short = input(" volume = ").strip()
|
||||
if not proj or not short:
|
||||
warn(" → Etiquetas incompletas; se omite.")
|
||||
continue
|
||||
|
||||
info(f"\nVolumen: {vname} (labels: project='{proj}', volume='{short}')")
|
||||
|
||||
if volume_exists(vname):
|
||||
# ¿ya tiene labels correctas? entonces solo restauramos datos sin recrear
|
||||
if labels_match_compose(vname, proj, short):
|
||||
info(" Volumen ya tiene labels de Compose correctas. Sobrescribiendo datos...")
|
||||
rc = restore_into_volume(vname, backup_dir, arch)
|
||||
if rc == 0:
|
||||
ok(" Restaurado (labels ya correctas).")
|
||||
else:
|
||||
warn(f" Falló la restauración (rc={rc}).")
|
||||
continue
|
||||
|
||||
# Pedir permiso para detener/eliminar contenedores y recrear volumen con labels correctas
|
||||
if not yes_no(" El volumen existe sin labels correctas. ¿Detener/eliminar contenedores y recrearlo con labels para evitar el warning?", default="y"):
|
||||
warn(" → Omitido (mantiene warning de Compose).")
|
||||
continue
|
||||
|
||||
ids = containers_using_volume(vname)
|
||||
if ids:
|
||||
info(f" Contenedores que usan '{vname}': {', '.join(ids)}")
|
||||
stop_containers(ids)
|
||||
remove_containers(ids)
|
||||
|
||||
if not remove_volume(vname):
|
||||
warn(" → No se pudo eliminar el volumen. Omitido.")
|
||||
continue
|
||||
|
||||
labels = {
|
||||
"com.docker.compose.project": proj,
|
||||
"com.docker.compose.volume": short,
|
||||
}
|
||||
if not create_volume(vname, labels=labels):
|
||||
warn(" → No se pudo crear el volumen con labels. Omitido.")
|
||||
continue
|
||||
|
||||
rc = restore_into_volume(vname, backup_dir, arch)
|
||||
if rc == 0:
|
||||
ok(" Restaurado con labels de Compose (warning resuelto).")
|
||||
else:
|
||||
warn(f" Falló la restauración (rc={rc}).")
|
||||
|
||||
# ---------- main ----------
|
||||
|
||||
def main():
|
||||
if not which("docker"):
|
||||
fail("No se encontró 'docker' en el PATH.")
|
||||
try:
|
||||
run(["docker", "version"], check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
fail("No se puede comunicar con el daemon de Docker. ¿Está corriendo?")
|
||||
|
||||
# Elegir carpeta docker-volumes-<ts>
|
||||
dirs = [p for p in PROJECT_ROOT.iterdir() if p.is_dir() and BACKUP_DIR_PATTERN.match(p.name)]
|
||||
dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if not dirs:
|
||||
warn("No hay carpetas de backup 'docker-volumes-<timestamp>'.")
|
||||
return
|
||||
|
||||
print("\nCarpetas de backup encontradas:")
|
||||
for i, d in enumerate(dirs, 1):
|
||||
print(f" {i}) {d.name}")
|
||||
chosen = None
|
||||
while not chosen:
|
||||
sel = input("> Elige una carpeta (número) o Enter para salir: ").strip()
|
||||
if not sel:
|
||||
warn("Cancelado."); return
|
||||
if sel.isdigit() and 1 <= int(sel) <= len(dirs):
|
||||
chosen = dirs[int(sel)-1]
|
||||
else:
|
||||
print("Opción inválida.")
|
||||
|
||||
# Archivos en carpeta
|
||||
archives = [p for p in chosen.iterdir() if p.is_file() and p.name.endswith(".tar.gz")]
|
||||
archives.sort(key=lambda p: p.name)
|
||||
if not archives:
|
||||
warn("No hay .tar.gz en esa carpeta."); return
|
||||
|
||||
print("\nBackups disponibles:")
|
||||
for i, f in enumerate(archives, 1):
|
||||
base = parse_archive_basename(f.name) or f.name
|
||||
print(f" {i}) {f.name} -> volumen: {base}")
|
||||
|
||||
print("\nOpciones de selección:")
|
||||
print(" a) Restaurar TODOS")
|
||||
print(" s) Elegir algunos (ej: 1,3,5)")
|
||||
selected: List[pathlib.Path] = []
|
||||
while not selected:
|
||||
mode = input("> Elige 'a' o 's': ").strip().lower()
|
||||
if mode == "a":
|
||||
selected = archives
|
||||
elif mode == "s":
|
||||
picks = input("> Números separados por coma: ").strip()
|
||||
try:
|
||||
idxs = [int(x.strip())-1 for x in picks.split(",") if x.strip()]
|
||||
selected = [archives[i] for i in sorted(set(i for i in idxs if 0 <= i < len(archives)))]
|
||||
except Exception:
|
||||
selected = []
|
||||
else:
|
||||
print("Opción inválida.")
|
||||
|
||||
# Modo de restauración
|
||||
choice = pick_restore_mode()
|
||||
if choice == "1":
|
||||
restore_traditional(chosen, selected)
|
||||
else:
|
||||
restore_with_compose_labels(chosen, selected)
|
||||
|
||||
ok("\nProceso finalizado.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n✓ Interrumpido por el usuario (Ctrl+C).")
|
||||
@@ -0,0 +1,20 @@
|
||||
# Dockerfile.dev
|
||||
FROM node:22.18
|
||||
|
||||
# Definir variables de entorno con valores predeterminados
|
||||
# ARG NODE_ENV=production
|
||||
# ARG PORT=3000
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copia archivos de configuración primero para aprovechar el cache
|
||||
COPY package*.json ./
|
||||
|
||||
# Instala dependencias
|
||||
RUN npm i
|
||||
|
||||
# Copia el resto de la app
|
||||
COPY . .
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
+300
-128
@@ -1,23 +1,34 @@
|
||||
{
|
||||
"name": "suitecoffee",
|
||||
"name": "aplication",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "suitecoffee",
|
||||
"name": "aplication",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.5.0",
|
||||
"dotenv": "^17.2.1",
|
||||
"express": "^5.1.0",
|
||||
"mysql2": "^3.14.0"
|
||||
"express-ejs-layouts": "^2.5.1",
|
||||
"pg": "^8.16.3",
|
||||
"pg-format": "^1.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.9"
|
||||
"cross-env": "^10.0.0",
|
||||
"nodemon": "^3.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -45,15 +56,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -95,9 +97,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -156,6 +158,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz",
|
||||
"integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -240,10 +254,43 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.0.0.tgz",
|
||||
"integrity": "sha512-aU8qlEK/nHYtVuN4p7UQgAwVljzMg8hB4YK5ThRqD2l/ziSnryncPNn7bMLt5cFYsKVKBh8HqLqyCoTupEUu7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -257,15 +304,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -276,9 +314,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.5.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
||||
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
|
||||
"version": "17.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
|
||||
"integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -403,6 +441,11 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-ejs-layouts": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-ejs-layouts/-/express-ejs-layouts-2.5.1.tgz",
|
||||
"integrity": "sha512-IXROv9n3xKga7FowT06n1Qn927JR8ZWDn5Dc9CJQoiiaaDqbhW5PDmWShzbpAa2wjWT1vJqaIM1S6vJwwX11gA=="
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -475,15 +518,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -596,6 +630,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -682,41 +725,12 @@
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
|
||||
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/lru.min": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz",
|
||||
"integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=1.30.0",
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
@@ -788,38 +802,6 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.0.tgz",
|
||||
"integrity": "sha512-8eMhmG6gt/hRkU1G+8KlGOdQi2w+CgtNoD1ksXZq9gQfkfDsX4LHaBwTe1SY0Imx//t2iZA03DFnyYKPinxSRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.1",
|
||||
"denque": "^2.1.0",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"long": "^5.2.1",
|
||||
"lru.min": "^1.0.0",
|
||||
"named-placeholders": "^1.1.3",
|
||||
"seq-queue": "^0.0.5",
|
||||
"sqlstring": "^2.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
|
||||
"integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru-cache": "^7.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
@@ -830,9 +812,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz",
|
||||
"integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==",
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
||||
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -919,6 +901,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
||||
@@ -928,6 +920,104 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
"pg-protocol": "^1.10.3",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.2.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
|
||||
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-format": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz",
|
||||
"integrity": "sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
|
||||
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
@@ -941,6 +1031,45 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -1056,9 +1185,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -1090,11 +1219,6 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/seq-queue": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||
@@ -1116,6 +1240,29 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
@@ -1201,19 +1348,19 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sqlstring": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
|
||||
"license": "MIT",
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
@@ -1303,11 +1450,36 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "aplication",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=production node ./src/index.js",
|
||||
"dev": "NODE_ENV=development npx nodemon ./src/index.js",
|
||||
"test": "NODE_ENV=stage node ./src/index.js"
|
||||
},
|
||||
"author": "Mateo Saldain",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.0.0",
|
||||
"nodemon": "^3.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.1",
|
||||
"express": "^5.1.0",
|
||||
"express-ejs-layouts": "^2.5.1",
|
||||
"pg": "^8.16.3",
|
||||
"pg-format": "^1.0.4"
|
||||
},
|
||||
"keywords": [],
|
||||
"description": ""
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// app/src/index.js
|
||||
import chalk from 'chalk'; // Colores!
|
||||
import express from 'express';
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
import cors from 'cors';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
// Rutas
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Variables de Entorno
|
||||
import dotenv, { config } from 'dotenv';
|
||||
|
||||
// Obtención de la ruta de la variable de entorno correspondiente a NODE_ENV
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.development' )});
|
||||
console.log(`Activando entorno de ->${chalk.green(` DEVELOPMENT `)}`);
|
||||
} else if (process.env.NODE_ENV === 'stage') {
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.test' )});
|
||||
console.log(`Activando entorno de ->${chalk.yellow(` TESTING `)}`);
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.production' )});
|
||||
console.log(`Activando entorno de ->${chalk.red(` PRODUCTION `)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("A ocurrido un error al seleccionar el entorno. \nError: " + error);
|
||||
}
|
||||
|
||||
// Renderiado
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'pages')));
|
||||
|
||||
// Configuración de conexión PostgreSQL
|
||||
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_LOCAL_PORT
|
||||
};
|
||||
|
||||
const pool = new Pool(dbConfig);
|
||||
|
||||
|
||||
async function verificarConexion() {
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
const res = await client.query('SELECT NOW() AS hora');
|
||||
console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`);
|
||||
console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
|
||||
client.release(); // liberar el cliente de nuevo al pool
|
||||
} catch (error) {
|
||||
console.error('Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error(`Troubleshooting:\n1. Compruebe que las bases de datos se iniciaron correctamente.\n2. Verifique las credenciales y puertos de acceso a la base de datos.\n3. Si está conectandose a una base de datos externa a localhost, verifique las reglas del firewal de entrada y salida de ambos dispositivos.`);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// === Servir páginas estáticas ===
|
||||
|
||||
app.get('/roles', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'roles.html')));
|
||||
app.get('/usuarios', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'usuarios.html')));
|
||||
app.get('/categorias',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'categorias.html')));
|
||||
app.get('/productos', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'productos.html')));
|
||||
|
||||
|
||||
// Helper de consulta con acquire/release explícito
|
||||
async function q(text, params) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
return await client.query(text, params);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// === API Roles ===
|
||||
// GET: listar
|
||||
app.get('/api/roles', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await q('SELECT id_rol, nombre FROM roles ORDER BY id_rol ASC');
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'No se pudo listar roles' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST: crear
|
||||
app.post('/api/roles', async (req, res) => {
|
||||
try {
|
||||
const { nombre } = req.body;
|
||||
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
|
||||
const { rows } = await q(
|
||||
'INSERT INTO roles (nombre) VALUES ($1) RETURNING id_rol, nombre',
|
||||
[nombre.trim()]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// Manejo de único/duplicado
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'El rol ya existe' });
|
||||
res.status(500).json({ error: 'No se pudo crear el rol' });
|
||||
}
|
||||
});
|
||||
|
||||
// === API Usuarios ===
|
||||
// GET: listar
|
||||
app.get('/api/usuarios', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await q(`
|
||||
SELECT id_usuario, documento, img_perfil, nombre, apellido, correo, telefono, fec_nacimiento, activo
|
||||
FROM usuarios
|
||||
ORDER BY id_usuario ASC
|
||||
`);
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'No se pudo listar usuarios' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST: crear
|
||||
app.post('/api/usuarios', async (req, res) => {
|
||||
try {
|
||||
const { documento, nombre, apellido, correo, telefono, fec_nacimiento } = req.body;
|
||||
if (!nombre || !apellido) return res.status(400).json({ error: 'Nombre y apellido requeridos' });
|
||||
|
||||
const { rows } = await q(`
|
||||
INSERT INTO usuarios (documento, nombre, apellido, correo, telefono, fec_nacimiento)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id_usuario, documento, nombre, apellido, correo, telefono, fec_nacimiento, activo
|
||||
`, [
|
||||
documento || null,
|
||||
nombre.trim(),
|
||||
apellido.trim(),
|
||||
correo || null,
|
||||
telefono || null,
|
||||
fec_nacimiento || null
|
||||
]);
|
||||
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'Documento/Correo/Teléfono ya existe' });
|
||||
res.status(500).json({ error: 'No se pudo crear el usuario' });
|
||||
}
|
||||
});
|
||||
|
||||
// === API Categorías ===
|
||||
// GET: listar
|
||||
app.get('/api/categorias', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await q('SELECT id_categoria, nombre, visible FROM categorias ORDER BY id_categoria ASC');
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'No se pudo listar categorías' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST: crear
|
||||
app.post('/api/categorias', async (req, res) => {
|
||||
try {
|
||||
const { nombre, visible } = req.body;
|
||||
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
|
||||
const vis = (typeof visible === 'boolean') ? visible : true;
|
||||
|
||||
const { rows } = await q(`
|
||||
INSERT INTO categorias (nombre, visible)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id_categoria, nombre, visible
|
||||
`, [nombre.trim(), vis]);
|
||||
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'La categoría ya existe' });
|
||||
res.status(500).json({ error: 'No se pudo crear la categoría' });
|
||||
}
|
||||
});
|
||||
|
||||
// === API Productos ===
|
||||
// GET: listar
|
||||
app.get('/api/productos', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await q(`
|
||||
SELECT id_producto, nombre, img_producto, precio, activo, id_categoria
|
||||
FROM productos
|
||||
ORDER BY id_producto ASC
|
||||
`);
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'No se pudo listar productos' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST: crear
|
||||
app.post('/api/productos', async (req, res) => {
|
||||
try {
|
||||
let { nombre, id_categoria, precio } = req.body;
|
||||
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
|
||||
id_categoria = parseInt(id_categoria, 10);
|
||||
precio = parseFloat(precio);
|
||||
if (!Number.isInteger(id_categoria)) return res.status(400).json({ error: 'id_categoria inválido' });
|
||||
if (!(precio >= 0)) return res.status(400).json({ error: 'precio inválido' });
|
||||
|
||||
const { rows } = await q(`
|
||||
INSERT INTO productos (nombre, id_categoria, precio)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id_producto, nombre, precio, activo, id_categoria
|
||||
`, [nombre.trim(), id_categoria, precio]);
|
||||
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// FK categories / checks
|
||||
if (e.code === '23503') return res.status(400).json({ error: 'La categoría no existe' });
|
||||
res.status(500).json({ error: 'No se pudo crear el producto' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Colores personalizados
|
||||
let primaryColor = chalk.hex('#'+`${process.env.COL_PRI}`);
|
||||
let secondaryColor = chalk.hex('#'+`${process.env.COL_SEC}`);
|
||||
// let backgroundColor = chalk.hex('#'+`${process.env.COL_BG}`);
|
||||
|
||||
|
||||
app.use(expressLayouts);
|
||||
// Iniciar servidor
|
||||
app.listen( process.env.PORT, () => {
|
||||
console.log(`Servidor de ${chalk.red('aplicación')} de ${secondaryColor('SuiteCoffee')} corriendo en ${chalk.yellow(`http://localhost:${process.env.PORT}\n`)}` );
|
||||
console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.green(process.env.DB_NAME)} del host ${chalk.white(`${process.env.DB_HOST}`)} ...`));
|
||||
verificarConexion();
|
||||
});
|
||||
|
||||
app.get("/health", async (req, res) => {
|
||||
// Podés chequear DB aquí. 200 = healthy; 503 = not ready.
|
||||
res.status(200).json({ status: "ok" });
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Categorías</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Categorías</h1>
|
||||
|
||||
<h2>Crear categoría</h2>
|
||||
<form id="form-categoria">
|
||||
<label>Nombre:
|
||||
<input type="text" name="nombre" required />
|
||||
</label>
|
||||
<label>Visible:
|
||||
<select name="visible">
|
||||
<option value="true" selected>Sí</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Guardar</button>
|
||||
</form>
|
||||
|
||||
<h2>Listado</h2>
|
||||
<button id="btn-recargar">Recargar</button>
|
||||
<table border="1" cellpadding="6">
|
||||
<thead><tr><th>ID</th><th>Nombre</th><th>Visible</th></tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
const API = '/api/categorias';
|
||||
|
||||
async function listar() {
|
||||
const res = await fetch(API);
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('tbody');
|
||||
tbody.innerHTML = '';
|
||||
data.forEach(c => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${c.id_categoria}</td><td>${c.nombre}</td><td>${c.visible ? 'Sí' : 'No'}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('form-categoria').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const nombre = fd.get('nombre').trim();
|
||||
const visible = fd.get('visible') === 'true';
|
||||
if (!nombre) return;
|
||||
const res = await fetch(API, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ nombre, visible })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(()=>({error:'Error'}));
|
||||
alert('Error: ' + (err.error || res.statusText));
|
||||
return;
|
||||
}
|
||||
e.target.reset();
|
||||
await listar();
|
||||
});
|
||||
|
||||
document.getElementById('btn-recargar').addEventListener('click', listar);
|
||||
listar();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Productos</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Productos</h1>
|
||||
|
||||
<h2>Crear producto</h2>
|
||||
<form id="form-producto">
|
||||
<div>
|
||||
<label>Nombre:
|
||||
<input name="nombre" type="text" required />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Precio:
|
||||
<input name="precio" type="number" step="0.01" min="0" required />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Categoría:
|
||||
<select name="id_categoria" id="sel-categoria" required></select>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Guardar</button>
|
||||
</form>
|
||||
|
||||
<h2>Listado</h2>
|
||||
<button id="btn-recargar">Recargar</button>
|
||||
<table border="1" cellpadding="6">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Nombre</th><th>Precio</th><th>Activo</th><th>ID Categoría</th></tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
const API = '/api/productos';
|
||||
const API_CAT = '/api/categorias';
|
||||
|
||||
async function cargarCategorias() {
|
||||
const res = await fetch(API_CAT);
|
||||
const data = await res.json();
|
||||
const sel = document.getElementById('sel-categoria');
|
||||
sel.innerHTML = '<option value="" disabled selected>Seleccione...</option>';
|
||||
data.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.id_categoria;
|
||||
opt.textContent = `${c.id_categoria} - ${c.nombre}`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
async function listar() {
|
||||
const res = await fetch(API);
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('tbody');
|
||||
tbody.innerHTML = '';
|
||||
data.forEach(p => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${p.id_producto}</td>
|
||||
<td>${p.nombre}</td>
|
||||
<td>${Number(p.precio).toFixed(2)}</td>
|
||||
<td>${p.activo ? 'Sí' : 'No'}</td>
|
||||
<td>${p.id_categoria}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('form-producto').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const payload = {
|
||||
nombre: fd.get('nombre').trim(),
|
||||
precio: parseFloat(fd.get('precio')),
|
||||
id_categoria: parseInt(fd.get('id_categoria'), 10)
|
||||
};
|
||||
if (!payload.nombre || isNaN(payload.precio) || isNaN(payload.id_categoria)) return;
|
||||
|
||||
const res = await fetch(API, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(()=>({error:'Error'}));
|
||||
alert('Error: ' + (err.error || res.statusText));
|
||||
return;
|
||||
}
|
||||
e.target.reset();
|
||||
await listar();
|
||||
});
|
||||
|
||||
document.getElementById('btn-recargar').addEventListener('click', listar);
|
||||
|
||||
(async () => {
|
||||
await cargarCategorias();
|
||||
await listar();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Roles</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Roles</h1>
|
||||
|
||||
<h2>Crear rol</h2>
|
||||
<form id="form-rol">
|
||||
<label>Nombre del rol:
|
||||
<input type="text" name="nombre" required />
|
||||
</label>
|
||||
<button type="submit">Guardar</button>
|
||||
</form>
|
||||
|
||||
<h2>Listado</h2>
|
||||
<button id="btn-recargar">Recargar</button>
|
||||
<table border="1" cellpadding="6">
|
||||
<thead><tr><th>ID</th><th>Nombre</th></tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
const API = '/api/roles';
|
||||
|
||||
async function listar() {
|
||||
const res = await fetch(API);
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('tbody');
|
||||
tbody.innerHTML = '';
|
||||
data.forEach(r => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `<td>${r.id_rol}</td><td>${r.nombre}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('form-rol').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const nombre = e.target.nombre.value.trim();
|
||||
if (!nombre) return;
|
||||
const res = await fetch(API, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ nombre })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(()=>({error:'Error'}));
|
||||
alert('Error: ' + (err.error || res.statusText));
|
||||
return;
|
||||
}
|
||||
e.target.reset();
|
||||
await listar();
|
||||
});
|
||||
|
||||
document.getElementById('btn-recargar').addEventListener('click', listar);
|
||||
listar();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,104 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Usuarios</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Usuarios</h1>
|
||||
|
||||
<h2>Crear usuario</h2>
|
||||
<form id="form-usuario">
|
||||
<div>
|
||||
<label>Documento:
|
||||
<input name="documento" type="text" />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Nombre:
|
||||
<input name="nombre" type="text" required />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Apellido:
|
||||
<input name="apellido" type="text" required />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Correo:
|
||||
<input name="correo" type="email" />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Teléfono:
|
||||
<input name="telefono" type="text" />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>Fecha de nacimiento:
|
||||
<input name="fec_nacimiento" type="date" />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Guardar</button>
|
||||
</form>
|
||||
|
||||
<h2>Listado</h2>
|
||||
<button id="btn-recargar">Recargar</button>
|
||||
<table border="1" cellpadding="6">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>Documento</th><th>Nombre</th><th>Apellido</th>
|
||||
<th>Correo</th><th>Teléfono</th><th>Nacimiento</th><th>Activo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
const API = '/api/usuarios';
|
||||
|
||||
async function listar() {
|
||||
const res = await fetch(API);
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('tbody');
|
||||
tbody.innerHTML = '';
|
||||
data.forEach(u => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${u.id_usuario}</td>
|
||||
<td>${u.documento ?? ''}</td>
|
||||
<td>${u.nombre}</td>
|
||||
<td>${u.apellido}</td>
|
||||
<td>${u.correo ?? ''}</td>
|
||||
<td>${u.telefono ?? ''}</td>
|
||||
<td>${u.fec_nacimiento ? u.fec_nacimiento.substring(0,10) : ''}</td>
|
||||
<td>${u.activo ? 'Sí' : 'No'}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('form-usuario').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const payload = Object.fromEntries(fd.entries());
|
||||
if (payload.fec_nacimiento === '') delete payload.fec_nacimiento;
|
||||
const res = await fetch(API, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(()=>({error:'Error'}));
|
||||
alert('Error: ' + (err.error || res.statusText));
|
||||
return;
|
||||
}
|
||||
e.target.reset();
|
||||
await listar();
|
||||
});
|
||||
|
||||
document.getElementById('btn-recargar').addEventListener('click', listar);
|
||||
listar();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
# Dockerfile.dev
|
||||
FROM node:22.18
|
||||
|
||||
# Definir variables de entorno con valores predeterminados
|
||||
# ARG NODE_ENV=production
|
||||
# ARG PORT=4000
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copia archivos de configuración primero para aprovechar el cache
|
||||
COPY package*.json ./
|
||||
|
||||
# Instala dependencias
|
||||
RUN npm i
|
||||
|
||||
# Copia el resto de la app
|
||||
COPY . .
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
Generated
+1988
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "authentication",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=production node ./src/index.js",
|
||||
"dev": "NODE_ENV=development npx nodemon ./src/index.js",
|
||||
"test": "NODE_ENV=stage node ./src/index.js"
|
||||
},
|
||||
"author": "Mateo Saldain",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.0.0",
|
||||
"nodemon": "^3.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"chalk": "^5.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.1",
|
||||
"express": "^5.1.0",
|
||||
"express-ejs-layouts": "^2.5.1",
|
||||
"pg": "^8.16.3",
|
||||
"pg-format": "^1.0.4"
|
||||
},
|
||||
"keywords": [],
|
||||
"description": ""
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// auth/src/index.js
|
||||
import chalk from 'chalk';
|
||||
import express from 'express';
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
import cors from 'cors';
|
||||
import { Pool } from 'pg';
|
||||
import bcrypt from'bcrypt';
|
||||
|
||||
// Rutas
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Variables de Entorno
|
||||
import dotenv, { config } from 'dotenv';
|
||||
|
||||
// Obtención de la ruta de la variable de entorno correspondiente a NODE_ENV
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.development' )});
|
||||
console.log(`Activando entorno de ->${chalk.green(` DEVELOPMENT `)}`);
|
||||
} else if (process.env.NODE_ENV === 'stage') {
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.test' )});
|
||||
console.log(`Activando entorno de ->${chalk.yellow(` TESTING `)}`);
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.production' )});
|
||||
console.log(`Activando entorno de ->${chalk.red(` PRODUCTION `)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("A ocurrido un error al seleccionar el entorno. \nError: " + error);
|
||||
}
|
||||
|
||||
// Configuración de renderizado
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.set('trust proxy', true);
|
||||
app.use(express.static(path.join(__dirname, 'pages')));
|
||||
|
||||
|
||||
// Configuración de conexión PostgreSQL
|
||||
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_LOCAL_PORT
|
||||
};
|
||||
|
||||
const pool = new Pool(dbConfig);
|
||||
|
||||
|
||||
async function verificarConexion() {
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
const res = await client.query('SELECT NOW() AS hora');
|
||||
console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`);
|
||||
console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
|
||||
client.release(); // liberar el cliente de nuevo al pool
|
||||
} catch (error) {
|
||||
console.error('Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error(`Troubleshooting:\n1. Compruebe que las bases de datos se iniciaron correctamente.\n2. Verifique las credenciales y puertos de acceso a la base de datos.\n3. Si está conectandose a una base de datos externa a localhost, verifique las reglas del firewal de entrada y salida de ambos dispositivos.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// === Servir páginas estáticas ===
|
||||
|
||||
app.get('/',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'index.html')));
|
||||
|
||||
app.get('/planes', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT id, nombre, descripcion, precio
|
||||
FROM plan
|
||||
WHERE activo = true
|
||||
ORDER BY id
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Error al cargar planes' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/registro', async (req, res) => {
|
||||
const {
|
||||
nombre_empresa,
|
||||
rut,
|
||||
correo,
|
||||
telefono,
|
||||
direccion,
|
||||
logo,
|
||||
clave_acceso,
|
||||
plan_id
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
|
||||
// 1. Hashear la contraseña
|
||||
const hash = await bcrypt.hash(clave_acceso, 10);
|
||||
|
||||
// 2. Insertar el tenant
|
||||
const result = await client.query(`
|
||||
INSERT INTO tenant (
|
||||
nombre_empresa, rut, correo, telefono, direccion, logo,
|
||||
clave_acceso, plan_id, nombre_base_datos
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, 'TEMPORAL'
|
||||
)
|
||||
RETURNING uuid;
|
||||
`, [
|
||||
nombre_empresa, rut, correo, telefono, direccion, logo,
|
||||
hash, plan_id
|
||||
]);
|
||||
|
||||
const uuid = result.rows[0].uuid;
|
||||
const nombre_base_datos = `tenantdb_${uuid}`.replace(/-/g, '').substring(0, 24); // ajustamos para longitud segura
|
||||
|
||||
// 3. Actualizar el campo nombre_base_datos
|
||||
await client.query(`
|
||||
UPDATE tenant SET nombre_base_datos = $1 WHERE uuid = $2
|
||||
`, [nombre_base_datos, uuid]);
|
||||
|
||||
client.release();
|
||||
|
||||
return res.status(201).json({
|
||||
message: 'Tenant registrado correctamente',
|
||||
uuid,
|
||||
nombre_base_datos
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Error al registrar tenant' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.post('/api/login', async (req, res) => {
|
||||
const { correo, clave_acceso } = req.body;
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
|
||||
const result = await client.query(`
|
||||
SELECT uuid, clave_acceso, nombre_empresa, nombre_base_datos
|
||||
FROM tenant
|
||||
WHERE correo = $1 AND estado = true
|
||||
`, [correo]);
|
||||
|
||||
client.release();
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Correo no registrado o inactivo' });
|
||||
}
|
||||
|
||||
const tenant = result.rows[0];
|
||||
const coincide = await bcrypt.compare(clave_acceso, tenant.clave_acceso);
|
||||
|
||||
if (!coincide) {
|
||||
return res.status(401).json({ error: 'Clave incorrecta' });
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Login correcto',
|
||||
uuid: tenant.uuid,
|
||||
nombre_empresa: tenant.nombre_empresa,
|
||||
base_datos: tenant.nombre_base_datos
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Error al validar login' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Colores personalizados
|
||||
let primaryColor = chalk.hex('#'+`${process.env.COL_PRI}`);
|
||||
let secondaryColor = chalk.hex('#'+`${process.env.COL_SEC}`);
|
||||
// let backgroundColor = chalk.hex('#'+`${process.env.COL_BG}`);
|
||||
|
||||
|
||||
app.use(expressLayouts);
|
||||
// Iniciar servidor
|
||||
app.listen( process.env.PORT, () => {
|
||||
console.log(`Servidor de ${chalk.yellow('autenticación')} de ${secondaryColor('SuiteCoffee')} corriendo en ${chalk.yellow(`http://localhost:${process.env.PORT}\n`)}` );
|
||||
console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.green(process.env.DB_NAME)} del host ${chalk.white(`${process.env.DB_HOST}`)} ...`));
|
||||
verificarConexion();
|
||||
});
|
||||
|
||||
app.get("/health", async (req, res) => {
|
||||
// Podés chequear DB aquí. 200 = healthy; 503 = not ready.
|
||||
res.status(200).json({ status: "ok" });
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>SuiteCoffee - Autenticación</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body class="bg-light d-flex justify-content-center align-items-center vh-100">
|
||||
|
||||
<div class="card shadow p-4" style="width: 100%; max-width: 400px;">
|
||||
<h4 class="text-center mb-3" id="form-title">Iniciar Sesión</h4>
|
||||
|
||||
<!-- Mensajes -->
|
||||
<div id="mensaje" class="alert d-none" role="alert"></div>
|
||||
|
||||
<!-- Formulario compartido -->
|
||||
<form id="formulario">
|
||||
<div id="registro-extra" style="display: none;">
|
||||
<div class="mb-2">
|
||||
<input type="text" class="form-control" id="nombre_empresa" placeholder="Nombre de la empresa" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input type="text" class="form-control" id="rut" placeholder="RUT (opcional)" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input type="text" class="form-control" id="telefono" placeholder="Teléfono">
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input type="text" class="form-control" id="direccion" placeholder="Dirección">
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input type="text" class="form-control" id="logo" placeholder="Logo URL (opcional)">
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<select class="form-select" id="plan_id" required>
|
||||
<option value="">Cargando planes...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<input type="email" class="form-control" id="correo" placeholder="Correo" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<input type="password" class="form-control" id="clave" placeholder="Contraseña" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100" id="btn-submit">Entrar</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<button class="btn btn-link btn-sm" id="toggle-mode">¿No tienes cuenta? Regístrate</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('formulario');
|
||||
const mensaje = document.getElementById('mensaje');
|
||||
const toggleModeBtn = document.getElementById('toggle-mode');
|
||||
const registroExtra = document.getElementById('registro-extra');
|
||||
const formTitle = document.getElementById('form-title');
|
||||
const btnSubmit = document.getElementById('btn-submit');
|
||||
|
||||
let modoRegistro = false;
|
||||
|
||||
toggleModeBtn.addEventListener('click', () => {
|
||||
modoRegistro = !modoRegistro;
|
||||
registroExtra.style.display = modoRegistro ? 'block' : 'none';
|
||||
formTitle.textContent = modoRegistro ? 'Registrar Cuenta' : 'Iniciar Sesión';
|
||||
btnSubmit.textContent = modoRegistro ? 'Registrarse' : 'Entrar';
|
||||
toggleModeBtn.textContent = modoRegistro ? '¿Ya tienes cuenta? Inicia sesión' : '¿No tienes cuenta? Regístrate';
|
||||
|
||||
if (modoRegistro) {
|
||||
cargarPlanes(); // ✅ ahora sí se ejecutará correctamente
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
mensaje.classList.add('d-none');
|
||||
|
||||
const datos = {
|
||||
correo: document.getElementById('correo').value,
|
||||
clave_acceso: document.getElementById('clave').value
|
||||
};
|
||||
|
||||
if (modoRegistro) {
|
||||
Object.assign(datos, {
|
||||
nombre_empresa: document.getElementById('nombre_empresa').value,
|
||||
rut: document.getElementById('rut').value,
|
||||
telefono: document.getElementById('telefono').value,
|
||||
direccion: document.getElementById('direccion').value,
|
||||
logo: document.getElementById('logo').value,
|
||||
plan_id: document.getElementById('plan_id').value
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const url = modoRegistro ? '/api/registro' : '/api/login';
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(datos)
|
||||
});
|
||||
|
||||
const resultado = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(resultado.error || 'Error inesperado');
|
||||
}
|
||||
|
||||
mensaje.className = 'alert alert-success';
|
||||
mensaje.textContent = resultado.message || (modoRegistro ? 'Registro exitoso' : 'Inicio exitoso');
|
||||
mensaje.classList.remove('d-none');
|
||||
|
||||
if (!modoRegistro) {
|
||||
// Redirigir a dashboard, por ejemplo
|
||||
// window.location.href = `/dashboard?tenant=${resultado.uuid}`;
|
||||
}
|
||||
} catch (err) {
|
||||
mensaje.className = 'alert alert-danger';
|
||||
mensaje.textContent = err.message;
|
||||
mensaje.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ Ahora la función está declarada correctamente
|
||||
async function cargarPlanes() {
|
||||
const select = document.getElementById('plan_id');
|
||||
select.innerHTML = '<option value="">Cargando planes...</option>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/planes');
|
||||
const planes = await res.json();
|
||||
|
||||
select.innerHTML = '<option value="">Seleccione un plan</option>';
|
||||
planes.forEach(plan => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = plan.id;
|
||||
opt.textContent = plan.nombre.charAt(0).toUpperCase() + plan.nombre.slice(1);
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
select.innerHTML = '<option value="">Error al cargar planes</option>';
|
||||
console.error('Error cargando planes:', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,40 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login Bootstrap</title>
|
||||
<!-- Bootstrap CDN -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
</head>
|
||||
<body class="bg-light d-flex justify-content-center align-items-center vh-100">
|
||||
|
||||
<div class="card shadow p-4" style="width: 100%; max-width: 350px;">
|
||||
<h4 class="text-center mb-4">Iniciar Sesión</h4>
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<label for="usuario" class="form-label">Usuario</label>
|
||||
<input type="text" class="form-control" id="usuario" placeholder="Ingrese su usuario" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="clave" class="form-label">Contraseña</label>
|
||||
<input type="password" class="form-control" id="clave" placeholder="Ingrese su contraseña" required>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="recordarme">
|
||||
<label class="form-check-label" for="recordarme">
|
||||
Recordarme
|
||||
</label>
|
||||
</div>
|
||||
<a href="#" class="small">¿Olvidaste tu contraseña?</a>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Entrar</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS (opcional) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,116 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Cargar Comanda</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Formulario de Carga de Comanda</h2>
|
||||
|
||||
<form id="formComanda">
|
||||
<label for="mesaSelect">Mesa:</label>
|
||||
<select id="mesaSelect" name="mesa_id" required></select>
|
||||
<br><br>
|
||||
|
||||
<label for="productoSelect">Producto:</label>
|
||||
<select id="productosSelect"></select>
|
||||
|
||||
<label for="cantidadInput">Cantidad:</label>
|
||||
<input type="number" id="cantidadInput" min="1" value="1">
|
||||
<button type="button" onclick="agregarProducto()">Agregar</button>
|
||||
|
||||
<ul id="listaProductos"></ul>
|
||||
|
||||
<input type="hidden" name="productos" id="productosJSON">
|
||||
|
||||
<br>
|
||||
<button type="submit">Guardar Comanda</button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Cargar categorías y mesas (productos se maneja distinto por el precio)
|
||||
async function cargarSelect(endpoint, selectId, mostrar) {
|
||||
const res = await fetch(endpoint);
|
||||
const data = await res.json();
|
||||
const select = document.getElementById(selectId);
|
||||
data.forEach(item => {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.id;
|
||||
option.textContent = mostrar(item);
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function cargarProductosConPrecio() {
|
||||
const res = await fetch('/api/obtenerProductos');
|
||||
const productos = await res.json();
|
||||
const select = document.getElementById('productosSelect');
|
||||
productos.forEach(prod => {
|
||||
const option = document.createElement('option');
|
||||
option.value = prod.id;
|
||||
option.textContent = `${prod.nombre} ($${prod.precio})`;
|
||||
option.setAttribute('data-precio', prod.precio);
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
const listaProductos = [];
|
||||
function agregarProducto() {
|
||||
const select = document.getElementById('productosSelect');
|
||||
const cantidad = parseInt(document.getElementById('cantidadInput').value);
|
||||
const productoId = select.value;
|
||||
const nombre = select.options[select.selectedIndex].textContent;
|
||||
const precioUnitario = parseFloat(select.options[select.selectedIndex].dataset.precio);
|
||||
|
||||
if (!productoId || isNaN(cantidad) || cantidad <= 0) {
|
||||
alert("Producto o cantidad inválida");
|
||||
return;
|
||||
}
|
||||
|
||||
listaProductos.push({
|
||||
producto_id: productoId,
|
||||
cantidad,
|
||||
precio_unitario: precioUnitario
|
||||
});
|
||||
|
||||
// Mostrar en lista visual
|
||||
const li = document.createElement('li');
|
||||
li.textContent = `${nombre} x${cantidad} - $${(precioUnitario * cantidad).toFixed(2)}`;
|
||||
document.getElementById('listaProductos').appendChild(li);
|
||||
|
||||
// Actualizar JSON oculto
|
||||
document.getElementById('productosJSON').value = JSON.stringify(listaProductos);
|
||||
}
|
||||
|
||||
document.getElementById('formComanda').addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const mesaId = document.getElementById('mesaSelect').value;
|
||||
const productos = listaProductos;
|
||||
|
||||
if (!mesaId || productos.length === 0) {
|
||||
alert('Selecciona una mesa y al menos un producto');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/cargarComandas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mesa_id: mesaId,
|
||||
productos: productos
|
||||
})
|
||||
});
|
||||
|
||||
const resultado = await res.json();
|
||||
alert(resultado.mensaje || 'Comanda cargada correctamente');
|
||||
location.reload();
|
||||
});
|
||||
|
||||
window.onload = () => {
|
||||
cargarSelect('/api/obtenerMesas', 'mesaSelect', mesa => `Mesa ${mesa.numero}`);
|
||||
cargarProductosConPrecio();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,100 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Comanda - Cafetería</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
<div class="container my-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">🧾 Comanda de Cafetería</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form>
|
||||
<!-- Datos generales -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="mesa" class="form-label">Número de Mesa</label>
|
||||
<select class="form-select" id="mesa" required>
|
||||
<option value="">Seleccionar mesa</option>
|
||||
<option>Mesa 1</option>
|
||||
<option>Mesa 2</option>
|
||||
<option>Mesa 3</option>
|
||||
<option>Mesa 4</option>
|
||||
<option>Mesa 5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="mozo" class="form-label">Mozo/a</label>
|
||||
<input type="text" class="form-control" id="mozo" placeholder="Nombre del mozo/a" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Productos -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Productos</label>
|
||||
|
||||
<!-- Producto 1 -->
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-12 col-md-6">
|
||||
<select class="form-select">
|
||||
<option value="">Seleccionar producto</option>
|
||||
<option>Café</option>
|
||||
<option>Café con leche</option>
|
||||
<option>Capuccino</option>
|
||||
<option>Medialuna</option>
|
||||
<option>Jugo natural</option>
|
||||
<option>Tostado</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<input type="number" class="form-control" placeholder="Cant.">
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<input type="text" class="form-control" placeholder="Notas">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Producto 2 -->
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-12 col-md-6">
|
||||
<select class="form-select">
|
||||
<option value="">Seleccionar producto</option>
|
||||
<option>Café</option>
|
||||
<option>Café con leche</option>
|
||||
<option>Capuccino</option>
|
||||
<option>Medialuna</option>
|
||||
<option>Jugo natural</option>
|
||||
<option>Tostado</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<input type="number" class="form-control" placeholder="Cant.">
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<input type="text" class="form-control" placeholder="Notas">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observaciones -->
|
||||
<div class="mb-4">
|
||||
<label for="observaciones" class="form-label">Observaciones</label>
|
||||
<textarea class="form-control" id="observaciones" rows="3" placeholder="Ej: Sin azúcar, entregar cuando esté completo"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-success">Enviar Comanda</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Mesas</h2>
|
||||
<select id="mesasSelect"></select>
|
||||
|
||||
<h2>Productos</h2>
|
||||
<select id="productosSelect"></select>
|
||||
|
||||
<h2>Categorías</h2>
|
||||
<select id="categoriasSelect"></select>
|
||||
|
||||
<h2>Comandas</h2>
|
||||
<select id="comandasSelect"></select>
|
||||
|
||||
<script>
|
||||
async function cargarDatos(endpoint, selectId, mostrar) {
|
||||
const res = await fetch(endpoint); // Usar endpoint relativo
|
||||
const data = await res.json();
|
||||
const select = document.getElementById(selectId);
|
||||
|
||||
data.forEach(item => {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.id;
|
||||
option.textContent = mostrar(item);
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// Al cargar la página, cargamos los datos
|
||||
window.onload = () => {
|
||||
cargarDatos('api/obtenerMesas', 'mesasSelect', mesa => `Mesa ${mesa.numero}`);
|
||||
cargarDatos('api/obtenerProductos', 'productosSelect', prod => `${prod.nombre} ($${prod.precio})`);
|
||||
cargarDatos('api/obtenerCategorias', 'categoriasSelect', cat => cat.nombre);
|
||||
cargarDatos('api/obtenerComandas', 'comandasSelect', com => `Comanda ${com.id} - Mesa ${com.mesa_id} - $${com.total}`);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from shutil import which
|
||||
|
||||
PROJECT_ROOT = os.path.abspath(os.getcwd())
|
||||
|
||||
# === Archivos Compose (entornos) ===
|
||||
BASE_COMPOSE = os.path.join(PROJECT_ROOT, "compose.yaml")
|
||||
DEV_COMPOSE = os.path.join(PROJECT_ROOT, "compose.dev.yaml")
|
||||
PROD_COMPOSE = os.path.join(PROJECT_ROOT, "compose.prod.yaml")
|
||||
|
||||
# === Archivos Compose (globales) ===
|
||||
NPM_COMPOSE = os.path.join(PROJECT_ROOT, "compose.npm.yaml")
|
||||
DBEAVER_COMPOSE = os.path.join(PROJECT_ROOT, "compose.dbeaver.yaml")
|
||||
|
||||
# Archivos .env
|
||||
ENV_FILES = {
|
||||
"development": ".env.development",
|
||||
"production": ".env.production",
|
||||
}
|
||||
|
||||
# Nombres de proyecto para permitir DEV y PROD simultáneos
|
||||
def _base_project():
|
||||
return os.path.basename(PROJECT_ROOT).lower() or "composeproj"
|
||||
|
||||
PROJECT_NAMES = {
|
||||
"development": f"{_base_project()}_dev",
|
||||
"production": f"{_base_project()}_prod",
|
||||
}
|
||||
|
||||
# Nombre de proyecto global (ambos yaml globales usan name: suitecoffee)
|
||||
GLOBAL_PROJECT_NAME = "suitecoffee"
|
||||
|
||||
# ---------- Utilidades ----------
|
||||
def check_prereqs():
|
||||
if which("docker") is None:
|
||||
fail("No se encontró 'docker' en el PATH.")
|
||||
try:
|
||||
run(["docker", "compose", "version"], check=True, capture_output=True)
|
||||
except Exception:
|
||||
fail("No se pudo ejecutar 'docker compose'. Asegúrate de tener Docker Compose v2.")
|
||||
|
||||
def run(cmd, check=False, capture_output=False):
|
||||
return subprocess.run(cmd, check=check, capture_output=capture_output, text=True)
|
||||
|
||||
def compose_files_args(env_key):
|
||||
"""
|
||||
Devuelve los -f correctos según el entorno (dev/prod) + base.
|
||||
"""
|
||||
if not os.path.exists(BASE_COMPOSE):
|
||||
fail("No se encontró compose.yaml en la raíz del proyecto.")
|
||||
|
||||
args = ["-f", BASE_COMPOSE]
|
||||
|
||||
if env_key == "development":
|
||||
if not os.path.exists(DEV_COMPOSE):
|
||||
fail("No se encontró compose.dev.yaml.")
|
||||
args += ["-f", DEV_COMPOSE]
|
||||
elif env_key == "production":
|
||||
if not os.path.exists(PROD_COMPOSE):
|
||||
fail("No se encontró compose.prod.yaml.")
|
||||
args += ["-f", PROD_COMPOSE]
|
||||
else:
|
||||
fail(f"Entorno desconocido: {env_key}")
|
||||
return args
|
||||
|
||||
def compose_files_args_global(kind):
|
||||
"""
|
||||
Devuelve los -f correctos para servicios globales (npm/dbeaver).
|
||||
"""
|
||||
if kind == "npm":
|
||||
if not os.path.exists(NPM_COMPOSE):
|
||||
fail("No se encontró compose.npm.yaml.")
|
||||
return ["-f", NPM_COMPOSE]
|
||||
elif kind == "dbeaver":
|
||||
if not os.path.exists(DBEAVER_COMPOSE):
|
||||
fail("No se encontró compose.dbeaver.yaml.")
|
||||
return ["-f", DBEAVER_COMPOSE]
|
||||
else:
|
||||
fail(f"Servicio global desconocido: {kind}")
|
||||
|
||||
def env_file_path(env_key):
|
||||
fname = ENV_FILES.get(env_key)
|
||||
if not fname:
|
||||
return None
|
||||
path = os.path.join(PROJECT_ROOT, fname)
|
||||
return path if os.path.exists(path) else None
|
||||
|
||||
def compose_cmd(base_args, env_key, env_file=None, project_name=None):
|
||||
"""
|
||||
Construye: docker compose -f base -f env --env-file ... -p <name> <COMANDO> [OPCIONES]
|
||||
(importante: --env-file y -p son opciones globales y van antes del subcomando)
|
||||
"""
|
||||
cmd = ["docker", "compose"]
|
||||
cmd += compose_files_args(env_key)
|
||||
if env_file:
|
||||
cmd += ["--env-file", env_file]
|
||||
if project_name:
|
||||
cmd += ["-p", project_name]
|
||||
cmd += base_args # ["up","-d","--force-recreate"] o ["ps","--status","running","-q"]
|
||||
return cmd
|
||||
|
||||
def compose_cmd_global(base_args, kind, project_name=GLOBAL_PROJECT_NAME):
|
||||
"""
|
||||
Comandos para servicios globales (npm/dbeaver):
|
||||
docker compose -f compose.<kind>.yaml -p suitecoffee <COMANDO> ...
|
||||
"""
|
||||
cmd = ["docker", "compose"]
|
||||
cmd += compose_files_args_global(kind)
|
||||
if project_name:
|
||||
cmd += ["-p", project_name]
|
||||
cmd += base_args
|
||||
return cmd
|
||||
|
||||
def yes_no(prompt, default="n"):
|
||||
default = default.lower()
|
||||
hint = "[Y/n]" if default == "y" else "[y/N]"
|
||||
while True:
|
||||
resp = input(f"{prompt} {hint} ").strip().lower()
|
||||
if not resp:
|
||||
return default == "y"
|
||||
if resp in ("y", "yes", "s", "si", "sí"):
|
||||
return True
|
||||
if resp in ("n", "no"):
|
||||
return False
|
||||
print("Respuesta no reconocida. Por favor, responde con 'y' o 'n'.")
|
||||
|
||||
def print_header(title):
|
||||
print("\n" + "=" * 60)
|
||||
print(title)
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
def info(msg): print(f"• {msg}")
|
||||
def ok(msg): print(f"✓ {msg}")
|
||||
def warn(msg): print(f"! {msg}")
|
||||
def fail(msg):
|
||||
print(f"✗ {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
# ---------- Helpers globales (servicios por archivo y ps por labels) ----------
|
||||
def list_services_from_compose_file(compose_path):
|
||||
"""Obtiene la lista de servicios definidos en un archivo compose específico."""
|
||||
cmd = ["docker", "compose", "-f", compose_path, "config", "--services"]
|
||||
proc = run(cmd, capture_output=True)
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
return [s.strip() for s in proc.stdout.splitlines() if s.strip()]
|
||||
|
||||
def list_services_global(kind):
|
||||
compose_path = NPM_COMPOSE if kind == "npm" else DBEAVER_COMPOSE
|
||||
return list_services_from_compose_file(compose_path)
|
||||
|
||||
def docker_ps_by_labels(project, service=None, running_only=True):
|
||||
"""Lista contenedores por labels de compose (project/service)."""
|
||||
cmd = ["docker", "ps"]
|
||||
if running_only:
|
||||
# por defecto docker ps ya lista solo running; se deja explícito por claridad
|
||||
pass
|
||||
cmd += ["--filter", f"label=com.docker.compose.project={project}"]
|
||||
if service:
|
||||
cmd += ["--filter", f"label=com.docker.compose.service={service}"]
|
||||
cmd += ["-q"]
|
||||
proc = run(cmd, capture_output=True)
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
return [l.strip() for l in proc.stdout.splitlines() if l.strip()]
|
||||
|
||||
# ---------- Acciones (entornos) ----------
|
||||
def bring_up(env_key, force_recreate=False):
|
||||
env_path = env_file_path(env_key)
|
||||
if not env_path:
|
||||
warn(f"No se encontró archivo de entorno para '{env_key}'. Continuando sin --env-file.")
|
||||
pname = PROJECT_NAMES.get(env_key)
|
||||
|
||||
base_args = ["up", "-d"]
|
||||
if force_recreate:
|
||||
base_args.append("--force-recreate")
|
||||
|
||||
cmd = compose_cmd(base_args, env_key=env_key, env_file=env_path, project_name=pname)
|
||||
info("Ejecutando: " + " ".join(cmd))
|
||||
proc = run(cmd)
|
||||
if proc.returncode == 0:
|
||||
ok(f"Entorno '{env_key}' levantado correctamente.")
|
||||
else:
|
||||
fail(f"Fallo al levantar entorno '{env_key}'. Código: {proc.returncode}")
|
||||
|
||||
def bring_down(env_key):
|
||||
env_path = env_file_path(env_key)
|
||||
pname = PROJECT_NAMES.get(env_key)
|
||||
cmd = compose_cmd(["down"], env_key=env_key, env_file=env_path, project_name=pname)
|
||||
info("Ejecutando: " + " ".join(cmd))
|
||||
proc = run(cmd)
|
||||
if proc.returncode == 0:
|
||||
ok(f"Entorno '{env_key}' detenido correctamente.")
|
||||
else:
|
||||
fail(f"Fallo al detener entorno '{env_key}'. Código: {proc.returncode}")
|
||||
|
||||
def running_ids(env_key):
|
||||
env_path = env_file_path(env_key)
|
||||
pname = PROJECT_NAMES.get(env_key)
|
||||
cmd = compose_cmd(["ps", "--status", "running", "-q"], env_key=env_key, env_file=env_path, project_name=pname)
|
||||
proc = run(cmd, capture_output=True)
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
return [l.strip() for l in proc.stdout.splitlines() if l.strip()]
|
||||
|
||||
# ---------- Acciones (globales) ----------
|
||||
def bring_up_global(kind, force_recreate=False):
|
||||
base_args = ["up", "-d"]
|
||||
if force_recreate:
|
||||
base_args.append("--force-recreate")
|
||||
cmd = compose_cmd_global(base_args, kind=kind)
|
||||
info("Ejecutando: " + " ".join(cmd))
|
||||
proc = run(cmd)
|
||||
if proc.returncode == 0:
|
||||
ok(f"Servicio global '{kind}' levantado correctamente.")
|
||||
else:
|
||||
fail(f"Fallo al levantar servicio global '{kind}'. Código: {proc.returncode}")
|
||||
|
||||
def bring_down_global(kind, remove=False):
|
||||
"""
|
||||
Apaga SOLO los servicios definidos en el compose global indicado.
|
||||
- Primero 'stop <servicios>'.
|
||||
- Opcionalmente, 'rm -f <servicios>' si remove=True.
|
||||
"""
|
||||
services = list_services_global(kind)
|
||||
if not services:
|
||||
warn(f"No se encontraron servicios en compose.{kind}.yaml.")
|
||||
return
|
||||
# stop específico
|
||||
cmd = compose_cmd_global(["stop"] + services, kind=kind)
|
||||
info("Ejecutando: " + " ".join(cmd))
|
||||
proc = run(cmd)
|
||||
if proc.returncode != 0:
|
||||
fail(f"Fallo al detener servicios de '{kind}'. Código: {proc.returncode}")
|
||||
|
||||
if remove:
|
||||
cmd_rm = compose_cmd_global(["rm", "-f"] + services, kind=kind)
|
||||
info("Ejecutando: " + " ".join(cmd_rm))
|
||||
proc_rm = run(cmd_rm)
|
||||
if proc_rm.returncode != 0:
|
||||
fail(f"Fallo al remover servicios de '{kind}'. Código: {proc_rm.returncode}")
|
||||
|
||||
ok(f"Servicio(s) global(es) '{kind}' detenido(s).")
|
||||
|
||||
def running_ids_global(kind):
|
||||
"""
|
||||
Detecta si el compose global está corriendo revisando contenedores por servicio,
|
||||
filtrando por labels (project+service).
|
||||
"""
|
||||
services = list_services_global(kind)
|
||||
ids = []
|
||||
for svc in services:
|
||||
ids += docker_ps_by_labels(GLOBAL_PROJECT_NAME, service=svc, running_only=True)
|
||||
# eliminar duplicados
|
||||
return list(dict.fromkeys(ids))
|
||||
|
||||
# ---------- Estado y flujo ----------
|
||||
def detect_status_summary():
|
||||
dev_running = running_ids("development")
|
||||
prod_running = running_ids("production")
|
||||
npm_running = running_ids_global("npm")
|
||||
dbeaver_running = running_ids_global("dbeaver")
|
||||
|
||||
print_header("Estado actual")
|
||||
info(f"DESARROLLO: {len(dev_running)} contenedor(es) en ejecución.")
|
||||
info(f"PRODUCCIÓN: {len(prod_running)} contenedor(es) en ejecución.")
|
||||
info(f"NPM (global): {len(npm_running)} contenedor(es) en ejecución.")
|
||||
info(f"DBEAVER (global): {len(dbeaver_running)} contenedor(es) en ejecución.\n")
|
||||
|
||||
return bool(dev_running), bool(prod_running), bool(npm_running), bool(dbeaver_running)
|
||||
|
||||
def detect_and_optionally_shutdown():
|
||||
"""Muestra estado y ofrece (opcional) apagar dev/prod.
|
||||
Los servicios globales se gestionan desde el menú principal (levantar/apagar).
|
||||
"""
|
||||
dev_on, prod_on, _npm_on, _dbeaver_on = detect_status_summary()
|
||||
|
||||
options = []
|
||||
if dev_on:
|
||||
options.append(("1", "Apagar entorno de DESARROLLO", "development"))
|
||||
if prod_on:
|
||||
options.append(("2", "Apagar entorno de PRODUCCIÓN", "production"))
|
||||
options.append(("3", "Continuar sin detener nada", None))
|
||||
|
||||
if len(options) == 1:
|
||||
return
|
||||
|
||||
print("Selecciona una opción:")
|
||||
for opt in options:
|
||||
key, label = opt[0], opt[1]
|
||||
print(f" {key}) {label}")
|
||||
|
||||
while True:
|
||||
choice = input("> ").strip()
|
||||
selected = next((opt for opt in options if opt[0] == choice), None)
|
||||
if not selected:
|
||||
print("Opción inválida.")
|
||||
continue
|
||||
if choice == "3" or selected[2] is None:
|
||||
ok("Continuamos sin detener nada.")
|
||||
return
|
||||
env_key = selected[2]
|
||||
bring_down(env_key)
|
||||
return
|
||||
|
||||
def main_menu():
|
||||
# Consultar estado de globales para decidir si mostrar opciones de "Levantar" o "Apagar"
|
||||
_dev_on, _prod_on, npm_on, dbeaver_on = detect_status_summary()
|
||||
|
||||
print_header("Gestor de entornos Docker Compose")
|
||||
print("Selecciona una opción:")
|
||||
print(" 1) Levantar entorno de DESARROLLO")
|
||||
print(" 2) Levantar entorno de PRODUCCIÓN")
|
||||
|
||||
dynamic_keys = {}
|
||||
next_key = 3
|
||||
|
||||
# NPM: opción según estado
|
||||
if not npm_on:
|
||||
print(f" {next_key}) Levantar NPM (compose.npm.yaml)")
|
||||
dynamic_keys[str(next_key)] = ("global_up", "npm")
|
||||
else:
|
||||
print(f" {next_key}) Apagar NPM (compose.npm.yaml)")
|
||||
dynamic_keys[str(next_key)] = ("global_down", "npm")
|
||||
next_key += 1
|
||||
|
||||
# DBEAVER: opción según estado
|
||||
if not dbeaver_on:
|
||||
print(f" {next_key}) Levantar DBEAVER (compose.dbeaver.yaml)")
|
||||
dynamic_keys[str(next_key)] = ("global_up", "dbeaver")
|
||||
else:
|
||||
print(f" {next_key}) Apagar DBEAVER (compose.dbeaver.yaml)")
|
||||
dynamic_keys[str(next_key)] = ("global_down", "dbeaver")
|
||||
next_key += 1
|
||||
|
||||
# Salir
|
||||
print(f" {next_key}) Salir")
|
||||
exit_key = str(next_key)
|
||||
|
||||
while True:
|
||||
choice = input("> ").strip()
|
||||
if choice == "1":
|
||||
force = yes_no("¿Usar --force-recreate para DESARROLLO?", default="n")
|
||||
bring_up("development", force_recreate=force)
|
||||
return
|
||||
elif choice == "2":
|
||||
force = yes_no("¿Usar --force-recreate para PRODUCCIÓN?", default="n")
|
||||
bring_up("production", force_recreate=force)
|
||||
return
|
||||
elif choice in dynamic_keys:
|
||||
action, kind = dynamic_keys[choice]
|
||||
if action == "global_up":
|
||||
force = yes_no(f"¿Usar --force-recreate para {kind.upper()}?", default="n")
|
||||
bring_up_global(kind, force_recreate=force)
|
||||
return
|
||||
elif action == "global_down":
|
||||
remove = yes_no(f"¿También remover contenedores de {kind.upper()}? (rm -f)", default="n")
|
||||
bring_down_global(kind, remove=remove)
|
||||
return
|
||||
elif choice == exit_key:
|
||||
ok("Saliendo.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("Opción inválida. Elige una de las opciones listadas.")
|
||||
|
||||
def main():
|
||||
try:
|
||||
check_prereqs()
|
||||
# Mostrar estado y permitir opcionalmente apagar dev/prod
|
||||
detect_and_optionally_shutdown()
|
||||
# Menú de gestión (incluye globales: levantar o apagar según estado)
|
||||
main_menu()
|
||||
except KeyboardInterrupt:
|
||||
print("\n")
|
||||
ok("Interrumpido por el usuario (Ctrl+C). Saliendo.")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
fail(f"Ocurrió un error inesperado: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user