Compare commits

...
Author SHA1 Message Date
admin e55684bf4f fix(web): compact installation parent checkbox options
DH V2 CI / API · typecheck, tests, build (push) Successful in 29s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 58s
2026-09-13 18:37:25 -03:00
admin ddf3b158e7 deploy: serialize Gitea production deployments
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 17s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 48s
2026-09-13 17:11:51 -03:00
admin 8564f1933c deploy: add native Gitea production workflow
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 17s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 48s
2026-09-13 16:56:54 -03:00
admin ea890cb807 ci(gitea): make API health probe DinD-compatible
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
Production dependency audit / API · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m5s
2026-09-13 16:40:46 -03:00
admin ce27cd01e9 ci(gitea): pin setup-gradle v4.4.3 for act compatibility
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m39s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
DH V2 CI / Docker / scripts contract (push) Failing after 1m38s
2026-09-13 13:03:21 -03:00
admin a155d9e075 Merge PR #59 · reparar Actividad global de Inventarios
Corrige la consulta SQL común del expediente de Actividad y aísla las facetas auxiliares para evitar errores globales.
2026-09-12 12:23:15 -03:00
5 changed files with 391 additions and 7 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
- name: Gradle 8.13
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
with:
gradle-version: '8.13'
+6 -1
View File
@@ -309,9 +309,14 @@ jobs:
docker compose --env-file .env.example build api
docker compose --env-file .env.example up -d api
# In containerized runners (Gitea DinD), 127.0.0.1 of the job
# is not the Docker daemon host. Probe the production API from
# inside its own container so this barrier works on GitHub and Gitea.
api_ready=0
for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then
if docker compose --env-file .env.example exec -T api \
node -e 'fetch(`http://127.0.0.1:${process.env.API_PORT}/api/v3/health`).then(async r => { const t = await r.text(); process.stdout.write(t); if (!r.ok) process.exit(1); }).catch(() => process.exit(1))' \
>/tmp/dhv2-health.json 2>/dev/null; then
api_ready=1
break
fi
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP="/var/www/dhv2.korexlabs.com"
BACKUP_ROOT="/root/DH_V2_BACKUPS"
LOCK="/var/lock/dhv2-deploy.lock"
exec 9>"$LOCK"
if ! flock -n 9; then
echo "Otro deploy de DH V2 ya está en curso. No se realiza ninguna modificación."
exit 0
fi
DEPLOY_REF="${DHV2_DEPLOY_REF:-deploy}"
STAMP="$(date +%Y%m%d_%H%M%S)"
BACKUP="$BACKUP_ROOT/GITEA_DEPLOY_${STAMP}"
STAGE="/root/dhv2-gitea-stage-${STAMP}"
LOG="/tmp/dhv2-gitea-deploy-${STAMP}.log"
API_TEST_IMAGE="dhv2-api:gitea-${STAMP}"
WEB_TEST_IMAGE="dhv2-web:gitea-${STAMP}"
PHASE="bootstrap"
PREV_SHA=""
TARGET_SHA=""
EXPECTED_API_VERSION=""
EXPECTED_WEB_VERSION=""
APP_TOUCHED=0
cd "$APP"
exec > >(tee -a "$LOG") 2>&1
cleanup() {
set +e
git worktree remove --force "$STAGE" >/dev/null 2>&1 || true
rm -rf "$STAGE"
docker image rm "$API_TEST_IMAGE" "$WEB_TEST_IMAGE" >/dev/null 2>&1 || true
}
publish_status() {
local rc="${1:-1}"
set +e
local outcome="failure"
[ "$rc" -eq 0 ] && outcome="success"
local current="unknown"
current="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
local status_file log_file status_blob log_blob tree commit
status_file="$(mktemp /tmp/dhv2-status.XXXXXX)"
log_file="$(mktemp /tmp/dhv2-log.XXXXXX)"
{
echo "status=$outcome"
echo "exit_code=$rc"
echo "phase=$PHASE"
echo "timestamp=$(date --iso-8601=seconds)"
echo "deploy_ref=$DEPLOY_REF"
echo "previous_sha=${PREV_SHA:-unknown}"
echo "target_sha=${TARGET_SHA:-unknown}"
echo "current_sha=$current"
echo "api_version=${EXPECTED_API_VERSION:-unknown}"
echo "web_version=${EXPECTED_WEB_VERSION:-unknown}"
echo "app_touched=$APP_TOUCHED"
echo "backup=${BACKUP:-unknown}"
} > "$status_file"
tail -n 500 "$LOG" > "$log_file" 2>/dev/null || true
status_blob="$(git hash-object -w "$status_file" 2>/dev/null || true)"
log_blob="$(git hash-object -w "$log_file" 2>/dev/null || true)"
if [ -n "$status_blob" ] && [ -n "$log_blob" ]; then
tree="$(printf '100644 blob %s\tdeploy.log\n100644 blob %s\tstatus.txt\n' "$log_blob" "$status_blob" | git mktree 2>/dev/null || true)"
if [ -n "$tree" ]; then
commit="$(printf 'deploy-status: %s · phase %s\n' "$outcome" "$PHASE" | git -c user.name='DH V2 Deploy Bot' -c user.email='deploy@dhv2.local' commit-tree "$tree" 2>/dev/null || true)"
[ -z "$commit" ] || git push --force origin "$commit:refs/heads/deploy-status" >/dev/null 2>&1 || true
fi
fi
rm -f "$status_file" "$log_file"
}
on_exit() {
local rc=$?
trap - EXIT ERR
cleanup
publish_status "$rc"
exit "$rc"
}
trap on_exit EXIT
rollback() {
local rc=$?
trap - ERR
PHASE="rollback"
echo
echo "============================================================"
echo " DH V2 · DEPLOY FALLÓ · ROLLBACK"
echo "============================================================"
cd "$APP"
if [ "$APP_TOUCHED" -eq 1 ] && [ -n "${PREV_SHA:-}" ]; then
echo "Restaurando aplicación al commit previo: $PREV_SHA"
git reset --hard "$PREV_SHA" || true
docker compose build api web </dev/null || true
docker compose up -d --no-deps --force-recreate api web </dev/null || true
else
echo "El candidato falló antes de modificar producción; no se reconstruye ni reinicia la aplicación activa."
fi
echo
echo "Estado actual:"
docker compose ps -a </dev/null || true
if [ "$APP_TOUCHED" -eq 1 ]; then
echo
echo "Últimos logs:"
docker compose logs --tail=160 api web </dev/null || true
fi
echo
if [ -d "$BACKUP" ]; then
echo "Backup PRE disponible en: $BACKUP"
echo "Las migraciones son forward-only; database-before.dump queda disponible para restauración manual si hiciera falta."
else
echo "No fue necesario crear backup PRE: el fallo ocurrió durante el preflight del candidato, antes de tocar producción."
fi
exit "$rc"
}
trap rollback ERR
echo
echo "============================================================"
echo " DH V2 · DEPLOY DESDE GITEA · $DEPLOY_REF"
echo "============================================================"
for cmd in git docker curl tar node; do
command -v "$cmd" >/dev/null || { echo "ERROR: falta $cmd"; false; }
done
[ -d .git ] || { echo "ERROR: $APP no es repositorio Git"; false; }
[ -f .env ] || { echo "ERROR: falta $APP/.env"; false; }
ORIGIN_URL="$(git remote get-url origin)"
EXPECTED_ORIGIN="https://git.korexlabs.com.ar/admin/dh-inspeccion-v2.git"
if [ "$ORIGIN_URL" != "$EXPECTED_ORIGIN" ]; then
echo "ERROR: origin no apunta al Gitea autorizado."
echo "Actual: $ORIGIN_URL"
echo "Esperado: $EXPECTED_ORIGIN"
false
fi
git config --global --get-all safe.directory 2>/dev/null | grep -Fxq "$APP" || git config --global --add safe.directory "$APP"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "ERROR: hay cambios locales versionados en producción."
git status --short
false
fi
PREV_SHA="$(git rev-parse HEAD)"
PHASE="fetch"
git fetch origin "$DEPLOY_REF"
TARGET_SHA="$(git rev-parse "origin/$DEPLOY_REF")"
echo "Actual: $PREV_SHA"
echo "Objetivo: $TARGET_SHA"
if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
echo "Producción ya está en el commit autorizado."
PHASE="complete"
exit 0
fi
if ! git merge-base --is-ancestor "$PREV_SHA" "$TARGET_SHA"; then
echo "ERROR: origin/$DEPLOY_REF no es fast-forward desde producción."
false
fi
PHASE="candidate-preflight"
rm -rf "$STAGE"
git worktree add --detach "$STAGE" "$TARGET_SHA" >/dev/null
EXPECTED_API_VERSION="$(node -p "require('$STAGE/api-v3/package.json').version")"
EXPECTED_WEB_VERSION="$(node -p "require('$STAGE/web-v2/package.json').version")"
echo "API candidata: $EXPECTED_API_VERSION"
echo "WEB candidata: $EXPECTED_WEB_VERSION"
docker compose --env-file "$APP/.env" -f "$STAGE/docker-compose.yml" config >/dev/null
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find "$STAGE/scripts" -type f -name '*.sh' -print0)
echo
echo "========== TEST API CANDIDATA =========="
docker build --target builder -t "$API_TEST_IMAGE" "$STAGE/api-v3" </dev/null
docker run --rm \
-v "$STAGE/api-v3/test:/app/test:ro" \
-v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
-v "$STAGE/docker-compose.yml:/docker-compose.yml:ro" \
-v "$STAGE/web-v2:/web-v2:ro" \
-v "$STAGE/android-app:/android-app:ro" \
"$API_TEST_IMAGE" npm test </dev/null
echo
echo "========== BUILD WEB CANDIDATA =========="
docker build -t "$WEB_TEST_IMAGE" "$STAGE/web-v2" </dev/null
PHASE="backup"
echo
echo "========== BACKUP PRE =========="
install -d -m 700 "$BACKUP"
docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' </dev/null > "$BACKUP/database-before.dump"
tar \
--exclude='./.git' \
--exclude='./.env' \
--exclude='*/node_modules' \
--exclude='*/dist' \
--exclude='*.zip' \
--exclude='*.tar.gz' \
--exclude='*.tgz' \
-czf "$BACKUP/source-before.tar.gz" .
install -m 600 .env "$BACKUP/.env"
git rev-parse HEAD > "$BACKUP/previous.sha"
printf '%s\n' "$TARGET_SHA" > "$BACKUP/target.sha"
docker compose ps -a > "$BACKUP/docker-before.txt"
(
cd "$BACKUP"
sha256sum database-before.dump source-before.tar.gz .env previous.sha target.sha docker-before.txt > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
)
chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true
PHASE="fast-forward"
echo
echo "========== FAST-FORWARD =========="
git log --oneline --no-decorate "$PREV_SHA..$TARGET_SHA"
APP_TOUCHED=1
git merge --ff-only "origin/$DEPLOY_REF"
PHASE="build"
echo
echo "========== BUILD PRODUCCIÓN =========="
docker compose build api migrate web </dev/null
PHASE="migrations"
echo
echo "========== MIGRACIONES =========="
docker compose --profile tools run --rm migrate </dev/null
docker compose --profile tools run --rm migrate npm run migration:show </dev/null | tee "$BACKUP/migrations.txt"
grep -Fq 'Pending migrations: no' "$BACKUP/migrations.txt"
PHASE="recreate"
echo
echo "========== RECREATE API + WEB =========="
docker compose up -d --no-deps --force-recreate api web </dev/null
PHASE="health"
echo
echo "========== HEALTH =========="
HEALTH_OK=0
for _ in $(seq 1 60); do
if curl -fsS --max-time 5 http://127.0.0.1:3101/api/v3/health > "$BACKUP/health.json" 2>/dev/null; then
if grep -Fq '"status":"ok"' "$BACKUP/health.json" \
&& grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json" \
&& grep -Fq '"database":"ok"' "$BACKUP/health.json"; then
HEALTH_OK=1
break
fi
CURRENT_API_VERSION="$(node -e 'try { const h = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(String(h.version || "unknown")); } catch { process.stdout.write("invalid"); }' "$BACKUP/health.json")"
echo "API respondió pero aún no es la candidata (actual=$CURRENT_API_VERSION, esperada=$EXPECTED_API_VERSION). Reintentando..."
fi
sleep 2
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "ERROR: API candidata no pasó healthcheck/version/database dentro del plazo."
[ ! -f "$BACKUP/health.json" ] || cat "$BACKUP/health.json"
docker compose logs --tail=180 api
false
fi
cat "$BACKUP/health.json"
echo
grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json"
grep -Fq '"database":"ok"' "$BACKUP/health.json"
WEB_CODE="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:8182/)"
[ "$WEB_CODE" = "200" ] || { echo "ERROR: WEB HTTP $WEB_CODE"; false; }
PHASE="verify"
echo
echo "========== VERIFICACIÓN FINAL =========="
docker compose ps -a | tee "$BACKUP/docker-after.txt"
if docker compose ps --status running --services | grep -Fxq api && docker compose ps --status running --services | grep -Fxq web && docker compose ps --status running --services | grep -Fxq db; then
echo "Servicios críticos: OK"
else
echo "ERROR: falta un servicio crítico en ejecución."
false
fi
PHASE="post-backup"
docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' </dev/null > "$BACKUP/database-after.dump"
git rev-parse HEAD > "$BACKUP/deployed.sha"
printf 'API=%s\nWEB=%s\n' "$EXPECTED_API_VERSION" "$EXPECTED_WEB_VERSION" > "$BACKUP/deployed-versions.txt"
(
cd "$BACKUP"
sha256sum database-after.dump deployed.sha deployed-versions.txt health.json migrations.txt docker-after.txt >> SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
)
chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true
PHASE="complete"
trap - ERR
echo
echo "============================================================"
echo " DH V2 · DEPLOY OK"
echo "============================================================"
echo "Commit: $TARGET_SHA"
echo "API: $EXPECTED_API_VERSION"
echo "WEB: $EXPECTED_WEB_VERSION"
echo "Backup: $BACKUP"
echo "============================================================"
@@ -245,8 +245,8 @@ export function AuthoritativeInventoryConfigPage() {
{canManage && <div style={{ marginTop: 18, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
<h3 style={{ marginTop: 0 }}>+ Nuevo tipo</h3>
<label className="field"><span>Nombre</span><input value={newTypeName} onChange={(event) => setNewTypeName(event.target.value)} placeholder={level === 'INSTALLATION' ? 'Ej. Planta de tratamiento' : 'Ej. Bomba centrífuga'} /></label>
{level === 'SUBINSTALLATION' && <div className="field"><span>Puede estar dentro de</span><div style={{ display: 'grid', gap: 8, marginTop: 8 }}>
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}><input type="checkbox" checked={newTypeParents.includes(family.id)} onChange={() => toggleParent(family.id)} />{family.name}</label>)}
{level === 'SUBINSTALLATION' && <div className="field"><span>Puede estar dentro de</span><div className="inventory-parent-options">
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} className="inventory-parent-option"><input type="checkbox" checked={newTypeParents.includes(family.id)} onChange={() => toggleParent(family.id)} /><span>{family.name}</span></label>)}
</div></div>}
<button type="button" className="button primary" disabled={saving || !newTypeName.trim()} onClick={() => void createType()}><Icon name="plus" />Crear tipo</button>
</div>}
@@ -265,10 +265,10 @@ export function AuthoritativeInventoryConfigPage() {
{selectedFamily.level === 'SUBINSTALLATION' && <div style={{ marginBottom: 22 }}>
<strong>Puede estar dentro de:</strong>
<div style={{ display: 'grid', gap: 8, marginTop: 10 }}>
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="inventory-parent-options">
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} className="inventory-parent-option">
<input type="checkbox" disabled={!canManage || saving} checked={selectedFamily.parentFamilyIds.includes(family.id)} onChange={() => void toggleSelectedParent(family.id)} />
{family.name}
<span>{family.name}</span>
</label>)}
</div>
</div>}
+52
View File
@@ -1296,3 +1296,55 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
/* D5.6.4 · combobox buscable global */
.searchable-select{position:relative;width:100%;min-width:0}.searchable-select-native{position:absolute!important;inset:0;width:1px!important;height:1px!important;opacity:0;pointer-events:none}.searchable-select-trigger{display:flex;width:100%;min-height:41px;align-items:center;justify-content:space-between;gap:10px;padding:9px 11px;border:1px solid #d7dce5;border-radius:8px;background:#fff;color:var(--ink);font-size:13px;text-align:left;cursor:pointer;outline:none}.searchable-select-trigger:hover{border-color:#bcc6d6}.searchable-select-trigger:focus-visible{border-color:#6b95ed;box-shadow:0 0 0 3px rgba(40,100,220,.1)}.searchable-select-trigger:disabled{cursor:not-allowed;color:#9199a8;background:#f1f3f6}.searchable-select-trigger>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.searchable-select-trigger .placeholder{color:#7e8796}.searchable-select-trigger .icon{flex:0 0 auto;transform:rotate(90deg)}.searchable-select-popup{position:fixed;z-index:10000;max-height:315px;padding:6px;border:1px solid #ccd4e0;border-radius:10px;background:#fff;box-shadow:0 14px 40px rgba(18,31,53,.18)}.searchable-select-search{display:flex;align-items:center;gap:7px;padding:5px 7px 7px;border-bottom:1px solid var(--line)}.searchable-select-search input{width:100%;min-width:0;height:34px;padding:6px 8px;border:0;outline:0;background:transparent;color:var(--ink);font-size:12px}.searchable-select-options{max-height:245px;overflow:auto;padding-top:4px}.searchable-select-options>button{display:flex;width:100%;align-items:center;justify-content:space-between;gap:8px;padding:8px 9px;border:0;border-radius:7px;background:transparent;color:var(--ink);font-size:12px;text-align:left;cursor:pointer}.searchable-select-options>button:hover,.searchable-select-options>button:focus-visible{background:#f2f5fa;outline:none}.searchable-select-options>button.selected{background:#eef4ff;color:#174ea6;font-weight:750}.searchable-select-options>button:disabled{cursor:not-allowed;color:#a0a7b3;background:transparent}.searchable-select-empty{padding:14px 10px;color:var(--muted);font-size:11px;text-align:center}.select-field>.searchable-select{width:100%}.survey-inline-select.searchable-select{min-width:130px;margin-top:6px;padding:0;border:0;background:transparent}.survey-inline-select.wide.searchable-select{min-width:175px;margin-top:0}.survey-inline-select .searchable-select-trigger{min-height:33px;padding:6px 8px;border-radius:7px;font-size:9px}.operational-context-selectors .searchable-select-trigger{min-height:36px;padding:7px 9px;font-size:10px}.parent-picker .searchable-select-trigger{border-radius:5px 5px 8px 8px}
.inspection-quick-create{max-width:980px;margin-left:auto;margin-right:auto}.inspection-quick-create .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.inspection-generated-code{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--soft)}.inspection-generated-code small{color:var(--muted);font-weight:700}.inspection-generated-code strong{font-size:15px;letter-spacing:.02em}@media(max-width:760px){.inspection-quick-create .form-grid{grid-template-columns:1fr}}
/* Parent choices need fixed-size controls even inside a generic form field. */
.inventory-parent-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr));
gap: 8px;
margin: 10px 0 14px;
}
.inventory-parent-option {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-width: 0;
min-height: 42px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--ink);
font-size: 12px;
font-weight: 600;
line-height: 1.4;
cursor: pointer;
}
.inventory-parent-option input[type="checkbox"] {
flex: 0 0 17px;
width: 17px;
height: 17px;
min-height: 17px;
padding: 0;
margin: 0;
accent-color: var(--blue);
cursor: inherit;
}
.inventory-parent-option > span {
display: block;
min-width: 0;
overflow-wrap: anywhere;
}
.inventory-parent-option:has(input:checked) {
border-color: #9db9ed;
background: #eef4ff;
}
.inventory-parent-option:has(input:focus-visible) {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.inventory-parent-option:has(input:disabled) {
cursor: default;
opacity: .65;
}