439 lines
29 KiB
JavaScript
439 lines
29 KiB
JavaScript
function outdoorHistorySeries(devices, rows) {
|
|
const selectedIds = new Set((devices || []).map(device => device.id));
|
|
const handled = new Set();
|
|
const definitions = [];
|
|
|
|
(app.deviceGroups || []).forEach(group => {
|
|
if (!group.outdoor_temperature_device_id) return;
|
|
const members = (group.device_ids || []).filter(id => selectedIds.has(id));
|
|
if (!members.length) return;
|
|
const representative = members.includes(group.outdoor_temperature_device_id) ? group.outdoor_temperature_device_id : members[0];
|
|
if (!rows.some(row => row.device_id === representative && Number.isFinite(historyNumber(row.outdoor_temperature)))) return;
|
|
members.forEach(id => handled.add(id));
|
|
definitions.push({ representative, label: `${group.name} · ${tr('history.sharedOutdoor')}` });
|
|
});
|
|
|
|
(devices || []).filter(device => !handled.has(device.id)).forEach(device => {
|
|
if (rows.some(row => row.device_id === device.id && Number.isFinite(historyNumber(row.outdoor_temperature)))) {
|
|
definitions.push({ representative: device.id, label: device.name });
|
|
}
|
|
});
|
|
|
|
return definitions.map((item, index) => ({
|
|
label: item.label,
|
|
color: historySeriesColor(index),
|
|
value: row => row.device_id === item.representative ? historyNumber(row.outdoor_temperature) : NaN,
|
|
}));
|
|
}
|
|
|
|
async function openOutdoorHistory() {
|
|
const host = $('#outdoorHistoryChartHost');
|
|
const current = $('#outdoorHistoryCurrent');
|
|
if (!host || !current) return;
|
|
current.textContent = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
|
|
host.innerHTML = `<div class="panel outdoor-history-loading">${esc(tr('common.loading'))}</div>`;
|
|
$$('#outdoorHistoryDialog [data-history-route]').forEach(link => {
|
|
const hours = link.dataset.historyHours;
|
|
link.href = withBase(`/history/overview${hours ? `?hours=${encodeURIComponent(hours)}` : ''}`);
|
|
});
|
|
openDialog('outdoorHistoryDialog');
|
|
try {
|
|
const data = await api('/api/history?scope=overview&hours=24&limit=20000');
|
|
const deviceRows = data.devices || [];
|
|
const outdoorDeviceSeries = outdoorHistorySeries(app.devices, deviceRows).map(item => ({
|
|
...item,
|
|
label: item.label.includes(' · ') ? item.label : `${item.label} · ${tr('history.greeOutdoor')}`,
|
|
}));
|
|
const sensorRows = (data.sensors || []).filter(row => row.kind === 'outdoor');
|
|
const outdoorEntities = [...new Set(sensorRows.map(row => row.entity_id))];
|
|
const outdoorHaSeries = outdoorEntities.map((entity, index) => ({
|
|
label: `HA · ${haSensorLabel(entity)}`,
|
|
color: historySeriesColor(index + outdoorDeviceSeries.length),
|
|
dash: [6, 4],
|
|
value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN,
|
|
}));
|
|
const series = [...outdoorDeviceSeries, ...outdoorHaSeries];
|
|
const rows = [...deviceRows, ...sensorRows];
|
|
host.innerHTML = historyChartMarkup('outdoorHistoryModalChart', tr('history.allOutdoor'), tr('house.outdoorHistoryHint'));
|
|
drawLineChart($('#outdoorHistoryModalChart'), series, rows, { height: 350 });
|
|
renderLegend($('#outdoorHistoryModalChartLegend'), series);
|
|
} catch (error) {
|
|
host.innerHTML = `<div class="empty"><strong>${esc(tr('history.noData'))}</strong><span>${esc(error.message)}</span></div>`;
|
|
toast(error.message, true);
|
|
}
|
|
}
|
|
|
|
function renderHistorySummary() {
|
|
const host = $('#historySummary'); if (!host) return;
|
|
if (app.historyTab === 'energy') { renderEnergyHistorySummary(); return; }
|
|
if (app.historyTab === 'network') { renderNetworkHistorySummary(); return; }
|
|
const deviceRows = app.historyData.devices, zoneRows = app.historyData.zones, sensorRows = app.historyData.sensors;
|
|
const cards = [
|
|
[tr('history.deviceSamples'), app.historyCounts.devices ?? deviceRows.length, tr('history.greeHistory')],
|
|
[tr('history.zoneSamples'), app.historyCounts.zones ?? zoneRows.length, tr('history.zoneHistory')],
|
|
[tr('history.haSamples'), app.historyCounts.ha ?? sensorRows.length, tr('history.haHistory')],
|
|
];
|
|
host.innerHTML = cards.map(([label, value, detail]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${esc(value)}</strong><span>${esc(detail)}</span></div>`).join('');
|
|
}
|
|
|
|
function renderOverviewHistory() {
|
|
const host = $('#historyCharts');
|
|
host.innerHTML = historyChartMarkup('overviewIndoorChart', tr('history.allGreeIndoor'), tr('history.allGreeIndoorHint'))
|
|
+ historyChartMarkup('overviewOutdoorChart', tr('history.allOutdoor'), tr('history.allOutdoorHint'))
|
|
+ historyChartMarkup('overviewZonesChart', tr('history.allZoneControl'), tr('history.allZoneControlHint'));
|
|
|
|
const deviceRows = app.historyData.devices;
|
|
const indoorSeries = app.devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => !row.zone_id && !row.entity_id && row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN }));
|
|
drawLineChart($('#overviewIndoorChart'), indoorSeries, deviceRows, { height: 360 }); renderLegend($('#overviewIndoorChartLegend'), indoorSeries);
|
|
|
|
const outdoorDeviceSeries = outdoorHistorySeries(app.devices, deviceRows).map(item => ({ ...item, label: item.label.includes(' · ') ? item.label : `${item.label} · ${tr('history.greeOutdoor')}` }));
|
|
const outdoorEntities = [...new Set(app.historyData.sensors.filter(row => row.kind === 'outdoor').map(row => row.entity_id))];
|
|
const outdoorHaSeries = outdoorEntities.map((entity, index) => ({ label: `HA · ${haSensorLabel(entity)}`, color: historySeriesColor(index + outdoorDeviceSeries.length), dash: [6, 4], value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN }));
|
|
const outdoorRows = [...deviceRows, ...app.historyData.sensors.filter(row => row.kind === 'outdoor')];
|
|
const outdoorSeries = [...outdoorDeviceSeries, ...outdoorHaSeries];
|
|
drawLineChart($('#overviewOutdoorChart'), outdoorSeries, outdoorRows, { height: 320 }); renderLegend($('#overviewOutdoorChartLegend'), outdoorSeries);
|
|
|
|
const zoneRows = app.historyData.zones;
|
|
const zoneSeries = app.zones.map((zone, index) => ({ label: zone.name, color: historySeriesColor(index), value: row => row.zone_id === zone.id ? historyNumber(row.control_temperature) : NaN }));
|
|
drawLineChart($('#overviewZonesChart'), zoneSeries, zoneRows, { height: 340 }); renderLegend($('#overviewZonesChartLegend'), zoneSeries);
|
|
}
|
|
|
|
function renderZoneHistory() {
|
|
const selected = app.historyZone;
|
|
const rows = selected === 'all' ? app.historyData.zones : app.historyData.zones.filter(row => row.zone_id === selected);
|
|
const host = $('#historyCharts');
|
|
host.innerHTML = historyChartMarkup('zoneTemperatureChart', tr('history.temperatureOverview'), tr('history.temperatureOverviewHint')) + historyChartMarkup('zoneOperationChart', tr('history.operationOverview'), tr('history.operationOverviewHint'), true);
|
|
if (selected === 'all') {
|
|
const series = app.zones.map((zone, index) => ({ label: zone.name, color: historySeriesColor(index), value: row => row.zone_id === zone.id ? historyNumber(row.control_temperature) : NaN }));
|
|
drawLineChart($('#zoneTemperatureChart'), series, rows, { height: 360 }); renderLegend($('#zoneTemperatureChartLegend'), series);
|
|
const targets = app.zones.map((zone, index) => ({ label: `${zone.name} · ${tr('history.targetShort')}`, color: historySeriesColor(index), dash: [5, 4], value: row => row.zone_id === zone.id ? historyNumber(row.target_temperature) : NaN }));
|
|
drawLineChart($('#zoneOperationChart'), targets, rows, { height: 260 }); renderLegend($('#zoneOperationChartLegend'), targets);
|
|
return;
|
|
}
|
|
const temperatureSeries = [
|
|
{ label: tr('history.greeSensor'), color: cssColor('--accent', '#3ecf8e'), value: row => historyNumber(row.gree_temperature) },
|
|
{ label: tr('history.roomSensor'), color: cssColor('--info', '#60a5fa'), value: row => historyNumber(row.external_temperature) },
|
|
{ label: tr('history.controlTemperature'), color: cssColor('--teal', '#2dd4bf'), width: 2.8, value: row => historyNumber(row.control_temperature) },
|
|
{ label: tr('history.comfortTarget'), color: cssColor('--warning', '#f59e0b'), dash: [7, 5], value: row => historyNumber(row.target_temperature) },
|
|
{ label: tr('history.deviceSetpoint'), color: cssColor('--purple', '#a78bfa'), dash: [3, 4], value: row => historyNumber(row.device_setpoint) },
|
|
{ label: tr('history.outdoorTemperature'), color: cssColor('--muted-strong', '#9ca3af'), dash: [2, 5], value: row => historyNumber(row.outdoor_temperature) },
|
|
];
|
|
drawLineChart($('#zoneTemperatureChart'), temperatureSeries, rows, { height: 360 }); renderLegend($('#zoneTemperatureChartLegend'), temperatureSeries);
|
|
const operationSeries = [
|
|
{ label: tr('history.fanSpeed'), color: cssColor('--info', '#60a5fa'), step: true, value: row => historyNumber(row.fan_speed), tooltipValue: value => fanLabel(Math.round(value)) },
|
|
{ label: tr('history.demand'), color: cssColor('--accent', '#3ecf8e'), step: true, width: 2.4, value: row => row.demand ? 4.5 : 0.5, tooltipValue: (_, row) => row.demand ? tr('common.active') : tr('common.disabled') },
|
|
{ label: tr('history.power'), color: cssColor('--warning', '#f59e0b'), step: true, dash: [5, 4], value: row => row.power ? 3.5 : 0.5, tooltipValue: (_, row) => row.power ? tr('common.on') : tr('common.off') },
|
|
];
|
|
drawLineChart($('#zoneOperationChart'), operationSeries, rows, { height: 260, minValue: 0, maxValue: 5, binaryLabels: true }); renderLegend($('#zoneOperationChartLegend'), operationSeries);
|
|
}
|
|
|
|
function renderDeviceHistory() {
|
|
const selected = app.historyDevice;
|
|
const rows = selected === 'all' ? app.historyData.devices : app.historyData.devices.filter(row => row.device_id === selected);
|
|
const host = $('#historyCharts');
|
|
host.innerHTML = historyChartMarkup('deviceIndoorChart', tr('history.deviceIndoor'), tr('history.deviceIndoorHint')) + historyChartMarkup('deviceOutdoorChart', tr('history.deviceOutdoor'), tr('history.deviceOutdoorHint')) + historyChartMarkup('deviceTargetChart', tr('history.deviceTargets'), tr('history.deviceTargetsHint'), true);
|
|
const devices = selected === 'all' ? app.devices : app.devices.filter(device => device.id === selected);
|
|
const indoor = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN }));
|
|
const outdoor = outdoorHistorySeries(devices, rows);
|
|
const targets = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), dash: [6, 4], value: row => row.device_id === device.id ? historyNumber(row.target_temperature) : NaN }));
|
|
drawLineChart($('#deviceIndoorChart'), indoor, rows, { height: 350 }); renderLegend($('#deviceIndoorChartLegend'), indoor);
|
|
drawLineChart($('#deviceOutdoorChart'), outdoor, rows, { height: 310 }); renderLegend($('#deviceOutdoorChartLegend'), outdoor);
|
|
drawLineChart($('#deviceTargetChart'), targets, rows, { height: 260 }); renderLegend($('#deviceTargetChartLegend'), targets);
|
|
}
|
|
|
|
function renderSensorHistory() {
|
|
const selected = app.historySensor;
|
|
const rows = selected === 'all' ? app.historyData.sensors : app.historyData.sensors.filter(row => row.entity_id === selected);
|
|
const entities = [...new Set(rows.map(row => row.entity_id))];
|
|
const host = $('#historyCharts');
|
|
host.innerHTML = historyChartMarkup('haSensorsChart', tr('history.haSensors'), tr('history.haSensorsHint'));
|
|
const series = entities.map((entity, index) => ({ label: haSensorLabel(entity), color: historySeriesColor(index), dash: rows.some(row => row.entity_id === entity && row.kind === 'outdoor') ? [6, 4] : [], value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN }));
|
|
drawLineChart($('#haSensorsChart'), series, rows, { height: 370 }); renderLegend($('#haSensorsChartLegend'), series);
|
|
}
|
|
|
|
function networkTargetRows() {
|
|
const selected = app.historyNetworkTarget || 'all';
|
|
return selected === 'all' ? (app.historyNetwork || []) : (app.historyNetwork || []).filter(row => row.target_id === selected);
|
|
}
|
|
|
|
function networkTargetLabel(id) {
|
|
return (app.historyNetworkTargets || []).find(target => target.id === id)?.name || id;
|
|
}
|
|
|
|
function renderNetworkHistorySummary() {
|
|
const host = $('#historySummary'); if (!host) return;
|
|
const rows = networkTargetRows();
|
|
const ids = [...new Set(rows.map(row => row.target_id))];
|
|
if (!ids.length) { host.innerHTML = ''; return; }
|
|
host.innerHTML = ids.map(id => {
|
|
const latest = rows.filter(row => row.target_id === id).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0];
|
|
const latency = Number.isFinite(Number(latest?.latency_ms)) ? `${Number(latest.latency_ms).toLocaleString(locale(), { maximumFractionDigits: 1 })} ms` : '—';
|
|
const jitter = Number.isFinite(Number(latest?.jitter_ms)) ? `${Number(latest.jitter_ms).toLocaleString(locale(), { maximumFractionDigits: 1 })} ms` : '—';
|
|
const loss = `${Number(latest?.packet_loss_pct || 0).toLocaleString(locale(), { maximumFractionDigits: 1 })}%`;
|
|
return `<div class="history-stat"><small>${esc(networkTargetLabel(id))}</small><strong>${esc(latency)}</strong><span>${esc(tr('history.networkJitter'))}: ${esc(jitter)} · ${esc(tr('history.networkLoss'))}: ${esc(loss)} · ${esc(latest?.successful_samples ?? 0)}/${esc(latest?.sample_count ?? 0)}</span></div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderNetworkHistory() {
|
|
const host = $('#historyCharts'); if (!host) return;
|
|
renderNetworkHistorySummary();
|
|
const rows = networkTargetRows();
|
|
if (!rows.length) {
|
|
host.innerHTML = `<div class="empty"><strong>${esc(tr('history.networkNoData'))}</strong><span>${esc(tr('history.networkNoDataHint'))}</span></div>`;
|
|
return;
|
|
}
|
|
const ids = [...new Set(rows.map(row => row.target_id))];
|
|
const latencySeries = [];
|
|
const lossSeries = [];
|
|
ids.forEach((id, index) => {
|
|
const label = networkTargetLabel(id);
|
|
const color = historySeriesColor(index);
|
|
latencySeries.push({
|
|
key: `${id}:latency`, label: `${label} · ${tr('history.networkLatency')}`, color,
|
|
value: row => row.target_id === id ? historyNumber(row.latency_ms) : NaN,
|
|
tooltipValue: value => `${Number(value).toLocaleString(locale(), { maximumFractionDigits: 2 })} ms`,
|
|
});
|
|
if (app.historyNetworkShowJitter !== false) latencySeries.push({
|
|
key: `${id}:jitter`, label: `${label} · ${tr('history.networkJitter')}`, color, dash: [7, 5],
|
|
value: row => row.target_id === id ? historyNumber(row.jitter_ms) : NaN,
|
|
tooltipValue: value => `${Number(value).toLocaleString(locale(), { maximumFractionDigits: 2 })} ms`,
|
|
});
|
|
lossSeries.push({
|
|
key: `${id}:loss`, label, color,
|
|
value: row => row.target_id === id ? historyNumber(row.packet_loss_pct) : NaN,
|
|
tooltipValue: value => `${Number(value).toLocaleString(locale(), { maximumFractionDigits: 2 })}%`,
|
|
});
|
|
});
|
|
host.innerHTML = historyChartMarkup('networkLatencyChart', tr('history.networkLatencyJitter'), tr('history.networkLatencyJitterHint'))
|
|
+ historyChartMarkup('networkLossChart', tr('history.networkLoss'), tr('history.networkLossHint'));
|
|
drawLineChart($('#networkLatencyChart'), latencySeries, rows, { height: 360, minValue: 0, axisSuffix: ' ms', tooltipUnit: ' ms', axisDigits: 0 });
|
|
renderLegend($('#networkLatencyChartLegend'), latencySeries);
|
|
drawLineChart($('#networkLossChart'), lossSeries, rows, { height: 300, minValue: 0, maxValue: 100, axisSuffix: '%', tooltipUnit: '%', axisDigits: 0 });
|
|
renderLegend($('#networkLossChartLegend'), lossSeries);
|
|
}
|
|
|
|
function customSeriesOptions() {
|
|
const items = [];
|
|
const groupedOutdoorIds = new Set();
|
|
(app.deviceGroups || []).forEach(group => {
|
|
if (!group.outdoor_temperature_device_id) return;
|
|
const representative = (group.device_ids || []).includes(group.outdoor_temperature_device_id)
|
|
? group.outdoor_temperature_device_id
|
|
: (group.device_ids || [])[0];
|
|
if (!representative) return;
|
|
(group.device_ids || []).forEach(id => groupedOutdoorIds.add(id));
|
|
items.push([`installation|${group.id}|outdoor`, `${group.name} · ${tr('history.sharedOutdoor')}`]);
|
|
});
|
|
app.devices.forEach(device => {
|
|
items.push([`device|${device.id}|indoor`, `${device.name} · ${tr('history.indoorTemperature')}`]);
|
|
if (!groupedOutdoorIds.has(device.id)) items.push([`device|${device.id}|outdoor`, `${device.name} · ${tr('history.greeOutdoor')}`]);
|
|
items.push([`device|${device.id}|target`, `${device.name} · ${tr('history.deviceTarget')}`]);
|
|
});
|
|
app.zones.forEach(zone => {
|
|
items.push([`zone|${zone.id}|control`, `${zone.name} · ${tr('history.controlTemperature')}`]);
|
|
items.push([`zone|${zone.id}|gree`, `${zone.name} · ${tr('history.greeSensor')}`]);
|
|
items.push([`zone|${zone.id}|external`, `${zone.name} · ${tr('history.roomSensor')}`]);
|
|
items.push([`zone|${zone.id}|target`, `${zone.name} · ${tr('history.comfortTarget')}`]);
|
|
items.push([`zone|${zone.id}|device_target`, `${zone.name} · ${tr('history.deviceSetpoint')}`]);
|
|
items.push([`zone|${zone.id}|outdoor`, `${zone.name} · ${tr('history.outdoorTemperature')}`]);
|
|
});
|
|
[...new Set(app.historyData.sensors.map(row => row.entity_id))].forEach(entity => items.push([`ha|${entity}|temperature`, `HA · ${haSensorLabel(entity)}`]));
|
|
return items;
|
|
}
|
|
|
|
function customSeriesDefinition(key, index = 0) {
|
|
const [kind, id, field] = String(key).split('|');
|
|
const color = historySeriesColor(index);
|
|
if (kind === 'device') {
|
|
const device = app.devices.find(item => item.id === id); if (!device) return null;
|
|
const labels = { indoor: tr('history.indoorTemperature'), outdoor: tr('history.greeOutdoor'), target: tr('history.deviceTarget') };
|
|
const fields = { indoor: 'indoor_temperature', outdoor: 'outdoor_temperature', target: 'target_temperature' };
|
|
return { key, label: `${device.name} · ${labels[field] || field}`, color, dash: field === 'target' ? [6, 4] : [], value: row => !row.zone_id && !row.entity_id && row.device_id === id ? historyNumber(row[fields[field]]) : NaN };
|
|
}
|
|
if (kind === 'installation' && field === 'outdoor') {
|
|
const group = (app.deviceGroups || []).find(item => item.id === id); if (!group) return null;
|
|
const representative = (group.device_ids || []).includes(group.outdoor_temperature_device_id)
|
|
? group.outdoor_temperature_device_id
|
|
: (group.device_ids || [])[0];
|
|
if (!representative) return null;
|
|
return { key, label: `${group.name} · ${tr('history.sharedOutdoor')}`, color, value: row => !row.zone_id && !row.entity_id && row.device_id === representative ? historyNumber(row.outdoor_temperature) : NaN };
|
|
}
|
|
if (kind === 'zone') {
|
|
const zone = app.zones.find(item => item.id === id); if (!zone) return null;
|
|
const fields = { control: ['control_temperature', tr('history.controlTemperature')], gree: ['gree_temperature', tr('history.greeSensor')], external: ['external_temperature', tr('history.roomSensor')], target: ['target_temperature', tr('history.comfortTarget')], device_target: ['device_setpoint', tr('history.deviceSetpoint')], outdoor: ['outdoor_temperature', tr('history.outdoorTemperature')] };
|
|
const info = fields[field]; if (!info) return null;
|
|
return { key, label: `${zone.name} · ${info[1]}`, color, dash: ['target', 'device_target', 'outdoor'].includes(field) ? [6, 4] : [], value: row => row.zone_id === id ? historyNumber(row[info[0]]) : NaN };
|
|
}
|
|
if (kind === 'ha') return { key, label: `HA · ${haSensorLabel(id)}`, color, dash: [3, 4], value: row => row.entity_id === id ? historyNumber(row.temperature) : NaN };
|
|
return null;
|
|
}
|
|
|
|
function persistSavedCharts() {
|
|
localStorage.setItem('gree_controller_saved_charts', JSON.stringify(app.savedCharts.slice(0, 30)));
|
|
}
|
|
|
|
function renderCustomBuilder() {
|
|
const host = $('#historyCustomBuilder'); if (!host) return;
|
|
if (app.historyTab !== 'custom') { host.innerHTML = ''; host.classList.remove('active'); return; }
|
|
host.classList.add('active');
|
|
const options = customSeriesOptions();
|
|
const selected = app.customChartSeries.map((key, index) => customSeriesDefinition(key, index)).filter(Boolean);
|
|
host.innerHTML = `<div class="panel custom-chart-panel"><div class="chart-title-row"><div><h3>${esc(tr('history.customTitle'))}</h3><p>${esc(tr('history.customDescription'))}</p></div></div>
|
|
<div class="custom-chart-add"><select id="customSeriesSelect">${options.map(([value, label]) => `<option value="${esc(value)}">${esc(label)}</option>`).join('')}</select><button class="secondary" data-history-action="add-series">${esc(tr('history.addSeries'))}</button></div>
|
|
<div class="custom-series-list">${selected.length ? selected.map((item, index) => `<span class="custom-series-chip"><i style="--chip-color:${esc(item.color)}"></i>${esc(item.label)}<button data-history-action="remove-series" data-index="${index}" aria-label="${esc(tr('actions.remove'))}">${uiIcon('close')}</button></span>`).join('') : `<span class="field-note">${esc(tr('history.noCustomSeries'))}</span>`}</div>
|
|
<div class="custom-chart-save"><input id="customChartName" placeholder="${esc(tr('history.chartName'))}"><button class="secondary" data-history-action="save-chart">${esc(tr('actions.save'))}</button><button class="primary" data-history-action="copy-chart-link">${esc(tr('history.copyLink'))}</button></div>
|
|
<div class="saved-chart-list">${app.savedCharts.length ? app.savedCharts.map(item => `<div class="saved-chart-row"><button data-history-action="load-chart" data-id="${esc(item.id)}"><strong>${esc(item.name)}</strong><small>${esc(item.series.length)} ${esc(tr('history.series'))}</small></button><button class="danger" data-history-action="delete-chart" data-id="${esc(item.id)}">${uiIcon('close')}</button></div>`).join('') : `<span class="field-note">${esc(tr('history.noSavedCharts'))}</span>`}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderCustomHistory() {
|
|
renderCustomBuilder();
|
|
const host = $('#historyCharts');
|
|
host.innerHTML = historyChartMarkup('customHistoryChart', tr('history.customChart'), tr('history.customChartHint'));
|
|
const series = app.customChartSeries.map((key, index) => customSeriesDefinition(key, index)).filter(Boolean);
|
|
const rows = [...app.historyData.devices, ...app.historyData.zones, ...app.historyData.sensors];
|
|
drawLineChart($('#customHistoryChart'), series, rows, { height: 390 }); renderLegend($('#customHistoryChartLegend'), series);
|
|
}
|
|
|
|
function renderHistoryPage() {
|
|
renderHistorySummary();
|
|
renderCustomBuilder();
|
|
if (app.historyTab === 'overview') renderOverviewHistory();
|
|
else if (app.historyTab === 'zones') renderZoneHistory();
|
|
else if (app.historyTab === 'devices') renderDeviceHistory();
|
|
else if (app.historyTab === 'energy') renderEnergyHistory();
|
|
else if (app.historyTab === 'network') renderNetworkHistory();
|
|
else if (app.historyTab === 'sensors') renderSensorHistory();
|
|
else renderCustomHistory();
|
|
}
|
|
|
|
function drawCurrentChartIfVisible() {
|
|
if (app.currentView === 'history') renderHistoryPage();
|
|
}
|
|
|
|
function publicCustomChartUrl(path) {
|
|
const configuredPublicBase = String(app.system?.public_chart_base_url || '').trim().replace(/\/+$/, '');
|
|
if (configuredPublicBase) return `${configuredPublicBase}${path}`;
|
|
if (!APP_BASE.startsWith('/api/hassio_ingress/')) return `${location.origin}${APP_BASE}${path}`;
|
|
const bind = String(app.system?.bind || '');
|
|
const port = bind.match(/:(\d+)$/)?.[1] || '8787';
|
|
const rawHost = location.hostname || 'localhost';
|
|
const host = rawHost.includes(':') && !rawHost.startsWith('[') ? `[${rawHost}]` : rawHost;
|
|
const configuredBase = String(app.system?.base_path || '').trim();
|
|
const directBase = configuredBase === '/' ? '' : configuredBase.replace(/\/$/, '');
|
|
return `http://${host}:${port}${directBase}${path}`;
|
|
}
|
|
|
|
async function handleHistoryAction(button) {
|
|
const action = button.dataset.historyAction;
|
|
if (action === 'add-series') {
|
|
const value = $('#customSeriesSelect')?.value;
|
|
if (value && !app.customChartSeries.includes(value)) app.customChartSeries.push(value);
|
|
renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return;
|
|
}
|
|
if (action === 'remove-series') {
|
|
app.customChartSeries.splice(Number(button.dataset.index), 1);
|
|
renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return;
|
|
}
|
|
if (action === 'save-chart') {
|
|
if (!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
|
|
const name = $('#customChartName')?.value.trim() || tr('history.customChart');
|
|
const item = { id: `chart-${Date.now()}`, name, series: [...app.customChartSeries], hours: $('#historyHours')?.value || '24' };
|
|
app.savedCharts.unshift(item); persistSavedCharts(); renderCustomHistory(); toast(tr('history.chartSaved')); return;
|
|
}
|
|
if (action === 'load-chart') {
|
|
const item = app.savedCharts.find(entry => entry.id === button.dataset.id); if (!item) return;
|
|
app.customChartSeries = [...item.series]; if ($('#historyHours') && item.hours) $('#historyHours').value = String(item.hours);
|
|
renderCustomHistory(); updateBrowserUrl(currentHistoryPath()); return;
|
|
}
|
|
if (action === 'delete-chart') {
|
|
app.savedCharts = app.savedCharts.filter(entry => entry.id !== button.dataset.id); persistSavedCharts(); renderCustomHistory(); return;
|
|
}
|
|
if (action === 'copy-chart-link') {
|
|
if (!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
|
|
try {
|
|
const share = await api('/api/charts/custom/share', {
|
|
method: 'POST',
|
|
body: {
|
|
title: $('#customChartName')?.value.trim() || tr('history.customChart'),
|
|
series: [...app.customChartSeries],
|
|
hours: Number($('#historyHours')?.value || 24),
|
|
lang: app.language === 'pl' ? 'pl' : 'en',
|
|
},
|
|
});
|
|
const link = publicCustomChartUrl(share.path);
|
|
try { await navigator.clipboard.writeText(link); } catch (_) { const area = document.createElement('textarea'); area.value = link; document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); }
|
|
toast(tr('history.linkCopied'));
|
|
} catch (error) {
|
|
toast(error.message, true);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
|
|
async function loadEnergyHistory() {
|
|
const targets = normalizeEnergyHistoryTargets();
|
|
if (!targets.length || !app.historyEnergyTargets.length) {
|
|
app.historyEnergy = [];
|
|
return;
|
|
}
|
|
const hours = Number($('#historyHours')?.value || 24);
|
|
const days = Math.max(1, Math.ceil(hours / 24));
|
|
const selected = app.historyEnergyTargets.slice(0, 8);
|
|
app.historyEnergy = await Promise.all(selected.map(targetId => api(`/api/history/energy?target_id=${encodeURIComponent(targetId)}&interval=${encodeURIComponent(app.historyEnergyInterval)}&days=${days}&limit=100000&compare=${encodeURIComponent(app.historyEnergyCompare)}`)));
|
|
}
|
|
|
|
function renderEnergyHistorySummary() {
|
|
const host = $('#historySummary'); if (!host) return;
|
|
const data = (app.historyEnergy || []).filter(item => item && item.source !== 'none');
|
|
if (!data.length) { host.innerHTML = ''; return; }
|
|
if (data.length === 1) {
|
|
const item = data[0];
|
|
const summary = item.summary || {};
|
|
const rows = [
|
|
[tr('energy.today'), summary.today],
|
|
[tr('energy.yesterday'), summary.yesterday],
|
|
[tr('energy.currentMonth'), summary.current_month],
|
|
[tr('energy.previousMonth'), summary.previous_month],
|
|
[tr('energy.periodTotal'), summary.period_total],
|
|
];
|
|
if (item.comparison) rows.push([tr('energy.comparisonPeriod'), item.comparison.period_total]);
|
|
host.innerHTML = rows.map(([label, value]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${Number(value || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh</strong><span>${esc(item.target_name || '')} · ${esc(item.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}</span></div>`).join('');
|
|
return;
|
|
}
|
|
host.innerHTML = data.map(item => `<div class="history-stat"><small>${esc(item.target_name || item.target_id)}</small><strong>${Number(item.summary?.period_total || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh</strong><span>${esc(item.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}${item.comparison ? ` · ${esc(tr('energy.comparisonPeriod'))}: ${Number(item.comparison.period_total || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh` : ''}</span></div>`).join('');
|
|
}
|
|
|
|
function renderEnergyHistory() {
|
|
const host = $('#historyCharts'); if (!host) return;
|
|
const data = (app.historyEnergy || []).filter(item => item && item.source !== 'none');
|
|
renderEnergyHistorySummary();
|
|
if (!data.length) {
|
|
host.innerHTML = `<div class="empty"><strong>${esc(tr('energy.noData'))}</strong></div>`;
|
|
return;
|
|
}
|
|
const series = [];
|
|
data.forEach((item, index) => {
|
|
const color = historySeriesColor(index);
|
|
series.push({
|
|
key: `${item.target_id}:current`,
|
|
label: data.length === 1 ? `${item.target_name} · ${tr('energy.currentPeriod')}` : item.target_name,
|
|
color,
|
|
buckets: item.buckets || [],
|
|
});
|
|
if (app.historyEnergyCompare !== 'none' && item.comparison?.buckets?.length) {
|
|
series.push({
|
|
key: `${item.target_id}:comparison`,
|
|
label: `${item.target_name} · ${tr('energy.comparisonPeriod')}`,
|
|
color,
|
|
comparison: true,
|
|
buckets: item.comparison.buckets,
|
|
});
|
|
}
|
|
});
|
|
host.innerHTML = historyChartMarkup('energyConsumptionChart', tr('energy.title'), tr('energy.chartHint'));
|
|
drawEnergyBarChart($('#energyConsumptionChart'), series, { height: 360, interval: app.historyEnergyInterval });
|
|
}
|
|
|