Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "helios",
"private": true,
"version": "0.5.0",
"version": "0.6.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
98 changes: 98 additions & 0 deletions app/src/components/UpdateRibbon.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="status"
className="mb-4 flex items-center gap-2.5 rounded-xl border border-amber-500/35 bg-amber-500/10 px-3.5 py-2.5 text-[13px] font-semibold text-amber-500"
>
<RefreshCw className="h-4 w-4 shrink-0" />
<span>{t('ajustes.about.updateAvailable', { version: latestVersion })}</span>
<a
href={`${REPO_URL}/releases`}
target="_blank"
rel="noreferrer"
className="ml-auto flex h-8 shrink-0 items-center rounded-lg border border-amber-500/40 px-3 text-xs font-medium text-amber-500 transition-colors hover:bg-amber-500/15"
>
{t('ajustes.about.viewRelease')}
</a>
</div>
);
}
2 changes: 2 additions & 0 deletions app/src/components/shell/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -422,6 +423,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</header>

<main className={cn('mx-auto max-w-[1440px] px-4 pb-24 pt-4 md:ml-16 md:px-6 md:pb-6 md:pt-6', lgMargin)}>
<UpdateRibbon />
{isDemo && <DemoBanner onExit={exitDemo} />}
{children}
<div className="hidden lg:block">
Expand Down
13 changes: 13 additions & 0 deletions app/src/lib/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
69 changes: 34 additions & 35 deletions app/src/pages/Ajustes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<div className="flex w-1/2 flex-col p-1.5" style={{ backgroundColor: bgC }}>
<div className="mb-1 h-1.5 w-full rounded" style={{ backgroundColor: barC }} />
<div className="flex flex-1 gap-1">
<div className="w-1/4 rounded" style={{ backgroundColor: barC }} />
<div className="flex flex-1 flex-col gap-1">
<div className="h-2 w-3/4 rounded bg-brand/60" />
<div className="h-4 flex-1 rounded" style={{ backgroundColor: surfaceC }} />
</div>
</div>
</div>
);
}

/** 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 (
<div className="flex h-[72px] w-full overflow-hidden rounded-lg border border-app">
<div className="flex w-1/2 flex-col bg-[#080D1A] p-1.5">
<div className="mb-1 h-1.5 w-full rounded bg-[#1E293B]" />
<div className="flex flex-1 gap-1">
<div className="w-1/4 rounded bg-[#1E293B]" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-2 w-3/4 rounded bg-brand/60" />
<div className="h-4 flex-1 rounded bg-[#1E293B]" />
</div>
</div>
</div>
<div className="flex w-1/2 flex-col bg-[#F4F6FA] p-1.5">
<div className="mb-1 h-1.5 w-full rounded bg-slate-200" />
<div className="flex flex-1 gap-1">
<div className="w-1/4 rounded bg-slate-200" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-2 w-3/4 rounded bg-brand/60" />
<div className="h-4 flex-1 rounded bg-slate-200" />
</div>
</div>
</div>
<PreviewBlock useLight={false} />
<PreviewBlock useLight />
</div>
);
}

return (
<div className={cn('flex h-[72px] w-full flex-col rounded-lg border border-app p-1.5', bg)}>
<div className={cn('mb-1 h-1.5 w-full rounded', surface === 'bg-[#FFFFFF]' ? 'bg-slate-200' : 'bg-[#1E293B]')} />
<div className="flex h-[72px] w-full flex-col rounded-lg border border-app p-1.5" style={{ backgroundColor: bg }}>
<div className="mb-1 h-1.5 w-full rounded" style={{ backgroundColor: bar }} />
<div className="flex flex-1 gap-1">
<div className={cn('w-1/4 rounded', surface === 'bg-[#FFFFFF]' ? 'bg-slate-200' : 'bg-[#1E293B]')} />
<div className="w-1/4 rounded" style={{ backgroundColor: bar }} />
<div className="flex flex-1 flex-col gap-1">
<div className={cn('h-2 w-3/4 rounded', accent)} />
<div className={cn('h-4 flex-1 rounded', surface === 'bg-[#FFFFFF]' ? 'bg-slate-200' : 'bg-[#1E293B]')} />
<div className="h-4 flex-1 rounded" style={{ backgroundColor: surface }} />
</div>
</div>
</div>
);
}

/** 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();
Expand Down Expand Up @@ -1642,7 +1641,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);
Expand Down
4 changes: 2 additions & 2 deletions server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "helios-server",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"type": "module",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion server/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading