Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8517102b63 | ||
|
|
c62ad626cc | ||
|
|
8835d8ca0c | ||
|
|
997a00c284 | ||
|
|
0fb904b9ac |
No files matched your search
@@ -37,6 +37,9 @@ flowchart LR
|
||||
SSH["Cliente SSH<br/>(laptop / tablet / lo que sea)"] -->|docker exec| C["Contenedor tmux-host<br/>--network host, --restart unless-stopped"]
|
||||
C --> T["tmux server<br/>sesión 'lab'"]
|
||||
T --> CC["proceso claude<br/>(Claude Code)"]
|
||||
C --> HC["healthcheck.sh (background)<br/>cada 30 min"]
|
||||
HC -.->|curl| NET["gitea.cruzcloud.net"]
|
||||
HC -.->|recrea si falta| T
|
||||
C -.->|bind mount| DATA["/DATA (host)<br/>repos y configs del lab"]
|
||||
C -.->|bind mount| SOCK["/var/run/docker.sock (host)<br/>control de Docker/k3d"]
|
||||
C -.->|bind mount| HOME["/DATA/AppData/k3d-lab-v3/claude-code-home (host)<br/>-> /root/.claude"]
|
||||
@@ -49,7 +52,17 @@ reinicia.
|
||||
|
||||
## Contenedor: `tmux-host`
|
||||
|
||||
La imagen se construye desde `scripts/tmux-host/Dockerfile` (antes era
|
||||
un `docker run ... alpine sh -c "apk add ..."` inline — mismo contenido,
|
||||
pero ahora como imagen versionada en vez de reinstalar todo en cada
|
||||
arranque). El Dockerfile fija `WORKDIR /DATA/AppData/k3d-lab-v3/lab-cruzcloud`
|
||||
(la raíz del lab, con `CLAUDE.md`, `apps-registry/`, `platform-infra/` y
|
||||
`scripts/`) y agrega `healthcheck.sh` (ver más abajo) como proceso en
|
||||
background del contenedor.
|
||||
|
||||
```bash
|
||||
docker build -t tmux-host:local scripts/tmux-host
|
||||
|
||||
docker run -d \
|
||||
--name tmux-host \
|
||||
--restart unless-stopped \
|
||||
@@ -59,9 +72,15 @@ docker run -d \
|
||||
-v /DATA/AppData/k3d-lab-v3/claude-code-home:/root/.claude \
|
||||
-v /DATA/AppData/k3d-lab-v3/claude-code-home.json:/root/.claude.json \
|
||||
--network host \
|
||||
alpine sh -c "apk add --no-cache tmux docker-cli bash curl git kubectl nodejs npm && npm install -g @anthropic-ai/claude-code && git config --global --add safe.directory '*' && tail -f /dev/null"
|
||||
tmux-host:local
|
||||
```
|
||||
|
||||
`WORKDIR` hace que `docker exec` (y por lo tanto `lab-session.sh`)
|
||||
arranquen ya posicionados en la raíz del lab sin tener que navegar a
|
||||
mano — `lab-session.sh` además pasa la ruta explícita vía `tmux new -c`
|
||||
por las dudas, para no depender de que `docker exec` herede el `WORKDIR`
|
||||
de la imagen.
|
||||
|
||||
| Volumen | Destino | Por qué |
|
||||
|---|---|---|
|
||||
| `/DATA` | `/DATA` | Acceso a los repos (`apps-registry`, `platform-infra`, `scripts`) y configs del lab, mismos paths que en el host |
|
||||
@@ -195,13 +214,47 @@ Si la sesión `lab` existe, se adjunta a ella (mismo shell, mismo
|
||||
ejemplo justo después de que el contenedor se recreó desde cero— la
|
||||
crea. No hay que recordar cuál de los dos casos aplica.
|
||||
|
||||
## Chequeo de salud automático (cada 30 min)
|
||||
|
||||
`scripts/tmux-host/healthcheck.sh` corre en background dentro del
|
||||
propio contenedor (lanzado por `entrypoint.sh`, el `ENTRYPOINT` de la
|
||||
imagen) y cada `HEALTHCHECK_INTERVAL_SEC` (1800s por defecto) hace dos
|
||||
chequeos independientes, con una respuesta distinta según el tipo de
|
||||
falla:
|
||||
|
||||
- **Conectividad de red real** (`curl` a `https://gitea.cruzcloud.net/`):
|
||||
si falla, **solo se registra** en el log y se notifica por Telegram —
|
||||
el contenedor nunca intenta "arreglar" la red del host. Un problema de
|
||||
red puede ser de hardware/BIOS (deuda técnica ya conocida, ver
|
||||
`incidente-medusa-crashloop-2026-08.md`), fuera del alcance real de un
|
||||
contenedor.
|
||||
- **Existencia de la sesión tmux `lab`** (`tmux has-session`): si no
|
||||
existe, **sí se recrea automáticamente** (`tmux new-session -d -s lab
|
||||
-c <WORKDIR>`) — es solo recuperar el propio entorno de trabajo del
|
||||
contenedor, no tocar infraestructura externa, así que es seguro
|
||||
hacerlo sin intervención humana.
|
||||
|
||||
Todo evento (chequeo ok, falla de red, sesión recreada) queda en
|
||||
`/DATA/AppData/k3d-lab-v3/tmux-host/logs/health.log` — persistente en
|
||||
`/DATA`, no solo en el stdout del contenedor (que se pierde con
|
||||
`docker logs` al recrearlo). Las notificaciones de Telegram reusan el
|
||||
mismo bot/`chat_id` ya configurado para `scripts/lab-agent/`
|
||||
(`lab-agent.env`), en vez de duplicar credenciales entre los dos
|
||||
agentes del lab.
|
||||
|
||||
Para probar el intervalo sin esperar 30 minutos, se puede levantar el
|
||||
contenedor con `-e HEALTHCHECK_INTERVAL_SEC=60` (solo para pruebas en un
|
||||
contenedor descartable — nunca cambiar esto en el `tmux-host` real sin
|
||||
volver a 1800 después).
|
||||
|
||||
## Qué sobrevive a qué
|
||||
|
||||
| Evento | ¿Sobrevive la sesión tmux (`lab`)? | ¿Sobrevive el login de Claude Code? |
|
||||
|---|---|---|
|
||||
| Corte de SSH / cerrar la terminal / cambiar de dispositivo | Sí — es exactamente el caso que este setup resuelve | Sí |
|
||||
| `docker restart tmux-host` (reinicio del NAS) | **No** — el proceso `tmux server` muere con el contenedor; `lab-session.sh` crea una sesión nueva automáticamente | Sí, vía el volumen montado en `/root/.claude` |
|
||||
| `docker rm` + recrear el contenedor desde cero | No | Sí, vía el mismo volumen — no hace falta volver a autenticar ni reconfirmar el diálogo de confianza del folder (ver el mount de `claude-code-home.json`) |
|
||||
| `docker restart tmux-host` (reinicio del NAS) | **No al toque, pero se auto-recupera solo** — el proceso `tmux server` muere con el contenedor; `healthcheck.sh` la recrea en el próximo ciclo (máx. 30 min) sin intervención, o `lab-session.sh` la crea al instante si alguien se reconecta antes | Sí, vía el volumen montado en `/root/.claude` |
|
||||
| `docker rm` + recrear el contenedor desde cero | No, pero igual se auto-recupera sola (mismo mecanismo de arriba) | Sí, vía el mismo volumen — no hace falta volver a autenticar ni reconfirmar el diálogo de confianza del folder (ver el mount de `claude-code-home.json`) |
|
||||
| Falla de red real del host/NAS | N/A | N/A — `healthcheck.sh` la detecta y notifica, pero no la corrige (fuera de su alcance) |
|
||||
|
||||
## Validación en vivo
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ spec:
|
||||
runAsNonRoot: false
|
||||
containers:
|
||||
- name: app
|
||||
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.207
|
||||
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.224
|
||||
# El base saca TODAS las capabilities. Probado en vivo y
|
||||
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
|
||||
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
|
||||
|
||||
@@ -65,7 +65,7 @@ spec:
|
||||
memory: 64Mi
|
||||
|
||||
- name: migrations
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.222
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.223
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- node
|
||||
@@ -86,7 +86,7 @@ spec:
|
||||
|
||||
containers:
|
||||
- name: app
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.222
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.223
|
||||
imagePullPolicy: IfNotPresent
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@@ -2,19 +2,36 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { Maximize2, Minus, Plus, RotateCcw, ZoomIn } from "lucide-react";
|
||||
import { PointerEvent, WheelEvent, useRef, useState } from "react";
|
||||
import { MouseEvent, PointerEvent, WheelEvent, useRef, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
|
||||
interface Offset { x: number; y: number }
|
||||
interface DragState { pointerId: number; startX: number; startY: number; originX: number; originY: number }
|
||||
interface HoverZoomOrigin { x: number; y: number }
|
||||
|
||||
// Zoom automatico al pasar el cursor (estilo lupa clasico de e-commerce)
|
||||
// sobre la imagen principal -- solo mouse (mousemove no dispara de forma
|
||||
// continua en touch, asi que en mobile simplemente no aparece el efecto,
|
||||
// sin romper nada). Independiente del zoom interactivo del modal
|
||||
// (click -> Dialog con scale/drag), que sigue igual.
|
||||
const HOVER_ZOOM_SCALE = 2.2;
|
||||
|
||||
export function ProductGallery({ images, name }: { images: string[]; name: string }) {
|
||||
const [selected, setSelected] = useState(images[0] ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
|
||||
const [hoverZoom, setHoverZoom] = useState<HoverZoomOrigin | null>(null);
|
||||
const drag = useRef<DragState | null>(null);
|
||||
|
||||
const trackHoverZoom = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setHoverZoom({
|
||||
x: ((event.clientX - rect.left) / rect.width) * 100,
|
||||
y: ((event.clientY - rect.top) / rect.height) * 100,
|
||||
});
|
||||
};
|
||||
|
||||
const applyScale = (next: number) => {
|
||||
const value = Math.min(3, Math.max(1, Number(next.toFixed(2))));
|
||||
setScale(value);
|
||||
@@ -44,8 +61,24 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={changeOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button type="button" className="group relative order-1 aspect-square overflow-hidden rounded-[2rem] bg-slate-50 lg:order-2">
|
||||
<Image src={selected} alt={name} fill priority sizes="(max-width: 1024px) 100vw, 50vw" className="object-cover transition duration-500 group-hover:scale-[1.03]" />
|
||||
<button
|
||||
type="button"
|
||||
className="group relative order-1 aspect-square cursor-zoom-in overflow-hidden rounded-[2rem] bg-slate-50 lg:order-2"
|
||||
onMouseMove={trackHoverZoom}
|
||||
onMouseLeave={() => setHoverZoom(null)}
|
||||
>
|
||||
<Image
|
||||
src={selected}
|
||||
alt={name}
|
||||
fill
|
||||
priority
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
className="object-cover transition-transform duration-150 ease-out"
|
||||
style={{
|
||||
transform: `scale(${hoverZoom ? HOVER_ZOOM_SCALE : 1})`,
|
||||
transformOrigin: hoverZoom ? `${hoverZoom.x}% ${hoverZoom.y}%` : "center",
|
||||
}}
|
||||
/>
|
||||
<span className="absolute bottom-5 right-5 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-black shadow-xl"><ZoomIn className="size-4" />Zoom interactivo</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
|
||||
Reference in new issue
Block a user