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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [Unreleased]

### Änderungen
- Portal: vollbreites Dashboard-Layout (Klima & SmartHome oben, Pi-hole mit Donut, Dienste als kompakte Startleiste)

---

## [1.116.0] — 2026-07-03

### Features
Expand Down
149 changes: 92 additions & 57 deletions public/css/portal.css

Large diffs are not rendered by default.

143 changes: 90 additions & 53 deletions public/js/portal.js

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2195,6 +2195,8 @@
"portal.midea.turbo": "Turbo",
"portal.midea.eco": "Eco",
"portal.midea.outdoor": "Außen",
"portal.midea.warmer": "wärmer",
"portal.midea.cooler": "kälter",
"portal.smarthome.title": "Smart Home",
"portal.smarthome.power": "Ein/Aus",
"portal.smarthome.brightness": "Helligkeit",
Expand All @@ -2206,5 +2208,7 @@
"portal.smarthome.motion": "Bewegung",
"portal.smarthome.no_motion": "keine Bewegung",
"portal.smarthome.wet": "nass",
"portal.smarthome.dry": "trocken"
"portal.smarthome.dry": "trocken",
"portal.smarthome.on": "An",
"portal.smarthome.off": "Aus"
}
6 changes: 5 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2251,6 +2251,8 @@
"portal.midea.turbo": "Turbo",
"portal.midea.eco": "Eco",
"portal.midea.outdoor": "Outdoor",
"portal.midea.warmer": "warmer",
"portal.midea.cooler": "cooler",
"portal.smarthome.title": "Smart Home",
"portal.smarthome.power": "Power",
"portal.smarthome.brightness": "Brightness",
Expand All @@ -2262,5 +2264,7 @@
"portal.smarthome.motion": "Motion",
"portal.smarthome.no_motion": "No motion",
"portal.smarthome.wet": "Wet",
"portal.smarthome.dry": "Dry"
"portal.smarthome.dry": "Dry",
"portal.smarthome.on": "On",
"portal.smarthome.off": "Off"
}
110 changes: 55 additions & 55 deletions templates/portal/portal.njk
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
mideaTurbo: t('portal.midea.turbo'),
mideaEco: t('portal.midea.eco'),
mideaOutdoor: t('portal.midea.outdoor'),
mideaWarmer: t('portal.midea.warmer'),
mideaCooler: t('portal.midea.cooler'),
smarthomePower: t('portal.smarthome.power'),
smarthomeBrightness: t('portal.smarthome.brightness'),
smarthomeActivate: t('portal.smarthome.activate'),
Expand All @@ -68,7 +70,9 @@
smarthomeMotion: t('portal.smarthome.motion'),
smarthomeNoMotion: t('portal.smarthome.no_motion'),
smarthomeWet: t('portal.smarthome.wet'),
smarthomeDry: t('portal.smarthome.dry')
smarthomeDry: t('portal.smarthome.dry'),
smarthomeOn: t('portal.smarthome.on'),
smarthomeOff: t('portal.smarthome.off')
} | dump | safe }}</script>
<script src="/js/portal.js?v={{ appVersion }}" defer></script>
</head>
Expand Down Expand Up @@ -113,51 +117,36 @@
<p>{{ t('portal.greeting_sub') }}</p>
</section>

{% if widgets.device %}
<!-- device status strip -->
<div class="strip c-device">
<span id="deviceStatus"></span>
<span class="s"><span class="lbl">{{ t('portal.device.vpn_address') }}</span><span class="ip" id="deviceAddress"></span></span>
<span class="s"><span class="lbl">{{ t('portal.device.dns') }}</span><span class="ip" id="deviceDns"></span></span>
<span class="s"><span class="lbl">{{ t('portal.device.last_handshake') }}</span><span id="deviceHandshake"></span></span>
<span id="deviceKv"></span>
</div>
{% endif %}

<!-- widget grid -->
<main class="grid">

{% if widgets.device %}
<!-- device status -->
<section class="card c-device">
<h2><span class="ic"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="2" width="14" height="20" rx="3" stroke="currentColor" stroke-width="2"/><path d="M11 18h2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>{{ t('portal.device.title') }}</h2>
<div class="kv" id="deviceKv">
<div class="row"><span class="k">{{ t('portal.device.status') }}</span><span class="v" id="deviceStatus"></span></div>
<div class="row"><span class="k">{{ t('portal.device.last_handshake') }}</span><span class="v" id="deviceHandshake"></span></div>
<div class="row"><span class="k">{{ t('portal.device.vpn_address') }}</span><span class="v" id="deviceAddress"></span></div>
<div class="row"><span class="k">{{ t('portal.device.dns') }}</span><span class="v" id="deviceDns"></span></div>
</div>
<div class="xfer">
<div class="b down"><div class="lab"><svg width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M6 13l6 6 6-6" stroke="var(--teal)" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>{{ t('portal.device.received') }}</div><div class="num" id="deviceRx"></div></div>
<div class="b up"><div class="lab"><svg width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M12 19V5M6 11l6-6 6 6" stroke="var(--coral)" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>{{ t('portal.device.sent') }}</div><div class="num" id="deviceTx"></div></div>
</div>
</section>
{% endif %}

{% if widgets.traffic %}
<!-- traffic -->
<section class="card c-traffic">
<h2><span class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M4 19V5M4 19h16M8 16l3-5 3 3 4-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span>{{ t('portal.traffic.title') }}
<span class="legend"><i style="background:var(--teal)"></i>{{ t('portal.traffic.legend_rx') }}<i style="background:var(--coral);margin-left:8px"></i>{{ t('portal.traffic.legend_tx') }}</span>
<span class="seg" id="seg">
<button data-r="24h" class="on">{{ t('portal.traffic.range_24h') }}</button>
<button data-r="7d">{{ t('portal.traffic.range_7d') }}</button>
<button data-r="30d">{{ t('portal.traffic.range_30d') }}</button>
</span>
</h2>
<div class="chart" id="chart"></div>
<div class="traffic-foot">
<span class="t">{{ t('portal.traffic.total') }}: <b id="tTotal"></b></span>
<span class="t">{{ t('portal.traffic.avg_day') }}: <b id="tAvg"></b></span>
<span class="t" style="margin-left:auto">{{ t('portal.traffic.peak') }}: <b id="tPeak"></b></span>
</div>
{% if widgets.midea %}
<!-- Klimaanlage — eigene Geräte -->
<section class="card c-midea midea-widget">
<h2><span class="ic"><svg viewBox="0 0 24 24" fill="none"><rect x="2" y="4" width="20" height="10" rx="2" stroke="currentColor" stroke-width="2"/><path d="M6 18v1M10 18v2M14 18v2M18 18v1" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>{{ t('portal.midea.title') }}</h2>
<div id="midea-list" class="midea-list"></div>
<div class="midea-msg" id="mideaMsg" style="display:none"></div>
</section>
{% endif %}

{% if widgets.services %}
<!-- services -->
<section class="card c-services">
<h2><span class="ic"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="7" rx="2" stroke="currentColor" stroke-width="2"/><rect x="3" y="13" width="18" height="7" rx="2" stroke="currentColor" stroke-width="2"/><path d="M7 7.5h.01M7 16.5h.01" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg></span>{{ t('portal.services.title') }}<span class="sub">{{ t('portal.services.sub') }}</span></h2>
<div class="tiles" id="servicesTiles"></div>
{% if widgets.smarthome %}
<!-- Smart Home — eigene Geräte -->
<section class="card c-smarthome">
<div class="card-head"><h2>{{ t('portal.smarthome.title') }}</h2></div>
<div id="smarthome-list" class="sh-tiles"></div>
<div id="smarthome-sensors" class="sh-sensors" style="display:none"></div>
<div id="smarthomeMsg" class="c-sh-msg" style="display:none"></div>
</section>
{% endif %}

Expand All @@ -172,8 +161,10 @@
</span>
</h2>
<div class="pihole-body">
<div class="pihole-rate"><b id="piPct">–</b><span>%</span></div>
<div class="pihole-bar"><i id="piBar"></i></div>
<div class="donut">
<svg viewBox="0 0 120 120" width="132" height="132"><circle cx="60" cy="60" r="50" fill="none" stroke="var(--track)" stroke-width="13"/><circle id="piDonut" cx="60" cy="60" r="50" fill="none" stroke="var(--coral)" stroke-width="13" stroke-linecap="round"/></svg>
<div class="dc"><div><b id="piPct">–</b><span>%</span></div></div>
</div>
<div class="pihole-nums">
<div><b id="piTotal">–</b><span>{{ t('portal.pihole.total') }}</span></div>
<div><b id="piBlocked">–</b><span>{{ t('portal.pihole.blocked') }}</span></div>
Expand All @@ -187,22 +178,31 @@
</section>
{% endif %}

{% if widgets.midea %}
<!-- Klimaanlage — eigene Geräte -->
<section class="card c-midea midea-widget">
<h2><span class="ic"><svg viewBox="0 0 24 24" fill="none"><rect x="2" y="4" width="20" height="10" rx="2" stroke="currentColor" stroke-width="2"/><path d="M6 18v1M10 18v2M14 18v2M18 18v1" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>{{ t('portal.midea.title') }}</h2>
<div id="midea-list" class="midea-list"></div>
<div class="midea-msg" id="mideaMsg" style="display:none"></div>
{% if widgets.traffic %}
<!-- traffic -->
<section class="card c-traffic">
<h2><span class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M4 19V5M4 19h16M8 16l3-5 3 3 4-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span>{{ t('portal.traffic.title') }}
<span class="legend"><i style="background:var(--teal)"></i>{{ t('portal.traffic.legend_rx') }}<i style="background:var(--coral);margin-left:8px"></i>{{ t('portal.traffic.legend_tx') }}</span>
<span class="seg" id="seg">
<button data-r="24h" class="on">{{ t('portal.traffic.range_24h') }}</button>
<button data-r="7d">{{ t('portal.traffic.range_7d') }}</button>
<button data-r="30d">{{ t('portal.traffic.range_30d') }}</button>
</span>
</h2>
<div class="chart" id="chart"></div>
<div class="traffic-foot">
<span class="t">{{ t('portal.traffic.total') }}: <b id="tTotal"></b></span>
<span class="t">{{ t('portal.traffic.avg_day') }}: <b id="tAvg"></b></span>
<span class="t" style="margin-left:auto">{{ t('portal.traffic.peak') }}: <b id="tPeak"></b></span>
</div>
</section>
{% endif %}

{% if widgets.smarthome %}
<!-- Smart Home — eigene Geräte -->
<section class="card c-smarthome">
<div class="card-head"><h2>{{ t('portal.smarthome.title') }}</h2></div>
<div id="smarthome-list" class="c-sh-list"></div>
<div id="smarthome-sensors" class="c-sh-sensors" style="display:none"></div>
<div id="smarthomeMsg" class="c-sh-msg" style="display:none"></div>
{% if widgets.services %}
<!-- services -->
<section class="card c-services">
<h2><span class="ic"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="7" rx="2" stroke="currentColor" stroke-width="2"/><rect x="3" y="13" width="18" height="7" rx="2" stroke="currentColor" stroke-width="2"/><path d="M7 7.5h.01M7 16.5h.01" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg></span>{{ t('portal.services.title') }}<span class="sub">{{ t('portal.services.sub') }}</span></h2>
<div class="svc-strip" id="servicesTiles"></div>
</section>
{% endif %}

Expand Down
7 changes: 3 additions & 4 deletions tests/portal_css_smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,12 @@ test('portal.css contains dark and light theme token blocks', async () => {
assert.ok(res.text.includes('[data-theme="light"]'), 'missing light theme block');
});

test('portal.css contains the TP2a per-device DNS-protection widget, not the old donut styling', async () => {
test('portal.css contains the TP2a per-device DNS-protection widget, with the enriched donut widget', async () => {
const res = await supertest(app).get('/css/portal.css').expect(200);
// TP2a re-adds the per-device Pi-hole widget (the "DNS-Schutz" card) deliberately.
assert.ok(res.text.includes('.c-pihole'), 'missing .c-pihole — TP2a per-device DNS widget styles');
// It uses a block-rate bar, NOT the old donut/pi-wrap implementation, which stays removed.
assert.ok(!res.text.includes('.donut'), 'found .donut — old Pi-hole donut styling must not return');
assert.ok(!res.text.includes('.pi-wrap'), 'found .pi-wrap — old Pi-hole donut styling must not return');
// Task 2 replaces the bar with a block-rate donut.
assert.ok(res.text.includes('.donut'), 'missing .donut — enriched Pi-hole donut styling');
});

test('portal.css contains JS-state rules (moved from portal.js inline injector for CSP safety)', async () => {
Expand Down
62 changes: 62 additions & 0 deletions tests/portal_redesign_layout.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use strict';
const fs = require('fs');
const crypto = require('crypto');
process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
const { test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const supertest = require('supertest');
const { setup, teardown } = require('./helpers/setup');

let app;
beforeEach(async () => { await setup(); app = require('../src/app').createApp(); });
afterEach(teardown);

async function portalHtml() {
const res = await supertest(app).get('/portal').expect(200);
return res.text;
}

test('status strip keeps .c-device class (portal.js hydrateDevice guard) + device IDs', async () => {
const h = await portalHtml();
assert.match(h, /class="[^"]*\bstrip\b[^"]*"/, 'status .strip present');
assert.match(h, /class="[^"]*\bc-device\b[^"]*"/, '.c-device class retained for JS guard');
for (const id of ['deviceStatus', 'deviceAddress', 'deviceDns', 'deviceHandshake', 'deviceKv']) {
assert.ok(h.includes('id="' + id + '"'), 'kept id ' + id);
}
});

test('control-first order: Klima (c-midea) appears before Dienste (c-services)', async () => {
const h = await portalHtml();
assert.ok(h.indexOf('c-midea') < h.indexOf('c-services'), 'Klima before Dienste');
assert.ok(h.indexOf('c-smarthome') < h.indexOf('c-services'), 'SmartHome before Dienste');
});

test('portal.css uses full-width wrapper 1560', async () => {
const res = await supertest(app).get('/css/portal.css').expect(200);
assert.ok(res.text.includes('max-width:1560px') || res.text.includes('max-width: 1560px'), '.wrap widened to 1560');
});

test('pi-hole widget renders a donut svg with piDonut id and keeps scope IDs', async () => {
const h = await portalHtml();
assert.match(h, /class="[^"]*\bdonut\b[^"]*"/, '.donut present');
assert.ok(h.includes('id="piDonut"'), 'donut arc has id piDonut');
for (const id of ['piPct', 'piTotal', 'piBlocked', 'piAllowed', 'piAllowedWrap', 'piOwnerExtra', 'piholeSeg']) {
assert.ok(h.includes('id="' + id + '"') || h.includes(id), 'kept ' + id);
}
assert.match(h, /class="[^"]*\bpihole-widget\b[^"]*"/, '.pihole-widget retained');
});

test('smarthome renders toggle tiles + sensor row (css + a11y switch role)', async () => {
const res = await supertest(app).get('/css/portal.css').expect(200);
assert.ok(res.text.includes('.sh-tile'), '.sh-tile styling present');
assert.ok(res.text.includes('.toggle'), '.toggle styling present');
assert.ok(res.text.includes('.sh-sensors'), '.sh-sensors row present');
const js = fs.readFileSync('public/js/portal.js', 'utf8');
assert.ok(js.includes('role="switch"') || js.includes("role='switch'"), 'smart-home toggle exposes role=switch');
});

test('services render as compact launcher (svc-strip + svc styling in css)', async () => {
const res = await supertest(app).get('/css/portal.css').expect(200);
assert.ok(res.text.includes('.svc-strip'), '.svc-strip grid present');
assert.ok(res.text.includes('.svc{') || res.text.includes('.svc '), '.svc tile styling present');
});
Loading