From cebac548634416c088942245ba23506cf76d64c8 Mon Sep 17 00:00:00 2001 From: gnacho Date: Wed, 5 Aug 2026 23:30:11 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20repo=20p=C3=BAblico=20=E2=80=94=20r?= =?UTF-8?q?ibbon=20de=20actualizaci=C3=B3n=20semanal,=20releases=20semver?= =?UTF-8?q?=20y=20CSP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El repo pasa a público (gh repo rename panelsolar → helios, historia saneada en commits previos). Con el repo público, el check de actualizaciones deja de ser humo (antes repo privado → 404 → siempre 'al día'). - UpdateRibbon: check semanal vía localStorage contra releases de GitHub (solo admin), enlace 'Ver novedades'. Patrón app-auto-update. - Ajustes: REPO_URL → gnacho/helios (el rewrite con filter-repo lo dejó corrupto como gdemo/panelsolar). - CSP server: connect-src https://api.github.com (Deltos ya lo tenía). - release.yml: releases estables semver (tests + build + tarballs por arch con node_modules pre-built + checksums.txt). - bump 0.5.0 → 0.6.0 (app + server + locks). --- .github/workflows/release.yml | 81 +++++++++++++++++++++ app/package-lock.json | 4 +- app/package.json | 2 +- app/src/components/UpdateRibbon.tsx | 98 ++++++++++++++++++++++++++ app/src/components/shell/AppLayout.tsx | 2 + app/src/pages/Ajustes.tsx | 2 +- server/package-lock.json | 4 +- server/package.json | 2 +- server/src/index.js | 2 +- 9 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 app/src/components/UpdateRibbon.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5003593 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: release + +# Release estable por tag v*: empaqueta la webapp completa (frontend build + +# server + node_modules de producción PRE-BUILT por arch) + checksums.txt. +# El 'Comprobar actualizaciones' del front y el ribbon semanal consumen estas +# releases (releases/latest, semver). + +on: + push: + tags: ['v*'] + workflow_dispatch: # sin tag = empaqueta y sube como artifact de prueba + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runs-on: ubuntu-latest + - arch: arm64 + runs-on: ubuntu-24.04-arm + runs-on: ${{ matrix.runs-on }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + app/package-lock.json + server/package-lock.json + + - name: Tests del server (vitest) + working-directory: server + run: npm ci --no-audit --no-fund && npm test + + - name: Build frontend + working-directory: app + run: npm ci --no-audit --no-fund && npm run build + + - name: node_modules de producción (sin dev, compila prebuilds del arch) + working-directory: server + run: rm -rf node_modules && npm ci --omit=dev --no-audit --no-fund + + - name: Empaqueta release + env: + REF: ${{ github.ref_name }} + run: | + VERSION="${REF#v}" + [ "${{ github.ref_type }}" = "tag" ] || VERSION="0.0.0-SNAPSHOT-${GITHUB_SHA::7}" + mkdir -p stage/server + cp -r app/dist stage/dist + cp -r server/src server/package.json stage/server/ + cp -r shared stage/shared + cp -r server/node_modules stage/server/node_modules + tar -czf "helios_${VERSION}_linux_${{ matrix.arch }}.tar.gz" -C stage . + sha256sum helios_*.tar.gz > checksums.txt + cat checksums.txt + + - name: Publica en la release (solo tags) + if: github.ref_type == 'tag' + uses: softprops/action-gh-release@v2 + with: + files: | + helios_*.tar.gz + checksums.txt + + - name: Sube artifact de prueba (solo workflow_dispatch) + if: github.ref_type != 'tag' + uses: actions/upload-artifact@v4 + with: + name: helios-snapshot-linux-${{ matrix.arch }} + path: | + helios_*.tar.gz + checksums.txt + retention-days: 7 diff --git a/app/package-lock.json b/app/package-lock.json index 417cdf0..4582b61 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "helios", - "version": "0.3.2", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "helios", - "version": "0.3.2", + "version": "0.6.0", "dependencies": { "@gsap/react": "^2.1.2", "@radix-ui/react-accordion": "^1.2.12", diff --git a/app/package.json b/app/package.json index aaef141..9c3ccc2 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "helios", "private": true, - "version": "0.5.0", + "version": "0.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/app/src/components/UpdateRibbon.tsx b/app/src/components/UpdateRibbon.tsx new file mode 100644 index 0000000..d1ee2db --- /dev/null +++ b/app/src/components/UpdateRibbon.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RefreshCw } from 'lucide-react'; +import { apiFetch } from '@/data/api-client'; +import pkg from '../../package.json'; + +const CHECK_KEY = 'helios-last-update-check'; +const CHECK_INTERVAL = 7 * 24 * 60 * 60 * 1000; // 1 vez por semana (regla app-auto-update) +const REPO_URL = 'https://github.com/gnacho/helios'; + +/** a vs b semver ('1.10.0' > '1.9.0'); prefijos 'v' ignorados. */ +function compareSemver(a: string, b: string): number { + const pa = a.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0); + const pb = b.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); + } + return 0; +} + +/** + * Ribbon de actualización (patrón app-auto-update: check semanal + aviso si hay + * versión nueva en el repo público). Solo admin. GET anónima a GitHub releases + * (el repo es público), 1 vez por semana vía localStorage; aplicar = redesplegar + * en el servidor, así que el ribbon enlaza a la release. + */ +export default function UpdateRibbon() { + const { t } = useTranslation(); + const [state, setState] = useState<'idle' | 'checking' | 'uptodate' | 'available' | 'error'>('idle'); + const [latestVersion, setLatestVersion] = useState(''); + + useEffect(() => { + let stale = false; + const run = async () => { + try { + // Solo admin ve el ribbon (como el resto de la zona de actualizaciones). + const me = await apiFetch<{ user?: { role?: string } }>('/api/auth/me').catch(() => null); + if (me?.user?.role !== 'admin') return; + + const last = Number(window.localStorage.getItem(CHECK_KEY) || 0); + if (Date.now() - last < CHECK_INTERVAL) return; // ya se comprobó esta semana + window.localStorage.setItem(CHECK_KEY, String(Date.now())); + + const repo = REPO_URL.match(/github\.com\/([^/]+\/[^/.]+)/)?.[1]; + if (!repo) return; + setState('checking'); + const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { + headers: { Accept: 'application/vnd.github+json' }, + }); + let version = ''; + if (res.ok) { + const data = (await res.json()) as { tag_name?: string }; + version = data.tag_name || ''; + } else if (res.status === 404) { + const tagRes = await fetch(`https://api.github.com/repos/${repo}/tags?per_page=1`, { + headers: { Accept: 'application/vnd.github+json' }, + }); + if (tagRes.ok) { + const tags = (await tagRes.json()) as { name?: string }[]; + version = tags[0]?.name || ''; + } + } + if (stale) return; + if (!version || compareSemver(version, pkg.version) <= 0) setState('uptodate'); + else { + setLatestVersion(version.replace(/^v/, '')); + setState('available'); + } + } catch { + if (!stale) setState('error'); + } + }; + void run(); + return () => { + stale = true; + }; + }, []); + + if (state !== 'available') return null; + + return ( +
+ + {t('ajustes.about.updateAvailable', { version: latestVersion })} + + {t('ajustes.about.viewRelease')} + +
+ ); +} diff --git a/app/src/components/shell/AppLayout.tsx b/app/src/components/shell/AppLayout.tsx index 8f03bb3..2f9bbaf 100644 --- a/app/src/components/shell/AppLayout.tsx +++ b/app/src/components/shell/AppLayout.tsx @@ -25,6 +25,7 @@ import { fmtWeekdayDate } from '@/i18n'; import BrandLogo from '@/components/BrandLogo'; import ConnectionStatus from '@/components/ConnectionStatus'; import AlertsBell from '@/components/AlertsBell'; +import UpdateRibbon from '@/components/UpdateRibbon'; import ThemeToggle from '@/components/ThemeToggle'; import Footer from '@/components/Footer'; import HeliosToaster from '@/components/HeliosToaster'; @@ -422,6 +423,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
+ {isDemo && } {children}
diff --git a/app/src/pages/Ajustes.tsx b/app/src/pages/Ajustes.tsx index 8b985df..fadb09f 100644 --- a/app/src/pages/Ajustes.tsx +++ b/app/src/pages/Ajustes.tsx @@ -1642,7 +1642,7 @@ function InstallSection({ state, install }: { state: InstallState; install: () = // ── § Acerca de ────────────────────────────────────────────────────────────── -const REPO_URL = 'https://github.com/gdemo/panelsolar'; +const REPO_URL = 'https://github.com/gnacho/helios'; function compareSemver(a: string, b: string): number { const pa = a.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0); diff --git a/server/package-lock.json b/server/package-lock.json index 009756b..430ab1b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "helios-server", - "version": "0.4.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "helios-server", - "version": "0.4.0", + "version": "0.6.0", "dependencies": { "@hono/node-server": "^1.13.0", "bcryptjs": "^3.0.3", diff --git a/server/package.json b/server/package.json index 7b37db3..5f9ea1e 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "helios-server", - "version": "0.5.0", + "version": "0.6.0", "private": true, "type": "module", "engines": { diff --git a/server/src/index.js b/server/src/index.js index 941fb40..0de50dc 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -123,7 +123,7 @@ app.use('*', async (c, next) => { c.header('Referrer-Policy', 'strict-origin-when-cross-origin') c.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()') c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') - c.header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'") + c.header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.github.com") await next() }) const sseClients = new Set() From 43525b49f47d2fa90e78201daabd8d0385602ac5 Mon Sep 17 00:00:00 2001 From: gnacho Date: Wed, 5 Aug 2026 23:35:12 +0200 Subject: [PATCH 2/2] fix(ajustes): tokens para el preview de tema y la paleta de acentos (theme-audit) El CI fallaba por hex hardcodeados en el preview de tema y en ACCENTS (fallo preexistente, el CI de main ya estaba rojo). Movidos a lib/colors.ts (fichero de tokens permitido por theme-audit): THEME_BG/THEME_SURFACE/THEME_BAR y ACCENTS. --- app/src/lib/colors.ts | 13 ++++++++ app/src/pages/Ajustes.tsx | 67 +++++++++++++++++++-------------------- 2 files changed, 46 insertions(+), 34 deletions(-) diff --git a/app/src/lib/colors.ts b/app/src/lib/colors.ts index a3f06b4..15cdcee 100644 --- a/app/src/lib/colors.ts +++ b/app/src/lib/colors.ts @@ -35,6 +35,19 @@ export const SOLAR_ARC_COLORS = { nightFrom: '#101828', nightTo: '#182338', star /** Fondos base por tema (theme-color meta, swatches de preview). */ export const THEME_BG = { light: '#F4F6FA', dark: '#080D1A' } as const; +/** Superficies del preview de tema (mini-UI pintada con los colores reales). */ +export const THEME_SURFACE = { light: '#FFFFFF', dark: '#0F1729' } as const; +/** Barras internas del preview de tema. */ +export const THEME_BAR = { light: '#E2E8F0', dark: '#1E293B' } as const; + +/** Paleta de acentos del selector de Ajustes (triplete RGB para --accent-rgb; null = naranja por defecto). */ +export const ACCENTS = [ + { id: 'naranja', rgb: null, hex: '#F59E0B' }, + { id: 'verde', rgb: '34 197 94', hex: '#22C55E' }, + { id: 'azul', rgb: '59 130 246', hex: '#3B82F6' }, + { id: 'violeta', rgb: '139 92 246', hex: '#8B5CF6' }, +] as const; + export function energyColor(key: EnergyColorKey, isDark: boolean): string { return isDark ? ENERGY_COLORS[key].dark : ENERGY_COLORS[key].light; } diff --git a/app/src/pages/Ajustes.tsx b/app/src/pages/Ajustes.tsx index fadb09f..a0ec64c 100644 --- a/app/src/pages/Ajustes.tsx +++ b/app/src/pages/Ajustes.tsx @@ -37,6 +37,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Switch } from '@/components/ui/switch'; import { useTheme } from '@/theme/ThemeProvider'; import { useEnergyData } from '@/data/EnergyDataProvider'; +import { THEME_BG, THEME_SURFACE, THEME_BAR, ACCENTS } from '@/lib/colors'; import { useEnergySettings } from '@/hooks/useEnergySettings'; import { usePush } from '@/hooks/usePush'; import BrandLogo from '@/components/BrandLogo'; @@ -327,59 +328,57 @@ function HealthBadge({ ok }: { ok: boolean }) { // ── §1 Tema ────────────────────────────────────────────────────────────────── -/** Mini-preview de tema: dibuja una mini-UI con divs usando variables CSS reales. */ +/** Mitad del preview 'split': un lado del tema con sus tokens reales. */ +function PreviewBlock({ useLight }: { useLight: boolean }) { + const bgC = useLight ? THEME_BG.light : THEME_BG.dark; + const surfaceC = useLight ? THEME_SURFACE.light : THEME_SURFACE.dark; + const barC = useLight ? THEME_BAR.light : THEME_BAR.dark; + return ( +
+
+
+
+
+
+
+
+
+
+ ); +} + +/** Mini-preview de tema: dibuja una mini-UI con divs usando los tokens reales. */ function ThemePreview({ variant }: { variant: 'dark' | 'light' | 'split' }) { - const bg = variant === 'dark' ? 'bg-[#080D1A]' : variant === 'light' ? 'bg-[#F4F6FA]' : ''; - const surface = variant === 'dark' ? 'bg-[#0F1729]' : variant === 'light' ? 'bg-[#FFFFFF]' : ''; + const { bg, surface, bar } = { + dark: { bg: THEME_BG.dark, surface: THEME_SURFACE.dark, bar: THEME_BAR.dark }, + light: { bg: THEME_BG.light, surface: THEME_SURFACE.light, bar: THEME_BAR.light }, + }[variant === 'split' ? 'dark' : variant]; const accent = 'bg-brand'; + if (variant === 'split') { return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ +
); } return ( -
-
+
+
-
+
-
+
); } -/** Acenos disponibles (triplete RGB para --accent-rgb; null = naranja por defecto). */ -const ACCENTS = [ - { id: 'naranja', rgb: null, hex: '#F59E0B' }, - { id: 'verde', rgb: '34 197 94', hex: '#22C55E' }, - { id: 'azul', rgb: '59 130 246', hex: '#3B82F6' }, - { id: 'violeta', rgb: '139 92 246', hex: '#8B5CF6' }, -] as const; +/** Acenos disponibles (paleta en lib/colors.ts). */ function ThemeSection() { const { mode, setMode, density, setDensity, reduceMotion, setReduceMotion, accent, setAccent } = useTheme();