deploy
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
.bc-page {
|
||||
--bc-accent: var(--brand-accent);
|
||||
--bc-accent-strong: var(--brand-accent-2);
|
||||
--bc-ink: var(--text);
|
||||
--bc-text: var(--text);
|
||||
--bc-muted: var(--muted);
|
||||
--bc-line: var(--line);
|
||||
--bc-panel: rgba(255, 255, 255, 0.78);
|
||||
--bc-panel-soft: rgba(248, 252, 252, 0.92);
|
||||
--bc-positive: #137333;
|
||||
--bc-negative: #c62828;
|
||||
color: var(--bc-text);
|
||||
}
|
||||
|
||||
.bc-page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bc-text {
|
||||
color: var(--bc-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bc-section-head {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bc-section-title {
|
||||
margin: 0;
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.bc-section-head p,
|
||||
.bc-section-copy {
|
||||
color: var(--bc-muted);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.bc-form-card,
|
||||
.bc-panel,
|
||||
.bc-stat,
|
||||
.bc-chart-card,
|
||||
.bc-position-row {
|
||||
border: 1px solid var(--bc-line);
|
||||
border-radius: 22px;
|
||||
background: var(--bc-panel);
|
||||
box-shadow: 0 10px 24px rgba(1, 22, 32, 0.06);
|
||||
}
|
||||
|
||||
.bc-form-card,
|
||||
.bc-panel,
|
||||
.bc-chart-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.bc-stat {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bc-field-label {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--bc-muted);
|
||||
}
|
||||
|
||||
.bc-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bc-button,
|
||||
.bc-tabs a,
|
||||
.bc-page button,
|
||||
.bc-page input,
|
||||
.bc-page select,
|
||||
.bc-page textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.bc-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
.bc-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bc-button--tab {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
color: var(--bc-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-button--tab-active {
|
||||
background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3));
|
||||
color: #fff7fb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-button--primary {
|
||||
background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3));
|
||||
color: #fff7fb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-button--secondary {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
color: var(--bc-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-button--ghost {
|
||||
background: color-mix(in srgb, var(--bc-accent) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--bc-accent) 34%, transparent);
|
||||
color: var(--bc-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bc-alert {
|
||||
padding: 16px 18px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.bc-alert--error {
|
||||
background: rgba(127, 29, 29, 0.28);
|
||||
border-color: rgba(252, 165, 165, 0.24);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.bc-alert--success {
|
||||
background: rgba(6, 78, 59, 0.28);
|
||||
border-color: rgba(134, 239, 172, 0.24);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.bc-toolbar,
|
||||
.bc-overview-grid,
|
||||
.bc-card-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bc-toolbar {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.bc-overview-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.bc-card-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.bc-stat-value {
|
||||
margin-top: 8px;
|
||||
font-size: 1.42rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-chart-shell {
|
||||
position: relative;
|
||||
min-height: 360px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bc-chart-svg {
|
||||
width: 100%;
|
||||
height: 340px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bc-chart-path {
|
||||
fill: none;
|
||||
stroke: var(--bc-accent);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 12px 24px color-mix(in srgb, var(--bc-accent) 18%, transparent));
|
||||
}
|
||||
|
||||
.bc-chart-area {
|
||||
fill: url(#bc-chart-fill);
|
||||
}
|
||||
|
||||
.bc-chart-grid line {
|
||||
stroke: rgba(16, 33, 43, 0.08);
|
||||
stroke-dasharray: 4 6;
|
||||
}
|
||||
|
||||
.bc-range-list {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bc-range-button {
|
||||
border: 1px solid color-mix(in srgb, var(--bc-accent) 26%, transparent);
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: var(--bc-text);
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: transform .18s ease, background .18s ease, border-color .18s ease;
|
||||
}
|
||||
|
||||
.bc-range-button:hover,
|
||||
.bc-range-button[aria-pressed="true"] {
|
||||
transform: translateY(-1px);
|
||||
background: color-mix(in srgb, var(--bc-accent) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--bc-accent) 42%, transparent);
|
||||
}
|
||||
|
||||
.bc-panel-fade {
|
||||
animation: bcPanelFade .35s ease;
|
||||
}
|
||||
|
||||
@keyframes bcPanelFade {
|
||||
from { opacity: 0; transform: translateY(8px) scale(.99); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.bc-position-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bc-position-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.8fr) repeat(4, minmax(96px, .72fr));
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.bc-performance {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bc-performance.is-positive {
|
||||
color: var(--bc-positive);
|
||||
}
|
||||
|
||||
.bc-performance.is-negative {
|
||||
color: var(--bc-negative);
|
||||
}
|
||||
|
||||
.bc-pill-soft {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bc-accent) 10%, transparent);
|
||||
color: var(--bc-text);
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.bc-table-shell {
|
||||
overflow: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--bc-line);
|
||||
}
|
||||
|
||||
.bc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.bc-table th,
|
||||
.bc-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.bc-table thead {
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.bc-page .setup-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bc-page input,
|
||||
.bc-page select,
|
||||
.bc-page textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--bc-line);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-strong);
|
||||
color: var(--bc-text);
|
||||
}
|
||||
|
||||
.bc-page input::placeholder,
|
||||
.bc-page textarea::placeholder {
|
||||
color: color-mix(in srgb, var(--bc-muted) 70%, transparent);
|
||||
}
|
||||
|
||||
.bc-page a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.bc-page .muted {
|
||||
color: var(--bc-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.bc-hero-top {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bc-position-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
(function () {
|
||||
const app = document.querySelector('[data-bc-home]');
|
||||
if (!app) return;
|
||||
|
||||
const chartShell = app.querySelector('[data-bc-chart]');
|
||||
const instrumentSelect = app.querySelector('[data-bc-instrument]');
|
||||
const instrumentNameNode = app.querySelector('[data-bc-instrument-name]');
|
||||
const instrumentMetaNode = app.querySelector('[data-bc-instrument-meta]');
|
||||
const rangeButtons = Array.from(app.querySelectorAll('[data-range]'));
|
||||
const statusNode = app.querySelector('[data-bc-chart-status]');
|
||||
const summaryNode = app.querySelector('[data-bc-chart-summary]');
|
||||
const endpoint = app.getAttribute('data-chart-endpoint') || '';
|
||||
const instrumentsScript = app.querySelector('[data-bc-instruments-json]');
|
||||
const instrumentMap = new Map();
|
||||
if (instrumentsScript?.textContent) {
|
||||
try {
|
||||
const items = JSON.parse(instrumentsScript.textContent);
|
||||
if (Array.isArray(items)) {
|
||||
items.forEach((item) => instrumentMap.set(String(item.instrument_id), item));
|
||||
}
|
||||
} catch (_error) {}
|
||||
}
|
||||
let activeRange = '1m';
|
||||
let currentPayload = null;
|
||||
|
||||
function pointsForRange(payload, range) {
|
||||
if (!payload) return [];
|
||||
const daily = payload.daily || [];
|
||||
const weekly = payload.weekly || [];
|
||||
const monthly = payload.monthly || [];
|
||||
switch (range) {
|
||||
case '1d': return daily.slice(-2);
|
||||
case '5d': return daily.slice(-5);
|
||||
case '1m': return daily.slice(-22);
|
||||
case '3m': return daily.slice(-66);
|
||||
case '6m': return weekly.slice(-26);
|
||||
case '1y': return weekly.slice(-52);
|
||||
case '5y': return monthly.slice(-60);
|
||||
default: return daily.slice(-22);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart(points) {
|
||||
if (!chartShell) return;
|
||||
chartShell.classList.remove('bc-panel-fade');
|
||||
void chartShell.offsetWidth;
|
||||
chartShell.classList.add('bc-panel-fade');
|
||||
|
||||
if (!points || points.length === 0) {
|
||||
chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const values = points.map((point) => Number(point.close || 0));
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const width = 920;
|
||||
const height = 340;
|
||||
const paddingX = 24;
|
||||
const paddingY = 28;
|
||||
const usableWidth = width - paddingX * 2;
|
||||
const usableHeight = height - paddingY * 2;
|
||||
const spread = max - min || 1;
|
||||
|
||||
const coords = points.map((point, index) => {
|
||||
const x = paddingX + (usableWidth * index / Math.max(points.length - 1, 1));
|
||||
const y = paddingY + usableHeight - ((Number(point.close || 0) - min) / spread) * usableHeight;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const path = coords.map((coord, index) => `${index === 0 ? 'M' : 'L'}${coord.x.toFixed(2)},${coord.y.toFixed(2)}`).join(' ');
|
||||
const area = `${path} L${coords[coords.length - 1].x.toFixed(2)},${height - paddingY} L${coords[0].x.toFixed(2)},${height - paddingY} Z`;
|
||||
const grid = [0, 1, 2, 3].map((step) => {
|
||||
const y = paddingY + (usableHeight * step / 3);
|
||||
return `<line x1="${paddingX}" y1="${y}" x2="${width - paddingX}" y2="${y}"></line>`;
|
||||
}).join('');
|
||||
|
||||
chartShell.innerHTML = `
|
||||
<svg class="bc-chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="bc-chart-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="rgba(94,234,212,0.32)"></stop>
|
||||
<stop offset="100%" stop-color="rgba(94,234,212,0.02)"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g class="bc-chart-grid">${grid}</g>
|
||||
<path class="bc-chart-area" d="${area}"></path>
|
||||
<path class="bc-chart-path" d="${path}"></path>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const first = values[0];
|
||||
const last = values[values.length - 1];
|
||||
const delta = last - first;
|
||||
const percent = first !== 0 ? (delta / first) * 100 : 0;
|
||||
if (summaryNode) {
|
||||
summaryNode.textContent = `${last.toFixed(2)} | ${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${percent.toFixed(2)}%)`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChart() {
|
||||
const instrumentId = instrumentSelect ? instrumentSelect.value : '';
|
||||
if (!instrumentId || !endpoint) {
|
||||
if (statusNode) statusNode.textContent = 'Keine Aktie fuer den Chart ausgewaehlt.';
|
||||
if (summaryNode) summaryNode.textContent = '-';
|
||||
if (chartShell) chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
|
||||
return;
|
||||
}
|
||||
if (statusNode) statusNode.textContent = 'Chartdaten werden geladen...';
|
||||
try {
|
||||
const response = await fetch(`${endpoint}${endpoint.includes('?') ? '&' : '?'}instrument_id=${encodeURIComponent(instrumentId)}`, { headers: { Accept: 'application/json' } });
|
||||
const payload = await response.json();
|
||||
if (!payload.ok) {
|
||||
throw new Error(payload.message || 'Chartdaten konnten nicht geladen werden.');
|
||||
}
|
||||
currentPayload = payload;
|
||||
renderChart(pointsForRange(payload, activeRange));
|
||||
if (statusNode) {
|
||||
const sourceLabel = payload.source_label || payload.source || 'Lokale Kurshistorie';
|
||||
const instrumentRef = payload.symbol || payload.isin || '';
|
||||
statusNode.textContent = instrumentRef
|
||||
? `Quelle: ${sourceLabel} | ${instrumentRef}`
|
||||
: `Quelle: ${sourceLabel}`;
|
||||
}
|
||||
} catch (error) {
|
||||
currentPayload = null;
|
||||
chartShell.innerHTML = `<div class="muted">${error.message}</div>`;
|
||||
if (statusNode) statusNode.textContent = 'Fehler beim Laden der Chartdaten.';
|
||||
}
|
||||
}
|
||||
|
||||
rangeButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
activeRange = button.getAttribute('data-range') || '1m';
|
||||
rangeButtons.forEach((item) => item.setAttribute('aria-pressed', item === button ? 'true' : 'false'));
|
||||
renderChart(pointsForRange(currentPayload, activeRange));
|
||||
});
|
||||
});
|
||||
|
||||
if (instrumentSelect) {
|
||||
instrumentSelect.addEventListener('change', () => {
|
||||
const meta = instrumentMap.get(String(instrumentSelect.value));
|
||||
if (meta) {
|
||||
if (instrumentNameNode) instrumentNameNode.textContent = meta.instrument_name || 'Keine Aktie ausgewaehlt';
|
||||
if (instrumentMetaNode) instrumentMetaNode.textContent = `${meta.symbol || ''} · ${meta.isin || '-'}`;
|
||||
}
|
||||
loadChart();
|
||||
});
|
||||
}
|
||||
|
||||
loadChart();
|
||||
})();
|
||||
@@ -0,0 +1,995 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\ModuleConfigException;
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Modules\\Boersenchecker\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
$moduleName = 'boersenchecker';
|
||||
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
|
||||
|
||||
$mm->registerFunction($moduleName, 'table', static function (string $name): string {
|
||||
$prefix = 'boersencheck_';
|
||||
$sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return $prefix . $sanitized;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): \PDO {
|
||||
$settings = modules()->settings($moduleName);
|
||||
$useSeparate = !empty($settings['use_separate_db']);
|
||||
|
||||
if ($useSeparate) {
|
||||
$module = modules()->get($moduleName);
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
return modules()->modulePdo($moduleName, $fallback);
|
||||
}
|
||||
|
||||
$base = app()->basePdo();
|
||||
if ($base instanceof \PDO) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
throw new ModuleConfigException(
|
||||
$moduleName,
|
||||
'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.'
|
||||
);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName): void {
|
||||
$pdo = module_fn($moduleName, 'pdo');
|
||||
$table = static fn (string $name): string => module_fn($moduleName, 'table', $name);
|
||||
$driver = strtolower((string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
|
||||
|
||||
$portfolioTable = $table('portfolios');
|
||||
$instrumentTable = $table('instruments');
|
||||
$positionTable = $table('positions');
|
||||
$quoteTable = $table('quotes');
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
isin VARCHAR(32) NULL,
|
||||
wkn VARCHAR(32) NULL,
|
||||
symbol VARCHAR(32) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
market VARCHAR(120) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
portfolio_id INTEGER NOT NULL,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
quantity NUMERIC(20,6) NOT NULL,
|
||||
purchase_price NUMERIC(20,8) NOT NULL,
|
||||
purchase_currency VARCHAR(10) NOT NULL,
|
||||
purchase_date DATE NOT NULL,
|
||||
fees NUMERIC(20,8) NULL,
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
price NUMERIC(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
quoted_at TIMESTAMP NOT NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)");
|
||||
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin) WHERE isin IS NOT NULL");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)");
|
||||
} elseif ($driver === 'mysql') {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY {$portfolioTable}_owner_idx (owner_sub)
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
isin VARCHAR(32) NULL,
|
||||
wkn VARCHAR(32) NULL,
|
||||
symbol VARCHAR(32) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
market VARCHAR(120) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY {$instrumentTable}_isin_uniq (isin),
|
||||
KEY {$instrumentTable}_symbol_idx (symbol)
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
portfolio_id INTEGER NOT NULL,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
quantity DECIMAL(20,6) NOT NULL,
|
||||
purchase_price DECIMAL(20,8) NOT NULL,
|
||||
purchase_currency VARCHAR(10) NOT NULL,
|
||||
purchase_date DATE NOT NULL,
|
||||
fees DECIMAL(20,8) NULL,
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY {$positionTable}_owner_idx (owner_sub),
|
||||
KEY {$positionTable}_portfolio_idx (portfolio_id),
|
||||
KEY {$positionTable}_instrument_idx (instrument_id)
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
price DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
quoted_at DATETIME NOT NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY {$quoteTable}_instrument_time_idx (instrument_id, quoted_at)
|
||||
)");
|
||||
} else {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
isin VARCHAR(32) NULL,
|
||||
wkn VARCHAR(32) NULL,
|
||||
symbol VARCHAR(32) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
|
||||
market VARCHAR(120) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_sub VARCHAR(190) NOT NULL,
|
||||
portfolio_id INTEGER NOT NULL,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
quantity DECIMAL(20,6) NOT NULL,
|
||||
purchase_price DECIMAL(20,8) NOT NULL,
|
||||
purchase_currency VARCHAR(10) NOT NULL,
|
||||
purchase_date DATE NOT NULL,
|
||||
fees DECIMAL(20,8) NULL,
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instrument_id INTEGER NOT NULL,
|
||||
price DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
quoted_at DATETIME NOT NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)");
|
||||
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)");
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_service', static function (): ?object {
|
||||
if (modules()->isEnabled('fx-rates') && modules()->hasFunction('fx-rates', 'service')) {
|
||||
try {
|
||||
return module_fn('fx-rates', 'service');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCurrency = 'EUR', float $maxAgeHours = 6.0): array {
|
||||
$service = module_fn('boersenchecker', 'fx_service');
|
||||
if (!$service || !method_exists($service, 'ensureFreshLatestRates')) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $service->ensureFreshLatestRates($maxAgeHours, strtoupper(trim($baseCurrency)) ?: 'EUR');
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => !empty($result['reused'])
|
||||
? 'Vorhandene FX-Daten weiterverwendet.'
|
||||
: 'FX-Daten aktualisiert.',
|
||||
'result' => $result,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_prepare_fetch', static function (
|
||||
string $baseCurrency = 'EUR',
|
||||
array $currencies = [],
|
||||
float $maxAgeHours = 6.0
|
||||
): array {
|
||||
$service = module_fn('boersenchecker', 'fx_service');
|
||||
if (!$service || !method_exists($service, 'ensureFreshLatestRates')) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.',
|
||||
];
|
||||
}
|
||||
|
||||
$baseCurrency = strtoupper(trim($baseCurrency)) ?: 'EUR';
|
||||
$currencies = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $code): string => strtoupper(trim((string) $code)),
|
||||
$currencies
|
||||
), static fn (string $code): bool => $code !== '')));
|
||||
|
||||
try {
|
||||
$result = $service->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies);
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => !empty($result['reused']) ? 'Vorhandene FX-Daten weiterverwendet.' : 'FX-Daten aktualisiert.',
|
||||
'result' => $result,
|
||||
'fetch_id' => is_numeric($result['fetch_id'] ?? null) ? (int) $result['fetch_id'] : null,
|
||||
'reused' => !empty($result['reused']),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_source_with_fetch_id', static function (string $source, ?int $fetchId = null): string {
|
||||
$source = trim($source) !== '' ? trim($source) : 'manual';
|
||||
if ($fetchId === null || $fetchId <= 0) {
|
||||
return preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source;
|
||||
}
|
||||
|
||||
$source = preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source;
|
||||
return $source . '|fx_fetch:' . $fetchId;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_extract_fetch_id', static function (?string $source): ?int {
|
||||
$source = trim((string) $source);
|
||||
if ($source === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\|fx_fetch:(\d+)$/', $source, $matches) === 1) {
|
||||
$fetchId = (int) ($matches[1] ?? 0);
|
||||
return $fetchId > 0 ? $fetchId : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_convert_with_fetch', static function (
|
||||
?float $amount,
|
||||
?string $fromCurrency,
|
||||
?string $toCurrency,
|
||||
?int $fetchId = null
|
||||
): ?float {
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$from = strtoupper(trim((string) $fromCurrency));
|
||||
$to = strtoupper(trim((string) $toCurrency));
|
||||
if ($from === '' || $to === '') {
|
||||
return null;
|
||||
}
|
||||
if ($from === $to) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
$service = module_fn('boersenchecker', 'fx_service');
|
||||
if (!$service) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
|
||||
if ($normalizedFetchId !== null && method_exists($service, 'snapshotByFetchId')) {
|
||||
try {
|
||||
$snapshot = $service->snapshotByFetchId($normalizedFetchId, null, [$from, $to]);
|
||||
if (is_array($snapshot)) {
|
||||
$base = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
$fromRate = $from === $base ? 1.0 : (is_numeric($rates[$from] ?? null) ? (float) $rates[$from] : null);
|
||||
$toRate = $to === $base ? 1.0 : (is_numeric($rates[$to] ?? null) ? (float) $rates[$to] : null);
|
||||
if ($fromRate !== null && $fromRate > 0 && $toRate !== null && $toRate > 0) {
|
||||
return $amount * ($toRate / $fromRate);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!method_exists($service, 'convert')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$value = $service->convert($amount, $from, $to);
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_request', static function (
|
||||
string $functionName,
|
||||
array $params = []
|
||||
): array {
|
||||
$settings = modules()->settings('boersenchecker');
|
||||
$apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? ''));
|
||||
$timeout = (int) (($settings['alpha_vantage_timeout_sec'] ?? null) ?: 12);
|
||||
$timeout = $timeout > 0 ? $timeout : 12;
|
||||
|
||||
if ($apiKey === '') {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:error',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'params' => $params,
|
||||
],
|
||||
'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.',
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.',
|
||||
];
|
||||
}
|
||||
|
||||
$url = 'https://www.alphavantage.co/query?' . http_build_query(array_merge([
|
||||
'function' => $functionName,
|
||||
'apikey' => $apiKey,
|
||||
], $params), '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
$responseBody = null;
|
||||
$httpCode = 0;
|
||||
$curlError = '';
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
if ($ch !== false) {
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => min(5, $timeout),
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
$responseBody = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_string($responseBody) || $responseBody === '') {
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => $timeout,
|
||||
'header' => "Accept: application/json\r\n",
|
||||
],
|
||||
]);
|
||||
$responseBody = @file_get_contents($url, false, $context);
|
||||
}
|
||||
|
||||
if (!is_string($responseBody) || $responseBody === '') {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:error',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'url' => $url,
|
||||
'params' => $params,
|
||||
],
|
||||
'response' => [
|
||||
'http_code' => $httpCode,
|
||||
'curl_error' => $curlError,
|
||||
'body' => null,
|
||||
],
|
||||
'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.',
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.'
|
||||
. ($curlError !== '' ? ' ' . $curlError : '')
|
||||
. ($httpCode > 0 ? ' HTTP ' . $httpCode : ''),
|
||||
];
|
||||
}
|
||||
|
||||
$decoded = json_decode($responseBody, true);
|
||||
if (!is_array($decoded)) {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:error',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'url' => $url,
|
||||
'params' => $params,
|
||||
],
|
||||
'response' => [
|
||||
'http_code' => $httpCode,
|
||||
'body_preview' => substr($responseBody, 0, 4000),
|
||||
],
|
||||
'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.',
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.',
|
||||
'raw_body' => $responseBody,
|
||||
];
|
||||
}
|
||||
|
||||
foreach (['Error Message', 'Information', 'Note'] as $errorKey) {
|
||||
if (isset($decoded[$errorKey]) && is_string($decoded[$errorKey]) && trim($decoded[$errorKey]) !== '') {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:error',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'url' => $url,
|
||||
'params' => $params,
|
||||
],
|
||||
'response' => [
|
||||
'http_code' => $httpCode,
|
||||
'body' => $decoded,
|
||||
],
|
||||
'message' => trim((string) $decoded[$errorKey]),
|
||||
]);
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => trim((string) $decoded[$errorKey]),
|
||||
'raw' => $decoded,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Alpha Vantage Request',
|
||||
'type' => 'api:response',
|
||||
'request' => [
|
||||
'function' => $functionName,
|
||||
'url' => $url,
|
||||
'params' => $params,
|
||||
],
|
||||
'response' => [
|
||||
'http_code' => $httpCode,
|
||||
'body' => $decoded,
|
||||
],
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'data' => $decoded,
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'display_timezone', static function (): \DateTimeZone {
|
||||
return new \DateTimeZone(nexus_display_timezone_name());
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'normalize_market_timestamp_utc', static function (mixed $value): string {
|
||||
if (is_numeric($value)) {
|
||||
return gmdate('Y-m-d H:i:s', (int) $value);
|
||||
}
|
||||
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
try {
|
||||
$date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC'));
|
||||
return $date->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
$timestamp = strtotime($raw);
|
||||
return $timestamp !== false ? gmdate('Y-m-d H:i:s', $timestamp) : gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'format_datetime_for_display', static function (
|
||||
?string $value,
|
||||
?string $source = null,
|
||||
string $format = 'Y-m-d H:i:s'
|
||||
): string {
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$displayTimezone = new \DateTimeZone(nexus_display_timezone_name());
|
||||
$source = trim((string) $source);
|
||||
|
||||
if (str_starts_with($source, 'bavest:') || str_starts_with($source, 'alphavantage:')) {
|
||||
$date = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new \DateTimeZone('UTC'));
|
||||
if (!$date instanceof \DateTimeImmutable) {
|
||||
try {
|
||||
$date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC'));
|
||||
} catch (\Throwable) {
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
return $date->setTimezone($displayTimezone)->format($format);
|
||||
}
|
||||
|
||||
if (preg_match('/(Z|[+\-]\d{2}:\d{2})$/', $raw) === 1) {
|
||||
try {
|
||||
return (new \DateTimeImmutable($raw))->setTimezone($displayTimezone)->format($format);
|
||||
} catch (\Throwable) {
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
|
||||
return str_replace('T', ' ', $raw);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'local_now_input_value', static function (): string {
|
||||
return (new \DateTimeImmutable('now', new \DateTimeZone(nexus_display_timezone_name())))->format('Y-m-d\TH:i');
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_extract_global_quote', static function (array $entry): ?array {
|
||||
$quote = is_array($entry['Global Quote'] ?? null) ? $entry['Global Quote'] : $entry;
|
||||
$price = $quote['05. price'] ?? null;
|
||||
if (!is_numeric($price)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'symbol' => trim((string) ($quote['01. symbol'] ?? '')),
|
||||
'price' => (float) $price,
|
||||
'currency' => '',
|
||||
'fetched_at' => gmdate('Y-m-d H:i:s'),
|
||||
'market_date' => trim((string) ($quote['07. latest trading day'] ?? '')),
|
||||
'source' => 'alphavantage:global_quote',
|
||||
'raw' => $quote,
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quote_by_symbol', static function (string $symbol): array {
|
||||
$symbol = strtoupper(trim($symbol));
|
||||
if ($symbol === '') {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Kein Symbol hinterlegt.',
|
||||
];
|
||||
}
|
||||
|
||||
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'GLOBAL_QUOTE', [
|
||||
'symbol' => $symbol,
|
||||
]);
|
||||
if (empty($response['ok'])) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$quote = module_fn('boersenchecker', 'alpha_vantage_extract_global_quote', (array) ($response['data'] ?? []));
|
||||
if (!is_array($quote)) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Alpha Vantage lieferte keinen Preis fuer das Symbol ' . $symbol . '.',
|
||||
];
|
||||
}
|
||||
|
||||
return ['ok' => true] + $quote;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quotes', static function (array $instruments): array {
|
||||
$quotes = [];
|
||||
$errors = [];
|
||||
foreach ($instruments as $instrument) {
|
||||
if (!is_array($instrument)) {
|
||||
continue;
|
||||
}
|
||||
$instrumentId = (int) ($instrument['id'] ?? 0);
|
||||
$symbol = strtoupper(trim((string) ($instrument['symbol'] ?? '')));
|
||||
if ($instrumentId <= 0 || $symbol === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
|
||||
if (empty($result['ok'])) {
|
||||
$errors[] = $symbol . ': ' . (string) ($result['message'] ?? 'API-Abruf fehlgeschlagen.');
|
||||
continue;
|
||||
}
|
||||
|
||||
$quotes[$instrumentId] = $result + ['instrument_id' => $instrumentId];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'quotes' => $quotes,
|
||||
'errors' => $errors,
|
||||
'message' => count($quotes) . ' Kurse ueber Alpha Vantage geladen.',
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'scheduled_refresh_quotes', static function (array $context = []): array {
|
||||
$pdo = module_fn('boersenchecker', 'pdo');
|
||||
$settings = modules()->settings('boersenchecker');
|
||||
$instrumentTable = module_fn('boersenchecker', 'table', 'instruments');
|
||||
$positionTable = module_fn('boersenchecker', 'table', 'positions');
|
||||
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
|
||||
|
||||
$defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
|
||||
$minIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
|
||||
if ($minIntervalMinutes <= 0) {
|
||||
$minIntervalMinutes = 60;
|
||||
}
|
||||
|
||||
$stmt = $pdo->query(
|
||||
'SELECT DISTINCT
|
||||
i.id,
|
||||
i.name,
|
||||
i.symbol,
|
||||
i.quote_currency
|
||||
FROM ' . $positionTable . ' p
|
||||
INNER JOIN ' . $instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE i.symbol IS NOT NULL
|
||||
AND i.symbol <> \'\'
|
||||
ORDER BY i.name ASC'
|
||||
);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
if ($rows === []) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Kein automatischer Kursabruf: keine Aktien mit Symbol vorhanden.',
|
||||
];
|
||||
}
|
||||
|
||||
$instrumentIds = array_values(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $rows));
|
||||
$instrumentIds = array_values(array_filter($instrumentIds, static fn (int $id): bool => $id > 0));
|
||||
$latestQuotes = [];
|
||||
if ($instrumentIds !== []) {
|
||||
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
|
||||
$latestStmt = $pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $quoteTable . '
|
||||
WHERE instrument_id IN (' . $placeholders . ')
|
||||
AND source LIKE ?
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC'
|
||||
);
|
||||
$latestStmt->execute([...$instrumentIds, 'alphavantage:%']);
|
||||
foreach ($latestStmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||
$instrumentId = (int) ($row['instrument_id'] ?? 0);
|
||||
if ($instrumentId > 0 && !isset($latestQuotes[$instrumentId])) {
|
||||
$latestQuotes[$instrumentId] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$reused = 0;
|
||||
$candidates = [];
|
||||
foreach ($rows as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$latest = $latestQuotes[$instrumentId] ?? null;
|
||||
$latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false;
|
||||
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($minIntervalMinutes * 60)) {
|
||||
$reused++;
|
||||
continue;
|
||||
}
|
||||
$candidates[] = $row;
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Automatischer Kursabruf uebersprungen: alle Kurse liegen noch innerhalb des Mindestabstands.',
|
||||
];
|
||||
}
|
||||
|
||||
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => strtoupper(trim((string) ($row['quote_currency'] ?? ''))),
|
||||
$candidates
|
||||
), static fn (string $code): bool => $code !== '')));
|
||||
$fxResult = module_fn('boersenchecker', 'fx_prepare_fetch', $defaultReportCurrency, $quoteCurrencies, (float) (($settings['fx_max_age_hours'] ?? null) ?: 6));
|
||||
if (empty($fxResult['ok'])) {
|
||||
return $fxResult;
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
|
||||
$bulkResult = module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $candidates);
|
||||
if (empty($bulkResult['ok'])) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => (string) ($bulkResult['message'] ?? 'Automatischer Alpha-Vantage-Abruf fehlgeschlagen.'),
|
||||
];
|
||||
}
|
||||
|
||||
$quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
|
||||
$errors = is_array($bulkResult['errors'] ?? null) ? $bulkResult['errors'] : [];
|
||||
$updated = 0;
|
||||
foreach ($candidates as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$quote = $quotes[$instrumentId] ?? null;
|
||||
if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$storeResult = module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $quote['price'],
|
||||
strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $defaultReportCurrency))) ?: $defaultReportCurrency,
|
||||
(string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')),
|
||||
(string) module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId)
|
||||
);
|
||||
if (!empty($storeResult['inserted'])) {
|
||||
$updated++;
|
||||
} else {
|
||||
$reused++;
|
||||
}
|
||||
}
|
||||
|
||||
$message = 'Automatischer Kursabruf: ' . $updated . ' neu, ' . $reused . ' wiederverwendet, ' . count($errors) . ' Fehler.';
|
||||
if ($errors !== []) {
|
||||
$message .= ' ' . implode(' | ', array_slice($errors, 0, 3));
|
||||
}
|
||||
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Intervall-Aufgabe',
|
||||
'type' => 'scheduler:run',
|
||||
'task' => 'auto_refresh_quotes',
|
||||
'context' => $context,
|
||||
'message' => $message,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => $errors === [],
|
||||
'message' => $message,
|
||||
'updated' => $updated,
|
||||
'reused' => $reused,
|
||||
'errors' => $errors,
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_search_symbols', static function (string $keywords): array {
|
||||
$keywords = trim($keywords);
|
||||
if ($keywords === '') {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Bitte Suchbegriff angeben.',
|
||||
'results' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'SYMBOL_SEARCH', [
|
||||
'keywords' => $keywords,
|
||||
]);
|
||||
if (empty($response['ok'])) {
|
||||
return $response + ['results' => []];
|
||||
}
|
||||
|
||||
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
|
||||
$items = is_array($data['bestMatches'] ?? null) ? $data['bestMatches'] : [];
|
||||
$results = [];
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$symbol = trim((string) ($item['1. symbol'] ?? ''));
|
||||
$name = trim((string) ($item['2. name'] ?? ''));
|
||||
$type = trim((string) ($item['3. type'] ?? ''));
|
||||
$region = trim((string) ($item['4. region'] ?? ''));
|
||||
$currency = strtoupper(trim((string) ($item['8. currency'] ?? '')));
|
||||
$matchScore = trim((string) ($item['9. matchScore'] ?? ''));
|
||||
if ($symbol === '' && $name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'symbol' => $symbol,
|
||||
'name' => $name,
|
||||
'isin' => '',
|
||||
'type' => $type,
|
||||
'region' => $region,
|
||||
'currency' => $currency,
|
||||
'match_score' => $matchScore,
|
||||
'raw' => $item,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => count($results) . ' Treffer gefunden.',
|
||||
'results' => $results,
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_chart_series', static function (string $symbol): array {
|
||||
$symbol = strtoupper(trim($symbol));
|
||||
if ($symbol === '') {
|
||||
return ['ok' => false, 'message' => 'Kein Symbol angegeben.'];
|
||||
}
|
||||
|
||||
$cacheDir = sys_get_temp_dir() . '/boersenchecker-alphavantage';
|
||||
if (!is_dir($cacheDir)) {
|
||||
@mkdir($cacheDir, 0775, true);
|
||||
}
|
||||
$cachePath = $cacheDir . '/' . md5('time_series_daily_adjusted|' . $symbol) . '.json';
|
||||
|
||||
$decoded = null;
|
||||
if (is_file($cachePath) && (time() - filemtime($cachePath)) < (6 * 3600)) {
|
||||
$cached = file_get_contents($cachePath);
|
||||
$decoded = is_string($cached) ? json_decode($cached, true) : null;
|
||||
}
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'TIME_SERIES_DAILY_ADJUSTED', [
|
||||
'symbol' => $symbol,
|
||||
'outputsize' => 'full',
|
||||
]);
|
||||
if (empty($response['ok'])) {
|
||||
return $response;
|
||||
}
|
||||
$decoded = is_array($response['data'] ?? null) ? $response['data'] : [];
|
||||
@file_put_contents($cachePath, json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
$rows = is_array($decoded['Time Series (Daily)'] ?? null)
|
||||
? $decoded['Time Series (Daily)']
|
||||
: (is_array($decoded['Time Series (Daily) Adjusted'] ?? null) ? $decoded['Time Series (Daily) Adjusted'] : []);
|
||||
|
||||
$daily = [];
|
||||
foreach ($rows as $date => $row) {
|
||||
if (!is_array($row) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $date)) {
|
||||
continue;
|
||||
}
|
||||
$close = $row['5. adjusted close'] ?? $row['4. close'] ?? null;
|
||||
if (!is_numeric($close)) {
|
||||
continue;
|
||||
}
|
||||
$daily[] = [
|
||||
'date' => $date,
|
||||
'close' => (float) $close,
|
||||
];
|
||||
}
|
||||
usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
|
||||
if ($daily === []) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Keine historischen Schlusskurse fuer ' . $symbol . ' verfuegbar.',
|
||||
];
|
||||
}
|
||||
|
||||
$aggregate = static function (array $points, string $format): array {
|
||||
$result = [];
|
||||
foreach ($points as $point) {
|
||||
$bucket = date($format, strtotime((string) $point['date']) ?: time());
|
||||
$result[$bucket] = $point;
|
||||
}
|
||||
return array_values($result);
|
||||
};
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'symbol' => $symbol,
|
||||
'daily' => $daily,
|
||||
'weekly' => $aggregate($daily, 'o-W'),
|
||||
'monthly' => $aggregate($daily, 'Y-m'),
|
||||
'source' => 'alphavantage:time_series_daily_adjusted',
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'store_market_quote', static function (
|
||||
int $instrumentId,
|
||||
float $price,
|
||||
string $currency,
|
||||
string $quotedAt,
|
||||
string $source
|
||||
): array {
|
||||
$pdo = module_fn('boersenchecker', 'pdo');
|
||||
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
|
||||
|
||||
$quotedAt = trim($quotedAt);
|
||||
$currency = strtoupper(trim($currency)) ?: 'EUR';
|
||||
$source = trim($source) !== '' ? trim($source) : 'alphavantage:global_quote';
|
||||
|
||||
$checkStmt = $pdo->prepare(
|
||||
'SELECT id
|
||||
FROM ' . $quoteTable . '
|
||||
WHERE instrument_id = :instrument_id
|
||||
AND price = :price
|
||||
AND currency = :currency
|
||||
AND quoted_at = :quoted_at
|
||||
AND source = :source
|
||||
LIMIT 1'
|
||||
);
|
||||
$checkStmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
]);
|
||||
$existingId = (int) $checkStmt->fetchColumn();
|
||||
if ($existingId > 0) {
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Quote Store',
|
||||
'type' => 'quote:reuse',
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
'message' => 'Identischer Snapshot bereits vorhanden.',
|
||||
]);
|
||||
return ['ok' => true, 'inserted' => false, 'id' => $existingId];
|
||||
}
|
||||
|
||||
$insertStmt = $pdo->prepare(
|
||||
'INSERT INTO ' . $quoteTable . ' (instrument_id, price, currency, quoted_at, source)
|
||||
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
|
||||
);
|
||||
$insertStmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
]);
|
||||
|
||||
$insertedId = (int) $pdo->lastInsertId();
|
||||
module_debug_push('boersenchecker', [
|
||||
'label' => 'Quote Store',
|
||||
'type' => 'quote:insert',
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
'inserted_id' => $insertedId,
|
||||
'message' => 'Neuer Snapshot gespeichert.',
|
||||
]);
|
||||
|
||||
return ['ok' => true, 'inserted' => true, 'id' => $insertedId];
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Boersenchecker",
|
||||
"description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.",
|
||||
"actions": [
|
||||
{ "label": "Nexus Übersicht", "href": "/" },
|
||||
{ "label": "Setup", "href": "/modules/setup/boersenchecker" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Ueberblick", "href": "/module/boersenchecker" },
|
||||
{ "label": "Depotverwaltung", "href": "/module/boersenchecker/depotverwaltung" },
|
||||
{ "label": "Aktienverwaltung", "href": "/module/boersenchecker/aktienverwaltung" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"title": "Börsenchecker",
|
||||
"version": "0.2.0",
|
||||
"description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.",
|
||||
"enabled_by_default": false,
|
||||
"setup": {
|
||||
"fields": [
|
||||
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
|
||||
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" },
|
||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
|
||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
|
||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
|
||||
{ "name": "report_currency", "label": "Standard-Berichtswahrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Portfolio-Summen, z.B. EUR." },
|
||||
{ "name": "fx_max_age_hours", "label": "Maximales FX-Alter (Stunden)", "type": "number", "required": false, "help": "Wird bei manueller Aktualisierung ueber das Modul fx-rates genutzt." },
|
||||
{ "name": "alpha_vantage_api_key", "label": "Alpha Vantage API Key", "type": "password", "required": false, "help": "API Key fuer Aktienkursabrufe und Suche ueber Alpha Vantage." },
|
||||
{ "name": "alpha_vantage_timeout_sec", "label": "Alpha Vantage Timeout (Sek.)", "type": "number", "required": false, "help": "HTTP-Timeout fuer API-Abrufe." },
|
||||
{ "name": "alpha_vantage_min_interval_minutes", "label": "Alpha Vantage Mindestabstand (Min.)", "type": "number", "required": false, "help": "Wenn bereits ein frischer Alpha-Vantage-Kurs existiert, wird dieser wiederverwendet statt erneut abzurufen." },
|
||||
{ "name": "auto_refresh_quotes_enabled", "label": "Automatischen Kursabruf aktivieren", "type": "checkbox", "required": false, "help": "Fuehrt Kursupdates automatisch beim ersten Modulaufruf nach Ablauf des Intervalls aus." },
|
||||
{ "name": "auto_refresh_quotes_interval_hours", "label": "Intervall fuer automatischen Kursabruf (Stunden)", "type": "number", "required": false, "help": "Nach Ablauf dieses Intervalls wird beim naechsten Modulaufruf ein automatischer Kursabruf gestartet." }
|
||||
]
|
||||
},
|
||||
"interval_tasks": [
|
||||
{
|
||||
"name": "auto_refresh_quotes",
|
||||
"label": "Automatischer Kursabruf",
|
||||
"callback": "scheduled_refresh_quotes",
|
||||
"enabled_setting": "auto_refresh_quotes_enabled",
|
||||
"interval_setting": "auto_refresh_quotes_interval_hours",
|
||||
"default_enabled": false,
|
||||
"default_interval_hours": 6,
|
||||
"lock_minutes": 20
|
||||
}
|
||||
],
|
||||
"db_defaults": {
|
||||
"driver": "pgsql",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"dbname": "",
|
||||
"schema": "public",
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"auth": {
|
||||
"required": true,
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_auth();
|
||||
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css');
|
||||
$assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true);
|
||||
}
|
||||
|
||||
$page = new \Modules\Boersenchecker\Support\InstrumentPage();
|
||||
module_tpl('boersenchecker', 'instruments', $page->handle());
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
$file = (string)($_GET['file'] ?? '');
|
||||
$base = realpath(__DIR__ . '/../assets');
|
||||
$map = [
|
||||
'boersenchecker.css' => $base . '/boersenchecker.css',
|
||||
'boersenchecker.js' => $base . '/boersenchecker.js',
|
||||
];
|
||||
|
||||
if (!isset($map[$file])) {
|
||||
http_response_code(404);
|
||||
exit('Not found');
|
||||
}
|
||||
|
||||
$path = $map[$file];
|
||||
if (!$base || !is_file($path) || !str_starts_with($path, $base)) {
|
||||
http_response_code(404);
|
||||
exit('Not found');
|
||||
}
|
||||
|
||||
$ext = pathinfo($path, PATHINFO_EXTENSION);
|
||||
if ($ext === 'css') {
|
||||
header('Content-Type: text/css; charset=utf-8');
|
||||
} elseif ($ext === 'js') {
|
||||
header('Content-Type: application/javascript; charset=utf-8');
|
||||
} else {
|
||||
header('Content-Type: application/octet-stream');
|
||||
}
|
||||
|
||||
readfile($path);
|
||||
exit;
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_auth();
|
||||
|
||||
$user = auth_user() ?? [];
|
||||
$ownerSub = trim((string) ($user['sub'] ?? 'local'));
|
||||
|
||||
$instrumentId = (int) ($_GET['instrument_id'] ?? 0);
|
||||
if ($instrumentId <= 0) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'message' => 'instrument_id fehlt.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = module_fn('boersenchecker', 'pdo');
|
||||
module_fn('boersenchecker', 'ensure_schema');
|
||||
$instrumentTable = module_fn('boersenchecker', 'table', 'instruments');
|
||||
$positionTable = module_fn('boersenchecker', 'table', 'positions');
|
||||
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT i.id, i.name, i.symbol, i.isin, i.quote_currency
|
||||
FROM ' . $instrumentTable . ' i
|
||||
INNER JOIN ' . $positionTable . ' p ON p.instrument_id = i.id
|
||||
WHERE i.id = :id AND p.owner_sub = :owner_sub
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $instrumentId,
|
||||
'owner_sub' => $ownerSub,
|
||||
]);
|
||||
$instrument = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if (!is_array($instrument)) {
|
||||
echo json_encode(['ok' => false, 'message' => 'Aktie nicht verfuegbar.'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$quoteStmt = $pdo->prepare(
|
||||
'SELECT id, price, currency, quoted_at, source, created_at
|
||||
FROM ' . $quoteTable . '
|
||||
WHERE instrument_id = :instrument_id
|
||||
ORDER BY quoted_at ASC, created_at ASC, id ASC'
|
||||
);
|
||||
$quoteStmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
]);
|
||||
$quotes = $quoteStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
if ($quotes === []) {
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'message' => 'Keine lokalen Kursdaten fuer diese Aktie vorhanden.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$dailyMap = [];
|
||||
foreach ($quotes as $quote) {
|
||||
$localDate = trim((string) module_fn(
|
||||
'boersenchecker',
|
||||
'format_datetime_for_display',
|
||||
(string) ($quote['quoted_at'] ?? ''),
|
||||
(string) ($quote['source'] ?? ''),
|
||||
'Y-m-d'
|
||||
));
|
||||
$localDateTime = trim((string) module_fn(
|
||||
'boersenchecker',
|
||||
'format_datetime_for_display',
|
||||
(string) ($quote['quoted_at'] ?? ''),
|
||||
(string) ($quote['source'] ?? ''),
|
||||
'Y-m-d H:i:s'
|
||||
));
|
||||
if ($localDate === '' || !is_numeric($quote['price'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$point = [
|
||||
'date' => $localDate,
|
||||
'close' => (float) $quote['price'],
|
||||
'currency' => strtoupper(trim((string) ($quote['currency'] ?? ''))),
|
||||
'quoted_at' => $localDateTime,
|
||||
'source' => (string) ($quote['source'] ?? ''),
|
||||
];
|
||||
|
||||
if (!isset($dailyMap[$localDate]) || strcmp($localDateTime, (string) ($dailyMap[$localDate]['quoted_at'] ?? '')) >= 0) {
|
||||
$dailyMap[$localDate] = $point;
|
||||
}
|
||||
}
|
||||
|
||||
$daily = array_values($dailyMap);
|
||||
usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
|
||||
|
||||
if ($daily === []) {
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'message' => 'Keine gueltigen lokalen Schlusskurse fuer diese Aktie vorhanden.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$aggregate = static function (array $points, string $format): array {
|
||||
$result = [];
|
||||
$timezone = new DateTimeZone(nexus_display_timezone_name());
|
||||
foreach ($points as $point) {
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d', (string) ($point['date'] ?? ''), $timezone);
|
||||
if (!$date instanceof DateTimeImmutable) {
|
||||
continue;
|
||||
}
|
||||
$bucket = $date->format($format);
|
||||
$result[$bucket] = $point;
|
||||
}
|
||||
return array_values($result);
|
||||
};
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'symbol' => strtoupper(trim((string) ($instrument['symbol'] ?? ''))),
|
||||
'isin' => strtoupper(trim((string) ($instrument['isin'] ?? ''))),
|
||||
'instrument_name' => (string) ($instrument['name'] ?? ''),
|
||||
'currency' => strtoupper(trim((string) ($instrument['quote_currency'] ?? ''))),
|
||||
'daily' => $daily,
|
||||
'weekly' => $aggregate($daily, 'o-W'),
|
||||
'monthly' => $aggregate($daily, 'Y-m'),
|
||||
'source' => 'database:quotes',
|
||||
'source_label' => 'Lokale Kurshistorie',
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_auth();
|
||||
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css');
|
||||
$assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true);
|
||||
}
|
||||
|
||||
$page = new \Modules\Boersenchecker\Support\DashboardPage();
|
||||
module_tpl('boersenchecker', 'dashboard', $page->handle());
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_auth();
|
||||
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css');
|
||||
$assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true);
|
||||
}
|
||||
|
||||
$page = new \Modules\Boersenchecker\Support\HomePage();
|
||||
module_tpl('boersenchecker', 'home', $page->handle());
|
||||
@@ -0,0 +1,512 @@
|
||||
<?php $ownerQuery = $isAdmin ? '?owner_sub=' . urlencode((string) $ownerSub) : ''; ?>
|
||||
<?= module_shell_header('boersenchecker', [
|
||||
'title' => 'Depotverwaltung',
|
||||
]) ?>
|
||||
<div class="bc-page">
|
||||
<?php if ($error): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
|
||||
<?php elseif ($notice): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($isAdmin): ?>
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Benutzer-Scope</h2>
|
||||
<p>Depots anderer Benutzer sind nur fuer `appadmin` sichtbar und bearbeitbar.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="get" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
|
||||
<label class="setup-field muted" style="margin:0; min-width:260px;">
|
||||
<span>Depots von Benutzer</span>
|
||||
<select name="owner_sub">
|
||||
<?php foreach ($availableOwners as $owner): ?>
|
||||
<option value="<?= e((string) $owner['sub']) ?>" <?= (string) $ownerSub === (string) $owner['sub'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $owner['label']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<button class="bc-button bc-button--primary" type="submit">Anzeigen</button>
|
||||
</form>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="bc-card-grid">
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title"><?= $editPortfolio ? 'Depot bearbeiten' : 'Neues Depot' ?></h2>
|
||||
<p>Stammdaten und Berichtswahrung fuer ein Depot pflegen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_portfolio">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="portfolio_id" value="<?= e((string) ($editPortfolio['id'] ?? '0')) ?>">
|
||||
<label class="setup-field muted">
|
||||
<span>Depotname</span>
|
||||
<input type="text" name="portfolio_name" value="<?= e((string) ($editPortfolio['name'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Berichtswahrung</span>
|
||||
<input type="text" name="portfolio_base_currency" value="<?= e((string) ($editPortfolio['base_currency'] ?? $defaultReportCurrency)) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Notizen</span>
|
||||
<textarea name="portfolio_notes" rows="3"><?= e((string) ($editPortfolio['notes'] ?? '')) ?></textarea>
|
||||
</label>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Depot speichern</button>
|
||||
<?php if ($editPortfolio): ?>
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung<?= e($ownerQuery) ?>">Abbrechen</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">API / FX</h2>
|
||||
<p>Kurs- und Waehrungsdaten zentral aktualisieren.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:16px;">
|
||||
Die Umrechnung liest gespeicherte FX-Daten zentral aus dem Modul fx-rates. Eine Aktualisierung wird nur manuell
|
||||
angestossen und respektiert die dortige Max-Age- und Reuse-Logik.
|
||||
</p>
|
||||
<p class="muted" style="margin-top:12px;">
|
||||
Aktienkurse werden ueber Alpha Vantage anhand des hinterlegten Symbols abgerufen. Die ISIN bleibt als Stammdatum erhalten.
|
||||
</p>
|
||||
<div class="bc-actions" style="margin-top:16px;">
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="refresh_fx">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<button class="bc-button bc-button--primary" type="submit">FX-Daten aktualisieren</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="refresh_market_data_all">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Alle API-Kurse abrufen</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="muted" style="margin-top:12px;">
|
||||
Alpha Vantage Mindestabstand: <?= e((string) $marketDataMinIntervalMinutes) ?> Min.
|
||||
</div>
|
||||
<div class="muted" style="margin-top:6px;">
|
||||
API-Key und Timeout fuer Aktienkurse werden ueber <a href="/modules/setup/boersenchecker">dieses Modul-Setup</a> gepflegt.
|
||||
</div>
|
||||
<div class="muted" style="margin-top:6px;">
|
||||
FX-Provider, API-Key und Waehrungskatalog werden im Modul <a href="/module/fx-rates">fx-rates</a> gepflegt.
|
||||
</div>
|
||||
<div class="muted" style="margin-top:12px;">
|
||||
Standard-Berichtswahrung: <?= e($defaultReportCurrency) ?> · Max. Alter: <?= e((string) $fxMaxAgeHours) ?>h
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title"><?= $editPosition ? 'Position bearbeiten' : 'Neue Position' ?></h2>
|
||||
<p>Aktienpositionen fuer ein Depot mit Kaufdaten und Kurswaehrung verwalten.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($portfolios === []): ?>
|
||||
<div class="muted" style="margin-top:16px;">Bitte zuerst ein Depot anlegen.</div>
|
||||
<?php else: ?>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_position">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="position_id" value="<?= e((string) ($editPosition['id'] ?? '0')) ?>">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) ($editPosition['instrument_id'] ?? '0')) ?>">
|
||||
<label class="setup-field muted">
|
||||
<span>Depot</span>
|
||||
<select name="portfolio_id" required>
|
||||
<option value="">Bitte waehlen</option>
|
||||
<?php foreach ($portfolios as $portfolio): ?>
|
||||
<option value="<?= e((string) $portfolio['id']) ?>" <?= (string) ($editPosition['portfolio_id'] ?? '') === (string) $portfolio['id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $portfolio['name']) ?> (<?= e((string) $portfolio['base_currency']) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted">
|
||||
<span>Aktienname</span>
|
||||
<input type="text" name="instrument_name" value="<?= e((string) ($editPosition['instrument_name'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>API-Symbol / Ticker</span>
|
||||
<input type="text" name="symbol" value="<?= e((string) ($editPosition['symbol'] ?? '')) ?>" placeholder="z.B. AAPL oder MBG.DE">
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>ISIN</span>
|
||||
<input type="text" name="isin" value="<?= e((string) ($editPosition['isin'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>WKN</span>
|
||||
<input type="text" name="wkn" value="<?= e((string) ($editPosition['wkn'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Boerse / Markt</span>
|
||||
<input type="text" name="market" value="<?= e((string) ($editPosition['market'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kurswaehrung</span>
|
||||
<input type="text" name="quote_currency" value="<?= e((string) ($editPosition['quote_currency'] ?? $defaultReportCurrency)) ?>" required>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted">
|
||||
<span>Stueckzahl</span>
|
||||
<input type="number" name="quantity" min="0" step="0.000001" value="<?= e((string) ($editPosition['quantity'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kaufpreis</span>
|
||||
<input type="number" name="purchase_price" min="0" step="0.00000001" value="<?= e((string) ($editPosition['purchase_price'] ?? '')) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kaufwaehrung</span>
|
||||
<input type="text" name="purchase_currency" value="<?= e((string) ($editPosition['purchase_currency'] ?? $defaultReportCurrency)) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kaufdatum</span>
|
||||
<input type="date" name="purchase_date" value="<?= e((string) ($editPosition['purchase_date'] ?? date('Y-m-d'))) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Gebuehren</span>
|
||||
<input type="number" name="fees" min="0" step="0.00000001" value="<?= e((string) ($editPosition['fees'] ?? '')) ?>">
|
||||
</label>
|
||||
</div>
|
||||
<label class="setup-field muted">
|
||||
<span>Notizen</span>
|
||||
<textarea name="position_notes" rows="3"><?= e((string) ($editPosition['notes'] ?? '')) ?></textarea>
|
||||
</label>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Position speichern</button>
|
||||
<?php if ($editPosition): ?>
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung<?= e($ownerQuery) ?>">Abbrechen</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Wertpapiersuche</h2>
|
||||
<p>Alpha-Vantage-Suchergebnisse pruefen und Daten direkt ins Positionsformular uebernehmen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-section-copy">
|
||||
<form method="post" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
|
||||
<input type="hidden" name="action" value="search_symbol">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<label class="setup-field muted" style="margin:0; min-width:260px; flex:1;">
|
||||
<span>Suchbegriff</span>
|
||||
<input type="text" name="search_keywords" value="<?= e($symbolSearchKeywords) ?>" placeholder="z.B. Mercedes, AAPL, Allianz" required>
|
||||
</label>
|
||||
<button class="bc-button bc-button--primary" type="submit">Suchen</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php if ($symbolSearchResults !== []): ?>
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Name</th>
|
||||
<th>Typ</th>
|
||||
<th>Region</th>
|
||||
<th>Waehrung</th>
|
||||
<th>Match</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($symbolSearchResults as $result): ?>
|
||||
<tr>
|
||||
<td><strong><?= e((string) ($result['symbol'] ?? '')) ?></strong></td>
|
||||
<td><?= e((string) ($result['name'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['type'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['region'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['currency'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['match_score'] ?? '')) ?></td>
|
||||
<td>
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&symbol_candidate=<?= urlencode((string) ($result['symbol'] ?? '')) ?>&instrument_name_candidate=<?= urlencode((string) ($result['name'] ?? '')) ?>&isin_candidate=<?= urlencode((string) ($result['isin'] ?? '')) ?>&market_candidate=<?= urlencode((string) ($result['region'] ?? '')) ?>"e_currency_candidate=<?= urlencode((string) ($result['currency'] ?? '')) ?>">
|
||||
In Formular uebernehmen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php else: ?>
|
||||
<div class="bc-section-copy">
|
||||
<div class="muted">Noch keine Symbolsuche ausgefuehrt.</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Manuellen Kurs erfassen</h2>
|
||||
<p>Kurse mit Uhrzeit und Quelle direkt in die Historie schreiben.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($instrumentList === []): ?>
|
||||
<div class="muted" style="margin-top:16px;">Sobald Positionen vorhanden sind, koennen hier Kurse mit Uhrzeit gespeichert werden.</div>
|
||||
<?php else: ?>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_quote">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted">
|
||||
<span>Aktie</span>
|
||||
<select name="quote_instrument_id" required>
|
||||
<option value="">Bitte waehlen</option>
|
||||
<?php foreach ($instrumentList as $instrument): ?>
|
||||
<option value="<?= e((string) $instrument['id']) ?>" <?= $selectedInstrumentForQuote === (int) $instrument['id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $instrument['name']) ?><?= $instrument['symbol'] !== '' ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Kurs</span>
|
||||
<input type="number" name="quote_price" min="0" step="0.00000001" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Waehrung</span>
|
||||
<input type="text" name="quote_currency" value="<?= e($selectedInstrumentQuoteCurrency) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Zeitpunkt</span>
|
||||
<input type="datetime-local" name="quoted_at" value="<?= e($localNowInputValue) ?>" required>
|
||||
</label>
|
||||
<label class="setup-field muted">
|
||||
<span>Quelle</span>
|
||||
<input type="text" name="quote_source" value="manual">
|
||||
</label>
|
||||
</div>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Kurs speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Depots</h2>
|
||||
<p>Uebersicht aller Depots mit Kennzahlen und Schnellaktionen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($portfolios === []): ?>
|
||||
<div class="muted" style="margin-top:16px;">Noch keine Depots vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-card-grid" style="margin-top:16px;">
|
||||
<?php foreach ($portfolios as $portfolio): ?>
|
||||
<?php
|
||||
$portfolioId = (int) $portfolio['id'];
|
||||
$stats = $portfolioStats[$portfolioId] ?? ['positions' => 0, 'invested' => 0.0, 'current' => 0.0, 'gain' => null, 'has_invested' => false, 'has_current' => false];
|
||||
?>
|
||||
<section class="card-box">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<strong><?= e((string) $portfolio['name']) ?></strong>
|
||||
<div class="muted"><?= e((string) $portfolio['base_currency']) ?> · <?= e((string) $stats['positions']) ?> Position(en)</div>
|
||||
</div>
|
||||
<div class="bc-actions">
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&edit_portfolio=<?= e((string) $portfolioId) ?>">Bearbeiten</a>
|
||||
<form method="post" onsubmit="return confirm('Depot wirklich loeschen?')">
|
||||
<input type="hidden" name="action" value="delete_portfolio">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="portfolio_id" value="<?= e((string) $portfolioId) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!empty($portfolio['notes'])): ?>
|
||||
<div class="muted" style="margin-top:10px;"><?= e((string) $portfolio['notes']) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="grid" style="margin-top:16px; grid-template-columns:repeat(auto-fit, minmax(140px, 1fr)); gap:10px;">
|
||||
<div class="bc-stat">
|
||||
<div class="muted">Investiert</div>
|
||||
<strong><?= $stats['has_invested'] ? e($fmtNumber((float) $stats['invested'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
|
||||
</div>
|
||||
<div class="bc-stat">
|
||||
<div class="muted">Aktuell</div>
|
||||
<strong><?= $stats['has_current'] ? e($fmtNumber((float) $stats['current'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
|
||||
</div>
|
||||
<div class="bc-stat">
|
||||
<div class="muted">Gewinn / Verlust</div>
|
||||
<strong><?= $stats['gain'] !== null ? e($fmtNumber((float) $stats['gain'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Positionen</h2>
|
||||
<p>Alle Positionen mit Kaufdaten, letztem Kurs und aktuellen Werten.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($positions === []): ?>
|
||||
<div class="bc-section-copy">
|
||||
<div class="muted">Noch keine Positionen vorhanden.</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Depot</th>
|
||||
<th>Aktie</th>
|
||||
<th>ISIN / WKN</th>
|
||||
<th>Stueck</th>
|
||||
<th>Kauf</th>
|
||||
<th>Letzter Kurs</th>
|
||||
<th>Wert</th>
|
||||
<th>Delta</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($positions as $position): ?>
|
||||
<tr>
|
||||
<td><?= e((string) ($portfolioById[(int) $position['portfolio_id']]['name'] ?? '')) ?></td>
|
||||
<td>
|
||||
<strong><?= e((string) $position['instrument_name']) ?></strong>
|
||||
<?php if (!empty($position['symbol'])): ?>
|
||||
<div class="muted"><?= e((string) $position['symbol']) ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string) ($position['isin'] ?: '-')) ?>
|
||||
<div class="muted"><?= e((string) ($position['wkn'] ?: '-')) ?></div>
|
||||
</td>
|
||||
<td><?= e($fmtNumber((float) $position['quantity'], 6)) ?></td>
|
||||
<td>
|
||||
<?= e($fmtNumber((float) $position['purchase_price'], 4)) ?> <?= e((string) $position['purchase_currency']) ?>
|
||||
<div class="muted"><?= e((string) $position['purchase_date']) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($position['latest_price'] !== null): ?>
|
||||
<?= e($fmtNumber((float) $position['latest_price'], 4)) ?> <?= e((string) $position['latest_currency']) ?>
|
||||
<div class="muted"><?= e($fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? ''))) ?></div>
|
||||
<?php else: ?>
|
||||
<span class="muted">kein Kurs</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($position['current_total_base'] !== null): ?>
|
||||
<?= e($fmtNumber((float) $position['current_total_base'])) ?> <?= e((string) $position['base_currency']) ?>
|
||||
<?php else: ?>
|
||||
<span class="muted">n/a</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($position['gain_base'] !== null): ?>
|
||||
<?= e($fmtNumber((float) $position['gain_base'])) ?> <?= e((string) $position['base_currency']) ?>
|
||||
<?php else: ?>
|
||||
<span class="muted">n/a</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="bc-actions">
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&edit_position=<?= e((string) $position['id']) ?>">Bearbeiten</a>
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&instrument_id=<?= e((string) $position['instrument_id']) ?>">Kurs erfassen</a>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="refresh_market_data_position">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">API-Kurs</button>
|
||||
</form>
|
||||
<form method="post" onsubmit="return confirm('Position wirklich loeschen?')">
|
||||
<input type="hidden" name="action" value="delete_position">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Kursverlauf</h2>
|
||||
<p>Historische Kurse pro Aktie mit Zeitstempel und Quelle.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($instrumentList === []): ?>
|
||||
<div class="muted" style="margin-top:16px;">Noch keine Kursdaten vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-card-grid" style="margin-top:16px;">
|
||||
<?php foreach ($instrumentList as $instrumentId => $instrument): ?>
|
||||
<?php $history = array_slice($quoteHistory[$instrumentId] ?? [], 0, 10); ?>
|
||||
<section class="card-box">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<strong><?= e((string) $instrument['name']) ?></strong>
|
||||
<div class="muted">
|
||||
<?= e((string) ($instrument['symbol'] ?: '-')) ?> · <?= e((string) ($instrument['isin'] ?: '-')) ?>
|
||||
</div>
|
||||
</div>
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&instrument_id=<?= e((string) $instrumentId) ?>">Neuen Kurs erfassen</a>
|
||||
</div>
|
||||
<?php if ($history === []): ?>
|
||||
<div class="muted" style="margin-top:12px;">Noch keine historischen Kurse vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-table-shell" style="margin-top:12px;">
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Kurs</th>
|
||||
<th>Quelle</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($history as $quote): ?>
|
||||
<tr>
|
||||
<td><?= e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?></td>
|
||||
<td><?= e($fmtNumber((float) $quote['price'], 4)) ?> <?= e((string) $quote['currency']) ?></td>
|
||||
<td><?= e((string) $quote['source']) ?></td>
|
||||
<td>
|
||||
<form method="post" onsubmit="return confirm('Kurseintrag wirklich loeschen?')">
|
||||
<input type="hidden" name="action" value="delete_quote">
|
||||
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
|
||||
<input type="hidden" name="quote_id" value="<?= e((string) $quote['id']) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,209 @@
|
||||
<?= module_shell_header('boersenchecker', [
|
||||
'title' => 'Depot-Ueberblick',
|
||||
]) ?>
|
||||
<div class="bc-page" data-bc-home data-chart-endpoint="<?= e($chartEndpoint) ?>">
|
||||
<script type="application/json" data-bc-instruments-json><?= json_encode(array_map(static function (array $position): array {
|
||||
return [
|
||||
'instrument_id' => (int) ($position['instrument_id'] ?? 0),
|
||||
'instrument_name' => (string) ($position['instrument_name'] ?? ''),
|
||||
'symbol' => (string) ($position['symbol'] ?? ''),
|
||||
'isin' => (string) ($position['isin'] ?? ''),
|
||||
];
|
||||
}, $positions), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?></script>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
|
||||
<?php elseif ($notice): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Marktueberblick</h2>
|
||||
<p>Depotauswahl, Aktienfokus und aktueller Kursabruf in einem Bereich.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-toolbar" style="margin-top:16px;">
|
||||
<form class="bc-panel" method="get">
|
||||
<div class="bc-field-label">Depotauswahl</div>
|
||||
<?php if ($portfolios === []): ?>
|
||||
<div class="bc-text" style="margin-top:12px;">Keine Depots vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<label class="setup-field" style="margin-top:12px;">
|
||||
<span class="bc-text">Depot</span>
|
||||
<select name="portfolio_id" onchange="this.form.submit()">
|
||||
<?php foreach ($portfolios as $portfolio): ?>
|
||||
<option value="<?= e((string) $portfolio['id']) ?>" <?= (string) $selectedPortfolioId === (string) $portfolio['id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $portfolio['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<form class="bc-panel" method="get">
|
||||
<input type="hidden" name="portfolio_id" value="<?= e((string) $selectedPortfolioId) ?>">
|
||||
<div class="bc-field-label">Aktienauswahl</div>
|
||||
<?php if ($positions === []): ?>
|
||||
<div class="bc-text" style="margin-top:12px;">Keine Aktien im ausgewaehlten Depot.</div>
|
||||
<?php else: ?>
|
||||
<label class="setup-field" style="margin-top:12px;">
|
||||
<span class="bc-text">Aktie</span>
|
||||
<select name="instrument_id" data-bc-instrument onchange="this.form.submit()">
|
||||
<?php foreach ($positions as $position): ?>
|
||||
<option value="<?= e((string) $position['instrument_id']) ?>" <?= (string) $selectedInstrumentId === (string) $position['instrument_id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $position['instrument_name']) ?><?= !empty($position['symbol']) ? ' (' . e((string) $position['symbol']) . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<form class="bc-panel" method="post">
|
||||
<input type="hidden" name="action" value="refresh_current_quotes_home">
|
||||
<input type="hidden" name="portfolio_id" value="<?= e((string) $selectedPortfolioId) ?>">
|
||||
<div class="bc-field-label">Marktdaten</div>
|
||||
<p class="bc-text" style="margin-top:12px;">Aktuelle Kurse fuer das gewaehlte Depot ueber Alpha Vantage anhand des hinterlegten Symbols abrufen.</p>
|
||||
<div class="bc-actions" style="margin-top:16px;">
|
||||
<button class="bc-button bc-button--primary" type="submit" <?= $selectedPortfolioId > 0 ? '' : 'disabled' ?>>Aktuelle Kurse abrufen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="bc-overview-grid">
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label">Positionen</div>
|
||||
<div class="bc-stat-value"><?= e((string) ($summary['positions'] ?? 0)) ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;">Aktien im aktuell gewaehlten Depot</div>
|
||||
</section>
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label">Investiert</div>
|
||||
<div class="bc-stat-value"><?= isset($summary['invested']) && $summary['invested'] !== null ? e(number_format((float) $summary['invested'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;">In Berichtswahrung bewertet</div>
|
||||
</section>
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label">Aktueller Wert</div>
|
||||
<div class="bc-stat-value"><?= isset($summary['current']) && $summary['current'] !== null ? e(number_format((float) $summary['current'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;">Basierend auf dem letzten gespeicherten Kurs</div>
|
||||
</section>
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label">Performance</div>
|
||||
<div class="bc-stat-value"><?= isset($summary['gain']) && $summary['gain'] !== null ? e(number_format((float) $summary['gain'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;"><?= !empty($summary['best']['instrument_name']) ? 'Top-Wert: ' . e((string) $summary['best']['instrument_name']) : 'Noch keine Vergleichsdaten' ?></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="bc-card-grid">
|
||||
<section class="card-box">
|
||||
<div class="bc-field-label">Bester Wert</div>
|
||||
<?php if (!empty($summary['best'])): ?>
|
||||
<div class="bc-stat-value"><?= e((string) $summary['best']['instrument_name']) ?></div>
|
||||
<div class="bc-pill-soft" style="margin-top:12px;"><?= e(number_format((float) ($summary['best']['gain_percent'] ?? 0), 2, ',', '.')) ?>%</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-text" style="margin-top:12px;">Noch keine Performance verfuegbar.</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card-box">
|
||||
<div class="bc-field-label">Schwaechster Wert</div>
|
||||
<?php if (!empty($summary['worst'])): ?>
|
||||
<div class="bc-stat-value"><?= e((string) $summary['worst']['instrument_name']) ?></div>
|
||||
<div class="bc-pill-soft" style="margin-top:12px;"><?= e(number_format((float) ($summary['worst']['gain_percent'] ?? 0), 2, ',', '.')) ?>%</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-text" style="margin-top:12px;">Noch keine Performance verfuegbar.</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<?php foreach (array_slice($positions, 0, 2) as $position): ?>
|
||||
<section class="card-box bc-stat">
|
||||
<div class="bc-field-label"><?= e((string) $position['instrument_name']) ?></div>
|
||||
<div class="bc-stat-value"><?= $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?></div>
|
||||
<div class="bc-text" style="margin-top:6px;"><?= e((string) (($position['latest_quoted_at'] ?? '') !== '' ? $fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? '')) : 'kein Kurs')) ?></div>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Kursverlauf</h2>
|
||||
<p>Schlusskurse ueber mehrere Zeitfenster fuer das aktuell gewaehlte Instrument.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-chart-card" style="margin-top:16px;">
|
||||
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; align-items:center;">
|
||||
<div>
|
||||
<div class="bc-field-label">Aktie</div>
|
||||
<div class="bc-stat-value" data-bc-instrument-name><?= e((string) ($selectedInstrument['instrument_name'] ?? 'Keine Aktie ausgewaehlt')) ?></div>
|
||||
<?php if ($selectedInstrument): ?>
|
||||
<div class="bc-text" data-bc-instrument-meta><?= e((string) ($selectedInstrument['symbol'] ?? '')) ?> · <?= e((string) ($selectedInstrument['isin'] ?? '-')) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="bc-range-list">
|
||||
<button type="button" class="bc-range-button" data-range="1d" aria-pressed="false">Tag</button>
|
||||
<button type="button" class="bc-range-button" data-range="5d" aria-pressed="false">5 Tage</button>
|
||||
<button type="button" class="bc-range-button" data-range="1m" aria-pressed="true">Monat</button>
|
||||
<button type="button" class="bc-range-button" data-range="3m" aria-pressed="false">3 Monate</button>
|
||||
<button type="button" class="bc-range-button" data-range="6m" aria-pressed="false">6 Monate</button>
|
||||
<button type="button" class="bc-range-button" data-range="1y" aria-pressed="false">Jahr</button>
|
||||
<button type="button" class="bc-range-button" data-range="5y" aria-pressed="false">5 Jahre</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-text" data-bc-chart-status style="margin-top:12px;">Chartdaten werden geladen...</div>
|
||||
<div class="bc-stat-value" data-bc-chart-summary style="margin-top:6px;">-</div>
|
||||
<div class="bc-chart-shell" data-bc-chart style="margin-top:18px;"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Aktien im Depot</h2>
|
||||
<p>Stueckzahl, Kaufdaten, letzter Kurs und Performance auf einen Blick.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-section-copy">
|
||||
<?php if ($positions === []): ?>
|
||||
<div class="bc-text" style="padding:0 0 18px;">Keine Aktien im ausgewaehlten Depot.</div>
|
||||
<?php else: ?>
|
||||
<div class="bc-position-list" style="padding:0 0 18px;">
|
||||
<?php foreach ($positions as $position): ?>
|
||||
<?php $gainClass = (($position['gain_report'] ?? 0) >= 0) ? 'is-positive' : 'is-negative'; ?>
|
||||
<div class="bc-position-row">
|
||||
<div>
|
||||
<strong><?= e((string) $position['instrument_name']) ?></strong>
|
||||
<div class="bc-text" style="margin-top:4px;"><?= e((string) ($position['symbol'] ?? '')) ?> · <?= e((string) ($position['isin'] ?? '-')) ?></div>
|
||||
<?php if (!empty($position['market'])): ?>
|
||||
<div class="bc-pill-soft" style="margin-top:10px;"><?= e((string) $position['market']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bc-field-label">Stueckzahl</div>
|
||||
<div><?= e(number_format((float) $position['quantity'], 6, ',', '.')) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bc-field-label">Kaufpreis</div>
|
||||
<div><?= e(number_format((float) $position['purchase_price'], 2, ',', '.')) ?> <?= e((string) $position['purchase_currency']) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bc-field-label">Letzter Kurs</div>
|
||||
<div><?= $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="bc-field-label">Performance</div>
|
||||
<div class="bc-performance <?= e($gainClass) ?>">
|
||||
<?= isset($position['gain_report']) && $position['gain_report'] !== null ? e(number_format((float) $position['gain_report'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,187 @@
|
||||
<?= module_shell_header('boersenchecker', [
|
||||
'title' => 'Aktienverwaltung',
|
||||
]) ?>
|
||||
<div class="bc-page">
|
||||
<?php if ($error): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
|
||||
<?php elseif ($notice): ?>
|
||||
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="bc-card-grid">
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Aktie waehlen</h2>
|
||||
<p>Systemweit vorhandene Aktie aus allen Depots auswaehlen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="get" style="margin-top:16px;">
|
||||
<label class="setup-field muted">
|
||||
<span>Aktien aller Depots</span>
|
||||
<select name="instrument_id" onchange="this.form.submit()">
|
||||
<?php foreach ($instruments as $instrument): ?>
|
||||
<option value="<?= e((string) $instrument['id']) ?>" <?= (string) $selectedInstrumentId === (string) $instrument['id'] ? 'selected' : '' ?>>
|
||||
<?= e((string) $instrument['name']) ?><?= !empty($instrument['symbol']) ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Wertpapiersuche</h2>
|
||||
<p>Alpha-Vantage-Suchergebnisse finden und direkt fuer die Aktie uebernehmen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-section-copy">
|
||||
<form method="post" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
|
||||
<input type="hidden" name="action" value="search_symbol">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) $selectedInstrumentId) ?>">
|
||||
<label class="setup-field muted" style="margin:0; min-width:260px; flex:1;">
|
||||
<span>Suchbegriff</span>
|
||||
<input type="text" name="search_keywords" value="<?= e($searchKeywords) ?>" placeholder="z.B. Apple, AAPL, Allianz" required>
|
||||
</label>
|
||||
<button class="bc-button bc-button--primary" type="submit">Suchen</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php if ($searchResults !== []): ?>
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Name</th>
|
||||
<th>Region</th>
|
||||
<th>Waehrung</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($searchResults as $result): ?>
|
||||
<tr>
|
||||
<td><strong><?= e((string) ($result['symbol'] ?? '')) ?></strong></td>
|
||||
<td><?= e((string) ($result['name'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['region'] ?? '')) ?></td>
|
||||
<td><?= e((string) ($result['currency'] ?? '')) ?></td>
|
||||
<td>
|
||||
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/aktienverwaltung?instrument_id=<?= e((string) $selectedInstrumentId) ?>&symbol_candidate=<?= urlencode((string) ($result['symbol'] ?? '')) ?>&instrument_name_candidate=<?= urlencode((string) ($result['name'] ?? '')) ?>&isin_candidate=<?= urlencode((string) ($result['isin'] ?? '')) ?>&market_candidate=<?= urlencode((string) ($result['region'] ?? '')) ?>"e_currency_candidate=<?= urlencode((string) ($result['currency'] ?? '')) ?>">
|
||||
Uebernehmen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php else: ?>
|
||||
<div class="bc-section-copy">
|
||||
<div class="muted">Noch keine Symbolsuche ausgefuehrt.</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Aktie bearbeiten</h2>
|
||||
<p>Stammdaten, Markt und Kurswaehrung zentral fuer die Aktie pflegen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!$selectedInstrument || empty($selectedInstrument['id'])): ?>
|
||||
<div class="muted" style="margin-top:16px;">Keine Aktie vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_instrument">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted"><span>Name</span><input type="text" name="instrument_name" value="<?= e((string) (($selectedInstrument['name'] ?? '') ?: ($_GET['instrument_name_candidate'] ?? ''))) ?>" required></label>
|
||||
<label class="setup-field muted"><span>Symbol</span><input type="text" name="symbol" value="<?= e((string) (($selectedInstrument['symbol'] ?? '') ?: ($_GET['symbol_candidate'] ?? ''))) ?>"></label>
|
||||
<label class="setup-field muted"><span>ISIN</span><input type="text" name="isin" value="<?= e((string) $selectedInstrument['isin'] ?? '') ?>"></label>
|
||||
<label class="setup-field muted"><span>WKN</span><input type="text" name="wkn" value="<?= e((string) $selectedInstrument['wkn'] ?? '') ?>"></label>
|
||||
<label class="setup-field muted"><span>Markt</span><input type="text" name="market" value="<?= e((string) (($selectedInstrument['market'] ?? '') ?: ($_GET['market_candidate'] ?? ''))) ?>"></label>
|
||||
<label class="setup-field muted"><span>Kurswaehrung</span><input type="text" name="quote_currency" value="<?= e((string) (($selectedInstrument['quote_currency'] ?? $defaultReportCurrency) ?: ($_GET['quote_currency_candidate'] ?? $defaultReportCurrency))) ?>"></label>
|
||||
</div>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Aktie speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" style="margin-top:12px;">
|
||||
<input type="hidden" name="action" value="refresh_market_data_instrument">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Aktuellen API-Kurs abrufen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Manuellen Kurs eingeben</h2>
|
||||
<p>Einzelne Kurse mit Zeitstempel und Quelle fuer die ausgewaehlte Aktie speichern.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!$selectedInstrument || empty($selectedInstrument['id'])): ?>
|
||||
<div class="muted" style="margin-top:16px;">Keine Aktie vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
|
||||
<input type="hidden" name="action" value="save_quote">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
|
||||
<label class="setup-field muted"><span>Kurs</span><input type="number" name="quote_price" min="0" step="0.00000001" required></label>
|
||||
<label class="setup-field muted"><span>Waehrung</span><input type="text" name="quote_currency" value="<?= e((string) ($selectedInstrument['quote_currency'] ?? $defaultReportCurrency)) ?>" required></label>
|
||||
<label class="setup-field muted"><span>Zeitpunkt</span><input type="datetime-local" name="quoted_at" value="<?= e($localNowInputValue) ?>" required></label>
|
||||
<label class="setup-field muted"><span>Quelle</span><input type="text" name="quote_source" value="manual"></label>
|
||||
</div>
|
||||
<div class="bc-actions">
|
||||
<button class="bc-button bc-button--primary" type="submit">Kurs speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="bc-section-head">
|
||||
<div>
|
||||
<h2 class="bc-section-title">Kursverlauf</h2>
|
||||
<p>Gespeicherte Kursdaten der ausgewaehlten Aktie mit Quelle und Loeschoption.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($quotes === []): ?>
|
||||
<div class="bc-section-copy">
|
||||
<div class="muted">Keine Kursdaten vorhanden.</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<table class="bc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Kurs</th>
|
||||
<th>Quelle</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($quotes as $quote): ?>
|
||||
<tr>
|
||||
<td><?= e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?></td>
|
||||
<td><?= e(number_format((float) $quote['price'], 4, ',', '.')) ?> <?= e((string) $quote['currency']) ?></td>
|
||||
<td><?= e((string) $quote['source']) ?></td>
|
||||
<td>
|
||||
<form method="post" onsubmit="return confirm('Kurseintrag wirklich loeschen?')">
|
||||
<input type="hidden" name="action" value="delete_quote">
|
||||
<input type="hidden" name="instrument_id" value="<?= e((string) $selectedInstrumentId) ?>">
|
||||
<input type="hidden" name="quote_id" value="<?= e((string) $quote['id']) ?>">
|
||||
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,898 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Boersenchecker\Support;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class DashboardPage
|
||||
{
|
||||
private PDO $pdo;
|
||||
private array $user;
|
||||
private bool $isAdmin;
|
||||
private string $ownerSub;
|
||||
private array $moduleSettings;
|
||||
private string $defaultReportCurrency;
|
||||
private float $fxMaxAgeHours;
|
||||
private int $marketDataMinIntervalMinutes;
|
||||
private int $editPortfolioId;
|
||||
private int $editPositionId;
|
||||
private string $portfolioTable;
|
||||
private string $instrumentTable;
|
||||
private string $positionTable;
|
||||
private string $quoteTable;
|
||||
private InstrumentRegistry $instrumentRegistry;
|
||||
private string $symbolSearchKeywords = '';
|
||||
private array $symbolSearchResults = [];
|
||||
private array $availableOwners = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pdo = \module_fn('boersenchecker', 'pdo');
|
||||
\module_fn('boersenchecker', 'ensure_schema');
|
||||
|
||||
$this->user = \auth_user() ?? [];
|
||||
$this->isAdmin = \auth_is_admin();
|
||||
$this->ownerSub = trim((string) ($this->user['sub'] ?? 'local'));
|
||||
$this->availableOwners = $this->buildAvailableOwners();
|
||||
if ($this->isAdmin) {
|
||||
$requestedOwner = trim((string) ($_GET['owner_sub'] ?? $_POST['owner_sub'] ?? ''));
|
||||
if ($requestedOwner !== '' && isset($this->availableOwners[$requestedOwner])) {
|
||||
$this->ownerSub = $requestedOwner;
|
||||
}
|
||||
}
|
||||
$this->moduleSettings = \modules()->settings('boersenchecker');
|
||||
$this->defaultReportCurrency = $this->normalizeCurrency((string) ($this->moduleSettings['report_currency'] ?? 'EUR'));
|
||||
$this->fxMaxAgeHours = (float) ($this->moduleSettings['fx_max_age_hours'] ?? 6);
|
||||
if ($this->fxMaxAgeHours <= 0) {
|
||||
$this->fxMaxAgeHours = 6.0;
|
||||
}
|
||||
|
||||
$this->marketDataMinIntervalMinutes = (int) (($this->moduleSettings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
|
||||
if ($this->marketDataMinIntervalMinutes <= 0) {
|
||||
$this->marketDataMinIntervalMinutes = 60;
|
||||
}
|
||||
|
||||
$this->editPortfolioId = (int) ($_GET['edit_portfolio'] ?? 0);
|
||||
$this->editPositionId = (int) ($_GET['edit_position'] ?? 0);
|
||||
|
||||
$table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
|
||||
$this->portfolioTable = $table('portfolios');
|
||||
$this->instrumentTable = $table('instruments');
|
||||
$this->positionTable = $table('positions');
|
||||
$this->quoteTable = $table('quotes');
|
||||
$this->instrumentRegistry = new InstrumentRegistry(
|
||||
$this->pdo,
|
||||
$this->instrumentTable,
|
||||
$this->positionTable,
|
||||
$this->quoteTable,
|
||||
);
|
||||
}
|
||||
|
||||
public function handle(): array
|
||||
{
|
||||
$notice = null;
|
||||
$error = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$notice = $this->handlePost();
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$state = $this->loadState();
|
||||
|
||||
if ($notice === null && isset($state['notice_override']) && is_string($state['notice_override'])) {
|
||||
$notice = $state['notice_override'];
|
||||
}
|
||||
if ($error === null && isset($state['error_override']) && is_string($state['error_override'])) {
|
||||
$error = $state['error_override'];
|
||||
}
|
||||
|
||||
return [
|
||||
'notice' => $notice,
|
||||
'error' => $error,
|
||||
'isAdmin' => $this->isAdmin,
|
||||
'ownerSub' => $this->ownerSub,
|
||||
'availableOwners' => array_values($this->availableOwners),
|
||||
'defaultReportCurrency' => $this->defaultReportCurrency,
|
||||
'fxMaxAgeHours' => $this->fxMaxAgeHours,
|
||||
'marketDataMinIntervalMinutes' => $this->marketDataMinIntervalMinutes,
|
||||
'symbolSearchKeywords' => $this->symbolSearchKeywords,
|
||||
'symbolSearchResults' => $this->symbolSearchResults,
|
||||
'editPortfolio' => $state['editPortfolio'],
|
||||
'editPosition' => $state['editPosition'],
|
||||
'portfolios' => $state['portfolios'],
|
||||
'portfolioById' => $state['portfolioById'],
|
||||
'portfolioStats' => $state['portfolioStats'],
|
||||
'positions' => $state['positions'],
|
||||
'instrumentList' => $state['instrumentList'],
|
||||
'quoteHistory' => $state['quoteHistory'],
|
||||
'selectedInstrumentForQuote' => $state['selectedInstrumentForQuote'],
|
||||
'selectedInstrumentQuoteCurrency' => $state['selectedInstrumentQuoteCurrency'],
|
||||
'fmtNumber' => fn (?float $value, int $scale = 2): string => $this->formatNumber($value, $scale),
|
||||
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
|
||||
'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'),
|
||||
];
|
||||
}
|
||||
|
||||
private function handlePost(): string
|
||||
{
|
||||
$action = trim((string) ($_POST['action'] ?? ''));
|
||||
|
||||
return match ($action) {
|
||||
'save_portfolio' => $this->savePortfolio(),
|
||||
'delete_portfolio' => $this->deletePortfolio(),
|
||||
'save_position' => $this->savePosition(),
|
||||
'delete_position' => $this->deletePosition(),
|
||||
'save_quote' => $this->saveQuote(),
|
||||
'refresh_market_data_position' => $this->refreshMarketDataPosition(),
|
||||
'refresh_market_data_all' => $this->refreshMarketDataAll(),
|
||||
'search_symbol' => $this->searchSymbol(),
|
||||
'delete_quote' => $this->deleteQuote(),
|
||||
'refresh_fx' => $this->refreshFx(),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function loadState(): array
|
||||
{
|
||||
$portfolios = $this->fetchPortfolios();
|
||||
$positions = $this->fetchPositions();
|
||||
$instrumentList = $this->buildInstrumentList($positions);
|
||||
[$quoteHistory, $latestQuotes] = $this->fetchQuotes(array_keys($instrumentList));
|
||||
$portfolioById = $this->buildPortfolioById($portfolios);
|
||||
[$positions, $portfolioStats] = $this->enrichPositions($positions, $portfolioById, $latestQuotes);
|
||||
|
||||
$editPortfolio = null;
|
||||
if ($this->editPortfolioId > 0 && isset($portfolioById[$this->editPortfolioId])) {
|
||||
$editPortfolio = $portfolioById[$this->editPortfolioId];
|
||||
}
|
||||
|
||||
$editPosition = null;
|
||||
if ($this->editPositionId > 0) {
|
||||
foreach ($positions as $position) {
|
||||
if ((int) $position['id'] === $this->editPositionId) {
|
||||
$editPosition = $position;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$selectedInstrumentForQuote = $editPosition
|
||||
? (int) $editPosition['instrument_id']
|
||||
: (int) ($_GET['instrument_id'] ?? 0);
|
||||
$selectedInstrumentQuoteCurrency = $this->defaultReportCurrency;
|
||||
if ($selectedInstrumentForQuote > 0 && isset($instrumentList[$selectedInstrumentForQuote])) {
|
||||
$selectedInstrumentQuoteCurrency = $this->normalizeCurrency(
|
||||
(string) ($instrumentList[$selectedInstrumentForQuote]['quote_currency'] ?? $this->defaultReportCurrency)
|
||||
);
|
||||
}
|
||||
|
||||
$candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? ''));
|
||||
$candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? ''));
|
||||
$candidateIsin = trim((string) ($_GET['isin_candidate'] ?? ''));
|
||||
$candidateMarket = trim((string) ($_GET['market_candidate'] ?? ''));
|
||||
$candidateCurrency = $this->normalizeCurrency((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency));
|
||||
|
||||
if ($editPosition === null) {
|
||||
$editPosition = [
|
||||
'instrument_name' => $candidateName,
|
||||
'symbol' => $candidateSymbol,
|
||||
'isin' => $candidateIsin,
|
||||
'market' => $candidateMarket,
|
||||
'quote_currency' => $candidateCurrency,
|
||||
'purchase_currency' => $this->defaultReportCurrency,
|
||||
'purchase_date' => date('Y-m-d'),
|
||||
];
|
||||
} else {
|
||||
if ($candidateName !== '') {
|
||||
$editPosition['instrument_name'] = $candidateName;
|
||||
}
|
||||
if ($candidateSymbol !== '') {
|
||||
$editPosition['symbol'] = $candidateSymbol;
|
||||
}
|
||||
if ($candidateIsin !== '') {
|
||||
$editPosition['isin'] = $candidateIsin;
|
||||
}
|
||||
if ($candidateMarket !== '') {
|
||||
$editPosition['market'] = $candidateMarket;
|
||||
}
|
||||
if ($candidateCurrency !== '') {
|
||||
$editPosition['quote_currency'] = $candidateCurrency;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'portfolios' => $portfolios,
|
||||
'portfolioById' => $portfolioById,
|
||||
'portfolioStats' => $portfolioStats,
|
||||
'positions' => $positions,
|
||||
'instrumentList' => $instrumentList,
|
||||
'quoteHistory' => $quoteHistory,
|
||||
'editPortfolio' => $editPortfolio,
|
||||
'editPosition' => $editPosition,
|
||||
'selectedInstrumentForQuote' => $selectedInstrumentForQuote,
|
||||
'selectedInstrumentQuoteCurrency' => $selectedInstrumentQuoteCurrency,
|
||||
];
|
||||
}
|
||||
|
||||
private function savePortfolio(): string
|
||||
{
|
||||
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
|
||||
$name = trim((string) ($_POST['portfolio_name'] ?? ''));
|
||||
$baseCurrency = $this->normalizeCurrency((string) ($_POST['portfolio_base_currency'] ?? $this->defaultReportCurrency));
|
||||
$notes = trim((string) ($_POST['portfolio_notes'] ?? ''));
|
||||
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Bitte einen Depotnamen angeben.');
|
||||
}
|
||||
|
||||
if ($portfolioId > 0) {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE ' . $this->portfolioTable . '
|
||||
SET name = :name, base_currency = :base_currency, notes = :notes, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id AND owner_sub = :owner_sub'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $portfolioId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
'name' => $name,
|
||||
'base_currency' => $baseCurrency,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
]);
|
||||
return 'Depot aktualisiert.';
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->portfolioTable . ' (owner_sub, name, base_currency, notes)
|
||||
VALUES (:owner_sub, :name, :base_currency, :notes)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'owner_sub' => $this->ownerSub,
|
||||
'name' => $name,
|
||||
'base_currency' => $baseCurrency,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
]);
|
||||
return 'Depot angelegt.';
|
||||
}
|
||||
|
||||
private function deletePortfolio(): string
|
||||
{
|
||||
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
|
||||
$countStmt = $this->pdo->prepare('SELECT COUNT(*) FROM ' . $this->positionTable . ' WHERE portfolio_id = :portfolio_id AND owner_sub = :owner_sub');
|
||||
$countStmt->execute([
|
||||
'portfolio_id' => $portfolioId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
if ((int) $countStmt->fetchColumn() > 0) {
|
||||
throw new RuntimeException('Depot kann erst geloescht werden, wenn alle Positionen entfernt wurden.');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub');
|
||||
$stmt->execute([
|
||||
'id' => $portfolioId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
return 'Depot geloescht.';
|
||||
}
|
||||
|
||||
private function savePosition(): string
|
||||
{
|
||||
$positionId = (int) ($_POST['position_id'] ?? 0);
|
||||
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
|
||||
$quantity = (float) ($_POST['quantity'] ?? 0);
|
||||
$purchasePrice = (float) ($_POST['purchase_price'] ?? 0);
|
||||
$purchaseCurrency = $this->normalizeCurrency((string) ($_POST['purchase_currency'] ?? $this->defaultReportCurrency));
|
||||
$purchaseDate = trim((string) ($_POST['purchase_date'] ?? ''));
|
||||
$fees = trim((string) ($_POST['fees'] ?? ''));
|
||||
$notes = trim((string) ($_POST['position_notes'] ?? ''));
|
||||
|
||||
if ($portfolioId <= 0) {
|
||||
throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.');
|
||||
}
|
||||
if ($quantity <= 0 || $purchasePrice <= 0 || $purchaseDate === '') {
|
||||
throw new RuntimeException('Bitte Stueckzahl, Kaufpreis und Kaufdatum angeben.');
|
||||
}
|
||||
|
||||
$portfolioOwnerStmt = $this->pdo->prepare('SELECT id FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub LIMIT 1');
|
||||
$portfolioOwnerStmt->execute([
|
||||
'id' => $portfolioId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
if ($portfolioOwnerStmt->fetchColumn() === false) {
|
||||
throw new RuntimeException('Das ausgewaehlte Depot ist nicht verfuegbar.');
|
||||
}
|
||||
|
||||
$instrumentId = $this->upsertInstrument([
|
||||
'id' => (int) ($_POST['instrument_id'] ?? 0),
|
||||
'isin' => $_POST['isin'] ?? '',
|
||||
'wkn' => $_POST['wkn'] ?? '',
|
||||
'symbol' => $_POST['symbol'] ?? '',
|
||||
'name' => $_POST['instrument_name'] ?? '',
|
||||
'quote_currency' => $_POST['quote_currency'] ?? $purchaseCurrency,
|
||||
'market' => $_POST['market'] ?? '',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'owner_sub' => $this->ownerSub,
|
||||
'portfolio_id' => $portfolioId,
|
||||
'instrument_id' => $instrumentId,
|
||||
'quantity' => $quantity,
|
||||
'purchase_price' => $purchasePrice,
|
||||
'purchase_currency' => $purchaseCurrency,
|
||||
'purchase_date' => $purchaseDate,
|
||||
'fees' => $fees !== '' ? (float) $fees : null,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
];
|
||||
|
||||
if ($positionId > 0) {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE ' . $this->positionTable . '
|
||||
SET portfolio_id = :portfolio_id,
|
||||
instrument_id = :instrument_id,
|
||||
quantity = :quantity,
|
||||
purchase_price = :purchase_price,
|
||||
purchase_currency = :purchase_currency,
|
||||
purchase_date = :purchase_date,
|
||||
fees = :fees,
|
||||
notes = :notes,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id AND owner_sub = :owner_sub'
|
||||
);
|
||||
$stmt->execute($payload + ['id' => $positionId]);
|
||||
return 'Position aktualisiert.';
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->positionTable . ' (
|
||||
owner_sub, portfolio_id, instrument_id, quantity, purchase_price, purchase_currency, purchase_date, fees, notes
|
||||
) VALUES (
|
||||
:owner_sub, :portfolio_id, :instrument_id, :quantity, :purchase_price, :purchase_currency, :purchase_date, :fees, :notes
|
||||
)'
|
||||
);
|
||||
$stmt->execute($payload);
|
||||
return 'Position gespeichert.';
|
||||
}
|
||||
|
||||
private function deletePosition(): string
|
||||
{
|
||||
$positionId = (int) ($_POST['position_id'] ?? 0);
|
||||
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->positionTable . ' WHERE id = :id AND owner_sub = :owner_sub');
|
||||
$stmt->execute([
|
||||
'id' => $positionId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
return 'Position geloescht.';
|
||||
}
|
||||
|
||||
private function saveQuote(): string
|
||||
{
|
||||
$instrumentId = (int) ($_POST['quote_instrument_id'] ?? 0);
|
||||
$price = (float) ($_POST['quote_price'] ?? 0);
|
||||
$currency = $this->normalizeCurrency((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency));
|
||||
$quotedAt = $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? ''));
|
||||
$source = trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual';
|
||||
|
||||
if ($instrumentId <= 0 || $price <= 0) {
|
||||
throw new RuntimeException('Bitte Aktie und Kurs angeben.');
|
||||
}
|
||||
|
||||
$this->storeQuote($instrumentId, $price, $currency, $quotedAt, $source);
|
||||
return 'Kurs gespeichert.';
|
||||
}
|
||||
|
||||
private function refreshMarketDataPosition(): string
|
||||
{
|
||||
$positionId = (int) ($_POST['position_id'] ?? 0);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT
|
||||
p.instrument_id,
|
||||
i.name AS instrument_name,
|
||||
i.symbol,
|
||||
i.isin,
|
||||
i.quote_currency
|
||||
FROM ' . $this->positionTable . ' p
|
||||
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE p.id = :id AND p.owner_sub = :owner_sub
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $positionId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($row)) {
|
||||
throw new RuntimeException('Position nicht gefunden.');
|
||||
}
|
||||
|
||||
$instrumentId = (int) $row['instrument_id'];
|
||||
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
|
||||
$quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency));
|
||||
if ($symbol === '') {
|
||||
throw new RuntimeException('Fuer diese Aktie ist noch kein Symbol hinterlegt.');
|
||||
}
|
||||
|
||||
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
$latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false;
|
||||
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
|
||||
return 'Vorhandener Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.';
|
||||
}
|
||||
|
||||
$apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
|
||||
if (empty($apiResult['ok'])) {
|
||||
throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.'));
|
||||
}
|
||||
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours);
|
||||
if (empty($fxResult['ok'])) {
|
||||
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
|
||||
$displayTime = (string) \module_fn(
|
||||
'boersenchecker',
|
||||
'format_datetime_for_display',
|
||||
(string) ($apiResult['fetched_at'] ?? ''),
|
||||
(string) ($apiResult['source'] ?? 'alphavantage:global_quote')
|
||||
);
|
||||
return 'Alpha Vantage lieferte fuer ' . (string) $row['instrument_name'] . ' keinen neueren Snapshot als ' . $displayTime . '.';
|
||||
}
|
||||
|
||||
$storeResult = \module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $apiResult['price'],
|
||||
$quoteCurrency,
|
||||
(string) $apiResult['fetched_at'],
|
||||
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
|
||||
);
|
||||
if (!empty($storeResult['inserted'])) {
|
||||
return 'Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' gespeichert.';
|
||||
}
|
||||
return 'Vorhandener Alpha-Vantage-Snapshot fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.';
|
||||
}
|
||||
|
||||
private function refreshMarketDataAll(): string
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT DISTINCT
|
||||
i.id,
|
||||
i.name,
|
||||
i.symbol,
|
||||
i.isin,
|
||||
i.quote_currency
|
||||
FROM ' . $this->positionTable . ' p
|
||||
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE p.owner_sub = :owner_sub
|
||||
ORDER BY i.name ASC'
|
||||
);
|
||||
$stmt->execute(['owner_sub' => $this->ownerSub]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
if ($rows === []) {
|
||||
throw new RuntimeException('Keine Positionen fuer den API-Abruf vorhanden.');
|
||||
}
|
||||
|
||||
$fetched = 0;
|
||||
$reused = 0;
|
||||
$stale = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
|
||||
$bulkRows = [];
|
||||
foreach ($rows as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
|
||||
if ($instrumentId <= 0 || $symbol === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
$latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false;
|
||||
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
|
||||
$reused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$bulkRows[] = $row;
|
||||
}
|
||||
|
||||
if ($bulkRows !== []) {
|
||||
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')),
|
||||
$bulkRows
|
||||
))));
|
||||
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours);
|
||||
if (empty($fxResult['ok'])) {
|
||||
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
|
||||
$bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkRows);
|
||||
if (empty($bulkResult['ok'])) {
|
||||
throw new RuntimeException((string) ($bulkResult['message'] ?? 'Alpha-Vantage-Abruf fehlgeschlagen.'));
|
||||
}
|
||||
|
||||
$bulkQuotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
|
||||
foreach ($bulkRows as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency));
|
||||
$apiResult = $bulkQuotes[$instrumentId] ?? null;
|
||||
if (!is_array($apiResult) || !is_numeric($apiResult['price'] ?? null)) {
|
||||
$failed++;
|
||||
$errors[] = (string) ($row['name'] ?? $instrumentId) . ': kein Preis in der Alpha-Vantage-Antwort.';
|
||||
continue;
|
||||
}
|
||||
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
|
||||
$stale++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$storeResult = \module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $apiResult['price'],
|
||||
$quoteCurrency,
|
||||
(string) $apiResult['fetched_at'],
|
||||
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
|
||||
);
|
||||
if (!empty($storeResult['inserted'])) {
|
||||
$fetched++;
|
||||
} else {
|
||||
$reused++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw new RuntimeException(
|
||||
'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler. '
|
||||
. implode(' | ', array_slice($errors, 0, 3))
|
||||
);
|
||||
}
|
||||
|
||||
return 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler.';
|
||||
}
|
||||
|
||||
private function searchSymbol(): string
|
||||
{
|
||||
$keywords = trim((string) ($_POST['search_keywords'] ?? ''));
|
||||
$this->symbolSearchKeywords = $keywords;
|
||||
$result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $keywords);
|
||||
$this->symbolSearchResults = is_array($result['results'] ?? null) ? $result['results'] : [];
|
||||
|
||||
if (empty($result['ok'])) {
|
||||
throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.'));
|
||||
}
|
||||
|
||||
return (string) ($result['message'] ?? 'Suche abgeschlossen.');
|
||||
}
|
||||
|
||||
private function deleteQuote(): string
|
||||
{
|
||||
$quoteId = (int) ($_POST['quote_id'] ?? 0);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'DELETE FROM ' . $this->quoteTable . '
|
||||
WHERE id = :id
|
||||
AND instrument_id IN (
|
||||
SELECT DISTINCT instrument_id
|
||||
FROM ' . $this->positionTable . '
|
||||
WHERE owner_sub = :owner_sub
|
||||
)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $quoteId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
return 'Kurseintrag geloescht.';
|
||||
}
|
||||
|
||||
private function refreshFx(): string
|
||||
{
|
||||
$result = \module_fn('boersenchecker', 'fx_refresh', $this->defaultReportCurrency, $this->fxMaxAgeHours);
|
||||
if (empty($result['ok'])) {
|
||||
throw new RuntimeException((string) ($result['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
return (string) ($result['message'] ?? 'FX-Daten aktualisiert.');
|
||||
}
|
||||
|
||||
private function fetchPortfolios(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC');
|
||||
$stmt->execute(['owner_sub' => $this->ownerSub]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
private function fetchPositions(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT
|
||||
p.*,
|
||||
i.isin,
|
||||
i.wkn,
|
||||
i.symbol,
|
||||
i.name AS instrument_name,
|
||||
i.quote_currency,
|
||||
i.market
|
||||
FROM ' . $this->positionTable . ' p
|
||||
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE p.owner_sub = :owner_sub
|
||||
ORDER BY p.purchase_date DESC, p.id DESC'
|
||||
);
|
||||
$stmt->execute(['owner_sub' => $this->ownerSub]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
private function buildInstrumentList(array $positions): array
|
||||
{
|
||||
$instrumentList = [];
|
||||
foreach ($positions as $position) {
|
||||
$instrumentId = (int) $position['instrument_id'];
|
||||
if (!isset($instrumentList[$instrumentId])) {
|
||||
$instrumentList[$instrumentId] = [
|
||||
'id' => $instrumentId,
|
||||
'name' => (string) $position['instrument_name'],
|
||||
'symbol' => (string) ($position['symbol'] ?? ''),
|
||||
'isin' => (string) ($position['isin'] ?? ''),
|
||||
'quote_currency' => (string) ($position['quote_currency'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $instrumentList;
|
||||
}
|
||||
|
||||
private function fetchQuotes(array $instrumentIds): array
|
||||
{
|
||||
$quoteHistory = [];
|
||||
$latestQuotes = [];
|
||||
if ($instrumentIds === []) {
|
||||
return [$quoteHistory, $latestQuotes];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $this->quoteTable . '
|
||||
WHERE instrument_id IN (' . $placeholders . ')
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC'
|
||||
);
|
||||
$stmt->execute($instrumentIds);
|
||||
$quotes = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
foreach ($quotes as $quote) {
|
||||
$instrumentId = (int) $quote['instrument_id'];
|
||||
$quoteHistory[$instrumentId][] = $quote;
|
||||
if (!isset($latestQuotes[$instrumentId])) {
|
||||
$latestQuotes[$instrumentId] = $quote;
|
||||
}
|
||||
}
|
||||
|
||||
return [$quoteHistory, $latestQuotes];
|
||||
}
|
||||
|
||||
private function buildPortfolioById(array $portfolios): array
|
||||
{
|
||||
$portfolioById = [];
|
||||
foreach ($portfolios as $portfolio) {
|
||||
$portfolio['base_currency'] = $this->normalizeCurrency((string) ($portfolio['base_currency'] ?? $this->defaultReportCurrency));
|
||||
$portfolioById[(int) $portfolio['id']] = $portfolio;
|
||||
}
|
||||
return $portfolioById;
|
||||
}
|
||||
|
||||
private function enrichPositions(array $positions, array $portfolioById, array $latestQuotes): array
|
||||
{
|
||||
$portfolioStats = [];
|
||||
foreach ($portfolioById as $portfolioId => $portfolio) {
|
||||
$portfolioStats[$portfolioId] = [
|
||||
'invested' => 0.0,
|
||||
'current' => 0.0,
|
||||
'gain' => 0.0,
|
||||
'positions' => 0,
|
||||
'has_invested' => false,
|
||||
'has_current' => false,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($positions as &$position) {
|
||||
$portfolioId = (int) $position['portfolio_id'];
|
||||
$baseCurrency = (string) ($portfolioById[$portfolioId]['base_currency'] ?? $this->defaultReportCurrency);
|
||||
$quantity = (float) $position['quantity'];
|
||||
$purchasePrice = (float) $position['purchase_price'];
|
||||
$fees = is_numeric($position['fees'] ?? null) ? (float) $position['fees'] : 0.0;
|
||||
$purchaseTotal = ($quantity * $purchasePrice) + $fees;
|
||||
$purchaseTotalBase = $this->convertAmount($purchaseTotal, (string) $position['purchase_currency'], $baseCurrency);
|
||||
$latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null;
|
||||
$currentTotalBase = null;
|
||||
|
||||
if (is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null)) {
|
||||
$currentOriginal = $quantity * (float) $latestQuote['price'];
|
||||
$currentTotalBase = $this->convertAmount($currentOriginal, (string) $latestQuote['currency'], $baseCurrency, (string) ($latestQuote['source'] ?? ''));
|
||||
$position['latest_price'] = (float) $latestQuote['price'];
|
||||
$position['latest_currency'] = (string) $latestQuote['currency'];
|
||||
$position['latest_quoted_at'] = (string) $latestQuote['quoted_at'];
|
||||
$position['latest_source'] = (string) ($latestQuote['source'] ?? '');
|
||||
$position['current_total_base'] = $currentTotalBase;
|
||||
} else {
|
||||
$position['latest_price'] = null;
|
||||
$position['latest_currency'] = null;
|
||||
$position['latest_quoted_at'] = null;
|
||||
$position['latest_source'] = null;
|
||||
$position['current_total_base'] = null;
|
||||
}
|
||||
|
||||
$position['purchase_total'] = $purchaseTotal;
|
||||
$position['purchase_total_base'] = $purchaseTotalBase;
|
||||
$position['base_currency'] = $baseCurrency;
|
||||
$position['gain_base'] = $currentTotalBase !== null && $purchaseTotalBase !== null
|
||||
? $currentTotalBase - $purchaseTotalBase
|
||||
: null;
|
||||
|
||||
if (isset($portfolioStats[$portfolioId])) {
|
||||
$portfolioStats[$portfolioId]['positions']++;
|
||||
if ($purchaseTotalBase !== null) {
|
||||
$portfolioStats[$portfolioId]['invested'] += $purchaseTotalBase;
|
||||
$portfolioStats[$portfolioId]['has_invested'] = true;
|
||||
}
|
||||
if ($currentTotalBase !== null) {
|
||||
$portfolioStats[$portfolioId]['current'] += $currentTotalBase;
|
||||
$portfolioStats[$portfolioId]['has_current'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($position);
|
||||
|
||||
foreach ($portfolioStats as &$stats) {
|
||||
$stats['gain'] = ($stats['has_invested'] && $stats['has_current'])
|
||||
? $stats['current'] - $stats['invested']
|
||||
: null;
|
||||
}
|
||||
unset($stats);
|
||||
|
||||
return [$positions, $portfolioStats];
|
||||
}
|
||||
|
||||
private function latestApiQuoteForInstrument(int $instrumentId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $this->quoteTable . '
|
||||
WHERE instrument_id = :instrument_id
|
||||
AND source LIKE :source
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'source' => 'alphavantage:%',
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
|
||||
{
|
||||
if (!is_array($latestQuote)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
|
||||
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
|
||||
if ($latestTimestamp === false || $incomingTimestamp === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $incomingTimestamp <= $latestTimestamp;
|
||||
}
|
||||
|
||||
private function upsertInstrument(array $payload): int
|
||||
{
|
||||
return $this->instrumentRegistry->save($payload);
|
||||
}
|
||||
|
||||
private function storeQuote(int $instrumentId, float $price, string $currency, string $quotedAt, string $source): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source)
|
||||
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => $currency,
|
||||
'quoted_at' => $quotedAt,
|
||||
'source' => $source,
|
||||
]);
|
||||
}
|
||||
|
||||
private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float
|
||||
{
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$from = $this->normalizeCurrency($from);
|
||||
$to = $this->normalizeCurrency($to);
|
||||
if ($from === $to) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
try {
|
||||
$fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0);
|
||||
$value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null);
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string
|
||||
{
|
||||
$normalized = strtoupper(trim((string) $value));
|
||||
return $normalized !== '' ? $normalized : $fallback;
|
||||
}
|
||||
|
||||
private function normalizeDateTimeLocal(?string $value): string
|
||||
{
|
||||
$timezone = new \DateTimeZone(nexus_display_timezone_name());
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone);
|
||||
if ($date instanceof \DateTimeImmutable) {
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
|
||||
private function formatNumber(?float $value, int $scale = 2): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return number_format($value, $scale, ',', '.');
|
||||
}
|
||||
|
||||
private function buildAvailableOwners(): array
|
||||
{
|
||||
$owners = [];
|
||||
$currentSub = trim((string) ($this->user['sub'] ?? 'local'));
|
||||
$owners[$currentSub] = [
|
||||
'sub' => $currentSub,
|
||||
'label' => trim((string) ($this->user['name'] ?? $this->user['email'] ?? $currentSub)) ?: $currentSub,
|
||||
];
|
||||
|
||||
if (!$this->isAdmin) {
|
||||
return $owners;
|
||||
}
|
||||
|
||||
foreach (\modules()->knownAuthUsers() as $knownUser) {
|
||||
$sub = trim((string) ($knownUser['sub'] ?? ''));
|
||||
if ($sub === '') {
|
||||
continue;
|
||||
}
|
||||
$label = trim((string) ($knownUser['name'] ?? $knownUser['email'] ?? $knownUser['username'] ?? $sub));
|
||||
$owners[$sub] = [
|
||||
'sub' => $sub,
|
||||
'label' => $label !== '' ? $label : $sub,
|
||||
];
|
||||
}
|
||||
|
||||
uasort($owners, static fn (array $left, array $right): int => strcmp((string) $left['label'], (string) $right['label']));
|
||||
return $owners;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Boersenchecker\Support;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class HomePage
|
||||
{
|
||||
private PDO $pdo;
|
||||
private string $ownerSub;
|
||||
private string $portfolioTable;
|
||||
private string $instrumentTable;
|
||||
private string $positionTable;
|
||||
private string $quoteTable;
|
||||
private string $defaultReportCurrency;
|
||||
private float $fxMaxAgeHours;
|
||||
private int $marketDataMinIntervalMinutes;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pdo = \module_fn('boersenchecker', 'pdo');
|
||||
\module_fn('boersenchecker', 'ensure_schema');
|
||||
$user = \auth_user() ?? [];
|
||||
$this->ownerSub = trim((string) ($user['sub'] ?? 'local'));
|
||||
|
||||
$settings = \modules()->settings('boersenchecker');
|
||||
$this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
|
||||
$this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6);
|
||||
if ($this->fxMaxAgeHours <= 0) {
|
||||
$this->fxMaxAgeHours = 6.0;
|
||||
}
|
||||
$this->marketDataMinIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
|
||||
if ($this->marketDataMinIntervalMinutes <= 0) {
|
||||
$this->marketDataMinIntervalMinutes = 60;
|
||||
}
|
||||
|
||||
$table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
|
||||
$this->portfolioTable = $table('portfolios');
|
||||
$this->instrumentTable = $table('instruments');
|
||||
$this->positionTable = $table('positions');
|
||||
$this->quoteTable = $table('quotes');
|
||||
}
|
||||
|
||||
public function handle(): array
|
||||
{
|
||||
$notice = null;
|
||||
$error = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'refresh_current_quotes_home') {
|
||||
try {
|
||||
$notice = $this->refreshCurrentQuotesForPortfolio((int) ($_POST['portfolio_id'] ?? 0));
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$portfolios = $this->fetchPortfolios();
|
||||
$selectedPortfolioId = (int) ($_GET['portfolio_id'] ?? ($_POST['portfolio_id'] ?? 0));
|
||||
if ($selectedPortfolioId <= 0 && $portfolios !== []) {
|
||||
$selectedPortfolioId = (int) $portfolios[0]['id'];
|
||||
}
|
||||
|
||||
$positions = $selectedPortfolioId > 0 ? $this->fetchPortfolioPositions($selectedPortfolioId) : [];
|
||||
$selectedInstrumentId = (int) ($_GET['instrument_id'] ?? 0);
|
||||
if ($selectedInstrumentId <= 0 && $positions !== []) {
|
||||
$selectedInstrumentId = (int) $positions[0]['instrument_id'];
|
||||
}
|
||||
|
||||
$latestQuotes = $this->fetchLatestQuotes(array_values(array_unique(array_map(static fn (array $row): int => (int) $row['instrument_id'], $positions))));
|
||||
foreach ($positions as &$position) {
|
||||
$latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null;
|
||||
$position['latest_price'] = is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null) ? (float) $latestQuote['price'] : null;
|
||||
$position['latest_currency'] = is_array($latestQuote) ? (string) ($latestQuote['currency'] ?? '') : '';
|
||||
$position['latest_quoted_at'] = is_array($latestQuote) ? (string) ($latestQuote['quoted_at'] ?? '') : '';
|
||||
$position['latest_source'] = is_array($latestQuote) ? (string) ($latestQuote['source'] ?? '') : '';
|
||||
$position['current_total_report'] = null;
|
||||
$position['gain_report'] = null;
|
||||
$position['gain_percent'] = null;
|
||||
if ($position['latest_price'] !== null) {
|
||||
$currentNative = (float) $position['latest_price'] * (float) ($position['quantity'] ?? 0);
|
||||
$currentReport = $this->convertAmount(
|
||||
$currentNative,
|
||||
(string) ($position['latest_currency'] ?: ($position['quote_currency'] ?? $this->defaultReportCurrency)),
|
||||
$this->defaultReportCurrency,
|
||||
(string) ($position['latest_source'] ?? '')
|
||||
);
|
||||
$position['current_total_report'] = $currentReport;
|
||||
if ($position['purchase_total_report'] !== null && $currentReport !== null) {
|
||||
$gain = $currentReport - (float) $position['purchase_total_report'];
|
||||
$position['gain_report'] = $gain;
|
||||
$base = (float) $position['purchase_total_report'];
|
||||
$position['gain_percent'] = $base > 0 ? ($gain / $base) * 100 : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($position);
|
||||
|
||||
$selectedInstrument = null;
|
||||
foreach ($positions as $position) {
|
||||
if ((int) $position['instrument_id'] === $selectedInstrumentId) {
|
||||
$selectedInstrument = $position;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'notice' => $notice,
|
||||
'error' => $error,
|
||||
'portfolios' => $portfolios,
|
||||
'selectedPortfolioId' => $selectedPortfolioId,
|
||||
'positions' => $positions,
|
||||
'selectedInstrumentId' => $selectedInstrumentId,
|
||||
'selectedInstrument' => $selectedInstrument,
|
||||
'summary' => $this->buildSummary($positions),
|
||||
'defaultReportCurrency' => $this->defaultReportCurrency,
|
||||
'chartEndpoint' => '/module/boersenchecker/chart_data',
|
||||
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
|
||||
];
|
||||
}
|
||||
|
||||
private function refreshCurrentQuotesForPortfolio(int $portfolioId): string
|
||||
{
|
||||
if ($portfolioId <= 0) {
|
||||
throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT DISTINCT i.id, i.name, i.symbol, i.isin, i.quote_currency
|
||||
FROM ' . $this->positionTable . ' p
|
||||
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'owner_sub' => $this->ownerSub,
|
||||
'portfolio_id' => $portfolioId,
|
||||
]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
if ($rows === []) {
|
||||
throw new RuntimeException('In diesem Depot sind keine Aktien vorhanden.');
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
$reused = 0;
|
||||
$stale = 0;
|
||||
$bulkCandidates = [];
|
||||
foreach ($rows as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
|
||||
if ($instrumentId <= 0 || $symbol === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$latest = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
$latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false;
|
||||
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
|
||||
$reused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$bulkCandidates[] = $row;
|
||||
}
|
||||
|
||||
if ($bulkCandidates !== []) {
|
||||
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')),
|
||||
$bulkCandidates
|
||||
))));
|
||||
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours);
|
||||
if (empty($fxResult['ok'])) {
|
||||
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
|
||||
$bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkCandidates);
|
||||
$quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
|
||||
foreach ($bulkCandidates as $row) {
|
||||
$instrumentId = (int) ($row['id'] ?? 0);
|
||||
$quote = $quotes[$instrumentId] ?? null;
|
||||
if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
$latest = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
if ($this->isApiSnapshotStale($latest, (string) ($quote['fetched_at'] ?? ''))) {
|
||||
$stale++;
|
||||
continue;
|
||||
}
|
||||
$storeResult = \module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $quote['price'],
|
||||
strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency,
|
||||
(string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')),
|
||||
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId)
|
||||
);
|
||||
if (!empty($storeResult['inserted'])) {
|
||||
$updated++;
|
||||
} else {
|
||||
$reused++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'Aktuelle Kurse: ' . $updated . ' aktualisiert, ' . $reused . ' wiederverwendet, ' . $stale . ' API-Snapshots waren nicht neuer.';
|
||||
}
|
||||
|
||||
private function fetchPortfolios(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC');
|
||||
$stmt->execute(['owner_sub' => $this->ownerSub]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
private function fetchPortfolioPositions(int $portfolioId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT p.*, i.name AS instrument_name, i.symbol, i.isin, i.wkn, i.quote_currency, i.market
|
||||
FROM ' . $this->positionTable . ' p
|
||||
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
|
||||
WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id
|
||||
ORDER BY i.name ASC'
|
||||
);
|
||||
$stmt->execute([
|
||||
'owner_sub' => $this->ownerSub,
|
||||
'portfolio_id' => $portfolioId,
|
||||
]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
foreach ($rows as &$row) {
|
||||
$quantity = (float) ($row['quantity'] ?? 0);
|
||||
$purchasePrice = (float) ($row['purchase_price'] ?? 0);
|
||||
$fees = is_numeric($row['fees'] ?? null) ? (float) $row['fees'] : 0.0;
|
||||
$purchaseTotal = ($quantity * $purchasePrice) + $fees;
|
||||
$row['purchase_total'] = $purchaseTotal;
|
||||
$row['purchase_total_report'] = $this->convertAmount(
|
||||
$purchaseTotal,
|
||||
(string) ($row['purchase_currency'] ?? $this->defaultReportCurrency),
|
||||
$this->defaultReportCurrency
|
||||
);
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function fetchLatestQuotes(array $instrumentIds): array
|
||||
{
|
||||
$result = [];
|
||||
if ($instrumentIds === []) {
|
||||
return $result;
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $this->quoteTable . '
|
||||
WHERE instrument_id IN (' . $placeholders . ')
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC'
|
||||
);
|
||||
$stmt->execute($instrumentIds);
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||
$instrumentId = (int) $row['instrument_id'];
|
||||
if (!isset($result[$instrumentId])) {
|
||||
$result[$instrumentId] = $row;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function latestApiQuoteForInstrument(int $instrumentId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $this->quoteTable . '
|
||||
WHERE instrument_id = :instrument_id AND source LIKE :source
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'source' => 'alphavantage:%',
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
|
||||
{
|
||||
if (!is_array($latestQuote)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
|
||||
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
|
||||
if ($latestTimestamp === false || $incomingTimestamp === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $incomingTimestamp <= $latestTimestamp;
|
||||
}
|
||||
|
||||
private function buildSummary(array $positions): array
|
||||
{
|
||||
$invested = 0.0;
|
||||
$current = 0.0;
|
||||
$hasInvested = false;
|
||||
$hasCurrent = false;
|
||||
$best = null;
|
||||
$worst = null;
|
||||
|
||||
foreach ($positions as $position) {
|
||||
if (is_numeric($position['purchase_total_report'] ?? null)) {
|
||||
$invested += (float) $position['purchase_total_report'];
|
||||
$hasInvested = true;
|
||||
}
|
||||
if (is_numeric($position['current_total_report'] ?? null)) {
|
||||
$current += (float) $position['current_total_report'];
|
||||
$hasCurrent = true;
|
||||
}
|
||||
if (is_numeric($position['gain_percent'] ?? null)) {
|
||||
if ($best === null || (float) $position['gain_percent'] > (float) ($best['gain_percent'] ?? 0)) {
|
||||
$best = $position;
|
||||
}
|
||||
if ($worst === null || (float) $position['gain_percent'] < (float) ($worst['gain_percent'] ?? 0)) {
|
||||
$worst = $position;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'positions' => count($positions),
|
||||
'invested' => $hasInvested ? $invested : null,
|
||||
'current' => $hasCurrent ? $current : null,
|
||||
'gain' => ($hasInvested && $hasCurrent) ? $current - $invested : null,
|
||||
'best' => $best,
|
||||
'worst' => $worst,
|
||||
];
|
||||
}
|
||||
|
||||
private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float
|
||||
{
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$from = $this->normalizeCurrency($from, $this->defaultReportCurrency);
|
||||
$to = $this->normalizeCurrency($to, $this->defaultReportCurrency);
|
||||
if ($from === $to) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
try {
|
||||
$fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0);
|
||||
$value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null);
|
||||
return is_numeric($value) ? (float) $value : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string
|
||||
{
|
||||
$normalized = strtoupper(trim((string) $value));
|
||||
return $normalized !== '' ? $normalized : $fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Boersenchecker\Support;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class InstrumentPage
|
||||
{
|
||||
private PDO $pdo;
|
||||
private string $ownerSub;
|
||||
private string $instrumentTable;
|
||||
private string $positionTable;
|
||||
private string $quoteTable;
|
||||
private string $defaultReportCurrency;
|
||||
private float $fxMaxAgeHours;
|
||||
private string $searchKeywords = '';
|
||||
private array $searchResults = [];
|
||||
private int $selectedInstrumentOverrideId = 0;
|
||||
private InstrumentRegistry $instrumentRegistry;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pdo = \module_fn('boersenchecker', 'pdo');
|
||||
\module_fn('boersenchecker', 'ensure_schema');
|
||||
$user = \auth_user() ?? [];
|
||||
$this->ownerSub = trim((string) ($user['sub'] ?? 'local'));
|
||||
|
||||
$settings = \modules()->settings('boersenchecker');
|
||||
$this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
|
||||
$this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6);
|
||||
if ($this->fxMaxAgeHours <= 0) {
|
||||
$this->fxMaxAgeHours = 6.0;
|
||||
}
|
||||
$table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
|
||||
$this->instrumentTable = $table('instruments');
|
||||
$this->positionTable = $table('positions');
|
||||
$this->quoteTable = $table('quotes');
|
||||
$this->instrumentRegistry = new InstrumentRegistry(
|
||||
$this->pdo,
|
||||
$this->instrumentTable,
|
||||
$this->positionTable,
|
||||
$this->quoteTable,
|
||||
);
|
||||
}
|
||||
|
||||
public function handle(): array
|
||||
{
|
||||
$notice = null;
|
||||
$error = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
try {
|
||||
$notice = $this->handlePost();
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$instruments = $this->fetchInstruments();
|
||||
$selectedInstrumentId = $this->selectedInstrumentOverrideId > 0
|
||||
? $this->selectedInstrumentOverrideId
|
||||
: (int) ($_GET['instrument_id'] ?? ($_POST['instrument_id'] ?? 0));
|
||||
if ($selectedInstrumentId <= 0 && $instruments !== []) {
|
||||
$selectedInstrumentId = (int) $instruments[0]['id'];
|
||||
}
|
||||
|
||||
$selectedInstrument = null;
|
||||
foreach ($instruments as $instrument) {
|
||||
if ((int) $instrument['id'] === $selectedInstrumentId) {
|
||||
$selectedInstrument = $instrument;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$quotes = $selectedInstrumentId > 0 ? $this->fetchQuotes($selectedInstrumentId) : [];
|
||||
|
||||
$candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? ''));
|
||||
$candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? ''));
|
||||
$candidateIsin = trim((string) ($_GET['isin_candidate'] ?? ''));
|
||||
$candidateMarket = trim((string) ($_GET['market_candidate'] ?? ''));
|
||||
$candidateCurrency = strtoupper(trim((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency;
|
||||
if ($selectedInstrument === null && ($candidateName !== '' || $candidateSymbol !== '' || $candidateMarket !== '')) {
|
||||
$selectedInstrument = [
|
||||
'id' => 0,
|
||||
'name' => $candidateName,
|
||||
'symbol' => $candidateSymbol,
|
||||
'isin' => $candidateIsin,
|
||||
'market' => $candidateMarket,
|
||||
'quote_currency' => $candidateCurrency,
|
||||
'wkn' => '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'notice' => $notice,
|
||||
'error' => $error,
|
||||
'instruments' => $instruments,
|
||||
'selectedInstrument' => $selectedInstrument,
|
||||
'selectedInstrumentId' => $selectedInstrumentId,
|
||||
'quotes' => $quotes,
|
||||
'searchKeywords' => $this->searchKeywords,
|
||||
'searchResults' => $this->searchResults,
|
||||
'defaultReportCurrency' => $this->defaultReportCurrency,
|
||||
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
|
||||
'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'),
|
||||
];
|
||||
}
|
||||
|
||||
private function handlePost(): string
|
||||
{
|
||||
$action = trim((string) ($_POST['action'] ?? ''));
|
||||
return match ($action) {
|
||||
'save_instrument' => $this->saveInstrument(),
|
||||
'save_quote' => $this->saveQuote(),
|
||||
'delete_quote' => $this->deleteQuote(),
|
||||
'refresh_market_data_instrument' => $this->refreshInstrumentQuote(),
|
||||
'search_symbol' => $this->searchSymbol(),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function fetchInstruments(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT DISTINCT i.*
|
||||
FROM ' . $this->instrumentTable . ' i
|
||||
INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id
|
||||
WHERE p.owner_sub = :owner_sub
|
||||
ORDER BY i.name ASC'
|
||||
);
|
||||
$stmt->execute(['owner_sub' => $this->ownerSub]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
private function fetchQuotes(int $instrumentId): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $this->quoteTable . '
|
||||
WHERE instrument_id = :instrument_id
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC
|
||||
LIMIT 30'
|
||||
);
|
||||
$stmt->execute(['instrument_id' => $instrumentId]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
private function saveInstrument(): string
|
||||
{
|
||||
$instrumentId = (int) ($_POST['instrument_id'] ?? 0);
|
||||
if ($instrumentId <= 0) {
|
||||
throw new RuntimeException('Bitte eine Aktie auswaehlen.');
|
||||
}
|
||||
$this->assertInstrumentAccessible($instrumentId);
|
||||
|
||||
$resolvedId = $this->instrumentRegistry->save([
|
||||
'id' => $instrumentId,
|
||||
'name' => $_POST['instrument_name'] ?? '',
|
||||
'symbol' => $_POST['symbol'] ?? '',
|
||||
'isin' => $_POST['isin'] ?? '',
|
||||
'wkn' => $_POST['wkn'] ?? '',
|
||||
'market' => $_POST['market'] ?? '',
|
||||
'quote_currency' => $_POST['quote_currency'] ?? $this->defaultReportCurrency,
|
||||
]);
|
||||
$this->selectedInstrumentOverrideId = $resolvedId;
|
||||
|
||||
return $resolvedId === $instrumentId
|
||||
? 'Aktie aktualisiert.'
|
||||
: 'Aktie aktualisiert und mit bestehendem Systemeintrag zusammengefuehrt.';
|
||||
}
|
||||
|
||||
private function saveQuote(): string
|
||||
{
|
||||
$instrumentId = (int) ($_POST['instrument_id'] ?? 0);
|
||||
$price = (float) ($_POST['quote_price'] ?? 0);
|
||||
if ($instrumentId <= 0 || $price <= 0) {
|
||||
throw new RuntimeException('Bitte Aktie und Kurs angeben.');
|
||||
}
|
||||
$this->assertInstrumentAccessible($instrumentId);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source)
|
||||
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'price' => $price,
|
||||
'currency' => strtoupper(trim((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency,
|
||||
'quoted_at' => $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? '')),
|
||||
'source' => trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual',
|
||||
]);
|
||||
return 'Kurs gespeichert.';
|
||||
}
|
||||
|
||||
private function deleteQuote(): string
|
||||
{
|
||||
$quoteId = (int) ($_POST['quote_id'] ?? 0);
|
||||
if ($quoteId <= 0) {
|
||||
throw new RuntimeException('Bitte einen Kurseintrag auswaehlen.');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT q.instrument_id
|
||||
FROM ' . $this->quoteTable . ' q
|
||||
WHERE q.id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $quoteId]);
|
||||
$instrumentId = (int) $stmt->fetchColumn();
|
||||
$this->assertInstrumentAccessible($instrumentId);
|
||||
|
||||
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->quoteTable . ' WHERE id = :id');
|
||||
$stmt->execute(['id' => $quoteId]);
|
||||
return 'Kurs geloescht.';
|
||||
}
|
||||
|
||||
private function refreshInstrumentQuote(): string
|
||||
{
|
||||
$instrumentId = (int) ($_POST['instrument_id'] ?? 0);
|
||||
$instrument = $this->assertInstrumentAccessible($instrumentId);
|
||||
$symbol = strtoupper(trim((string) ($instrument['symbol'] ?? '')));
|
||||
if ($symbol === '') {
|
||||
throw new RuntimeException('Fuer diese Aktie ist kein Symbol hinterlegt.');
|
||||
}
|
||||
|
||||
$apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
|
||||
if (empty($apiResult['ok'])) {
|
||||
throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.'));
|
||||
}
|
||||
$quoteCurrency = strtoupper(trim((string) ($instrument['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency;
|
||||
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours);
|
||||
if (empty($fxResult['ok'])) {
|
||||
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
|
||||
}
|
||||
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
|
||||
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
|
||||
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
|
||||
$displayTime = (string) \module_fn(
|
||||
'boersenchecker',
|
||||
'format_datetime_for_display',
|
||||
(string) ($apiResult['fetched_at'] ?? ''),
|
||||
(string) ($apiResult['source'] ?? 'alphavantage:global_quote')
|
||||
);
|
||||
return 'Alpha Vantage lieferte keinen neueren Snapshot als ' . $displayTime . '.';
|
||||
}
|
||||
|
||||
$storeResult = \module_fn(
|
||||
'boersenchecker',
|
||||
'store_market_quote',
|
||||
$instrumentId,
|
||||
(float) $apiResult['price'],
|
||||
$quoteCurrency,
|
||||
(string) $apiResult['fetched_at'],
|
||||
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
|
||||
);
|
||||
|
||||
return !empty($storeResult['inserted'])
|
||||
? 'Alpha-Vantage-Kurs gespeichert.'
|
||||
: 'Vorhandener Alpha-Vantage-Snapshot wiederverwendet.';
|
||||
}
|
||||
|
||||
private function searchSymbol(): string
|
||||
{
|
||||
$this->searchKeywords = trim((string) ($_POST['search_keywords'] ?? ''));
|
||||
$result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $this->searchKeywords);
|
||||
$this->searchResults = is_array($result['results'] ?? null) ? $result['results'] : [];
|
||||
if (empty($result['ok'])) {
|
||||
throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.'));
|
||||
}
|
||||
return (string) ($result['message'] ?? 'Suche abgeschlossen.');
|
||||
}
|
||||
|
||||
private function assertInstrumentAccessible(int $instrumentId): array
|
||||
{
|
||||
if ($instrumentId <= 0) {
|
||||
throw new RuntimeException('Aktie nicht gefunden.');
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT DISTINCT i.*
|
||||
FROM ' . $this->instrumentTable . ' i
|
||||
INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id
|
||||
WHERE i.id = :id AND p.owner_sub = :owner_sub
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $instrumentId,
|
||||
'owner_sub' => $this->ownerSub,
|
||||
]);
|
||||
$instrument = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!is_array($instrument)) {
|
||||
throw new RuntimeException('Aktie ist nicht verfuegbar.');
|
||||
}
|
||||
|
||||
return $instrument;
|
||||
}
|
||||
|
||||
private function normalizeDateTimeLocal(?string $value): string
|
||||
{
|
||||
$timezone = new \DateTimeZone(nexus_display_timezone_name());
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone);
|
||||
if ($date instanceof \DateTimeImmutable) {
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
|
||||
private function latestApiQuoteForInstrument(int $instrumentId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ' . $this->quoteTable . '
|
||||
WHERE instrument_id = :instrument_id
|
||||
AND source LIKE :source
|
||||
ORDER BY quoted_at DESC, created_at DESC, id DESC
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'instrument_id' => $instrumentId,
|
||||
'source' => 'alphavantage:%',
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
|
||||
{
|
||||
if (!is_array($latestQuote)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
|
||||
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
|
||||
if ($latestTimestamp === false || $incomingTimestamp === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $incomingTimestamp <= $latestTimestamp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Boersenchecker\Support;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class InstrumentRegistry
|
||||
{
|
||||
public function __construct(
|
||||
private PDO $pdo,
|
||||
private string $instrumentTable,
|
||||
private string $positionTable,
|
||||
private string $quoteTable,
|
||||
) {
|
||||
}
|
||||
|
||||
public function save(array $payload): int
|
||||
{
|
||||
$currentId = (int) ($payload['id'] ?? 0);
|
||||
$data = $this->normalizePayload($payload);
|
||||
$matchingId = $this->findMatchingInstrumentId($data, $currentId);
|
||||
|
||||
if ($currentId > 0 && $matchingId > 0 && $matchingId !== $currentId) {
|
||||
return $this->mergeIntoExistingInstrument($currentId, $matchingId, $data);
|
||||
}
|
||||
|
||||
if ($currentId > 0) {
|
||||
$this->updateInstrument($currentId, $data);
|
||||
return $currentId;
|
||||
}
|
||||
|
||||
if ($matchingId > 0) {
|
||||
$this->updateInstrument($matchingId, $data);
|
||||
return $matchingId;
|
||||
}
|
||||
|
||||
return $this->insertInstrument($data);
|
||||
}
|
||||
|
||||
public function findMatchingInstrumentId(array $payload, int $excludeId = 0): ?int
|
||||
{
|
||||
$data = $this->normalizePayload($payload);
|
||||
$conditions = [];
|
||||
$excludeSql = $excludeId > 0 ? ' AND id <> :exclude_id' : '';
|
||||
|
||||
if ($data['isin'] !== null) {
|
||||
$conditions[] = [
|
||||
'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE isin = :isin' . $excludeSql . ' LIMIT 1',
|
||||
'params' => ['isin' => $data['isin']],
|
||||
];
|
||||
}
|
||||
|
||||
if ($data['symbol'] !== null && $data['market'] !== null) {
|
||||
$conditions[] = [
|
||||
'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND market = :market' . $excludeSql . ' LIMIT 1',
|
||||
'params' => ['symbol' => $data['symbol'], 'market' => $data['market']],
|
||||
];
|
||||
}
|
||||
|
||||
if ($data['symbol'] !== null && $data['name'] !== '') {
|
||||
$conditions[] = [
|
||||
'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND name = :name' . $excludeSql . ' LIMIT 1',
|
||||
'params' => ['symbol' => $data['symbol'], 'name' => $data['name']],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($conditions as $condition) {
|
||||
$params = $condition['params'];
|
||||
if ($excludeId > 0) {
|
||||
$params['exclude_id'] = $excludeId;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare($condition['sql']);
|
||||
$stmt->execute($params);
|
||||
$id = $stmt->fetchColumn();
|
||||
if ($id !== false) {
|
||||
return (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizePayload(array $payload): array
|
||||
{
|
||||
$data = [
|
||||
'isin' => $this->normalizeUpper($payload['isin'] ?? null),
|
||||
'wkn' => $this->normalizeUpper($payload['wkn'] ?? null),
|
||||
'symbol' => $this->normalizeUpper($payload['symbol'] ?? null),
|
||||
'name' => trim((string) ($payload['name'] ?? '')),
|
||||
'quote_currency' => $this->normalizeUpper($payload['quote_currency'] ?? 'EUR', 'EUR'),
|
||||
'market' => trim((string) ($payload['market'] ?? '')) ?: null,
|
||||
];
|
||||
|
||||
if ($data['name'] === '') {
|
||||
throw new RuntimeException('Bitte mindestens einen Aktiennamen angeben.');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function normalizeUpper(mixed $value, string $fallback = ''): ?string
|
||||
{
|
||||
$normalized = strtoupper(trim((string) $value));
|
||||
if ($normalized !== '') {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
return $fallback !== '' ? $fallback : null;
|
||||
}
|
||||
|
||||
private function updateInstrument(int $instrumentId, array $data): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE ' . $this->instrumentTable . '
|
||||
SET isin = :isin,
|
||||
wkn = :wkn,
|
||||
symbol = :symbol,
|
||||
name = :name,
|
||||
quote_currency = :quote_currency,
|
||||
market = :market,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id'
|
||||
);
|
||||
$stmt->execute($data + ['id' => $instrumentId]);
|
||||
}
|
||||
|
||||
private function insertInstrument(array $data): int
|
||||
{
|
||||
$driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
|
||||
if ($driver === 'pgsql') {
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
|
||||
VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)
|
||||
RETURNING id'
|
||||
);
|
||||
$stmt->execute($data);
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
|
||||
VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)'
|
||||
);
|
||||
$stmt->execute($data);
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
private function mergeIntoExistingInstrument(int $sourceId, int $targetId, array $data): int
|
||||
{
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
$this->updateInstrument($targetId, $data);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE ' . $this->positionTable . '
|
||||
SET instrument_id = :target_id, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE instrument_id = :source_id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'target_id' => $targetId,
|
||||
'source_id' => $sourceId,
|
||||
]);
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'UPDATE ' . $this->quoteTable . '
|
||||
SET instrument_id = :target_id
|
||||
WHERE instrument_id = :source_id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'target_id' => $targetId,
|
||||
'source_id' => $sourceId,
|
||||
]);
|
||||
|
||||
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->instrumentTable . ' WHERE id = :id');
|
||||
$stmt->execute(['id' => $sourceId]);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $targetId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
(new Modules\FxRates\Api\Router($service))->handle($_GET['path'] ?? '');
|
||||
@@ -0,0 +1,168 @@
|
||||
(() => {
|
||||
const root = document.getElementById('fx-rates-currencies');
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = JSON.parse(root.dataset.page || '{}');
|
||||
const currencies = Array.isArray(page.currencies) ? page.currencies : [];
|
||||
const selected = new Set(
|
||||
(Array.isArray(page.preferred_currencies) ? page.preferred_currencies : [])
|
||||
.map((code) => String(code || '').trim().toUpperCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
let displayBase = String(page.display_base_currency || '').trim().toUpperCase();
|
||||
const savedDisplayBase = String(page.saved_display_base_currency || displayBase || '').trim().toUpperCase();
|
||||
|
||||
const nodes = {
|
||||
tokenList: root.querySelector('[data-fx-token-list]'),
|
||||
searchInput: root.querySelector('[data-fx-search-input]'),
|
||||
suggestions: root.querySelector('[data-fx-suggestions]'),
|
||||
displayBaseSelect: root.querySelector('[data-fx-display-base-select]'),
|
||||
displayBaseHidden: root.querySelector('[data-fx-display-base-hidden]'),
|
||||
hiddenPreferred: root.querySelector('[data-fx-hidden-preferred]'),
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
const currencyByCode = new Map(
|
||||
currencies.map((currency) => [String(currency.code || '').toUpperCase(), currency])
|
||||
);
|
||||
|
||||
const sortedSelectedCodes = () => Array.from(selected).sort((left, right) => left.localeCompare(right));
|
||||
|
||||
const ensureDisplayBase = () => {
|
||||
const available = sortedSelectedCodes();
|
||||
if (available.length === 0) {
|
||||
displayBase = '';
|
||||
return;
|
||||
}
|
||||
if (!displayBase || !selected.has(displayBase)) {
|
||||
displayBase = available[0];
|
||||
}
|
||||
};
|
||||
|
||||
const renderHiddenInputs = () => {
|
||||
if (!nodes.hiddenPreferred) {
|
||||
return;
|
||||
}
|
||||
nodes.hiddenPreferred.innerHTML = sortedSelectedCodes()
|
||||
.map((code) => `<input type="hidden" name="preferred_currencies[]" value="${escapeHtml(code)}">`)
|
||||
.join('');
|
||||
if (nodes.displayBaseHidden) {
|
||||
nodes.displayBaseHidden.value = displayBase;
|
||||
}
|
||||
};
|
||||
|
||||
const renderDisplayBase = () => {
|
||||
if (!nodes.displayBaseSelect) {
|
||||
return;
|
||||
}
|
||||
ensureDisplayBase();
|
||||
const available = sortedSelectedCodes();
|
||||
nodes.displayBaseSelect.innerHTML = available.length
|
||||
? available.map((code) => `<option value="${escapeHtml(code)}" ${code === displayBase ? 'selected' : ''}>${escapeHtml(code)}</option>`).join('')
|
||||
: '<option value="">Keine Waehrungen ausgewaehlt</option>';
|
||||
nodes.displayBaseSelect.disabled = available.length === 0;
|
||||
};
|
||||
|
||||
const removeCode = (code) => {
|
||||
selected.delete(code);
|
||||
renderAll();
|
||||
};
|
||||
|
||||
const renderTokens = () => {
|
||||
if (!nodes.tokenList) {
|
||||
return;
|
||||
}
|
||||
const selectedCodes = sortedSelectedCodes();
|
||||
if (selectedCodes.length === 0) {
|
||||
nodes.tokenList.innerHTML = '<div class="fx-text">Noch keine bevorzugten Waehrungen ausgewaehlt.</div>';
|
||||
return;
|
||||
}
|
||||
nodes.tokenList.innerHTML = selectedCodes.map((code) => {
|
||||
const currency = currencyByCode.get(code) || { code, name: code };
|
||||
return `
|
||||
<button type="button" class="fx-token" data-remove-code="${escapeHtml(code)}" title="${escapeHtml(code)} entfernen">
|
||||
<span>${escapeHtml(`${code} (${currency.name || code})`)}</span>
|
||||
<span class="fx-token-close">x</span>
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
nodes.tokenList.querySelectorAll('[data-remove-code]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
removeCode(String(button.getAttribute('data-remove-code') || '').toUpperCase());
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderSuggestions = () => {
|
||||
if (!nodes.suggestions || !nodes.searchInput) {
|
||||
return;
|
||||
}
|
||||
const needle = String(nodes.searchInput.value || '').trim().toLowerCase();
|
||||
if (!needle) {
|
||||
nodes.suggestions.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const matches = currencies
|
||||
.filter((currency) => !selected.has(String(currency.code || '').toUpperCase()))
|
||||
.filter((currency) => {
|
||||
const code = String(currency.code || '').toLowerCase();
|
||||
const name = String(currency.name || '').toLowerCase();
|
||||
return code.includes(needle) || name.includes(needle);
|
||||
})
|
||||
.slice(0, 12);
|
||||
|
||||
nodes.suggestions.innerHTML = matches.map((currency) => `
|
||||
<button type="button" class="fx-suggestion" data-add-code="${escapeHtml(String(currency.code || '').toUpperCase())}">
|
||||
<strong>${escapeHtml(String(currency.code || '').toUpperCase())}</strong>
|
||||
<span>${escapeHtml(String(currency.name || ''))}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
nodes.suggestions.querySelectorAll('[data-add-code]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const code = String(button.getAttribute('data-add-code') || '').toUpperCase();
|
||||
if (code) {
|
||||
selected.add(code);
|
||||
if (nodes.searchInput) {
|
||||
nodes.searchInput.value = '';
|
||||
}
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderAll = () => {
|
||||
ensureDisplayBase();
|
||||
renderTokens();
|
||||
renderDisplayBase();
|
||||
renderHiddenInputs();
|
||||
renderSuggestions();
|
||||
};
|
||||
|
||||
nodes.searchInput?.addEventListener('input', renderSuggestions);
|
||||
nodes.displayBaseSelect?.addEventListener('change', () => {
|
||||
displayBase = String(nodes.displayBaseSelect?.value || '').trim().toUpperCase();
|
||||
renderHiddenInputs();
|
||||
const url = new URL(window.location.href);
|
||||
if (displayBase) {
|
||||
url.searchParams.set('base', displayBase);
|
||||
} else if (savedDisplayBase) {
|
||||
url.searchParams.set('base', savedDisplayBase);
|
||||
} else {
|
||||
url.searchParams.delete('base');
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
|
||||
renderAll();
|
||||
})();
|
||||
@@ -0,0 +1,287 @@
|
||||
#fx-rates-app,
|
||||
#fx-rates-currencies {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.fx-section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-section-head h2,
|
||||
.section-box h2,
|
||||
.card-box h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.fx-section-head p,
|
||||
.section-box p,
|
||||
.card-box p {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.fx-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-form-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.fx-form-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.fx-form-grid label,
|
||||
.fx-block {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.fx-form-grid span,
|
||||
.fx-block span {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.fx-form-grid input,
|
||||
.fx-form-grid select,
|
||||
.fx-block input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-message {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-message.is-error {
|
||||
color: #d92d20;
|
||||
}
|
||||
|
||||
.fx-message.is-success {
|
||||
color: color-mix(in srgb, var(--accent-green) 78%, var(--text));
|
||||
}
|
||||
|
||||
.fx-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fx-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.fx-table th,
|
||||
.fx-table td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0.65rem 0.4rem;
|
||||
}
|
||||
|
||||
.fx-api-note {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fx-convert-result {
|
||||
margin-top: 1rem;
|
||||
min-height: 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-card-meta {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fx-action-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fx-action-row form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fx-save-row {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.fx-save-row form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fx-mini-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.fx-card-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.fx-mini-label,
|
||||
.fx-field-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fx-card-value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fx-currency-selection-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.fx-token-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.fx-token-list--inline {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.fx-currency-search {
|
||||
flex: 0 1 360px;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.fx-input,
|
||||
.fx-select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 0.8rem 1rem;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-field {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.fx-token,
|
||||
.fx-suggestion {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.7rem 1rem;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-token {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fx-token:hover,
|
||||
.fx-suggestion:hover {
|
||||
border-color: color-mix(in srgb, var(--brand-accent-3) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--brand-accent) 6%, var(--surface-strong));
|
||||
}
|
||||
|
||||
.fx-token-close {
|
||||
color: var(--brand-accent-3);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fx-suggestion-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.fx-suggestion {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fx-suggestion strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fx-text {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.fx-history-date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.fx-info-button {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-strong);
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.fx-currency-selection-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fx-card-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.fx-currency-search {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
(() => {
|
||||
const root = document.getElementById('fx-rates-app');
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = JSON.parse(root.dataset.page || '{}');
|
||||
const settings = page.settings || {};
|
||||
const nodes = {
|
||||
historyHead: root.querySelector('[data-bind="history-head"]'),
|
||||
historyBody: root.querySelector('[data-bind="history-body"]'),
|
||||
convertResult: root.querySelector('[data-bind="convert-result"]'),
|
||||
convertFrom: root.querySelector('select[name="convert_from"]'),
|
||||
convertTo: root.querySelector('select[name="convert_to"]'),
|
||||
convertAmount: root.querySelector('input[name="convert_amount"]'),
|
||||
};
|
||||
const apiBase = '/api/fx-rates/v1';
|
||||
const preferredCurrencies = Array.isArray(page.preferred_currencies)
|
||||
? page.preferred_currencies
|
||||
.map((item) => String(item || '').trim().toUpperCase())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const refreshMaxAgeMinutes = Math.max(1, Number(settings.refresh_max_age_minutes || 60));
|
||||
|
||||
const parseDateValue = (value) => {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalized = raw.replace(' ', 'T');
|
||||
normalized = normalized.replace(/([+-]\d{2})$/, '$1:00');
|
||||
const parsed = new Date(normalized);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
const latestFetchedAt = () => {
|
||||
const latest = page.latest && typeof page.latest === 'object' ? page.latest : null;
|
||||
const direct = parseDateValue(latest?.fetched_at);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : [];
|
||||
for (const entry of recentFetches) {
|
||||
const parsed = parseDateValue(entry?.fetched_at);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const bindManualRefreshAction = () => {
|
||||
const refreshLink = Array.from(document.querySelectorAll('a[href]')).find((link) => {
|
||||
try {
|
||||
const url = new URL(link.href, window.location.origin);
|
||||
return url.pathname === '/module/fx-rates' && url.searchParams.get('refresh') === '1';
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!refreshLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshLink.addEventListener('click', (event) => {
|
||||
const url = new URL(refreshLink.href, window.location.origin);
|
||||
if (url.searchParams.get('force') === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastFetch = latestFetchedAt();
|
||||
if (!lastFetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ageMinutes = (Date.now() - lastFetch.getTime()) / 60000;
|
||||
if (!Number.isFinite(ageMinutes) || ageMinutes >= refreshMaxAgeMinutes) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const confirmed = window.confirm(
|
||||
`Der letzte gespeicherte Abruf ist juenger als ${refreshMaxAgeMinutes} Minuten. ` +
|
||||
'Ein manueller Abruf wuerde die externe API trotzdem erneut aufrufen. Jetzt trotzdem abrufen?'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
url.searchParams.set('force', '1');
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
const renderHistory = (rows, currencies) => {
|
||||
if (!nodes.historyHead || !nodes.historyBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
const series = Array.isArray(currencies) ? currencies : [];
|
||||
if (!series.length) {
|
||||
nodes.historyHead.innerHTML = '<tr><th>Datum</th><th>Kurse</th></tr>';
|
||||
nodes.historyBody.innerHTML = '<tr><td colspan="2">Keine bevorzugten Waehrungen fuer den Verlauf vorhanden.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.historyHead.innerHTML = `
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
${series.map((currency) => `<th>${currency}</th>`).join('')}
|
||||
</tr>
|
||||
`;
|
||||
|
||||
const entries = Array.isArray(rows) ? rows : [];
|
||||
if (!entries.length) {
|
||||
nodes.historyBody.innerHTML = `<tr><td colspan="${series.length + 1}">Noch keine Verlaufsdaten vorhanden.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.historyBody.innerHTML = entries.map((entry) => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fx-history-date">
|
||||
<span>${entry.label}</span>
|
||||
${entry.fetch ? `
|
||||
<button
|
||||
type="button"
|
||||
class="fx-info-button"
|
||||
title="${escapeHtml(`Basis: ${entry.fetch.base_currency || '-'} | Provider: ${entry.fetch.provider || '-'} | Ausloeser: ${entry.fetch.trigger_source_label || entry.fetch.trigger_source || '-'}`)}"
|
||||
aria-label="${escapeHtml(`Abrufinfo fuer ${entry.label}`)}"
|
||||
>i</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</td>
|
||||
${series.map((currency) => {
|
||||
const value = entry.rates?.[currency];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return '<td>–</td>';
|
||||
}
|
||||
return `<td>${value.toLocaleString('de-DE', { maximumFractionDigits: 8 })}</td>`;
|
||||
}).join('')}
|
||||
</tr>
|
||||
`).join('');
|
||||
};
|
||||
|
||||
const request = async (path, options = {}) => {
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return payload.data;
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
const base = String(
|
||||
settings.display_base_currency || settings.default_base_currency || 'EUR'
|
||||
).trim().toUpperCase();
|
||||
const selectedCurrencies = preferredCurrencies.length
|
||||
? preferredCurrencies
|
||||
: [base];
|
||||
const historyCurrencies = selectedCurrencies.filter((currency) => currency !== base);
|
||||
|
||||
if (!selectedCurrencies.length) {
|
||||
renderHistory([], []);
|
||||
return;
|
||||
}
|
||||
|
||||
const histories = await Promise.all(historyCurrencies.map(async (currency) => {
|
||||
const query = new URLSearchParams({ from: base, to: currency, limit: '20' });
|
||||
const rows = await request(`/history?${query.toString()}`);
|
||||
return { currency, rows: Array.isArray(rows) ? rows : [] };
|
||||
}));
|
||||
|
||||
const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : [];
|
||||
const byDate = new Map();
|
||||
recentFetches.forEach((fetch) => {
|
||||
const key = String(fetch?.fetched_at || '').trim();
|
||||
if (!key || byDate.has(key)) {
|
||||
return;
|
||||
}
|
||||
byDate.set(key, {
|
||||
sortKey: key,
|
||||
label: fetch?.fetched_at_display || fetch?.fetched_at || key,
|
||||
fetch,
|
||||
rates: base !== '' ? { [base]: 1 } : {},
|
||||
});
|
||||
});
|
||||
|
||||
histories.forEach(({ currency, rows }) => {
|
||||
rows.forEach((row) => {
|
||||
const key = String(row?.fetched_at || row?.rate_date || '').trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
if (!byDate.has(key)) {
|
||||
byDate.set(key, {
|
||||
sortKey: key,
|
||||
label: row?.fetched_at_display || row?.fetched_at || row?.rate_date || key,
|
||||
fetch: recentFetches.find((fetch) => String(fetch?.fetched_at || '').trim() === key) || null,
|
||||
rates: base !== '' ? { [base]: 1 } : {},
|
||||
});
|
||||
}
|
||||
const entry = byDate.get(key);
|
||||
if (entry && typeof row?.rate === 'number' && Number.isFinite(row.rate)) {
|
||||
entry.rates[currency] = row.rate;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const mergedRows = Array.from(byDate.values())
|
||||
.sort((left, right) => String(right.sortKey).localeCompare(String(left.sortKey)))
|
||||
.slice(0, 15);
|
||||
|
||||
renderHistory(mergedRows, selectedCurrencies);
|
||||
};
|
||||
|
||||
const calculateConversion = async () => {
|
||||
if (!nodes.convertFrom || !nodes.convertTo || !nodes.convertAmount || !nodes.convertResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = String(nodes.convertFrom.value || '').trim().toUpperCase();
|
||||
const to = String(nodes.convertTo.value || '').trim().toUpperCase();
|
||||
const amount = Number(nodes.convertAmount.value || '0');
|
||||
|
||||
if (!from || !to || !Number.isFinite(amount)) {
|
||||
nodes.convertResult.textContent = 'Bitte Quellwaehrung, Zielwaehrung und Betrag angeben.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (from === to) {
|
||||
nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to}`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams({ from, to });
|
||||
const data = await request(`/rate?${query.toString()}`);
|
||||
const rate = Number(data?.rate || 0);
|
||||
if (!Number.isFinite(rate) || rate <= 0) {
|
||||
throw new Error('Kein Kurs verfuegbar.');
|
||||
}
|
||||
const converted = amount * rate;
|
||||
nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${converted.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to} | Kurs ${rate.toLocaleString('de-DE', { maximumFractionDigits: 8 })}`;
|
||||
} catch (error) {
|
||||
nodes.convertResult.textContent = error.message || 'Umrechnung konnte nicht berechnet werden.';
|
||||
}
|
||||
};
|
||||
|
||||
[nodes.convertFrom, nodes.convertTo, nodes.convertAmount].forEach((node) => {
|
||||
node?.addEventListener('change', () => {
|
||||
calculateConversion().catch(() => {});
|
||||
});
|
||||
node?.addEventListener('input', () => {
|
||||
calculateConversion().catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
bindManualRefreshAction();
|
||||
|
||||
loadHistory().catch(() => {
|
||||
renderHistory([], preferredCurrencies);
|
||||
});
|
||||
calculateConversion().catch(() => {});
|
||||
})();
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\ModuleConfigException;
|
||||
use Modules\FxRates\Domain\FxRatesService;
|
||||
use Modules\FxRates\Infrastructure\FxRatesRepository;
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Modules\\FxRates\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
$moduleName = 'fx-rates';
|
||||
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
|
||||
|
||||
$mm->registerFunction($moduleName, 'table', static function (string $name): string {
|
||||
$prefix = 'fxrate_';
|
||||
$sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return $prefix . $sanitized;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'settings', static function (): array {
|
||||
$saved = modules()->settings('fx-rates');
|
||||
$provider = trim((string) ($saved['provider'] ?? (getenv('FX_RATES_PROVIDER') ?: getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi')));
|
||||
$apiVersion = strtolower(trim((string) ($saved['api_version'] ?? 'v2')));
|
||||
$apiUrl = rtrim((string) ($saved['api_url'] ?? (getenv('FX_RATES_API_URL') ?: getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net')), '/');
|
||||
$apiKey = trim((string) ($saved['api_key'] ?? (getenv('FX_RATES_API_KEY') ?: getenv('MINING_CHECKER_FX_API_KEY') ?: '')));
|
||||
$timeout = max(2, (int) ($saved['timeout_sec'] ?? (getenv('FX_RATES_TIMEOUT') ?: getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10)));
|
||||
|
||||
$preferredCurrencies = $saved['preferred_currencies'] ?? ['EUR', 'USD', 'DOGE'];
|
||||
if (is_string($preferredCurrencies)) {
|
||||
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
|
||||
}
|
||||
$preferredCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $code): string => strtoupper(trim((string) $code)),
|
||||
is_array($preferredCurrencies) ? $preferredCurrencies : []
|
||||
), static fn (string $code): bool => $code !== '')));
|
||||
|
||||
$currencyCatalog = $saved['currency_catalog'] ?? [];
|
||||
$currencyCatalog = array_values(array_filter(array_map(static function (mixed $item): ?array {
|
||||
if (!is_array($item)) {
|
||||
return null;
|
||||
}
|
||||
$code = strtoupper(trim((string) ($item['code'] ?? '')));
|
||||
$name = trim((string) ($item['name'] ?? ''));
|
||||
if ($code === '' || $name === '') {
|
||||
return null;
|
||||
}
|
||||
return ['code' => $code, 'name' => $name];
|
||||
}, is_array($currencyCatalog) ? $currencyCatalog : [])));
|
||||
|
||||
return [
|
||||
'provider' => $provider !== '' ? $provider : 'currencyapi',
|
||||
'api_version' => in_array($apiVersion, ['v2', 'v3'], true) ? $apiVersion : 'v2',
|
||||
'api_url' => $apiUrl,
|
||||
'api_key' => $apiKey,
|
||||
'timeout_sec' => $timeout,
|
||||
'refresh_max_age_minutes' => max(1, (int) ($saved['refresh_max_age_minutes'] ?? 60)),
|
||||
'default_base_currency' => strtoupper(trim((string) ($saved['default_base_currency'] ?? 'EUR'))) ?: 'EUR',
|
||||
'display_base_currency' => strtoupper(trim((string) ($saved['display_base_currency'] ?? ($saved['default_base_currency'] ?? 'EUR')))) ?: 'EUR',
|
||||
'preferred_currencies' => $preferredCurrencies,
|
||||
'currency_catalog' => $currencyCatalog,
|
||||
'currency_catalog_synced_at' => trim((string) ($saved['currency_catalog_synced_at'] ?? '')),
|
||||
'schedule_timezone' => trim((string) ($saved['schedule_timezone'] ?? nexus_cron_timezone_name())) ?: nexus_cron_timezone_name(),
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'save_runtime_settings', static function (array $payload): array {
|
||||
$current = modules()->settings('fx-rates');
|
||||
$normalized = module_fn('fx-rates', 'settings');
|
||||
|
||||
if (array_key_exists('default_base_currency', $payload)) {
|
||||
$normalized['default_base_currency'] = strtoupper(trim((string) $payload['default_base_currency'])) ?: $normalized['default_base_currency'];
|
||||
}
|
||||
if (array_key_exists('display_base_currency', $payload)) {
|
||||
$normalized['display_base_currency'] = strtoupper(trim((string) $payload['display_base_currency'])) ?: $normalized['display_base_currency'];
|
||||
}
|
||||
if (array_key_exists('preferred_currencies', $payload)) {
|
||||
$preferredCurrencies = $payload['preferred_currencies'];
|
||||
if (is_string($preferredCurrencies)) {
|
||||
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
|
||||
}
|
||||
$normalized['preferred_currencies'] = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $code): string => strtoupper(trim((string) $code)),
|
||||
is_array($preferredCurrencies) ? $preferredCurrencies : []
|
||||
), static fn (string $code): bool => $code !== '')));
|
||||
}
|
||||
|
||||
$catalogCodes = [];
|
||||
foreach (($normalized['currency_catalog'] ?? []) as $currency) {
|
||||
if (is_array($currency)) {
|
||||
$code = strtoupper(trim((string) ($currency['code'] ?? '')));
|
||||
if ($code !== '') {
|
||||
$catalogCodes[$code] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($catalogCodes !== [] && !isset($catalogCodes[$normalized['display_base_currency']])) {
|
||||
$normalized['display_base_currency'] = $normalized['default_base_currency'];
|
||||
}
|
||||
if ($catalogCodes !== []) {
|
||||
$normalized['preferred_currencies'] = array_values(array_filter(
|
||||
$normalized['preferred_currencies'],
|
||||
static fn (string $code): bool => isset($catalogCodes[$code])
|
||||
));
|
||||
}
|
||||
|
||||
$toSave = array_merge($current, [
|
||||
'default_base_currency' => $normalized['default_base_currency'],
|
||||
'display_base_currency' => $normalized['display_base_currency'],
|
||||
'preferred_currencies' => $normalized['preferred_currencies'],
|
||||
]);
|
||||
|
||||
modules()->saveSettings('fx-rates', $toSave);
|
||||
|
||||
return module_fn('fx-rates', 'settings');
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): PDO {
|
||||
$settings = modules()->settings($moduleName);
|
||||
$useSeparate = !empty($settings['use_separate_db']);
|
||||
|
||||
if ($useSeparate) {
|
||||
$module = modules()->get($moduleName);
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
return modules()->modulePdo($moduleName, $fallback);
|
||||
}
|
||||
|
||||
$base = app()->basePdo();
|
||||
if ($base instanceof PDO) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
throw new ModuleConfigException(
|
||||
$moduleName,
|
||||
'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.'
|
||||
);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'repository', static function (): FxRatesRepository {
|
||||
return new FxRatesRepository(module_fn('fx-rates', 'pdo'), 'fxrate_');
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'ensure_schema', static function (): void {
|
||||
module_fn('fx-rates', 'repository')->ensureSchema();
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'service', static function (): FxRatesService {
|
||||
module_fn('fx-rates', 'ensure_schema');
|
||||
return new FxRatesService(
|
||||
module_fn('fx-rates', 'repository'),
|
||||
module_fn('fx-rates', 'settings')
|
||||
);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'setup_actions', static function (): array {
|
||||
return [
|
||||
[
|
||||
'name' => 'sync_currency_catalog',
|
||||
'label' => 'Waehrungskatalog synchronisieren',
|
||||
'help' => 'Laedt die verfuegbaren Waehrungen einmalig aus dem konfigurierten FX-Provider.',
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'run_setup_action', static function (string $action): array {
|
||||
return match ($action) {
|
||||
'sync_currency_catalog' => (static function (): array {
|
||||
$result = module_fn('fx-rates', 'service')->refreshCurrencyCatalog();
|
||||
$current = modules()->settings('fx-rates');
|
||||
|
||||
$catalog = [];
|
||||
foreach (is_array($result['currencies'] ?? null) ? $result['currencies'] : [] as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$code = strtoupper(trim((string) ($item['code'] ?? '')));
|
||||
$name = trim((string) ($item['name'] ?? ''));
|
||||
if ($code === '' || $name === '') {
|
||||
continue;
|
||||
}
|
||||
$catalog[$code] = ['code' => $code, 'name' => $name];
|
||||
}
|
||||
|
||||
$latest = module_fn('fx-rates', 'service')->latestStatus();
|
||||
if (is_array($latest) && !empty($latest['id'])) {
|
||||
$snapshot = module_fn('fx-rates', 'snapshot', null, null, null, null);
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
foreach (array_keys($rates) as $code) {
|
||||
$code = strtoupper(trim((string) $code));
|
||||
if ($code !== '' && !isset($catalog[$code])) {
|
||||
$catalog[$code] = ['code' => $code, 'name' => $code];
|
||||
}
|
||||
}
|
||||
$baseCode = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
|
||||
if ($baseCode !== '' && !isset($catalog[$baseCode])) {
|
||||
$catalog[$baseCode] = ['code' => $baseCode, 'name' => $baseCode];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
(string) ($current['default_base_currency'] ?? ''),
|
||||
(string) ($current['display_base_currency'] ?? ''),
|
||||
...((is_array($current['preferred_currencies'] ?? null) ? $current['preferred_currencies'] : [])),
|
||||
] as $code) {
|
||||
$code = strtoupper(trim((string) $code));
|
||||
if ($code !== '' && !isset($catalog[$code])) {
|
||||
$catalog[$code] = ['code' => $code, 'name' => $code];
|
||||
}
|
||||
}
|
||||
|
||||
ksort($catalog);
|
||||
$current['currency_catalog'] = array_values($catalog);
|
||||
$current['currency_catalog_synced_at'] = gmdate('Y-m-d H:i:s');
|
||||
modules()->saveSettings('fx-rates', $current);
|
||||
return $result + [
|
||||
'currencies' => array_values($catalog),
|
||||
'synced_count' => count($catalog),
|
||||
'message' => 'Waehrungskatalog synchronisiert. ' . count($catalog) . ' Waehrungen verarbeitet.',
|
||||
];
|
||||
})(),
|
||||
default => throw new \RuntimeException('Unbekannte Setup-Aktion.'),
|
||||
};
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'refresh_latest', static function (?array $currencies = null, ?string $baseCurrency = null): array {
|
||||
return module_fn('fx-rates', 'service')->refreshLatestRates($currencies, $baseCurrency);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'ensure_fresh_latest_rates', static function (float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null): array {
|
||||
return module_fn('fx-rates', 'service')->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'rate', static function (string $fromCurrency, string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array {
|
||||
return module_fn('fx-rates', 'service')->findRate($fromCurrency, $toCurrency, $at, $windowMinutes);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'convert', static function (?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float {
|
||||
return module_fn('fx-rates', 'service')->convert($amount, $fromCurrency, $toCurrency, $at, $windowMinutes);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'snapshot', static function (?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array {
|
||||
return module_fn('fx-rates', 'service')->snapshot($baseCurrency, $at, $symbols, $windowMinutes);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'recent_fetches', static function (int $limit = 20): array {
|
||||
return module_fn('fx-rates', 'service')->recentFetches($limit);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'scheduled_refresh', static function (array $context = []): array {
|
||||
$result = module_fn('fx-rates', 'service')->runScheduledRefresh($context);
|
||||
if (function_exists('module_debug_push')) {
|
||||
module_debug_push('fx-rates', [
|
||||
'label' => 'Intervall-Aufgabe',
|
||||
'type' => 'scheduler:run',
|
||||
'task' => 'daily_refresh',
|
||||
'context' => $context,
|
||||
'message' => (string) ($result['message'] ?? ''),
|
||||
]);
|
||||
}
|
||||
return $result;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Waehrungskurse",
|
||||
"description": "Zentrale Verwaltung fuer Waehrungskurse, Snapshots und FX-API-Abrufe.",
|
||||
"actions": [
|
||||
{ "label": "Setup", "href": "/modules/setup/fx-rates", "variant": "secondary" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Ueberblick", "href": "/module/fx-rates" },
|
||||
{ "label": "Waehrungen", "href": "/module/fx-rates/currencies", "match_prefixes": ["/module/fx-rates/currencies"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"title": "Waehrungskurse",
|
||||
"version": "0.1.5",
|
||||
"description": "Zentrales Modul fuer Waehrungskurse, Historie und API-Abrufe.",
|
||||
"enabled_by_default": true,
|
||||
"setup": {
|
||||
"fields": [
|
||||
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
|
||||
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" },
|
||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
|
||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
|
||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
|
||||
{ "name": "provider", "label": "FX Provider", "type": "text", "required": false, "help": "Unterstuetzt legacy currencyapi.net und currencyapi.com v3." },
|
||||
{ "name": "api_version", "label": "FX API Version", "type": "select", "required": false, "help": "Steuert die Endpoint-Version unabhaengig von der Domain." },
|
||||
{ "name": "api_url", "label": "FX API URL", "type": "text", "required": false, "help": "Nur die Basis-URL eintragen, z.B. https://api.currencyapi.com oder https://currencyapi.net." },
|
||||
{ "name": "api_key", "label": "FX API Key", "type": "password", "required": false },
|
||||
{ "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false },
|
||||
{ "name": "refresh_max_age_minutes", "label": "Max. Alter fuer API-Refresh (Min.)", "type": "number", "required": false, "help": "Blockiert neue API-Refresh-Aufrufe, solange der letzte gespeicherte Abruf juenger ist. Manuelle Abrufe koennen nach Hinweis trotzdem erzwungen werden; Cron ignoriert diesen Wert." },
|
||||
{ "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." },
|
||||
{ "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
|
||||
]
|
||||
},
|
||||
"scheduler_jobs": [
|
||||
{
|
||||
"name": "rates_refresh",
|
||||
"label": "Kursabruf",
|
||||
"callback": "scheduled_refresh",
|
||||
"mode": "multi",
|
||||
"default_enabled": true,
|
||||
"default_cron": "0 18 * * *",
|
||||
"default_timezone": "Europe/Berlin",
|
||||
"timezone_setting": "schedule_timezone",
|
||||
"lock_minutes": 120,
|
||||
"help": "Zeitgesteuerter Abruf und das Speichern neuer FX-Snapshots.",
|
||||
"builder": {
|
||||
"allow_manual": true,
|
||||
"presets": ["daily", "every_x_days", "weekly", "monthly_day", "every_x_hours"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"db_defaults": {
|
||||
"driver": "pgsql",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"dbname": "",
|
||||
"schema": "public",
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"auth": {
|
||||
"required": true,
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
$file = (string) ($_GET['file'] ?? '');
|
||||
$base = realpath(__DIR__ . '/../assets');
|
||||
$map = [
|
||||
'fx-rates.css' => $base . '/fx-rates.css',
|
||||
'fx-rates.js' => $base . '/fx-rates.js',
|
||||
'fx-rates-currencies.js' => $base . '/fx-rates-currencies.js',
|
||||
];
|
||||
|
||||
if (!isset($map[$file])) {
|
||||
http_response_code(404);
|
||||
exit('Not found');
|
||||
}
|
||||
|
||||
$path = $map[$file];
|
||||
if (!$base || !is_file($path) || !str_starts_with($path, $base)) {
|
||||
http_response_code(404);
|
||||
exit('Not found');
|
||||
}
|
||||
|
||||
$ext = pathinfo($path, PATHINFO_EXTENSION);
|
||||
if ($ext === 'css') {
|
||||
header('Content-Type: text/css; charset=utf-8');
|
||||
} elseif ($ext === 'js') {
|
||||
header('Content-Type: application/javascript; charset=utf-8');
|
||||
} else {
|
||||
header('Content-Type: application/octet-stream');
|
||||
}
|
||||
|
||||
readfile($path);
|
||||
exit;
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/fx-rates/asset?file=fx-rates.css');
|
||||
$assets->addScript('/module/fx-rates/asset?file=fx-rates-currencies.js', 'footer', true);
|
||||
}
|
||||
|
||||
$settings = module_fn('fx-rates', 'settings');
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
$notice = trim((string) ($_GET['notice'] ?? ''));
|
||||
$error = trim((string) ($_GET['error'] ?? ''));
|
||||
|
||||
if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
|
||||
try {
|
||||
$action = trim((string) ($_POST['fx_action'] ?? ''));
|
||||
if ($action === 'save_selection') {
|
||||
$payload = [
|
||||
'display_base_currency' => (string) ($_POST['display_base_currency'] ?? ''),
|
||||
'preferred_currencies' => $_POST['preferred_currencies'] ?? [],
|
||||
];
|
||||
$saved = module_fn('fx-rates', 'save_runtime_settings', $payload);
|
||||
$params = ['notice' => 'Waehrungs-Auswahl gespeichert.'];
|
||||
if (is_array($saved) && !empty($saved['display_base_currency'])) {
|
||||
$params['base'] = (string) $saved['display_base_currency'];
|
||||
}
|
||||
redirect('/module/fx-rates/currencies?' . http_build_query($params));
|
||||
}
|
||||
|
||||
if ($action === 'sync_catalog') {
|
||||
$result = module_fn('fx-rates', 'run_setup_action', 'sync_currency_catalog');
|
||||
redirect('/module/fx-rates/currencies?' . http_build_query([
|
||||
'notice' => sprintf('Waehrungskatalog synchronisiert. %d Waehrungen verarbeitet.', (int) ($result['synced_count'] ?? 0)),
|
||||
]));
|
||||
}
|
||||
|
||||
if ($action === 'refresh_rates') {
|
||||
$result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual');
|
||||
redirect('/module/fx-rates/currencies?' . http_build_query([
|
||||
'notice' => sprintf('Alle Wechselkurse aktualisiert. %d Werte gespeichert.', (int) ($result['updated_count'] ?? 0)),
|
||||
]));
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
redirect('/module/fx-rates/currencies?' . http_build_query([
|
||||
'error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Aktion konnte nicht ausgefuehrt werden.',
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
$catalog = is_array($settings['currency_catalog'] ?? null) ? $settings['currency_catalog'] : [];
|
||||
$preferredCurrencies = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : [];
|
||||
$savedDisplayBaseCurrency = strtoupper(trim((string) ($settings['display_base_currency'] ?? $settings['default_base_currency'] ?? 'EUR')));
|
||||
$requestedDisplayBaseCurrency = strtoupper(trim((string) ($_GET['base'] ?? '')));
|
||||
$latest = $service->latestStatus();
|
||||
$recentFetches = $service->recentFetches(15);
|
||||
|
||||
$currencies = [];
|
||||
foreach ($catalog as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$code = strtoupper(trim((string) ($item['code'] ?? '')));
|
||||
$name = trim((string) ($item['name'] ?? ''));
|
||||
if ($code === '' || $name === '') {
|
||||
continue;
|
||||
}
|
||||
$currencies[] = [
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
];
|
||||
}
|
||||
|
||||
$cryptoCodes = array_fill_keys([
|
||||
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
|
||||
'SOL', 'USDC', 'USDT', 'XAG', 'XAU', 'XRP',
|
||||
], true);
|
||||
$fiatCount = 0;
|
||||
$cryptoCount = 0;
|
||||
foreach ($currencies as $currency) {
|
||||
if (isset($cryptoCodes[$currency['code']])) {
|
||||
$cryptoCount++;
|
||||
} else {
|
||||
$fiatCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$catalogCodes = [];
|
||||
foreach ($currencies as $currency) {
|
||||
$catalogCodes[(string) $currency['code']] = true;
|
||||
}
|
||||
|
||||
$displayBaseCurrency = $requestedDisplayBaseCurrency !== '' ? $requestedDisplayBaseCurrency : $savedDisplayBaseCurrency;
|
||||
if ($displayBaseCurrency === '' || (!isset($catalogCodes[$displayBaseCurrency]) && $preferredCurrencies !== [])) {
|
||||
$displayBaseCurrency = $savedDisplayBaseCurrency !== '' ? $savedDisplayBaseCurrency : (string) ($preferredCurrencies[0] ?? 'EUR');
|
||||
}
|
||||
|
||||
$tableCurrencies = [];
|
||||
foreach ([$displayBaseCurrency, ...$preferredCurrencies] as $currency) {
|
||||
$currency = strtoupper(trim((string) $currency));
|
||||
if ($currency !== '' && !in_array($currency, $tableCurrencies, true)) {
|
||||
$tableCurrencies[] = $currency;
|
||||
}
|
||||
}
|
||||
|
||||
$currencyPageData = json_encode([
|
||||
'currencies' => $currencies,
|
||||
'preferred_currencies' => array_values(array_unique(array_map(static fn (mixed $code): string => strtoupper(trim((string) $code)), $preferredCurrencies))),
|
||||
'display_base_currency' => $displayBaseCurrency,
|
||||
'saved_display_base_currency' => $savedDisplayBaseCurrency,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$tabs = [
|
||||
['label' => 'Ueberblick', 'href' => '/module/fx-rates'],
|
||||
['label' => 'Waehrungen', 'href' => '/module/fx-rates/currencies', 'active' => true],
|
||||
];
|
||||
?>
|
||||
<?= module_shell_header('fx-rates', [
|
||||
'title' => 'Waehrungen',
|
||||
'tabs' => $tabs,
|
||||
'actions' => [
|
||||
['label' => 'Nexus Übersicht', 'href' => '/', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
['label' => 'Setup', 'href' => '/modules/setup/fx-rates', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
],
|
||||
]) ?>
|
||||
<div id="fx-rates-currencies" data-page='<?= e(is_string($currencyPageData) ? $currencyPageData : '{}') ?>'>
|
||||
<?php if ($notice !== ''): ?>
|
||||
<section class="section-box">
|
||||
<div class="fx-message is-success"><?= e($notice) ?></div>
|
||||
</section>
|
||||
<?php elseif ($error !== ''): ?>
|
||||
<section class="section-box">
|
||||
<div class="fx-message is-error"><?= e($error) ?></div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-section-head">
|
||||
<div>
|
||||
<h2>Waehrungs-Update</h2>
|
||||
<p>Auswahl wird in den Waehrungskurs-Einstellungen gespeichert und steht damit auf Handy und Desktop gleich zur Verfuegung.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fx-action-row">
|
||||
<form method="post">
|
||||
<input type="hidden" name="fx_action" value="refresh_rates">
|
||||
<button type="submit" class="module-button module-button--primary">Alle Wechselkurse aktualisieren</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
<input type="hidden" name="fx_action" value="sync_catalog">
|
||||
<button type="submit" class="module-button module-button--ghost">Waehrungskatalog sync</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="fx-card-grid">
|
||||
<section class="card-box">
|
||||
<div class="fx-mini-label">Fiat</div>
|
||||
<div class="fx-card-value"><?= e((string) $fiatCount) ?> Waehrungen</div>
|
||||
</section>
|
||||
<section class="card-box">
|
||||
<div class="fx-mini-label">Krypto</div>
|
||||
<div class="fx-card-value"><?= e((string) $cryptoCount) ?> Waehrungen</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-field-label">Bevorzugte Waehrungen fuer Anzeige</div>
|
||||
<div class="fx-currency-selection-row">
|
||||
<div class="fx-token-list fx-token-list--inline" data-fx-token-list></div>
|
||||
<div class="fx-currency-search">
|
||||
<input type="text" class="fx-input" value="" placeholder="Waehrung hinzufuegen: EUR, USD, DOGE oder Euro" autocomplete="off" data-fx-search-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fx-suggestion-list" data-fx-suggestions></div>
|
||||
|
||||
<div class="fx-field fx-currency-search">
|
||||
<label class="fx-field-label" for="fx-display-base-select">Darstellung auf Basis von</label>
|
||||
<select id="fx-display-base-select" class="fx-select" data-fx-display-base-select></select>
|
||||
</div>
|
||||
|
||||
<div class="fx-save-row">
|
||||
<form method="post">
|
||||
<input type="hidden" name="fx_action" value="save_selection">
|
||||
<input type="hidden" name="display_base_currency" value="<?= e($displayBaseCurrency) ?>" data-fx-display-base-hidden>
|
||||
<div data-fx-hidden-preferred></div>
|
||||
<button type="submit" class="module-button module-button--ghost">Auswahl speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-card-head">
|
||||
<div>
|
||||
<h2>Letzte 15 Kurs-Uploads</h2>
|
||||
<p>Zeigt die zuletzt gespeicherten Wechselkurse aus der Datenbank.</p>
|
||||
</div>
|
||||
<div class="fx-card-meta">
|
||||
<div><strong>Anzeige-Basis:</strong> <?= e($displayBaseCurrency) ?></div>
|
||||
<div><strong>Letzter Abruf:</strong> <?= e((string) ($latest['fetched_at_display'] ?? $latest['fetched_at'] ?? 'noch keiner')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fx-table-wrap">
|
||||
<table class="fx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeit</th>
|
||||
<?php foreach ($tableCurrencies as $currency): ?>
|
||||
<th><?= e((string) $currency) ?></th>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($recentFetches === []): ?>
|
||||
<tr><td colspan="<?= 1 + count($tableCurrencies) ?>">Noch keine Abrufe vorhanden.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($recentFetches as $fetch): ?>
|
||||
<?php
|
||||
$fetchBaseCurrency = strtoupper(trim((string) ($fetch['base_currency'] ?? '')));
|
||||
$snapshot = $service->snapshotByFetchId((int) ($fetch['id'] ?? 0), $fetchBaseCurrency, $tableCurrencies);
|
||||
$originalRates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
$displayBaseRate = $displayBaseCurrency === $fetchBaseCurrency
|
||||
? 1.0
|
||||
: (is_numeric($originalRates[$displayBaseCurrency] ?? null) ? (float) $originalRates[$displayBaseCurrency] : null);
|
||||
$tableRates = [];
|
||||
foreach ($tableCurrencies as $currency) {
|
||||
$currency = strtoupper(trim((string) $currency));
|
||||
if ($currency === '') {
|
||||
continue;
|
||||
}
|
||||
if ($currency === $displayBaseCurrency) {
|
||||
$tableRates[$currency] = 1.0;
|
||||
continue;
|
||||
}
|
||||
if ($displayBaseRate === null || $displayBaseRate <= 0) {
|
||||
$tableRates[$currency] = null;
|
||||
continue;
|
||||
}
|
||||
if ($currency === $fetchBaseCurrency) {
|
||||
$tableRates[$currency] = 1 / $displayBaseRate;
|
||||
continue;
|
||||
}
|
||||
$rawRate = $originalRates[$currency] ?? null;
|
||||
$tableRates[$currency] = is_numeric($rawRate) ? ((float) $rawRate / $displayBaseRate) : null;
|
||||
}
|
||||
$infoTitle = sprintf(
|
||||
'Basis: %s | Provider: %s | Ausloeser: %s',
|
||||
(string) ($fetch['base_currency'] ?? '-'),
|
||||
(string) ($fetch['provider'] ?? '-'),
|
||||
(string) ($fetch['trigger_source_label'] ?? $fetch['trigger_source'] ?? '-')
|
||||
);
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fx-history-date">
|
||||
<span><?= e((string) ($fetch['fetched_at_display'] ?? $fetch['fetched_at'] ?? '')) ?></span>
|
||||
<button
|
||||
type="button"
|
||||
class="fx-info-button"
|
||||
title="<?= e($infoTitle) ?>"
|
||||
aria-label="<?= e('Abrufinfo fuer ' . (string) ($fetch['fetched_at_display'] ?? $fetch['fetched_at'] ?? '')) ?>"
|
||||
>i</button>
|
||||
</div>
|
||||
</td>
|
||||
<?php foreach ($tableCurrencies as $currency): ?>
|
||||
<?php $value = $tableRates[(string) $currency] ?? null; ?>
|
||||
<td><?= is_numeric($value) ? e(number_format((float) $value, 8, ',', '')) : '–' ?></td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/fx-rates/asset?file=fx-rates.css');
|
||||
$assets->addScript('/module/fx-rates/asset?file=fx-rates.js', 'footer', true);
|
||||
}
|
||||
|
||||
$settings = module_fn('fx-rates', 'settings');
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
$preferredCurrencies = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : [];
|
||||
$apiDescribeUrl = APP_API_BASE . '/fx-rates/v1/endpoints';
|
||||
$notice = trim((string) ($_GET['notice'] ?? ''));
|
||||
$error = trim((string) ($_GET['error'] ?? ''));
|
||||
|
||||
if ((string) ($_GET['refresh'] ?? '') === '1') {
|
||||
try {
|
||||
$force = !empty($_GET['force']);
|
||||
if ($force) {
|
||||
$result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual');
|
||||
} else {
|
||||
$result = $service->autoRefreshLatestRates(
|
||||
(string) ($settings['default_base_currency'] ?? ''),
|
||||
null,
|
||||
(int) ($settings['refresh_max_age_minutes'] ?? 60),
|
||||
'manual'
|
||||
);
|
||||
}
|
||||
|
||||
$params = !empty($result['reused'])
|
||||
? [
|
||||
'notice' => sprintf(
|
||||
'Kein neuer API-Abruf. Der letzte gespeicherte Snapshot ist juenger als %d Minuten. Fuer einen erzwungenen Abruf bitte bestaetigen.',
|
||||
(int) ($settings['refresh_max_age_minutes'] ?? 60)
|
||||
),
|
||||
]
|
||||
: [
|
||||
'notice' => sprintf(
|
||||
'Aktuelle Kurse gespeichert. %d Werte aktualisiert.',
|
||||
(int) ($result['updated_count'] ?? 0)
|
||||
),
|
||||
];
|
||||
} catch (\Throwable $exception) {
|
||||
$params = ['error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Kurse konnten nicht aktualisiert werden.'];
|
||||
}
|
||||
|
||||
redirect('/module/fx-rates?' . http_build_query($params));
|
||||
}
|
||||
|
||||
$latest = $service->latestStatus();
|
||||
$recentFetches = $service->recentFetches(15);
|
||||
$pageData = json_encode([
|
||||
'settings' => $settings,
|
||||
'latest' => $latest,
|
||||
'preferred_currencies' => $preferredCurrencies,
|
||||
'recent_fetches' => $recentFetches,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$tabs = [
|
||||
['label' => 'Ueberblick', 'href' => '/module/fx-rates', 'active' => true],
|
||||
['label' => 'Waehrungen', 'href' => '/module/fx-rates/currencies'],
|
||||
];
|
||||
?>
|
||||
<?= module_shell_header('fx-rates', [
|
||||
'title' => 'Ueberblick',
|
||||
'tabs' => $tabs,
|
||||
'actions' => [
|
||||
['label' => 'Nexus Übersicht', 'href' => '/', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
['label' => 'Setup', 'href' => '/modules/setup/fx-rates', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
['label' => 'Aktuelle Kurse abrufen', 'href' => '/module/fx-rates?refresh=1', 'variant' => 'secondary', 'size' => 'sm'],
|
||||
],
|
||||
]) ?>
|
||||
<div id="fx-rates-app" data-page='<?= e(is_string($pageData) ? $pageData : '{}') ?>'>
|
||||
<?php if ($notice !== ''): ?>
|
||||
<section class="section-box">
|
||||
<div class="fx-message is-success"><?= e($notice) ?></div>
|
||||
</section>
|
||||
<?php elseif ($error !== ''): ?>
|
||||
<section class="section-box">
|
||||
<div class="fx-message is-error"><?= e($error) ?></div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-section-head">
|
||||
<div>
|
||||
<h2>Umrechnung</h2>
|
||||
<p>Umrechnung auf Basis des letzten verfuegbaren Kurses zwischen den bevorzugten Waehrungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="fx-api-note">
|
||||
API-Self-Describe-Endpoint:
|
||||
<a href="<?= e($apiDescribeUrl) ?>" target="_blank" rel="noopener noreferrer"><?= e($apiDescribeUrl) ?></a>
|
||||
</p>
|
||||
<div class="fx-form-grid">
|
||||
<label>
|
||||
<span>Quellwaehrung</span>
|
||||
<select name="convert_from">
|
||||
<?php foreach ($preferredCurrencies as $currency): ?>
|
||||
<option value="<?= e((string) $currency) ?>"><?= e((string) $currency) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Zielwaehrung</span>
|
||||
<select name="convert_to">
|
||||
<?php foreach ($preferredCurrencies as $currency): ?>
|
||||
<option value="<?= e((string) $currency) ?>" <?= (string) $currency === (string) ($preferredCurrencies[1] ?? $preferredCurrencies[0] ?? '') ? 'selected' : '' ?>><?= e((string) $currency) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Betrag</span>
|
||||
<input type="number" name="convert_amount" min="0" step="0.00000001" value="1">
|
||||
</label>
|
||||
</div>
|
||||
<div class="fx-convert-result" data-bind="convert-result">Noch keine Umrechnung berechnet.</div>
|
||||
</section>
|
||||
|
||||
<section class="section-box">
|
||||
<div class="fx-card-head">
|
||||
<div>
|
||||
<h2>Kursverlauf</h2>
|
||||
<p>Neueste Abrufe zuerst. Verlauf der bevorzugten Waehrungen relativ zur Anzeige-Basiswaehrung.</p>
|
||||
</div>
|
||||
<div class="fx-card-meta">
|
||||
<div><strong>Anzeige-Basis:</strong> <?= e((string) ($settings['display_base_currency'] ?? $settings['default_base_currency'] ?? '')) ?></div>
|
||||
<div><strong>Letzter Abruf:</strong> <?= e((string) ($latest['fetched_at_display'] ?? $latest['fetched_at'] ?? 'noch keiner')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fx-table-wrap">
|
||||
<table class="fx-table">
|
||||
<thead data-bind="history-head">
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Kurse</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-bind="history-body">
|
||||
<tr><td colspan="2">Noch keine Verlaufsdaten geladen.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Api;
|
||||
|
||||
use Modules\FxRates\Domain\FxRatesService;
|
||||
|
||||
final class Router
|
||||
{
|
||||
public function __construct(
|
||||
private FxRatesService $service
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(string $relativePath): never
|
||||
{
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$path = trim($relativePath, '/');
|
||||
|
||||
try {
|
||||
if ($path === 'v1/health' && $method === 'GET') {
|
||||
$this->respond(['ok' => true, 'module' => 'fx-rates']);
|
||||
}
|
||||
|
||||
if ($path === 'v1/endpoints' && $method === 'GET') {
|
||||
$this->respond(['data' => $this->endpointCatalog()]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/status' && $method === 'GET') {
|
||||
$this->respond(['data' => $this->service->latestStatuses()]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/recent-fetches' && $method === 'GET') {
|
||||
$limit = max(1, min(50, (int) ($_GET['limit'] ?? 12)));
|
||||
$this->respond(['data' => $this->service->recentFetches($limit)]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/latest' && $method === 'GET') {
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
if ($symbols === null) {
|
||||
$settings = module_fn('fx-rates', 'settings');
|
||||
$symbols = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : null;
|
||||
}
|
||||
$snapshot = $this->service->snapshot($base, null, $symbols, null);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/fetch' && $method === 'GET') {
|
||||
$fetchId = max(0, (int) ($_GET['fetch_id'] ?? 0));
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$snapshot = $this->service->snapshotByFetchId($fetchId, $base, $symbols);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/nearest' && $method === 'GET') {
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$at = $this->stringOrNull($_GET['at'] ?? null);
|
||||
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
|
||||
$snapshot = $this->service->nearestSnapshot($base, (string) $at, $symbols, $windowMinutes);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/snapshot' && $method === 'GET') {
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$at = $this->stringOrNull($_GET['at'] ?? null);
|
||||
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
|
||||
$snapshot = $this->service->snapshot($base, $at, $symbols, $windowMinutes);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/rate' && $method === 'GET') {
|
||||
$from = $this->stringOrNull($_GET['from'] ?? null);
|
||||
$to = $this->stringOrNull($_GET['to'] ?? null);
|
||||
$at = $this->stringOrNull($_GET['at'] ?? null);
|
||||
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
|
||||
$rate = $this->service->findRate($from, $to, $at, $windowMinutes);
|
||||
$this->respond(['data' => $rate]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/history' && $method === 'GET') {
|
||||
$from = $this->stringOrNull($_GET['from'] ?? null);
|
||||
$to = $this->stringOrNull($_GET['to'] ?? null);
|
||||
$fromAt = $this->stringOrNull($_GET['from_at'] ?? null);
|
||||
$toAt = $this->stringOrNull($_GET['to_at'] ?? null);
|
||||
$limit = max(1, min(1000, (int) ($_GET['limit'] ?? 200)));
|
||||
$history = $this->service->history((string) $from, (string) $to, $fromAt, $toAt, $limit);
|
||||
$this->respond(['data' => $history]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/refresh' && $method === 'POST') {
|
||||
$input = $this->input();
|
||||
$base = $this->stringOrNull($input['base'] ?? null);
|
||||
$force = !empty($input['force']);
|
||||
$maxAgeMinutes = is_numeric($input['max_age_minutes'] ?? null) ? (int) $input['max_age_minutes'] : null;
|
||||
|
||||
$result = $force
|
||||
? $this->service->refreshLatestRates(null, $base, 'api')
|
||||
: $this->service->autoRefreshLatestRates($base, null, $maxAgeMinutes, 'api');
|
||||
|
||||
$this->respond(['data' => $result], 201);
|
||||
}
|
||||
|
||||
if ($path === 'v1/probe' && $method === 'GET') {
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$this->respond(['data' => $this->service->probeLatestRates($base)]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/settings' && $method === 'GET') {
|
||||
$this->respond(['data' => module_fn('fx-rates', 'settings')]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/settings' && $method === 'PUT') {
|
||||
$this->respond(['data' => module_fn('fx-rates', 'save_runtime_settings', $this->input())]);
|
||||
}
|
||||
|
||||
$this->respond(['error' => 'Unbekannter API-Pfad.'], 404);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->respond([
|
||||
'error' => 'FX-API Fehler.',
|
||||
'context' => ['message' => $exception->getMessage()],
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function respond(array $payload, int $statusCode = 200): never
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
private function input(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
$decoded = json_decode((string) $raw, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function parseCsv(mixed $value): ?array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$items = $value;
|
||||
} else {
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$items = explode(',', $value);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($items as $item) {
|
||||
$item = strtoupper(trim((string) $item));
|
||||
if ($item !== '') {
|
||||
$result[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
$result = array_values(array_unique($result));
|
||||
return $result !== [] ? $result : null;
|
||||
}
|
||||
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function intOrNull(mixed $value): ?int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
|
||||
private function endpointCatalog(): array
|
||||
{
|
||||
return [
|
||||
'module' => 'fx-rates',
|
||||
'version' => 'v1',
|
||||
'languages' => ['de', 'en'],
|
||||
'endpoints' => [
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/endpoints',
|
||||
'method' => 'GET',
|
||||
'description_de' => 'Gibt alle verfuegbaren FX-API-Endpunkte mit deutscher und englischer Erklaerung zurueck.',
|
||||
'description_en' => 'Returns all available FX API endpoints with German and English explanations.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/latest',
|
||||
'method' => 'GET',
|
||||
'params' => ['base', 'symbols'],
|
||||
'description_de' => 'Liefert den neuesten gespeicherten Snapshot, optional auf eine Zielbasis umgerechnet und auf ausgewaehlte Waehrungen gefiltert.',
|
||||
'description_en' => 'Returns the latest stored snapshot, optionally rebased to a target currency and filtered to selected symbols.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/fetch',
|
||||
'method' => 'GET',
|
||||
'params' => ['fetch_id', 'base', 'symbols'],
|
||||
'description_de' => 'Liefert einen gespeicherten Snapshot anhand der fetch_id, optional umgerechnet auf eine Zielbasis und gefiltert auf einzelne Waehrungen.',
|
||||
'description_en' => 'Returns a stored snapshot by fetch_id, optionally rebased to a target currency and filtered to selected symbols.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/nearest',
|
||||
'method' => 'GET',
|
||||
'params' => ['at', 'base', 'symbols', 'window_minutes'],
|
||||
'description_de' => 'Liefert den zeitlich naechsten gespeicherten Snapshot zu einem Datum/Uhrzeit-Wert.',
|
||||
'description_en' => 'Returns the stored snapshot nearest to a given date/time value.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/snapshot',
|
||||
'method' => 'GET',
|
||||
'params' => ['at', 'base', 'symbols', 'window_minutes'],
|
||||
'description_de' => 'Liefert einen Snapshot zur Zielbasis und sucht fuer einen Zeitpunkt den naechsten passenden gespeicherten Kurs.',
|
||||
'description_en' => 'Returns a snapshot for the requested base and finds the nearest matching stored rate for a given timestamp.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/rate',
|
||||
'method' => 'GET',
|
||||
'params' => ['from', 'to', 'at', 'window_minutes'],
|
||||
'description_de' => 'Liefert einen Einzelkurs zwischen zwei Waehrungen, direkt oder als Kreuzkurs aus gespeicherten Snapshots.',
|
||||
'description_en' => 'Returns a single rate between two currencies, directly or as a cross-rate from stored snapshots.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/history',
|
||||
'method' => 'GET',
|
||||
'params' => ['from', 'to', 'from_at', 'to_at', 'limit'],
|
||||
'description_de' => 'Liefert den gespeicherten Kursverlauf zwischen zwei Waehrungen fuer einen Zeitraum.',
|
||||
'description_en' => 'Returns the stored rate history between two currencies for a given time range.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/refresh',
|
||||
'method' => 'POST',
|
||||
'body' => ['base', 'force', 'max_age_minutes'],
|
||||
'description_de' => 'Aktualisiert Kurse nur dann neu, wenn der letzte Abruf aelter als die erlaubte Zeitspanne ist. Die Antwort enthaelt immer die fetch_id des verwendeten Snapshots.',
|
||||
'description_en' => 'Refreshes rates only if the last fetch is older than the allowed age. The response always includes the fetch_id of the snapshot used.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/status',
|
||||
'method' => 'GET',
|
||||
'description_de' => 'Liefert den neuesten gespeicherten Abruf je Basiswaehrung.',
|
||||
'description_en' => 'Returns the most recent stored fetch per base currency.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/recent-fetches',
|
||||
'method' => 'GET',
|
||||
'params' => ['limit'],
|
||||
'description_de' => 'Liefert die zuletzt gespeicherten Abrufe mit fetch_id und Zeitstempel.',
|
||||
'description_en' => 'Returns the most recently stored fetches including fetch_id and timestamp.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/probe',
|
||||
'method' => 'GET',
|
||||
'params' => ['base'],
|
||||
'description_de' => 'Prueft, ob der konfigurierte Provider aktuelle Kurse liefern kann.',
|
||||
'description_en' => 'Checks whether the configured provider can return current rates.',
|
||||
],
|
||||
[
|
||||
'path' => '/api/fx-rates/v1/settings',
|
||||
'method' => 'GET',
|
||||
'description_de' => 'Liefert die aktuellen Modul-Settings.',
|
||||
'description_en' => 'Returns the current module settings.',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,546 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Infrastructure;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class FxRatesRepository
|
||||
{
|
||||
private string $driver;
|
||||
|
||||
public function __construct(
|
||||
private PDO $pdo,
|
||||
private string $tablePrefix = 'fxrate_'
|
||||
) {
|
||||
$this->driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
|
||||
}
|
||||
|
||||
public function ensureSchema(): void
|
||||
{
|
||||
$fetchTable = $this->table('fetches');
|
||||
$rateTable = $this->table('rates');
|
||||
|
||||
if ($this->driver === 'pgsql') {
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
provider VARCHAR(64) NOT NULL,
|
||||
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$this->pdo->exec("ALTER TABLE {$fetchTable} ADD COLUMN IF NOT EXISTS trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
fetch_id INTEGER NOT NULL REFERENCES {$fetchTable}(id) ON DELETE CASCADE,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value NUMERIC(20,10) NOT NULL
|
||||
)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)");
|
||||
} elseif ($this->driver === 'mysql') {
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
provider VARCHAR(64) NOT NULL,
|
||||
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY {$fetchTable}_base_fetch_idx (base_currency, fetched_at, id),
|
||||
KEY {$fetchTable}_rate_date_idx (rate_date)
|
||||
)");
|
||||
$this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
fetch_id INTEGER NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL,
|
||||
KEY {$rateTable}_fetch_idx (fetch_id),
|
||||
KEY {$rateTable}_currency_idx (currency_code)
|
||||
)");
|
||||
} else {
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider VARCHAR(64) NOT NULL,
|
||||
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fetch_id INTEGER NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL
|
||||
)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)");
|
||||
}
|
||||
}
|
||||
|
||||
public function getLatestFetch(?string $baseCurrency = null): ?array
|
||||
{
|
||||
$sql = 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches');
|
||||
$params = [];
|
||||
if ($baseCurrency !== null && trim($baseCurrency) !== '') {
|
||||
$sql .= ' WHERE base_currency = :base_currency';
|
||||
$params['base_currency'] = strtoupper(trim($baseCurrency));
|
||||
}
|
||||
$sql .= ' ORDER BY fetched_at DESC, id DESC LIMIT 1';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
public function listLatestFetches(): array
|
||||
{
|
||||
$stmt = $this->pdo->query(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
ORDER BY fetched_at DESC, id DESC'
|
||||
);
|
||||
|
||||
$latestByBase = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||
$base = strtoupper(trim((string) ($row['base_currency'] ?? '')));
|
||||
if ($base === '' || isset($latestByBase[$base])) {
|
||||
continue;
|
||||
}
|
||||
$latestByBase[$base] = $this->normalizeFetch($row);
|
||||
}
|
||||
|
||||
ksort($latestByBase);
|
||||
return array_values($latestByBase);
|
||||
}
|
||||
|
||||
public function listRecentFetches(int $limit = 20): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
ORDER BY fetched_at DESC, id DESC
|
||||
LIMIT :limit'
|
||||
);
|
||||
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(
|
||||
fn (array $row): array => $this->normalizeFetch($row),
|
||||
$stmt->fetchAll(PDO::FETCH_ASSOC) ?: []
|
||||
);
|
||||
}
|
||||
|
||||
public function getSnapshotByFetchId(int $fetchId, ?array $symbols = null): ?array
|
||||
{
|
||||
$fetch = $this->getFetchById($fetchId);
|
||||
if ($fetch === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $fetch + [
|
||||
'rates' => $this->ratesForFetch($fetchId, $symbols),
|
||||
];
|
||||
}
|
||||
|
||||
public function findNearestFetch(?string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$targetTs = strtotime($timestamp);
|
||||
if ($targetTs === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($baseCurrency !== null && trim($baseCurrency) !== '') {
|
||||
return $this->getNearestFetch(strtoupper(trim($baseCurrency)), $timestamp, $windowMinutes);
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
foreach (['<=', '>='] as $operator) {
|
||||
$order = $operator === '<=' ? 'DESC' : 'ASC';
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE fetched_at ' . $operator . ' :target_at
|
||||
ORDER BY fetched_at ' . $order . ', id ' . $order . '
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['target_at' => $timestamp]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (is_array($row)) {
|
||||
$candidate = $this->normalizeFetch($row);
|
||||
$candidateTs = strtotime((string) ($candidate['fetched_at'] ?? ''));
|
||||
if ($candidateTs !== false) {
|
||||
$candidate['distance_seconds'] = abs($candidateTs - $targetTs);
|
||||
$candidates[] = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($candidates, static function (array $left, array $right): int {
|
||||
return ((int) ($left['distance_seconds'] ?? PHP_INT_MAX)) <=> ((int) ($right['distance_seconds'] ?? PHP_INT_MAX));
|
||||
});
|
||||
|
||||
$selected = $candidates[0];
|
||||
if ($windowMinutes !== null && $windowMinutes > 0 && (int) ($selected['distance_seconds'] ?? 0) > ($windowMinutes * 60)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
public function getNearestFetch(string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$baseCurrency = strtoupper(trim($baseCurrency));
|
||||
if ($baseCurrency === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$before = $this->findNeighborFetch($baseCurrency, $timestamp, '<=');
|
||||
$after = $this->findNeighborFetch($baseCurrency, $timestamp, '>=');
|
||||
$targetTs = strtotime($timestamp);
|
||||
if ($targetTs === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$selected = null;
|
||||
$selectedDiff = null;
|
||||
foreach ([$before, $after] as $candidate) {
|
||||
if (!is_array($candidate)) {
|
||||
continue;
|
||||
}
|
||||
$candidateTs = strtotime((string) ($candidate['fetched_at'] ?? ''));
|
||||
if ($candidateTs === false) {
|
||||
continue;
|
||||
}
|
||||
$diffSeconds = abs($candidateTs - $targetTs);
|
||||
if ($selected === null || $diffSeconds < (int) $selectedDiff) {
|
||||
$selected = $candidate;
|
||||
$selectedDiff = $diffSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
if ($selected === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($windowMinutes !== null && $windowMinutes > 0 && $selectedDiff !== null && $selectedDiff > ($windowMinutes * 60)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $selected + ['distance_seconds' => $selectedDiff];
|
||||
}
|
||||
|
||||
public function listDirectHistory(string $baseCurrency, string $targetCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array
|
||||
{
|
||||
$sql = 'SELECT
|
||||
r.id,
|
||||
f.id AS fetch_id,
|
||||
f.base_currency,
|
||||
r.currency_code AS target_currency,
|
||||
r.current_value AS rate,
|
||||
f.rate_date,
|
||||
f.provider,
|
||||
f.fetched_at
|
||||
FROM ' . $this->table('rates') . ' r
|
||||
INNER JOIN ' . $this->table('fetches') . ' f ON f.id = r.fetch_id
|
||||
WHERE f.base_currency = :base_currency
|
||||
AND r.currency_code = :target_currency';
|
||||
$params = [
|
||||
'base_currency' => strtoupper(trim($baseCurrency)),
|
||||
'target_currency' => strtoupper(trim($targetCurrency)),
|
||||
];
|
||||
|
||||
if ($from !== null && trim($from) !== '') {
|
||||
$sql .= ' AND f.fetched_at >= :from_at';
|
||||
$params['from_at'] = $from;
|
||||
}
|
||||
if ($to !== null && trim($to) !== '') {
|
||||
$sql .= ' AND f.fetched_at <= :to_at';
|
||||
$params['to_at'] = $to;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY f.fetched_at DESC, r.id DESC LIMIT :limit';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($params as $key => $value) {
|
||||
$stmt->bindValue(':' . $key, $value);
|
||||
}
|
||||
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(fn (array $row): array => $this->normalizeRate($row), $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
|
||||
}
|
||||
|
||||
public function saveFetch(string $baseCurrency, string $provider, string $rateDate, array $rates, ?string $fetchedAt = null, string $triggerSource = 'manual'): array
|
||||
{
|
||||
$baseCurrency = strtoupper(trim($baseCurrency));
|
||||
$provider = trim($provider) !== '' ? trim($provider) : 'currencyapi';
|
||||
$fetchedAt = trim((string) $fetchedAt) !== '' ? trim((string) $fetchedAt) : gmdate('Y-m-d H:i:s');
|
||||
$triggerSource = $this->normalizeTriggerSource($triggerSource);
|
||||
$normalizedRates = [];
|
||||
foreach ($rates as $currencyCode => $rate) {
|
||||
$currencyCode = strtoupper(trim((string) $currencyCode));
|
||||
if ($currencyCode === '' || $currencyCode === $baseCurrency || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$normalizedRates[$currencyCode] = (float) $rate;
|
||||
}
|
||||
|
||||
$startedTransaction = false;
|
||||
if (!$this->pdo->inTransaction()) {
|
||||
$this->pdo->beginTransaction();
|
||||
$startedTransaction = true;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->driver === 'pgsql') {
|
||||
$fetchStmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->table('fetches') . ' (
|
||||
provider, trigger_source, base_currency, rate_date, fetched_at
|
||||
) VALUES (
|
||||
:provider, :trigger_source, :base_currency, :rate_date, :fetched_at
|
||||
)
|
||||
RETURNING *'
|
||||
);
|
||||
$fetchStmt->execute([
|
||||
'provider' => $provider,
|
||||
'trigger_source' => $triggerSource,
|
||||
'base_currency' => $baseCurrency,
|
||||
'rate_date' => $rateDate,
|
||||
'fetched_at' => $fetchedAt,
|
||||
]);
|
||||
$fetch = $this->normalizeFetch($fetchStmt->fetch(PDO::FETCH_ASSOC) ?: []);
|
||||
} else {
|
||||
$fetchStmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->table('fetches') . ' (
|
||||
provider, trigger_source, base_currency, rate_date, fetched_at
|
||||
) VALUES (
|
||||
:provider, :trigger_source, :base_currency, :rate_date, :fetched_at
|
||||
)'
|
||||
);
|
||||
$fetchStmt->execute([
|
||||
'provider' => $provider,
|
||||
'trigger_source' => $triggerSource,
|
||||
'base_currency' => $baseCurrency,
|
||||
'rate_date' => $rateDate,
|
||||
'fetched_at' => $fetchedAt,
|
||||
]);
|
||||
$fetch = $this->getFetchById((int) $this->pdo->lastInsertId()) ?? [];
|
||||
}
|
||||
|
||||
$savedRates = [];
|
||||
if ($normalizedRates !== []) {
|
||||
$placeholders = [];
|
||||
$params = ['fetch_id' => (int) ($fetch['id'] ?? 0)];
|
||||
$index = 0;
|
||||
foreach ($normalizedRates as $currencyCode => $rate) {
|
||||
$codeKey = 'currency_code_' . $index;
|
||||
$valueKey = 'current_value_' . $index;
|
||||
$placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})";
|
||||
$params[$codeKey] = $currencyCode;
|
||||
$params[$valueKey] = $rate;
|
||||
$savedRates[] = [
|
||||
'fetch_id' => $fetch['id'] ?? null,
|
||||
'base_currency' => $baseCurrency,
|
||||
'target_currency' => $currencyCode,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $provider,
|
||||
'fetched_at' => $fetchedAt,
|
||||
];
|
||||
$index++;
|
||||
}
|
||||
|
||||
$insert = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->table('rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders)
|
||||
);
|
||||
$insert->execute($params);
|
||||
}
|
||||
|
||||
if ($startedTransaction) {
|
||||
$this->pdo->commit();
|
||||
}
|
||||
|
||||
return [
|
||||
'fetch' => $fetch,
|
||||
'rates' => $savedRates,
|
||||
];
|
||||
} catch (\Throwable $exception) {
|
||||
if ($startedTransaction && $this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function findFetchByBaseAndFetchedAt(string $baseCurrency, string $fetchedAt): ?array
|
||||
{
|
||||
$baseCurrency = strtoupper(trim($baseCurrency));
|
||||
$fetchedAt = trim($fetchedAt);
|
||||
if ($baseCurrency === '' || $fetchedAt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE base_currency = :base_currency
|
||||
AND fetched_at = :fetched_at
|
||||
ORDER BY id ASC
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'base_currency' => $baseCurrency,
|
||||
'fetched_at' => $fetchedAt,
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
private function getFetchById(int $fetchId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $fetchId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
private function findNeighborFetch(string $baseCurrency, string $timestamp, string $operator): ?array
|
||||
{
|
||||
$order = $operator === '<=' ? 'DESC' : 'ASC';
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE base_currency = :base_currency
|
||||
AND fetched_at ' . $operator . ' :target_at
|
||||
ORDER BY fetched_at ' . $order . ', id ' . $order . '
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'base_currency' => $baseCurrency,
|
||||
'target_at' => $timestamp,
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
private function ratesForFetch(int $fetchId, ?array $symbols = null): array
|
||||
{
|
||||
$sql = 'SELECT currency_code, current_value FROM ' . $this->table('rates') . ' WHERE fetch_id = :fetch_id';
|
||||
$params = ['fetch_id' => $fetchId];
|
||||
|
||||
$normalizedSymbols = [];
|
||||
if (is_array($symbols)) {
|
||||
foreach ($symbols as $symbol) {
|
||||
$symbol = strtoupper(trim((string) $symbol));
|
||||
if ($symbol !== '') {
|
||||
$normalizedSymbols[] = $symbol;
|
||||
}
|
||||
}
|
||||
$normalizedSymbols = array_values(array_unique($normalizedSymbols));
|
||||
}
|
||||
|
||||
if ($normalizedSymbols !== []) {
|
||||
$placeholders = [];
|
||||
foreach ($normalizedSymbols as $index => $symbol) {
|
||||
$key = 'symbol_' . $index;
|
||||
$placeholders[] = ':' . $key;
|
||||
$params[$key] = $symbol;
|
||||
}
|
||||
$sql .= ' AND currency_code IN (' . implode(', ', $placeholders) . ')';
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY currency_code ASC';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
$rates = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||
$code = strtoupper(trim((string) ($row['currency_code'] ?? '')));
|
||||
$rate = $row['current_value'] ?? null;
|
||||
if ($code === '' || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$rates[$code] = (float) $rate;
|
||||
}
|
||||
|
||||
return $rates;
|
||||
}
|
||||
|
||||
private function normalizeFetch(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => isset($row['id']) ? (int) $row['id'] : null,
|
||||
'provider' => (string) ($row['provider'] ?? ''),
|
||||
'trigger_source' => (string) ($row['trigger_source'] ?? 'manual'),
|
||||
'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')),
|
||||
'rate_date' => (string) ($row['rate_date'] ?? ''),
|
||||
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureColumn(string $table, string $column, string $alterSql): void
|
||||
{
|
||||
try {
|
||||
$stmt = $this->pdo->query('SELECT * FROM ' . $table . ' LIMIT 1');
|
||||
if ($stmt instanceof \PDOStatement) {
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
if (in_array(strtolower($column), array_map('strtolower', array_keys($row)), true)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$this->pdo->exec($alterSql);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeTriggerSource(string $source): string
|
||||
{
|
||||
$source = strtolower(trim($source));
|
||||
return match ($source) {
|
||||
'cron', 'manual', 'api', 'migration' => $source,
|
||||
default => 'manual',
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeRate(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => isset($row['id']) ? (int) $row['id'] : null,
|
||||
'fetch_id' => isset($row['fetch_id']) ? (int) $row['fetch_id'] : null,
|
||||
'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')),
|
||||
'target_currency' => strtoupper((string) ($row['target_currency'] ?? '')),
|
||||
'rate' => is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null,
|
||||
'rate_date' => (string) ($row['rate_date'] ?? ''),
|
||||
'provider' => (string) ($row['provider'] ?? ''),
|
||||
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function table(string $logicalName): string
|
||||
{
|
||||
return $this->tablePrefix . preg_replace('/[^a-zA-Z0-9_]/', '', $logicalName);
|
||||
}
|
||||
}
|
||||
12
docs/Umsetzungsanweisung/Old-Nexus/modules/kea/design.json
Normal file
12
docs/Umsetzungsanweisung/Old-Nexus/modules/kea/design.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "KEA DHCP",
|
||||
"description": "Verwaltung von KEA DHCP Hosts und Reservierungen.",
|
||||
"actions": [
|
||||
{ "label": "Setup", "href": "/modules/setup/kea", "variant": "secondary" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Hosts", "href": "/module/kea", "match_prefixes": ["/module/kea", "/module/kea/edit"] },
|
||||
{ "label": "Gruppen", "href": "/module/kea/groups", "match_prefixes": ["/module/kea/groups"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Database;
|
||||
use App\ModuleMigrationContext;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
|
||||
return new class {
|
||||
public function up(ModuleMigrationContext $context): void
|
||||
{
|
||||
$settings = $context->settings();
|
||||
$fallback = is_array($context->module['metadata_db_defaults'] ?? null)
|
||||
? $context->module['metadata_db_defaults']
|
||||
: [];
|
||||
$config = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($fallback, $settings['metadata_db'])
|
||||
: $fallback;
|
||||
|
||||
if (empty($config['driver']) || empty($config['dbname'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$repo = new KeaHostMetadataRepository(Database::createFromArray($config));
|
||||
$repo->ensureSchema();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Database;
|
||||
use App\ModuleMigrationContext;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
|
||||
return new class {
|
||||
public function up(ModuleMigrationContext $context): void
|
||||
{
|
||||
$settings = $context->settings();
|
||||
$fallback = is_array($context->module['metadata_db_defaults'] ?? null)
|
||||
? $context->module['metadata_db_defaults']
|
||||
: [];
|
||||
$config = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($fallback, $settings['metadata_db'])
|
||||
: $fallback;
|
||||
|
||||
if (empty($config['driver']) || empty($config['dbname'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$repo = new KeaHostMetadataRepository(Database::createFromArray($config));
|
||||
$repo->ensureSchema();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Database;
|
||||
use App\ModuleMigrationContext;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
|
||||
return new class {
|
||||
public function up(ModuleMigrationContext $context): void
|
||||
{
|
||||
$settings = $context->settings();
|
||||
$fallback = is_array($context->module['metadata_db_defaults'] ?? null)
|
||||
? $context->module['metadata_db_defaults']
|
||||
: [];
|
||||
$config = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($fallback, $settings['metadata_db'])
|
||||
: $fallback;
|
||||
|
||||
if (empty($config['driver']) || empty($config['dbname'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$repo = new KeaHostMetadataRepository(Database::createFromArray($config));
|
||||
$repo->ensureSchema();
|
||||
}
|
||||
};
|
||||
46
docs/Umsetzungsanweisung/Old-Nexus/modules/kea/module.json
Normal file
46
docs/Umsetzungsanweisung/Old-Nexus/modules/kea/module.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"title": "KEA DHCP",
|
||||
"version": "1.2.0",
|
||||
"schema_version": 3,
|
||||
"description": "Verwaltung von KEA DHCP Hosts und Reservierungen.",
|
||||
"setup": {
|
||||
"fields": [
|
||||
{ "name": "db.driver", "label": "KEA DB Driver", "type": "text", "required": true, "help": "Standard-KEA-Datenbank, die auch vom KEA-Dienst selbst genutzt wird." },
|
||||
{ "name": "db.host", "label": "KEA DB Host", "type": "text", "required": true },
|
||||
{ "name": "db.port", "label": "KEA DB Port", "type": "number", "required": true },
|
||||
{ "name": "db.dbname", "label": "KEA DB Name", "type": "text", "required": true },
|
||||
{ "name": "db.schema", "label": "KEA DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "KEA DB User", "type": "text", "required": true },
|
||||
{ "name": "db.password", "label": "KEA DB Passwort", "type": "password", "required": true },
|
||||
{ "name": "metadata_db.driver", "label": "Nexus DHCP DB Driver", "type": "text", "required": true, "help": "Separate Datenbank fuer Nexus-eigene DHCP-Zusatzinfos, nicht fuer KEA-Standardtabellen." },
|
||||
{ "name": "metadata_db.host", "label": "Nexus DHCP DB Host", "type": "text", "required": true },
|
||||
{ "name": "metadata_db.port", "label": "Nexus DHCP DB Port", "type": "number", "required": true },
|
||||
{ "name": "metadata_db.dbname", "label": "Nexus DHCP DB Name", "type": "text", "required": true },
|
||||
{ "name": "metadata_db.schema", "label": "Nexus DHCP DB Schema", "type": "text", "required": false },
|
||||
{ "name": "metadata_db.user", "label": "Nexus DHCP DB User", "type": "text", "required": true },
|
||||
{ "name": "metadata_db.password", "label": "Nexus DHCP DB Passwort", "type": "password", "required": true },
|
||||
{ "name": "kea_db_version", "label": "KEA DB Version", "type": "text", "required": false },
|
||||
{ "name": "kea_init_script", "label": "KEA Init Script", "type": "text", "required": false },
|
||||
{ "name": "kea_init_cmd", "label": "KEA Init Command", "type": "text", "required": false },
|
||||
{ "name": "kea_auto_init", "label": "KEA Auto-Init", "type": "checkbox", "required": false }
|
||||
]
|
||||
},
|
||||
"db_defaults": {
|
||||
"driver": "pgsql",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"dbname": "",
|
||||
"schema": "public",
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"metadata_db_defaults": {
|
||||
"driver": "mysql",
|
||||
"host": "192.168.178.10",
|
||||
"port": 3306,
|
||||
"dbname": "",
|
||||
"schema": "",
|
||||
"user": "",
|
||||
"password": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
use App\Database;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
use App\Repository\KeaHostRepository;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$module = modules()->get('kea');
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
$settings = modules()->settings('kea');
|
||||
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
|
||||
$metadataConfig = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($metadataFallback, $settings['metadata_db'])
|
||||
: $metadataFallback;
|
||||
|
||||
try {
|
||||
$metadataRepo = null;
|
||||
$pdo = modules()->modulePdo('kea', $fallback);
|
||||
if (!empty($metadataConfig['driver']) && !empty($metadataConfig['dbname'])) {
|
||||
try {
|
||||
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
||||
$metadataRepo->ensureSchema();
|
||||
} catch (\Throwable) {
|
||||
$metadataRepo = null;
|
||||
}
|
||||
}
|
||||
|
||||
$repo = new KeaHostRepository($pdo, $metadataRepo);
|
||||
$hosts = $repo->findAll(200);
|
||||
$stats = [
|
||||
'total' => $repo->countReservations() + $repo->countLeases(),
|
||||
'reservations' => $repo->countReservations(),
|
||||
'leases' => $repo->countLeases(),
|
||||
'groups' => [],
|
||||
'free_ips' => [],
|
||||
];
|
||||
|
||||
foreach ($hosts as $host) {
|
||||
$group = trim((string)($host['metadata']['group_name'] ?? ''));
|
||||
if ($group !== '') {
|
||||
$stats['groups'][$group] = ($stats['groups'][$group] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ($metadataRepo !== null) {
|
||||
$stats['free_ips'] = array_map(
|
||||
static fn(array $ips): int => count($ips),
|
||||
$metadataRepo->availableIpsByGroup(
|
||||
array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()),
|
||||
4096
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$rows = array_map(static function (array $host): array {
|
||||
return [
|
||||
'source' => (string)($host['source'] ?? 'reservation'),
|
||||
'host_id' => (string)($host['host_id'] ?? '0'),
|
||||
'display_name' => (string)($host['metadata']['device_name'] ?? $host['metadata']['real_name'] ?? $host['display_name'] ?? $host['hostname'] ?? 'Unbekannt'),
|
||||
'ipv4_address' => (string)($host['ipv4_address'] ?? ''),
|
||||
'dhcp_identifier' => (string)($host['dhcp_identifier'] ?? ''),
|
||||
'last_seen_at' => (string)($host['last_seen_at'] ?? '-'),
|
||||
'lease_expires_at' => (string)($host['lease_expires_at'] ?? '-'),
|
||||
'real_name' => (string)($host['metadata']['real_name'] ?? '-'),
|
||||
'location' => (string)($host['metadata']['location'] ?? '-'),
|
||||
'group_name' => (string)($host['metadata']['group_name'] ?? '-'),
|
||||
];
|
||||
}, $hosts);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'stats' => [
|
||||
'total' => (int)$stats['total'],
|
||||
'reservations' => (int)$stats['reservations'],
|
||||
'leases' => (int)$stats['leases'],
|
||||
'groups' => count($stats['groups']),
|
||||
'free_ips' => array_sum($stats['free_ips']),
|
||||
],
|
||||
'rows' => $rows,
|
||||
'updated_at' => date('H:i:s'),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
exit;
|
||||
266
docs/Umsetzungsanweisung/Old-Nexus/modules/kea/pages/edit.php
Normal file
266
docs/Umsetzungsanweisung/Old-Nexus/modules/kea/pages/edit.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
use App\Database;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
use App\Repository\KeaHostRepository;
|
||||
|
||||
$module = modules()->get('kea');
|
||||
$settings = modules()->settings('kea');
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
|
||||
$metadataConfig = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($metadataFallback, $settings['metadata_db'])
|
||||
: $metadataFallback;
|
||||
|
||||
$source = (string)($_GET['source'] ?? $_POST['source'] ?? 'reservation');
|
||||
$source = $source === 'lease' ? 'lease' : 'reservation';
|
||||
$id = (int)($_GET['id'] ?? $_POST['id'] ?? 0);
|
||||
$error = null;
|
||||
$notice = null;
|
||||
$host = null;
|
||||
$metadataRepo = null;
|
||||
$groups = [];
|
||||
$availableIpsByGroup = [];
|
||||
$checks = [];
|
||||
$hostKey = '';
|
||||
|
||||
try {
|
||||
$pdo = modules()->modulePdo('kea', $fallback);
|
||||
if (empty($metadataConfig['driver']) || empty($metadataConfig['dbname'])) {
|
||||
throw new RuntimeException('Nexus DHCP Zusatzdatenbank ist nicht konfiguriert.');
|
||||
}
|
||||
|
||||
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
||||
$metadataRepo->ensureSchema();
|
||||
$groups = $metadataRepo->listGroups();
|
||||
$repo = new KeaHostRepository($pdo, $metadataRepo);
|
||||
$host = $repo->findDisplayByKey($source, $id);
|
||||
if (!$host) {
|
||||
throw new RuntimeException('KEA Eintrag wurde nicht gefunden.');
|
||||
}
|
||||
$hostKey = $source . ':' . (string)($host['host_id'] ?? $id);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = (string)($_POST['action'] ?? 'save_metadata');
|
||||
if ($action === 'dns_lookup') {
|
||||
$ip = (string)($host['ipv4_address'] ?? '');
|
||||
$hostname = trim((string)($host['metadata']['device_name'] ?? $host['hostname'] ?? ''));
|
||||
$reverse = $ip !== '' ? gethostbyaddr($ip) : '';
|
||||
$forward = $hostname !== '' ? gethostbyname($hostname) : '';
|
||||
$success = ($reverse !== '' && $reverse !== $ip) || ($forward !== '' && $forward !== $hostname);
|
||||
|
||||
$metadataRepo->saveCheck($hostKey, 'dns', $success ? 'success' : 'warning', [
|
||||
'ip' => $ip,
|
||||
'hostname' => $hostname,
|
||||
'reverse' => $reverse,
|
||||
'forward' => $forward,
|
||||
]);
|
||||
$notice = $success ? 'DNS-Pruefung gespeichert.' : 'DNS-Pruefung gespeichert, aber kein eindeutiger Name gefunden.';
|
||||
} else {
|
||||
$metadata = [
|
||||
'real_name' => $_POST['real_name'] ?? '',
|
||||
'device_name' => $_POST['device_name'] ?? '',
|
||||
'owner' => $_POST['owner'] ?? '',
|
||||
'location' => $_POST['location'] ?? '',
|
||||
'device_type' => $_POST['device_type'] ?? '',
|
||||
'group_name' => $_POST['group_name'] ?? '',
|
||||
'desired_ip' => $_POST['desired_ip'] ?? '',
|
||||
'notes' => $_POST['notes'] ?? '',
|
||||
'tags' => [],
|
||||
];
|
||||
$desiredIp = trim((string)$metadata['desired_ip']);
|
||||
if ($desiredIp !== '') {
|
||||
$newHostId = $repo->reserveDisplayEntry($host, $desiredIp, $metadata);
|
||||
$source = 'reservation';
|
||||
$id = $newHostId;
|
||||
$notice = 'Zusatzdaten gespeichert und KEA-Reservierung gesetzt.';
|
||||
} else {
|
||||
$metadataRepo->saveForHost(
|
||||
$id,
|
||||
(string)($host['dhcp_identifier'] ?? ''),
|
||||
(string)($host['ipv4_address'] ?? ''),
|
||||
$metadata
|
||||
);
|
||||
$notice = 'Zusatzdaten gespeichert.';
|
||||
}
|
||||
}
|
||||
$host = $repo->findDisplayByKey($source, $id) ?: $host;
|
||||
$hostKey = $source . ':' . (string)($host['host_id'] ?? $id);
|
||||
}
|
||||
|
||||
$usedIps = array_diff(
|
||||
array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()),
|
||||
[(string)($host['ipv4_address'] ?? ''), (string)($host['metadata']['desired_ip'] ?? '')]
|
||||
);
|
||||
$availableIpsByGroup = $metadataRepo->availableIpsByGroup($usedIps);
|
||||
$checks = $metadataRepo->latestChecks([$hostKey])[$hostKey] ?? [];
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
|
||||
$metadata = is_array($host['metadata'] ?? null) ? $host['metadata'] : [];
|
||||
$selectedGroup = (string)($metadata['group_name'] ?? '');
|
||||
$selectedIp = (string)($metadata['desired_ip'] ?? '');
|
||||
?>
|
||||
<?= module_shell_header('kea', [
|
||||
'title' => 'KEA Eintrag bearbeiten',
|
||||
]) ?>
|
||||
<div class="module-flow kea-page">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">KEA Eintrag bearbeiten</h2>
|
||||
<p>Zusatzdaten werden separat von der KEA-Datenbank gespeichert.</p>
|
||||
</div>
|
||||
<div class="setup-actions">
|
||||
<a class="module-button module-button--secondary" href="/module/kea/groups">Gruppen verwalten</a>
|
||||
<a class="module-button module-button--secondary" href="/module/kea">Zurueck</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="kea-message kea-message--error" role="alert">
|
||||
<strong>Fehler</strong>
|
||||
<p><?= e($error) ?></p>
|
||||
</div>
|
||||
<?php elseif ($host): ?>
|
||||
<?php if ($notice): ?>
|
||||
<div class="kea-message kea-message--success">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="module-box kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill"><?= ($source === 'lease') ? 'Lease' : 'Reservierung' ?></span>
|
||||
<h2 class="module-box-title"><?= e((string)($host['hostname'] ?: 'Unbekannt')) ?></h2>
|
||||
<p class="muted">
|
||||
IP <?= e((string)($host['ipv4_address'] ?? '')) ?> · MAC <?= e((string)($host['dhcp_identifier'] ?? '')) ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="kea-edit-form">
|
||||
<input type="hidden" name="action" value="save_metadata">
|
||||
<input type="hidden" name="source" value="<?= e($source) ?>">
|
||||
<input type="hidden" name="id" value="<?= e((string)$id) ?>">
|
||||
|
||||
<label class="setup-field">
|
||||
<span>Echter Name</span>
|
||||
<input type="text" name="real_name" value="<?= e((string)($metadata['real_name'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Gerätename</span>
|
||||
<input type="text" name="device_name" value="<?= e((string)($metadata['device_name'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Besitzer</span>
|
||||
<input type="text" name="owner" value="<?= e((string)($metadata['owner'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Standort</span>
|
||||
<input type="text" name="location" value="<?= e((string)($metadata['location'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Gerätetyp</span>
|
||||
<input type="text" name="device_type" value="<?= e((string)($metadata['device_type'] ?? '')) ?>">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Gruppe</span>
|
||||
<select name="group_name" data-kea-group-select>
|
||||
<option value="">Bitte waehlen</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= e($group) ?>" <?= $selectedGroup === $group ? 'selected' : '' ?>><?= e($group) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ($groups === []): ?>
|
||||
<small class="muted">Noch keine Gruppen definiert. Oeffne zuerst Gruppen verwalten.</small>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Feste IP</span>
|
||||
<select name="desired_ip" data-kea-ip-select data-selected-ip="<?= e($selectedIp) ?>">
|
||||
<option value="">Erst Gruppe waehlen</option>
|
||||
</select>
|
||||
<small class="muted">Es werden nur freie IPs aus dem IP-Bereich der gewaehlten Gruppe angeboten.</small>
|
||||
</label>
|
||||
<label class="setup-field kea-edit-form__wide">
|
||||
<span>Notizen</span>
|
||||
<textarea name="notes" rows="4"><?= e((string)($metadata['notes'] ?? '')) ?></textarea>
|
||||
</label>
|
||||
|
||||
<div class="setup-actions kea-edit-form__wide">
|
||||
<button class="cta-button" type="submit">Speichern</button>
|
||||
<a class="nav-link" href="/module/kea">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="module-box kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">Pruefungen</span>
|
||||
<h2 class="module-box-title">Gerätechecks</h2>
|
||||
<p class="muted">Pruefergebnisse werden in der Nexus-DHCP-Datenbank gespeichert und koennen spaeter fuer Reports genutzt werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kea-check-grid">
|
||||
<div class="kea-check-card">
|
||||
<h4>DNS / Hostname</h4>
|
||||
<?php $dnsCheck = $checks['dns'] ?? null; ?>
|
||||
<?php if ($dnsCheck): ?>
|
||||
<?php $dnsResult = json_decode((string)($dnsCheck['result_json'] ?? '{}'), true) ?: []; ?>
|
||||
<p class="muted">Status: <?= e((string)($dnsCheck['status'] ?? '')) ?> · <?= e((string)($dnsCheck['checked_at'] ?? '')) ?></p>
|
||||
<p class="mono">Reverse: <?= e((string)($dnsResult['reverse'] ?? '-')) ?></p>
|
||||
<p class="mono">Forward: <?= e((string)($dnsResult['forward'] ?? '-')) ?></p>
|
||||
<?php else: ?>
|
||||
<p class="muted">Noch keine DNS-Pruefung gespeichert.</p>
|
||||
<?php endif; ?>
|
||||
<form method="post" class="setup-actions">
|
||||
<input type="hidden" name="action" value="dns_lookup">
|
||||
<input type="hidden" name="source" value="<?= e($source) ?>">
|
||||
<input type="hidden" name="id" value="<?= e((string)$id) ?>">
|
||||
<button class="nav-link" type="submit">DNS jetzt pruefen</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="kea-check-card">
|
||||
<h4>Login-Erkennung</h4>
|
||||
<p class="muted">Vorbereitet fuer spaetere HTTP/Port-Erkennung. Noch nicht automatisch aktiv, damit keine ungewollten Scans laufen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(() => {
|
||||
const ipsByGroup = <?= json_encode($availableIpsByGroup, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||||
const groupSelect = document.querySelector('[data-kea-group-select]');
|
||||
const ipSelect = document.querySelector('[data-kea-ip-select]');
|
||||
if (!groupSelect || !ipSelect) {
|
||||
return;
|
||||
}
|
||||
const selectedIp = ipSelect.dataset.selectedIp || '';
|
||||
const renderIps = () => {
|
||||
const ips = ipsByGroup[groupSelect.value] || [];
|
||||
ipSelect.innerHTML = '';
|
||||
const empty = document.createElement('option');
|
||||
empty.value = '';
|
||||
empty.textContent = groupSelect.value ? 'Keine feste IP' : 'Erst Gruppe waehlen';
|
||||
ipSelect.appendChild(empty);
|
||||
if (selectedIp && !ips.includes(selectedIp)) {
|
||||
ips.unshift(selectedIp);
|
||||
}
|
||||
for (const ip of ips) {
|
||||
const option = document.createElement('option');
|
||||
option.value = ip;
|
||||
option.textContent = ip;
|
||||
option.selected = ip === selectedIp;
|
||||
ipSelect.appendChild(option);
|
||||
}
|
||||
};
|
||||
groupSelect.addEventListener('change', renderIps);
|
||||
renderIps();
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
227
docs/Umsetzungsanweisung/Old-Nexus/modules/kea/pages/groups.php
Normal file
227
docs/Umsetzungsanweisung/Old-Nexus/modules/kea/pages/groups.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
use App\Database;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
use App\Repository\KeaHostRepository;
|
||||
|
||||
$module = modules()->get('kea');
|
||||
$settings = modules()->settings('kea');
|
||||
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
|
||||
$metadataConfig = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($metadataFallback, $settings['metadata_db'])
|
||||
: $metadataFallback;
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
$error = null;
|
||||
$notice = null;
|
||||
$groups = [];
|
||||
$availableIpsByGroup = [];
|
||||
$usedIps = [];
|
||||
|
||||
try {
|
||||
if (empty($metadataConfig['driver']) || empty($metadataConfig['dbname'])) {
|
||||
throw new RuntimeException('Nexus DHCP Zusatzdatenbank ist nicht konfiguriert.');
|
||||
}
|
||||
|
||||
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
||||
$metadataRepo->ensureSchema();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
if ($action === 'save_group') {
|
||||
$metadataRepo->saveGroup(
|
||||
(string)($_POST['name'] ?? ''),
|
||||
(string)($_POST['description'] ?? ''),
|
||||
(string)($_POST['parent_name'] ?? '')
|
||||
);
|
||||
$notice = 'Gruppe gespeichert.';
|
||||
} elseif ($action === 'add_range') {
|
||||
$metadataRepo->addRange(
|
||||
(string)($_POST['group_name'] ?? ''),
|
||||
(string)($_POST['start_ip'] ?? ''),
|
||||
(string)($_POST['end_ip'] ?? '')
|
||||
);
|
||||
$notice = 'IP-Bereich gespeichert.';
|
||||
}
|
||||
}
|
||||
|
||||
$groups = $metadataRepo->listGroupsWithRanges();
|
||||
$keaRepo = new KeaHostRepository(modules()->modulePdo('kea', $fallback), $metadataRepo);
|
||||
$usedIps = array_merge($keaRepo->usedIpAddresses(), $metadataRepo->desiredIps());
|
||||
$availableIpsByGroup = $metadataRepo->availableIpsByGroup($usedIps, 4096);
|
||||
} catch (Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
|
||||
$usedIpLookup = array_flip(array_filter(array_map('strval', $usedIps)));
|
||||
$matrixForGroup = static function (array $group) use ($usedIpLookup): array {
|
||||
$dots = [];
|
||||
foreach (($group['ranges'] ?? []) as $range) {
|
||||
$start = ip2long((string)($range['start_ip'] ?? ''));
|
||||
$end = ip2long((string)($range['end_ip'] ?? ''));
|
||||
if ($start === false || $end === false) {
|
||||
continue;
|
||||
}
|
||||
for ($ip = $start; $ip <= $end && count($dots) < 512; $ip++) {
|
||||
$address = long2ip($ip);
|
||||
if ($address === false) {
|
||||
continue;
|
||||
}
|
||||
$dots[] = [
|
||||
'ip' => $address,
|
||||
'used' => isset($usedIpLookup[$address]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $dots;
|
||||
};
|
||||
?>
|
||||
<?= module_shell_header('kea', [
|
||||
'title' => 'KEA Gruppen',
|
||||
]) ?>
|
||||
<div class="module-flow kea-page">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">KEA Gruppen</h2>
|
||||
<p>Gruppen und IP-Bereiche fuer DHCP-Reservierungen.</p>
|
||||
</div>
|
||||
<a class="module-button module-button--secondary" href="/module/kea">Zurueck</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="kea-message kea-message--error" role="alert">
|
||||
<strong>Fehler</strong>
|
||||
<p><?= e($error) ?></p>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="kea-message kea-message--success"><?= e($notice) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="module-box kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">Gruppe</span>
|
||||
<h2 class="module-box-title">Gruppe anlegen</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="kea-edit-form">
|
||||
<input type="hidden" name="action" value="save_group">
|
||||
<label class="setup-field">
|
||||
<span>Name</span>
|
||||
<input type="text" name="name" required>
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Beschreibung</span>
|
||||
<input type="text" name="description">
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Uebergeordnete Gruppe</span>
|
||||
<select name="parent_name">
|
||||
<option value="">Keine</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= e((string)$group['name']) ?>"><?= e((string)$group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<div class="setup-actions kea-edit-form__wide">
|
||||
<button class="cta-button" type="submit">Gruppe speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="module-box kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">IP-Bereich</span>
|
||||
<h2 class="module-box-title">Bereich zuweisen</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="kea-edit-form">
|
||||
<input type="hidden" name="action" value="add_range">
|
||||
<label class="setup-field">
|
||||
<span>Gruppe</span>
|
||||
<select name="group_name" required>
|
||||
<option value="">Bitte waehlen</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= e((string)$group['name']) ?>"><?= e((string)$group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>Start-IP</span>
|
||||
<input type="text" name="start_ip" placeholder="192.168.178.50" required>
|
||||
</label>
|
||||
<label class="setup-field">
|
||||
<span>End-IP</span>
|
||||
<input type="text" name="end_ip" placeholder="192.168.178.99" required>
|
||||
</label>
|
||||
<div class="setup-actions kea-edit-form__wide">
|
||||
<button class="cta-button" type="submit">Bereich speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="module-box-table kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">Uebersicht</span>
|
||||
<h2 class="module-box-title">Gruppen und freie IPs</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kea-table-wrap">
|
||||
<table class="kea-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gruppe</th>
|
||||
<th>Untergruppe von</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Bereiche</th>
|
||||
<th>Freie IPs</th>
|
||||
<th>Matrix</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($groups === []): ?>
|
||||
<tr><td colspan="6" class="kea-empty">Noch keine Gruppen definiert.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<?php $available = $availableIpsByGroup[(string)$group['name']] ?? []; ?>
|
||||
<tr>
|
||||
<td><?= e((string)$group['name']) ?></td>
|
||||
<td><?= e((string)($group['parent_name'] ?: '-')) ?></td>
|
||||
<td><?= e((string)($group['description'] ?? '-')) ?></td>
|
||||
<td>
|
||||
<?php if (($group['ranges'] ?? []) === []): ?>
|
||||
<span class="muted">Kein Bereich</span>
|
||||
<?php else: ?>
|
||||
<?php foreach ($group['ranges'] as $range): ?>
|
||||
<div class="mono"><?= e((string)$range['start_ip']) ?> - <?= e((string)$range['end_ip']) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= e((string)count($available)) ?></td>
|
||||
<td>
|
||||
<?php $matrix = $matrixForGroup($group); ?>
|
||||
<?php if ($matrix === []): ?>
|
||||
<span class="muted">Kein Bereich</span>
|
||||
<?php else: ?>
|
||||
<div class="ip-matrix" aria-label="IP Matrix fuer <?= e((string)$group['name']) ?>">
|
||||
<?php foreach ($matrix as $dot): ?>
|
||||
<span
|
||||
class="ip-dot <?= $dot['used'] ? 'is-used' : 'is-free' ?>"
|
||||
title="<?= e((string)$dot['ip']) ?> · <?= $dot['used'] ? 'belegt' : 'frei' ?>"
|
||||
></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
use App\Database;
|
||||
use App\Repository\KeaHostRepository;
|
||||
use App\Repository\KeaHostMetadataRepository;
|
||||
|
||||
$module = modules()->get('kea');
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
|
||||
$settings = modules()->settings('kea');
|
||||
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
|
||||
$metadataConfig = is_array($settings['metadata_db'] ?? null)
|
||||
? array_replace($metadataFallback, $settings['metadata_db'])
|
||||
: $metadataFallback;
|
||||
$metadataRepo = null;
|
||||
$hosts = [];
|
||||
$error = null;
|
||||
$warnings = [];
|
||||
$stats = [
|
||||
'total' => 0,
|
||||
'reservations' => 0,
|
||||
'leases' => 0,
|
||||
'groups' => [],
|
||||
'free_ips' => [],
|
||||
];
|
||||
|
||||
try {
|
||||
$pdo = modules()->modulePdo('kea', $fallback);
|
||||
if (!empty($metadataConfig['driver']) && !empty($metadataConfig['dbname'])) {
|
||||
try {
|
||||
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
|
||||
$metadataRepo->ensureSchema();
|
||||
} catch (\Throwable $e) {
|
||||
$warnings[] = 'Nexus DHCP Zusatzdatenbank nicht verfuegbar: ' . $e->getMessage();
|
||||
$metadataRepo = null;
|
||||
}
|
||||
}
|
||||
|
||||
$repo = new KeaHostRepository($pdo, $metadataRepo);
|
||||
$hosts = $repo->findAll(200);
|
||||
$stats['reservations'] = $repo->countReservations();
|
||||
$stats['leases'] = $repo->countLeases();
|
||||
$stats['total'] = $stats['reservations'] + $stats['leases'];
|
||||
foreach ($hosts as $host) {
|
||||
$group = trim((string)($host['metadata']['group_name'] ?? ''));
|
||||
if ($group !== '') {
|
||||
$stats['groups'][$group] = ($stats['groups'][$group] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
if ($metadataRepo !== null) {
|
||||
$stats['free_ips'] = array_map(
|
||||
static fn(array $ips): int => count($ips),
|
||||
$metadataRepo->availableIpsByGroup(
|
||||
array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()),
|
||||
4096
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$error = "Datenbankfehler: " . $e->getMessage();
|
||||
}
|
||||
|
||||
module_tpl('kea', 'dashboard', compact('hosts', 'error', 'warnings', 'stats'));
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
/**
|
||||
* @var array $hosts Die Liste der KEA-Hosts.
|
||||
* @var string|null $error Eine Fehlermeldung, falls vorhanden.
|
||||
* @var array $warnings Hinweise, falls Zusatzdaten nicht geladen werden konnten.
|
||||
* @var array $stats Kennzahlen fuer die Uebersicht.
|
||||
*/
|
||||
?>
|
||||
<?= module_shell_header('kea', [
|
||||
'title' => 'KEA DHCP Hosts',
|
||||
'description' => 'Reservierungen und aktuelle Leases aus der KEA-Datenbank.',
|
||||
]) ?>
|
||||
<div class="module-flow kea-page">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">KEA DHCP Hosts</h2>
|
||||
<p>Reservierungen und aktuelle Leases aus der KEA-Datenbank.</p>
|
||||
<p class="muted kea-refresh-state" data-kea-refresh-state>
|
||||
Automatische Aktualisierung alle 5 Sekunden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="kea-message kea-message--error" role="alert">
|
||||
<strong>Fehler</strong>
|
||||
<p><?= e($error) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php foreach (($warnings ?? []) as $warning): ?>
|
||||
<div class="kea-message kea-message--warning" role="alert">
|
||||
<p><?= e((string)$warning) ?></p>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="module-box-grid module-box-grid--stats stats">
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Einträge</span>
|
||||
<span class="stat-value" data-kea-stat="total"><?= e((string)($stats['total'] ?? 0)) ?></span>
|
||||
</section>
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Reservierungen</span>
|
||||
<span class="stat-value" data-kea-stat="reservations"><?= e((string)($stats['reservations'] ?? 0)) ?></span>
|
||||
</section>
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Leases</span>
|
||||
<span class="stat-value" data-kea-stat="leases"><?= e((string)($stats['leases'] ?? 0)) ?></span>
|
||||
</section>
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Gruppen</span>
|
||||
<span class="stat-value" data-kea-stat="groups"><?= e((string)count($stats['groups'] ?? [])) ?></span>
|
||||
</section>
|
||||
<section class="module-box-soft stat-card">
|
||||
<span class="stat-label">Freie Gruppen-IPs</span>
|
||||
<span class="stat-value" data-kea-stat="free_ips"><?= e((string)array_sum($stats['free_ips'] ?? [])) ?></span>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="module-box-table kea-panel">
|
||||
<div class="module-box-head kea-panel__head">
|
||||
<div>
|
||||
<span class="pill">Inventar</span>
|
||||
<h2 class="module-box-title">Registrierte Geräte</h2>
|
||||
<p class="muted">Zusatzdaten werden in der separaten Nexus-DHCP-Datenbank gespeichert.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kea-table-wrap">
|
||||
<table class="kea-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quelle</th>
|
||||
<th>Hostname</th>
|
||||
<th>IP Adresse</th>
|
||||
<th>MAC Adresse</th>
|
||||
<th>Zuletzt gesehen</th>
|
||||
<th>Lease bis</th>
|
||||
<th>Echter Name</th>
|
||||
<th>Standort</th>
|
||||
<th>Gruppe</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-kea-host-rows>
|
||||
<?php if (empty($hosts)): ?>
|
||||
<tr>
|
||||
<td colspan="10" class="kea-empty">
|
||||
Keine Reservierungen oder aktiven Leases gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($hosts as $host): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pill"><?= ($host['source'] ?? '') === 'lease' ? 'Lease' : 'Reservierung' ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['metadata']['device_name'] ?? $host['metadata']['real_name'] ?? $host['display_name'] ?? $host['hostname'] ?? 'Unbekannt')) ?>
|
||||
</td>
|
||||
<td class="mono">
|
||||
<?= e($host['ipv4_address']) ?>
|
||||
</td>
|
||||
<td class="mono">
|
||||
<?= e($host['dhcp_identifier']) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['last_seen_at'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['lease_expires_at'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['metadata']['real_name'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['metadata']['location'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= e((string)($host['metadata']['group_name'] ?? '-')) ?>
|
||||
</td>
|
||||
<td>
|
||||
<a class="nav-link" href="/module/kea/edit?source=<?= e((string)($host['source'] ?? 'reservation')) ?>&id=<?= e((string)($host['host_id'] ?? '0')) ?>">
|
||||
Bearbeiten
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const rowsTarget = document.querySelector('[data-kea-host-rows]');
|
||||
const stateTarget = document.querySelector('[data-kea-refresh-state]');
|
||||
if (!rowsTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setText = (selector, value) => {
|
||||
const target = document.querySelector(selector);
|
||||
if (target) {
|
||||
target.textContent = String(value ?? '0');
|
||||
}
|
||||
};
|
||||
|
||||
const cell = (value, className = '') => {
|
||||
const td = document.createElement('td');
|
||||
if (className) {
|
||||
td.className = className;
|
||||
}
|
||||
td.textContent = value || '-';
|
||||
return td;
|
||||
};
|
||||
|
||||
const renderRows = (rows) => {
|
||||
rowsTarget.textContent = '';
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
const tr = document.createElement('tr');
|
||||
const td = cell('Keine Reservierungen oder aktiven Leases gefunden.', 'kea-empty');
|
||||
td.colSpan = 10;
|
||||
tr.appendChild(td);
|
||||
rowsTarget.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
const source = document.createElement('td');
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'pill';
|
||||
pill.textContent = row.source === 'lease' ? 'Lease' : 'Reservierung';
|
||||
source.appendChild(pill);
|
||||
tr.appendChild(source);
|
||||
|
||||
tr.appendChild(cell(row.display_name));
|
||||
tr.appendChild(cell(row.ipv4_address, 'mono'));
|
||||
tr.appendChild(cell(row.dhcp_identifier, 'mono'));
|
||||
tr.appendChild(cell(row.last_seen_at));
|
||||
tr.appendChild(cell(row.lease_expires_at));
|
||||
tr.appendChild(cell(row.real_name));
|
||||
tr.appendChild(cell(row.location));
|
||||
tr.appendChild(cell(row.group_name));
|
||||
|
||||
const action = document.createElement('td');
|
||||
const link = document.createElement('a');
|
||||
link.className = 'nav-link';
|
||||
link.href = `/module/kea/edit?source=${encodeURIComponent(row.source || 'reservation')}&id=${encodeURIComponent(row.host_id || '0')}`;
|
||||
link.textContent = 'Bearbeiten';
|
||||
action.appendChild(link);
|
||||
tr.appendChild(action);
|
||||
|
||||
rowsTarget.appendChild(tr);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const response = await fetch('/module/kea/data', {
|
||||
headers: {Accept: 'application/json'},
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.error || 'Aktualisierung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
setText('[data-kea-stat="total"]', payload.stats?.total);
|
||||
setText('[data-kea-stat="reservations"]', payload.stats?.reservations);
|
||||
setText('[data-kea-stat="leases"]', payload.stats?.leases);
|
||||
setText('[data-kea-stat="groups"]', payload.stats?.groups);
|
||||
setText('[data-kea-stat="free_ips"]', payload.stats?.free_ips);
|
||||
renderRows(payload.rows);
|
||||
if (stateTarget) {
|
||||
stateTarget.textContent = `Automatische Aktualisierung aktiv. Zuletzt aktualisiert: ${payload.updated_at || '-'}`;
|
||||
}
|
||||
} catch (error) {
|
||||
if (stateTarget) {
|
||||
stateTarget.textContent = `Automatische Aktualisierung fehlgeschlagen: ${error.message}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.setInterval(refresh, 5000);
|
||||
})();
|
||||
</script>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
(new Modules\MiningChecker\Api\Router(dirname(__DIR__)))->handle($_GET['path'] ?? '');
|
||||
@@ -0,0 +1,772 @@
|
||||
#mining-checker-app {
|
||||
--mc-surface: var(--surface);
|
||||
--mc-surface-strong: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(248,252,252,0.92));
|
||||
--mc-line: var(--line);
|
||||
--mc-line-strong: color-mix(in srgb, var(--brand-accent) 28%, transparent);
|
||||
--mc-text: var(--text);
|
||||
--mc-text-muted: var(--muted);
|
||||
--mc-accent: var(--brand-accent);
|
||||
--mc-accent-strong: var(--brand-accent-3);
|
||||
--mc-danger: #d92d20;
|
||||
--mc-success: #14804a;
|
||||
--mc-warning: #b54708;
|
||||
min-height: 0;
|
||||
background: none;
|
||||
color: var(--mc-text);
|
||||
font-family: inherit;
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
#mining-checker-app,
|
||||
#mining-checker-app * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-grid-bg {
|
||||
background: none;
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-shell {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-stat-card,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-target-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty,
|
||||
#mining-checker-app .mc-table-shell,
|
||||
#mining-checker-app .mc-display-field {
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 22px;
|
||||
background: var(--mc-surface);
|
||||
/* box-shadow: 0 12px 30px rgba(1, 22, 32, 0.08); */
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty,
|
||||
#mining-checker-app .mc-table-shell {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-top,
|
||||
#mining-checker-app .mc-inline-row,
|
||||
#mining-checker-app .mc-flex-split,
|
||||
#mining-checker-app .mc-section-head {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-top,
|
||||
#mining-checker-app .mc-flex-split,
|
||||
#mining-checker-app .mc-section-head {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-copy,
|
||||
#mining-checker-app .mc-hero-controls,
|
||||
#mining-checker-app .mc-panel-body,
|
||||
#mining-checker-app .mc-form,
|
||||
#mining-checker-app .mc-field,
|
||||
#mining-checker-app .mc-filter-grid,
|
||||
#mining-checker-app .mc-chart,
|
||||
#mining-checker-app .mc-target-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-filter-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-text,
|
||||
#mining-checker-app p,
|
||||
#mining-checker-app td,
|
||||
#mining-checker-app th,
|
||||
#mining-checker-app label,
|
||||
#mining-checker-app summary,
|
||||
#mining-checker-app pre {
|
||||
color: var(--mc-text-muted);
|
||||
}
|
||||
|
||||
#mining-checker-app h1,
|
||||
#mining-checker-app h2,
|
||||
#mining-checker-app h3 {
|
||||
margin: 0;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stats-grid,
|
||||
#mining-checker-app .mc-target-grid,
|
||||
#mining-checker-app .mc-overview-grid,
|
||||
#mining-checker-app .mc-two-col,
|
||||
#mining-checker-app .mc-main-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stats-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-overview-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-target-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-two-col {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-card {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-balance {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-row {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-asset-row strong {
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-main-grid {
|
||||
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-card,
|
||||
#mining-checker-app .mc-target-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-card {
|
||||
background: var(--mc-surface-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-kicker {
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--mc-accent-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
border: 1px solid var(--mc-line-strong);
|
||||
background: color-mix(in srgb, var(--mc-accent) 16%, transparent);
|
||||
color: var(--mc-accent-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge--warn {
|
||||
background: color-mix(in srgb, var(--mc-warning) 12%, transparent);
|
||||
color: var(--mc-warning);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge--info {
|
||||
background: color-mix(in srgb, var(--mc-accent) 16%, transparent);
|
||||
color: var(--mc-accent-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge--danger {
|
||||
background: color-mix(in srgb, var(--mc-danger) 12%, transparent);
|
||||
color: var(--mc-danger);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge--success {
|
||||
background: color-mix(in srgb, var(--mc-success) 12%, transparent);
|
||||
color: var(--mc-success);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button,
|
||||
#mining-checker-app button,
|
||||
#mining-checker-app input,
|
||||
#mining-checker-app select,
|
||||
#mining-checker-app textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--primary {
|
||||
background: linear-gradient(135deg, var(--mc-accent), var(--mc-accent-strong));
|
||||
color: #05121f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--secondary {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--mc-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--danger {
|
||||
background: linear-gradient(135deg, rgba(251, 113, 133, 0.92), rgba(239, 68, 68, 0.92));
|
||||
color: #fff7f7;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--ghost {
|
||||
background: color-mix(in srgb, var(--mc-accent) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--mc-accent) 34%, transparent);
|
||||
color: var(--mc-accent-strong);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-console {
|
||||
border-color: rgba(125, 211, 252, 0.28);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-view-switch {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-log {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-text-console {
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-debug-entry {
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
background: color-mix(in srgb, var(--brand-accent-2) 8%, var(--mc-surface));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-field {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-field-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--mc-text-muted);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-input,
|
||||
#mining-checker-app .mc-select,
|
||||
#mining-checker-app .mc-textarea,
|
||||
#mining-checker-app .mc-file {
|
||||
width: 100%;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 16px;
|
||||
padding: 13px 15px;
|
||||
color: var(--mc-text);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-select option {
|
||||
color: #09111f;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-input::placeholder,
|
||||
#mining-checker-app .mc-textarea::placeholder {
|
||||
color: #6d7c90;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-input:focus,
|
||||
#mining-checker-app .mc-select:focus,
|
||||
#mining-checker-app .mc-textarea:focus,
|
||||
#mining-checker-app .mc-file:focus {
|
||||
border-color: rgba(125, 211, 252, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(125, 211, 252, 0.12);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-inline-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-inline-fields > .mc-field {
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-alert--error {
|
||||
border-color: rgba(251, 113, 133, 0.28);
|
||||
background: rgba(127, 29, 29, 0.35);
|
||||
color: #ffe4e6;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-alert--warning {
|
||||
border-color: rgba(245, 158, 11, 0.28);
|
||||
background: rgba(120, 53, 15, 0.34);
|
||||
color: #fef3c7;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-alert--success {
|
||||
border-color: rgba(52, 211, 153, 0.28);
|
||||
background: rgba(6, 78, 59, 0.34);
|
||||
color: #d1fae5;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table-shell {
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table th,
|
||||
#mining-checker-app .mc-table td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table thead {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-empty {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
#mining-checker-app details {
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
#mining-checker-app pre {
|
||||
white-space: pre-wrap;
|
||||
margin: 12px 0 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-mini-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-mini-card,
|
||||
#mining-checker-app .mc-display-field {
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-code-block {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: "JetBrains Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-chart svg {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(2, 6, 23, 0.72);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal {
|
||||
width: min(720px, 100%);
|
||||
max-height: min(80vh, 900px);
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--mc-line);
|
||||
border-radius: 28px;
|
||||
background: rgba(9, 17, 31, 0.96);
|
||||
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.34);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-chart path,
|
||||
#mining-checker-app .mc-chart polyline,
|
||||
#mining-checker-app .mc-chart line,
|
||||
#mining-checker-app .mc-chart rect {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
#mining-checker-app .mc-hero-top,
|
||||
#mining-checker-app .mc-inline-row,
|
||||
#mining-checker-app .mc-flex-split,
|
||||
#mining-checker-app .mc-section-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-main-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-shell {
|
||||
width: min(100% - 10px, 1360px);
|
||||
padding: 8px 0 20px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stack {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero,
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty,
|
||||
#mining-checker-app .mc-table-shell {
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-title {
|
||||
font-size: clamp(1.45rem, 8vw, 2.15rem);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-copy {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-home-link {
|
||||
justify-self: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-tabs {
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
margin: 12px -4px 0;
|
||||
padding: 0 4px 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button {
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button--tab {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table th,
|
||||
#mining-checker-app .mc-table td {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal {
|
||||
max-height: min(92vh, 900px);
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#mining-checker-app,
|
||||
#mining-checker-app * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#mining-checker-app {
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-grid-bg {
|
||||
overflow-x: hidden;
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-shell {
|
||||
width: 100%;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stack {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero,
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty {
|
||||
width: 100%;
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero {
|
||||
border-radius: 0 0 18px 18px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-panel,
|
||||
#mining-checker-app .mc-dashboard-card,
|
||||
#mining-checker-app .mc-alert,
|
||||
#mining-checker-app .mc-empty,
|
||||
#mining-checker-app .mc-table-shell {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-title,
|
||||
#mining-checker-app .mc-hero-copy p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-hero-top {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-kicker {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-tabs {
|
||||
margin: 10px -6px 0;
|
||||
padding: 0 6px 6px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button {
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-button:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stats-grid,
|
||||
#mining-checker-app .mc-target-grid,
|
||||
#mining-checker-app .mc-overview-grid,
|
||||
#mining-checker-app .mc-two-col,
|
||||
#mining-checker-app .mc-main-grid,
|
||||
#mining-checker-app .mc-filter-grid,
|
||||
#mining-checker-app .mc-mini-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-card,
|
||||
#mining-checker-app .mc-target-card,
|
||||
#mining-checker-app .mc-mini-card,
|
||||
#mining-checker-app .mc-display-field {
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-stat-value {
|
||||
font-size: clamp(1.45rem, 8vw, 1.85rem);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#mining-checker-app h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
#mining-checker-app h3 {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-text,
|
||||
#mining-checker-app p,
|
||||
#mining-checker-app td,
|
||||
#mining-checker-app th,
|
||||
#mining-checker-app label,
|
||||
#mining-checker-app summary,
|
||||
#mining-checker-app pre {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-input,
|
||||
#mining-checker-app .mc-select,
|
||||
#mining-checker-app .mc-textarea,
|
||||
#mining-checker-app .mc-file {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-inline-fields,
|
||||
#mining-checker-app .mc-debug-tools,
|
||||
#mining-checker-app .mc-debug-view-switch {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-inline-fields > .mc-field {
|
||||
flex-basis: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-badge {
|
||||
padding: 7px 10px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table-shell {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 16px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table {
|
||||
max-width: none;
|
||||
min-width: 640px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-table th,
|
||||
#mining-checker-app .mc-table td {
|
||||
padding: 9px 10px;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-chart svg {
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal-backdrop {
|
||||
align-items: stretch;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#mining-checker-app .mc-modal {
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 16px);
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\ConnectionFactory;
|
||||
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
||||
use Modules\MiningChecker\Infrastructure\ModuleConfig;
|
||||
use Modules\MiningChecker\Infrastructure\SchemaManager;
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Modules\\MiningChecker\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, strlen($prefix));
|
||||
$file = __DIR__ . '/src/' . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
|
||||
$moduleName = 'mining-checker';
|
||||
|
||||
$mm->registerFunction($moduleName, 'runtime_settings', static function (): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
$projectKey = $config->defaultProjectKey();
|
||||
$user = auth_user() ?? [];
|
||||
$ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local';
|
||||
|
||||
$pdo = ConnectionFactory::make($config);
|
||||
$repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub);
|
||||
|
||||
$settings = $repository->getSettings($projectKey) ?? [];
|
||||
$displayTimezone = trim((string) ($settings['display_timezone'] ?? nexus_display_timezone_name()));
|
||||
if ($displayTimezone === '') {
|
||||
$displayTimezone = nexus_display_timezone_name();
|
||||
}
|
||||
|
||||
$baselineMeasuredAt = trim((string) ($settings['baseline_measured_at'] ?? ''));
|
||||
if ($baselineMeasuredAt !== '') {
|
||||
try {
|
||||
$baselineMeasuredAt = (new DateTimeImmutable($baselineMeasuredAt, new DateTimeZone('UTC')))
|
||||
->setTimezone(new DateTimeZone($displayTimezone))
|
||||
->format('Y-m-d\TH:i');
|
||||
} catch (\Throwable) {
|
||||
$baselineMeasuredAt = '';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'baseline_measured_at' => $baselineMeasuredAt,
|
||||
'baseline_coins_total' => isset($settings['baseline_coins_total']) ? (string) $settings['baseline_coins_total'] : '',
|
||||
'report_currency' => strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR',
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'save_runtime_settings', static function (array $payload): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
$projectKey = $config->defaultProjectKey();
|
||||
$user = auth_user() ?? [];
|
||||
$ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local';
|
||||
|
||||
$pdo = ConnectionFactory::make($config);
|
||||
$schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath);
|
||||
$schema->ensureSchema();
|
||||
|
||||
$repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub);
|
||||
$repository->ensureProject($projectKey, strtoupper(str_replace('-', ' ', $projectKey)));
|
||||
|
||||
$existing = $repository->getSettings($projectKey) ?? [];
|
||||
$displayTimezone = trim((string) ($existing['display_timezone'] ?? nexus_display_timezone_name()));
|
||||
if ($displayTimezone === '') {
|
||||
$displayTimezone = nexus_display_timezone_name();
|
||||
}
|
||||
|
||||
try {
|
||||
$displayTz = new DateTimeZone($displayTimezone);
|
||||
} catch (\Throwable) {
|
||||
throw new RuntimeException('Ungueltige Anzeige-Zeitzone.');
|
||||
}
|
||||
|
||||
$baselineInput = trim((string) ($payload['baseline_measured_at'] ?? ($existing['baseline_measured_at'] ?? '')));
|
||||
if ($baselineInput === '') {
|
||||
throw new RuntimeException('Baseline Zeitpunkt fehlt.');
|
||||
}
|
||||
|
||||
try {
|
||||
$baselineUtc = (new DateTimeImmutable($baselineInput, $displayTz))
|
||||
->setTimezone(new DateTimeZone('UTC'))
|
||||
->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
throw new RuntimeException('Baseline Zeitpunkt ist ungueltig.');
|
||||
}
|
||||
|
||||
$baselineCoins = trim((string) ($payload['baseline_coins_total'] ?? ($existing['baseline_coins_total'] ?? '')));
|
||||
if ($baselineCoins === '' || !is_numeric($baselineCoins)) {
|
||||
throw new RuntimeException('Baseline Coins muessen numerisch sein.');
|
||||
}
|
||||
|
||||
$preferredCurrencies = $existing['preferred_currencies'] ?? ['DOGE', 'USD', 'EUR'];
|
||||
if (is_string($preferredCurrencies)) {
|
||||
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
|
||||
}
|
||||
$preferredCurrencies = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $value): string => strtoupper(trim((string) $value)),
|
||||
is_array($preferredCurrencies) ? $preferredCurrencies : []
|
||||
), static fn (string $value): bool => $value !== '')));
|
||||
|
||||
$settings = [
|
||||
'baseline_measured_at' => $baselineUtc,
|
||||
'baseline_coins_total' => (float) $baselineCoins,
|
||||
'daily_cost_amount' => isset($existing['daily_cost_amount']) && is_numeric((string) $existing['daily_cost_amount'])
|
||||
? (float) $existing['daily_cost_amount']
|
||||
: 0.0,
|
||||
'daily_cost_currency' => strtoupper(trim((string) ($existing['daily_cost_currency'] ?? 'EUR'))) ?: 'EUR',
|
||||
'report_currency' => strtoupper(trim((string) ($payload['report_currency'] ?? ($existing['report_currency'] ?? 'EUR')))) ?: 'EUR',
|
||||
'crypto_currency' => strtoupper(trim((string) ($existing['crypto_currency'] ?? 'DOGE'))) ?: 'DOGE',
|
||||
'display_timezone' => $displayTimezone,
|
||||
'fx_max_age_hours' => isset($existing['fx_max_age_hours']) && is_numeric((string) $existing['fx_max_age_hours'])
|
||||
? (int) $existing['fx_max_age_hours']
|
||||
: 3,
|
||||
'module_theme_mode' => in_array((string) ($existing['module_theme_mode'] ?? 'inherit'), ['inherit', 'custom'], true)
|
||||
? (string) $existing['module_theme_mode']
|
||||
: 'inherit',
|
||||
'module_theme_accent' => in_array((string) ($existing['module_theme_accent'] ?? 'teal'), ['teal', 'logo', 'pink', 'cyan', 'orange', 'green'], true)
|
||||
? (string) $existing['module_theme_accent']
|
||||
: 'teal',
|
||||
'preferred_currencies' => $preferredCurrencies,
|
||||
];
|
||||
|
||||
$repository->saveSettings($projectKey, $settings);
|
||||
|
||||
return module_fn('mining-checker', 'runtime_settings');
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'setup_actions', static function (): array {
|
||||
return [
|
||||
[
|
||||
'name' => 'initialize_schema',
|
||||
'label' => 'Tabellen importieren',
|
||||
'section' => 'database',
|
||||
'help' => 'Legt die Mining-Checker Tabellen an, wenn sie noch nicht vorhanden sind.',
|
||||
],
|
||||
[
|
||||
'name' => 'upgrade_schema',
|
||||
'label' => 'Tabellen updaten',
|
||||
'section' => 'database',
|
||||
'help' => 'Fuehrt fehlende Tabellen- und Spalten-Upgrades fuer den Mining-Checker aus.',
|
||||
],
|
||||
[
|
||||
'name' => 'seed_import',
|
||||
'label' => 'Seed-Daten importieren',
|
||||
'section' => 'database',
|
||||
'help' => 'Importiert die vordefinierten Startdaten fuer das Standardprojekt.',
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'setup_status', static function (): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
$pdo = ConnectionFactory::make($config);
|
||||
$schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath);
|
||||
$status = $schema->schemaStatus();
|
||||
|
||||
return [
|
||||
'title' => 'Tabellenstatus',
|
||||
'type' => !empty($status['all_present']) && empty($status['pending_upgrades']) ? 'success' : 'hint',
|
||||
'text' => !empty($status['all_present'])
|
||||
? (empty($status['pending_upgrades'])
|
||||
? 'Alle Mining-Checker Tabellen sind vorhanden.'
|
||||
: 'Alle Grundtabellen sind vorhanden, aber es gibt noch ausstehende Upgrades.')
|
||||
: 'Das Mining-Checker Schema ist noch unvollstaendig.',
|
||||
'stats' => [
|
||||
['label' => 'Vorhandene Tabellen', 'value' => (string) ((int) ($status['present_count'] ?? 0) . '/' . count((array) ($status['required_tables'] ?? [])))],
|
||||
['label' => 'Fehlende Tabellen', 'value' => !empty($status['missing_tables']) ? implode(', ', (array) $status['missing_tables']) : 'keine'],
|
||||
['label' => 'Ausstehende Upgrades', 'value' => !empty($status['pending_upgrades']) ? implode(', ', (array) $status['pending_upgrades']) : 'keine'],
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'run_setup_action', static function (string $action): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
$projectKey = $config->defaultProjectKey();
|
||||
$user = auth_user() ?? [];
|
||||
$ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local';
|
||||
|
||||
$pdo = ConnectionFactory::make($config);
|
||||
$schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath);
|
||||
$repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub);
|
||||
|
||||
return match ($action) {
|
||||
'initialize_schema' => (static function () use ($schema): array {
|
||||
$result = $schema->initializeSchema(false);
|
||||
$after = is_array($result['after'] ?? null) ? $result['after'] : [];
|
||||
return [
|
||||
'message' => ($result['message'] ?? 'Schema initialisiert.')
|
||||
. ' Vorhanden: ' . (int) ($after['present_count'] ?? 0)
|
||||
. '/' . count((array) ($after['required_tables'] ?? [])) . '.',
|
||||
];
|
||||
})(),
|
||||
'upgrade_schema' => (static function () use ($schema): array {
|
||||
$result = $schema->upgradeSchemaDirect();
|
||||
$applied = array_values(array_filter(array_map('strval', (array) ($result['upgraded'] ?? []))));
|
||||
return [
|
||||
'message' => ($result['message'] ?? 'Schema-Upgrade ausgefuehrt.')
|
||||
. ($applied !== [] ? ' Upgrades: ' . implode(', ', $applied) . '.' : ''),
|
||||
];
|
||||
})(),
|
||||
'seed_import' => (static function () use ($schema, $repository, $projectKey): array {
|
||||
$schema->ensureSchema();
|
||||
$repository->ensureProject($projectKey, strtoupper(str_replace('-', ' ', $projectKey)));
|
||||
$result = (new \Modules\MiningChecker\Domain\SeedImporter($repository))->import($projectKey);
|
||||
return [
|
||||
'message' => 'Seed-Daten importiert. Eingefuegt: ' . (int) ($result['inserted'] ?? 0) . '.',
|
||||
];
|
||||
})(),
|
||||
default => throw new RuntimeException('Unbekannte Setup-Aktion.'),
|
||||
};
|
||||
});
|
||||
|
||||
$mm->registerFunction('mining-checker', 'sql_import_target', static function (): array {
|
||||
$moduleBasePath = __DIR__;
|
||||
$config = ModuleConfig::load($moduleBasePath);
|
||||
|
||||
return [
|
||||
'pdo' => ConnectionFactory::make($config),
|
||||
'label' => 'Mining-Checker Projekt-Datenbank',
|
||||
];
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'MINING_CHECKER_DEFAULT_PROJECT_KEY' => 'doge-main',
|
||||
'MINING_CHECKER_OCR_PROVIDERS' => 'ocrspace,tesseract',
|
||||
'MINING_CHECKER_OCR_SPACE_URL' => 'https://api.ocr.space/parse/image',
|
||||
'MINING_CHECKER_OCR_SPACE_API_KEY' => 'K83150278888957',
|
||||
'MINING_CHECKER_OCR_SPACE_LANGUAGE' => 'eng',
|
||||
'MINING_CHECKER_OCR_SPACE_ENGINE' => '2',
|
||||
'MINING_CHECKER_OCR_SPACE_SCALE' => 'true',
|
||||
'MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION' => 'true',
|
||||
'MINING_CHECKER_OCR_SPACE_IS_TABLE' => 'false',
|
||||
'MINING_CHECKER_OCR_SPACE_TIMEOUT' => '25',
|
||||
'MINING_CHECKER_TESSERACT_BIN' => '/usr/bin/tesseract',
|
||||
'MINING_CHECKER_TESSERACT_LANG' => 'eng',
|
||||
'MINING_CHECKER_FX_PROVIDER' => 'currencyapi',
|
||||
'MINING_CHECKER_FX_URL' => 'https://currencyapi.net',
|
||||
'MINING_CHECKER_FX_CURRENCIES_URL' => 'https://currencyapi.net',
|
||||
'MINING_CHECKER_FX_API_KEY' => 'eb18ce459ffb0461c59229b478f2e00388d1',
|
||||
'MINING_CHECKER_FX_TIMEOUT' => '10',
|
||||
'MINING_CHECKER_FX_CACHE_TTL' => '21600',
|
||||
'MINING_CHECKER_FX_AUTO_FETCH_ON_MISS' => 'false',
|
||||
];
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default_project_key' => getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main',
|
||||
'use_project_database' => true,
|
||||
'table_prefix' => 'miningcheck_',
|
||||
'uploads_dir' => dirname(__DIR__, 3) . '/data/mining-checker/uploads',
|
||||
'uploads_public_prefix' => '/data/mining-checker/uploads',
|
||||
'ocr' => [
|
||||
'providers' => array_values(array_filter(array_map(
|
||||
static fn (string $provider): string => trim(strtolower($provider)),
|
||||
explode(',', getenv('MINING_CHECKER_OCR_PROVIDERS') ?: 'ocrspace,tesseract')
|
||||
))),
|
||||
'ocrspace' => [
|
||||
'url' => getenv('MINING_CHECKER_OCR_SPACE_URL') ?: 'https://api.ocr.space/parse/image',
|
||||
'api_key' => getenv('MINING_CHECKER_OCR_SPACE_API_KEY') ?: 'K83150278888957',
|
||||
'language' => getenv('MINING_CHECKER_OCR_SPACE_LANGUAGE') ?: 'eng',
|
||||
'engine' => (int) (getenv('MINING_CHECKER_OCR_SPACE_ENGINE') ?: 2),
|
||||
'scale' => getenv('MINING_CHECKER_OCR_SPACE_SCALE') ?: 'true',
|
||||
'detect_orientation' => getenv('MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION') ?: 'true',
|
||||
'is_table' => getenv('MINING_CHECKER_OCR_SPACE_IS_TABLE') ?: 'false',
|
||||
'timeout' => (int) (getenv('MINING_CHECKER_OCR_SPACE_TIMEOUT') ?: 25),
|
||||
],
|
||||
'tesseract' => [
|
||||
'binary' => getenv('MINING_CHECKER_TESSERACT_BIN') ?: 'tesseract',
|
||||
'language' => getenv('MINING_CHECKER_TESSERACT_LANG') ?: 'eng',
|
||||
],
|
||||
],
|
||||
'fx' => [
|
||||
'provider' => getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi',
|
||||
'url' => getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net',
|
||||
'currencies_url' => getenv('MINING_CHECKER_FX_CURRENCIES_URL') ?: 'https://currencyapi.net',
|
||||
'api_key' => getenv('MINING_CHECKER_FX_API_KEY') ?: 'eb18ce459ffb0461c59229b478f2e00388d1',
|
||||
'timeout' => (int) (getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10),
|
||||
'cache_ttl' => (int) (getenv('MINING_CHECKER_FX_CACHE_TTL') ?: 21600),
|
||||
'auto_fetch_on_miss' => filter_var(
|
||||
getenv('MINING_CHECKER_FX_AUTO_FETCH_ON_MISS') ?: 'false',
|
||||
FILTER_VALIDATE_BOOL
|
||||
),
|
||||
],
|
||||
'debug' => [
|
||||
'enabled' => filter_var(getenv('MINING_CHECKER_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL),
|
||||
'dir' => getenv('MINING_CHECKER_DEBUG_DIR') ?: dirname(__DIR__, 3) . '/data/mining-checker/debug',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Mining-Checker",
|
||||
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
|
||||
"actions": [
|
||||
{ "label": "Nexus Übersicht", "href": "/modules" },
|
||||
{ "label": "Setup", "href": "/modules/setup/mining-checker", "variant": "secondary" }
|
||||
],
|
||||
"sections": [
|
||||
{ "key": "overview", "label": "Übersicht" },
|
||||
{ "key": "upload", "label": "Upload" },
|
||||
{ "key": "measurements", "label": "Mining-History" },
|
||||
{ "key": "wallet", "label": "Wallet" },
|
||||
{ "key": "mining", "label": "Miner-Daten" },
|
||||
{ "key": "dashboards", "label": "Dashboards" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# Mining-Checker Modul
|
||||
|
||||
## Zweck
|
||||
|
||||
Das Modul erfasst DOGE-Mining-Messpunkte, analysiert OCR-Vorschlaege aus Screenshots, speichert Messreihen projektbezogen und berechnet Performance-, Kurs- und Zielmetriken.
|
||||
|
||||
## Ordnerstruktur
|
||||
|
||||
```text
|
||||
modules/mining-checker/
|
||||
|-- api/
|
||||
|-- assets/
|
||||
| |-- css/
|
||||
| `-- js/
|
||||
|-- config/
|
||||
|-- docs/
|
||||
|-- pages/
|
||||
|-- partials/
|
||||
|-- sql/
|
||||
| `-- migrations/
|
||||
|-- src/
|
||||
| |-- Api/
|
||||
| |-- Domain/
|
||||
| |-- Infrastructure/
|
||||
| `-- Support/
|
||||
|-- storage/uploads/
|
||||
|-- bootstrap.php
|
||||
`-- module.json
|
||||
```
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
- `GET /api/mining-checker/v1/health`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/bootstrap`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/measurements`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/measurements`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/ocr-preview`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/settings`
|
||||
- `PUT /api/mining-checker/v1/projects/{projectKey}/settings`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/targets`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/targets`
|
||||
- `PATCH /api/mining-checker/v1/projects/{projectKey}/targets/{targetId}`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboards`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/dashboards`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboard-data`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/seed-import`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/schema-status`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/initialize`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/upgrade`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/connection-test`
|
||||
- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history`
|
||||
- `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate`
|
||||
|
||||
## Integration
|
||||
|
||||
1. SQL aus dem passenden Dialekt-Schema ausfuehren:
|
||||
- MySQL/MariaDB: `sql/schema.mysql.sql`
|
||||
- PostgreSQL: `sql/schema.pgsql.sql`
|
||||
- `sql/schema.sql` bleibt der Rueckfall fuer bestehende Setups
|
||||
2. Das Modul nutzt bewusst dieselbe Projekt-Datenbank wie die Anwendung und legt seine Tabellen mit dem Praefix `miningcheck_` an.
|
||||
3. Modulroute ueber `/module/mining-checker` aufrufen.
|
||||
4. REST-API wird ueber `/api/mining-checker/...` vom Hauptprojekt geroutet.
|
||||
|
||||
Hinweis:
|
||||
Wenn beim ersten API-Zugriff noch keine `miningcheck_*` Tabellen vorhanden sind, importiert das Modul automatisch das zum aktiven PDO-Treiber passende Schema.
|
||||
Seed-Daten werden dabei nicht automatisch eingespielt.
|
||||
Fuer eine manuelle Initialisierung, ein inkrementelles Upgrade oder einen Reset gibt es zusaetzlich `schema-status`, `upgrade` und `initialize`. Mit `{ "drop_existing": true }` werden vorhandene `miningcheck_*` Tabellen inklusive Daten geloescht und das Schema neu angelegt.
|
||||
|
||||
## OCR-Hinweis
|
||||
|
||||
Das Modul unterstuetzt einen OCR-Provider-Stack. Standardmaessig wird zuerst `ocr.space` verwendet und danach optional auf lokales `tesseract` zurueckgefallen.
|
||||
|
||||
Empfohlene Umgebungsvariablen:
|
||||
|
||||
- `MINING_CHECKER_OCR_PROVIDERS=ocrspace,tesseract`
|
||||
- `MINING_CHECKER_OCR_SPACE_URL=https://api.ocr.space/parse/image`
|
||||
- `MINING_CHECKER_OCR_SPACE_API_KEY=...`
|
||||
- `MINING_CHECKER_OCR_SPACE_LANGUAGE=eng`
|
||||
- `MINING_CHECKER_OCR_SPACE_ENGINE=2`
|
||||
- `MINING_CHECKER_OCR_SPACE_SCALE=true`
|
||||
- `MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION=true`
|
||||
- `MINING_CHECKER_OCR_SPACE_IS_TABLE=false`
|
||||
- `MINING_CHECKER_OCR_SPACE_TIMEOUT=25`
|
||||
- `MINING_CHECKER_TESSERACT_BIN=/usr/bin/tesseract`
|
||||
- `MINING_CHECKER_TESSERACT_LANG=eng`
|
||||
|
||||
Laut OCR.space-Doku wird `POST https://api.ocr.space/parse/image` mit `file`, Header-`apikey`, optional `language`, `scale`, `detectOrientation`, `isTable` und `OCREngine` verwendet. Der Modulparser wertet die OCR.space-Felder `ParsedResults`, `ParsedText`, `IsErroredOnProcessing`, `ErrorMessage` und `OCRExitCode` aus. Quellen: https://ocr.space/ocrapi
|
||||
|
||||
## Wechselkurse und Waehrungen
|
||||
|
||||
Der Mining-Checker speichert keine eigenen FX-Snapshots mehr, sondern referenziert die `fetch_id` aus `fx-rates`.
|
||||
Auch der Waehrungskatalog und die bevorzugten Waehrungen kommen ausschliesslich aus `fx-rates`. Der Mining-Checker fuehrt keine eigene Waehrungstabelle und keine eigene Alias-Verwaltung mehr im Laufzeitpfad.
|
||||
- `MINING_CHECKER_FX_AUTO_FETCH_ON_MISS=false`
|
||||
|
||||
Optionaler JSON-Body:
|
||||
|
||||
- `base`: Standard `EUR`
|
||||
- `symbols`: wird aktuell ignoriert; der Mining-Checker speichert immer den kompletten Waehrungssatz des Fetches
|
||||
|
||||
Beispiel:
|
||||
|
||||
```json
|
||||
{
|
||||
"base": "EUR"
|
||||
}
|
||||
```
|
||||
|
||||
`currencyapi.net` wird ueber das Modul `fx-rates` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die eigentlichen Fetches und Raten liegen im Modul `fx-rates`.
|
||||
|
||||
Pro Abruf entsteht genau ein Datensatz in `fx-rates` mit Basiswaehrung, Provider und Stichtag. Neue Mining-Messpunkte pruefen beim Speichern, ob ein neuer FX-Fetch noetig ist; falls nicht, wird die letzte passende `fetch_id` wiederverwendet.
|
||||
|
||||
Falls noch historische Mining-Checker-Fetches in `miningcheck_fx_fetches` und `miningcheck_fx_rates` liegen, kann `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate` diese nach `fx-rates` ueberfuehren. Danach werden bestehende Messpunkte soweit moeglich auf die passende `fx_fetch_id` aktualisiert.
|
||||
|
||||
Fuer Auswertungen, Berichte und Listen speichert der Mining-Checker pro Messpunkt die damals passende `fx_fetch_id`. Historische Umrechnungen laufen damit gegen genau den zugeordneten `fx-rates`-Snapshot.
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Mining-Checker",
|
||||
"title": "Mining-Checker",
|
||||
"version": "0.3.0",
|
||||
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
|
||||
"enabled_by_default": true,
|
||||
"setup": {
|
||||
"sections": {
|
||||
"database": true
|
||||
},
|
||||
"fields": [
|
||||
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
|
||||
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql oder mysql" },
|
||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
|
||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
|
||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
|
||||
{ "name": "baseline_measured_at", "label": "Baseline Zeitpunkt", "type": "datetime-local", "required": false, "help": "Referenzzeitpunkt fuer die erste Baseline-Messung." },
|
||||
{ "name": "baseline_coins_total", "label": "Baseline Coins", "type": "number", "required": false, "help": "Coin-Bestand zum Baseline-Zeitpunkt." },
|
||||
{ "name": "report_currency", "label": "Standard-Berichtswaehrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Kennzahlen und Berichte, z.B. EUR." }
|
||||
]
|
||||
},
|
||||
"auth": {
|
||||
"required": true,
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$moduleConfig = require dirname(__DIR__) . '/config/module.php';
|
||||
$design = module_design('mining-checker');
|
||||
$defaultProjectKey = (string) ($moduleConfig['default_project_key'] ?? 'doge-main');
|
||||
$sections = is_array($design['sections'] ?? null) ? $design['sections'] : [];
|
||||
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
|
||||
$allowedViews = [];
|
||||
$tabs = [];
|
||||
foreach ($sections as $section) {
|
||||
if (!is_array($section)) {
|
||||
continue;
|
||||
}
|
||||
$key = trim((string) ($section['key'] ?? ''));
|
||||
$label = trim((string) ($section['label'] ?? ''));
|
||||
if ($key === '' || $label === '') {
|
||||
continue;
|
||||
}
|
||||
$allowedViews[] = $key;
|
||||
$tabs[] = [
|
||||
'label' => $label,
|
||||
'href' => '/module/mining-checker?view=' . rawurlencode($key),
|
||||
'active' => $activeView === $key,
|
||||
];
|
||||
}
|
||||
if ($allowedViews === []) {
|
||||
$allowedViews = ['overview'];
|
||||
}
|
||||
if (!in_array($activeView, $allowedViews, true)) {
|
||||
$activeView = $allowedViews[0];
|
||||
foreach ($tabs as &$tab) {
|
||||
$tab['active'] = str_ends_with((string) ($tab['href'] ?? ''), 'view=' . $activeView);
|
||||
}
|
||||
unset($tab);
|
||||
}
|
||||
$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$moduleCss = file_get_contents(dirname(__DIR__) . '/assets/css/app.css') ?: '';
|
||||
$moduleJs = file_get_contents(dirname(__DIR__) . '/assets/js/app.js') ?: '';
|
||||
$moduleJs = str_replace('</script>', '<\/script>', $moduleJs);
|
||||
?>
|
||||
<?= module_shell_header('mining-checker', [
|
||||
'title' => 'DOGE Mining-Checker',
|
||||
'tabs' => $tabs,
|
||||
]) ?>
|
||||
<div class="module-flow">
|
||||
<div id="mining-checker-app"
|
||||
data-default-project-key="<?= e($defaultProjectKey) ?>"
|
||||
data-api-base="/api/mining-checker/v1"
|
||||
data-active-view="<?= e($activeView) ?>"
|
||||
data-sections-json="<?= e(is_string($sectionsJson) ? $sectionsJson : '[]') ?>"></div>
|
||||
</div>
|
||||
<style><?= $moduleCss ?></style>
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script><?= $moduleJs ?></script>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,124 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
baseline_measured_at DATETIME NOT NULL,
|
||||
baseline_coins_total DECIMAL(20,6) NOT NULL,
|
||||
daily_cost_amount DECIMAL(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10) NULL,
|
||||
crypto_currency VARCHAR(10) NULL,
|
||||
display_timezone VARCHAR(64) NULL,
|
||||
fx_max_age_hours DECIMAL(10,2) NULL,
|
||||
module_theme_mode VARCHAR(16) NULL,
|
||||
module_theme_accent VARCHAR(16) NULL,
|
||||
preferred_currencies JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
runtime_months INT NOT NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
base_price_amount DECIMAL(20,8) NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
coins_total DECIMAL(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin DECIMAL(20,8) NULL,
|
||||
price_currency VARCHAR(10) NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, measured_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat DECIMAL(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount DECIMAL(20,8) NULL,
|
||||
total_value_currency VARCHAR(10) NULL,
|
||||
wallet_balance DECIMAL(28,10) NULL,
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSON NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
INSERT INTO miningcheck_projects (project_key, project_name)
|
||||
VALUES ('doge-main', 'DOGE Mining Main')
|
||||
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
|
||||
|
||||
INSERT INTO miningcheck_settings (
|
||||
project_key,
|
||||
baseline_measured_at,
|
||||
baseline_coins_total,
|
||||
daily_cost_amount,
|
||||
daily_cost_currency,
|
||||
preferred_currencies
|
||||
)
|
||||
VALUES (
|
||||
'doge-main',
|
||||
'2026-03-16 01:32:00',
|
||||
27.617864,
|
||||
0.3123287671,
|
||||
'EUR',
|
||||
'["DOGE","USD","EUR"]'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
baseline_measured_at = VALUES(baseline_measured_at),
|
||||
baseline_coins_total = VALUES(baseline_coins_total),
|
||||
daily_cost_amount = VALUES(daily_cost_amount),
|
||||
daily_cost_currency = VALUES(daily_cost_currency),
|
||||
preferred_currencies = VALUES(preferred_currencies);
|
||||
|
||||
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
|
||||
VALUES
|
||||
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
|
||||
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
target_amount_fiat = VALUES(target_amount_fiat),
|
||||
currency = VALUES(currency),
|
||||
is_active = VALUES(is_active),
|
||||
sort_order = VALUES(sort_order);
|
||||
|
||||
INSERT INTO miningcheck_dashboard_definitions (
|
||||
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
|
||||
)
|
||||
VALUES
|
||||
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
|
||||
('doge-main', 'Performance-Verlauf', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
|
||||
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currency":"EUR"}', 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
chart_type = VALUES(chart_type),
|
||||
x_field = VALUES(x_field),
|
||||
y_field = VALUES(y_field),
|
||||
aggregation = VALUES(aggregation),
|
||||
filters_json = VALUES(filters_json),
|
||||
is_active = VALUES(is_active);
|
||||
|
||||
INSERT INTO miningcheck_measurements (
|
||||
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
|
||||
)
|
||||
VALUES
|
||||
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
price_per_coin = VALUES(price_per_coin),
|
||||
price_currency = VALUES(price_currency),
|
||||
note = VALUES(note),
|
||||
source = VALUES(source);
|
||||
@@ -0,0 +1,34 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD COLUMN IF NOT EXISTS display_timezone VARCHAR(64);
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET display_timezone = 'Europe/Berlin'
|
||||
WHERE display_timezone IS NULL OR BTRIM(display_timezone) = '';
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET baseline_measured_at = ((baseline_measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE baseline_measured_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_cost_plans
|
||||
SET starts_at = ((starts_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE starts_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_measurements
|
||||
SET measured_at = ((measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE measured_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_payouts
|
||||
SET payout_at = ((payout_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE payout_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_purchased_miners
|
||||
SET purchased_at = ((purchased_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE purchased_at IS NOT NULL;
|
||||
|
||||
UPDATE miningcheck_fx_fetches
|
||||
SET fetched_at = ((fetched_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
|
||||
WHERE fetched_at IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,72 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans_legacy AS
|
||||
SELECT *
|
||||
FROM miningcheck_cost_plans
|
||||
WHERE 1 = 0;
|
||||
|
||||
INSERT INTO miningcheck_cost_plans_legacy
|
||||
SELECT cp.*
|
||||
FROM miningcheck_cost_plans cp
|
||||
LEFT JOIN miningcheck_cost_plans_legacy legacy
|
||||
ON legacy.id = cp.id
|
||||
WHERE legacy.id IS NULL;
|
||||
|
||||
INSERT INTO miningcheck_purchased_miners (
|
||||
project_key,
|
||||
miner_offer_id,
|
||||
purchased_at,
|
||||
label,
|
||||
runtime_months,
|
||||
mining_speed_value,
|
||||
mining_speed_unit,
|
||||
bonus_speed_value,
|
||||
bonus_speed_unit,
|
||||
total_cost_amount,
|
||||
currency,
|
||||
usd_reference_amount,
|
||||
reference_price_amount,
|
||||
reference_price_currency,
|
||||
auto_renew,
|
||||
note,
|
||||
is_active
|
||||
)
|
||||
SELECT
|
||||
cp.project_key,
|
||||
NULL AS miner_offer_id,
|
||||
cp.starts_at AS purchased_at,
|
||||
cp.label,
|
||||
cp.runtime_months,
|
||||
cp.mining_speed_value,
|
||||
cp.mining_speed_unit,
|
||||
cp.bonus_speed_value,
|
||||
cp.bonus_speed_unit,
|
||||
cp.total_cost_amount,
|
||||
cp.currency,
|
||||
CASE
|
||||
WHEN COALESCE(s.report_currency, 'EUR') = 'USD' THEN cp.base_price_amount
|
||||
ELSE NULL
|
||||
END AS usd_reference_amount,
|
||||
cp.base_price_amount AS reference_price_amount,
|
||||
COALESCE(s.report_currency, 'EUR') AS reference_price_currency,
|
||||
cp.auto_renew,
|
||||
CASE
|
||||
WHEN cp.note IS NULL OR BTRIM(cp.note) = '' THEN 'Migriert aus miningcheck_cost_plans'
|
||||
ELSE cp.note || ' | Migriert aus miningcheck_cost_plans'
|
||||
END AS note,
|
||||
cp.is_active
|
||||
FROM miningcheck_cost_plans cp
|
||||
LEFT JOIN miningcheck_settings s
|
||||
ON s.project_key = cp.project_key
|
||||
LEFT JOIN miningcheck_purchased_miners pm
|
||||
ON pm.project_key = cp.project_key
|
||||
AND pm.miner_offer_id IS NULL
|
||||
AND pm.purchased_at = cp.starts_at
|
||||
AND pm.label = cp.label
|
||||
AND pm.total_cost_amount = cp.total_cost_amount
|
||||
AND pm.currency = cp.currency
|
||||
WHERE pm.id IS NULL;
|
||||
|
||||
DELETE FROM miningcheck_cost_plans;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,15 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD COLUMN IF NOT EXISTS module_theme_mode VARCHAR(16),
|
||||
ADD COLUMN IF NOT EXISTS module_theme_accent VARCHAR(16);
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET module_theme_mode = 'inherit'
|
||||
WHERE module_theme_mode IS NULL OR BTRIM(module_theme_mode) = '';
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET module_theme_accent = 'teal'
|
||||
WHERE module_theme_accent IS NULL OR BTRIM(module_theme_accent) = '';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,182 @@
|
||||
-- Bestehende benutzerspezifische Mining-Daten werden diesem Keycloak-Sub zugeordnet:
|
||||
-- adea1766-5d1c-4c2e-98bd-5239861f745f
|
||||
-- Die Keycloak-Sub ist stabiler als preferred_username und wird fuer alle benutzerspezifischen Mining-Daten genutzt.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_cost_plans
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_measurements
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_measurement_rates
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_payouts
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_targets
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
ALTER TABLE miningcheck_purchased_miners
|
||||
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
|
||||
|
||||
UPDATE miningcheck_settings
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_cost_plans
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_measurements
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_measurement_rates mr
|
||||
SET owner_sub = m.owner_sub
|
||||
FROM miningcheck_measurements m
|
||||
WHERE mr.measurement_id = m.id
|
||||
AND (mr.owner_sub IS NULL OR BTRIM(mr.owner_sub) = '');
|
||||
|
||||
UPDATE miningcheck_measurement_rates
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_payouts
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_targets
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_dashboard_definitions
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
UPDATE miningcheck_purchased_miners
|
||||
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
|
||||
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_cost_plans
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_measurements
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_measurement_rates
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_payouts
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_targets
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_purchased_miners
|
||||
ALTER COLUMN owner_sub SET NOT NULL;
|
||||
|
||||
ALTER TABLE miningcheck_settings
|
||||
DROP CONSTRAINT IF EXISTS miningcheck_settings_project_key_key;
|
||||
|
||||
ALTER TABLE miningcheck_measurements
|
||||
DROP CONSTRAINT IF EXISTS uq_mining_measurements_unique;
|
||||
|
||||
ALTER TABLE miningcheck_targets
|
||||
DROP CONSTRAINT IF EXISTS uq_mining_targets_project_label;
|
||||
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
DROP CONSTRAINT IF EXISTS uq_mining_dashboards_project_name;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_settings'
|
||||
AND constraint_name = 'uq_mining_settings_project_owner'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_settings
|
||||
ADD CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_measurements'
|
||||
AND constraint_name = 'uq_mining_measurements_unique'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_measurements
|
||||
ADD CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_targets'
|
||||
AND constraint_name = 'uq_mining_targets_project_label'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_targets
|
||||
ADD CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'miningcheck_dashboard_definitions'
|
||||
AND constraint_name = 'uq_mining_dashboards_project_name'
|
||||
) THEN
|
||||
ALTER TABLE miningcheck_dashboard_definitions
|
||||
ADD CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_owner_start
|
||||
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_owner_measured_at
|
||||
ON miningcheck_measurements(project_key, owner_sub, measured_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_owner_measurement
|
||||
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_owner_payout_at
|
||||
ON miningcheck_payouts(project_key, owner_sub, payout_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_targets_project_owner
|
||||
ON miningcheck_targets(project_key, owner_sub, sort_order, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_dashboards_project_owner
|
||||
ON miningcheck_dashboard_definitions(project_key, owner_sub, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_purchased_miners_project_owner_purchased_at
|
||||
ON miningcheck_purchased_miners(project_key, owner_sub, purchased_at);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,223 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
baseline_measured_at DATETIME NOT NULL,
|
||||
baseline_coins_total DECIMAL(20,6) NOT NULL,
|
||||
daily_cost_amount DECIMAL(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10) NULL,
|
||||
crypto_currency VARCHAR(10) NULL,
|
||||
display_timezone VARCHAR(64) NULL,
|
||||
fx_max_age_hours DECIMAL(10,2) NULL,
|
||||
module_theme_mode VARCHAR(16) NULL,
|
||||
module_theme_accent VARCHAR(16) NULL,
|
||||
preferred_currencies JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
runtime_months INT NOT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
base_price_amount DECIMAL(20,8) NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
coins_total DECIMAL(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin DECIMAL(20,8) NULL,
|
||||
price_currency VARCHAR(10) NULL,
|
||||
fx_fetch_id BIGINT UNSIGNED NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, measured_at);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_fx_fetch
|
||||
ON miningcheck_measurements(fx_fetch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
measurement_id BIGINT UNSIGNED NOT NULL,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL,
|
||||
rate DECIMAL(20,10) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
|
||||
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
payout_at TIMESTAMP NOT NULL,
|
||||
coins_amount DECIMAL(20,6) NOT NULL,
|
||||
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount DECIMAL(20,8) NULL,
|
||||
total_value_currency VARCHAR(10) NULL,
|
||||
wallet_balance DECIMAL(28,10) NULL,
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSON NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
base_price_amount DECIMAL(20,8) NOT NULL,
|
||||
base_price_currency VARCHAR(10) NOT NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat DECIMAL(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
purchased_at TIMESTAMP NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
usd_reference_amount DECIMAL(20,8) NULL,
|
||||
reference_price_amount DECIMAL(20,8) NULL,
|
||||
reference_price_currency VARCHAR(10) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
fetch_id BIGINT UNSIGNED NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL,
|
||||
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
|
||||
KEY idx_miningcheck_fx_rates_currency (currency_code),
|
||||
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,243 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
baseline_measured_at TIMESTAMP NOT NULL,
|
||||
baseline_coins_total NUMERIC(20,6) NOT NULL,
|
||||
daily_cost_amount NUMERIC(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10),
|
||||
crypto_currency VARCHAR(10),
|
||||
display_timezone VARCHAR(64),
|
||||
fx_max_age_hours NUMERIC(10,2),
|
||||
module_theme_mode VARCHAR(16),
|
||||
module_theme_accent VARCHAR(16),
|
||||
preferred_currencies JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at TIMESTAMP NOT NULL,
|
||||
runtime_months INTEGER NOT NULL,
|
||||
mining_speed_value NUMERIC(20,4),
|
||||
mining_speed_unit VARCHAR(8),
|
||||
bonus_speed_value NUMERIC(20,4),
|
||||
bonus_speed_unit VARCHAR(8),
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
base_price_amount NUMERIC(20,8),
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount NUMERIC(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
coins_total NUMERIC(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin NUMERIC(20,8),
|
||||
price_currency VARCHAR(10),
|
||||
fx_fetch_id BIGINT,
|
||||
note TEXT,
|
||||
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
|
||||
image_path VARCHAR(255),
|
||||
ocr_raw_text TEXT,
|
||||
ocr_confidence NUMERIC(6,4),
|
||||
ocr_flags JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, owner_sub, measured_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch
|
||||
ON miningcheck_measurements(fx_fetch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
measurement_id BIGINT NOT NULL,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL,
|
||||
rate NUMERIC(20,10) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_measurement
|
||||
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
payout_at TIMESTAMP NOT NULL,
|
||||
coins_amount NUMERIC(20,6) NOT NULL,
|
||||
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at
|
||||
ON miningcheck_payouts(project_key, owner_sub, payout_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount NUMERIC(20,8),
|
||||
total_value_currency VARCHAR(10),
|
||||
wallet_balance NUMERIC(28,10),
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSONB,
|
||||
note TEXT,
|
||||
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
|
||||
image_path VARCHAR(255),
|
||||
ocr_raw_text TEXT,
|
||||
ocr_confidence NUMERIC(6,4),
|
||||
ocr_flags JSONB,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at
|
||||
ON miningcheck_wallet_snapshots(project_key, owner_sub, measured_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INTEGER,
|
||||
mining_speed_value NUMERIC(20,4),
|
||||
mining_speed_unit VARCHAR(8),
|
||||
bonus_speed_value NUMERIC(20,4),
|
||||
bonus_speed_unit VARCHAR(8),
|
||||
base_price_amount NUMERIC(20,8) NOT NULL,
|
||||
base_price_currency VARCHAR(10) NOT NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
note TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat NUMERIC(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type VARCHAR(16) NOT NULL CHECK (chart_type IN ('line', 'bar', 'area', 'table')),
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSONB,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
miner_offer_id BIGINT,
|
||||
purchased_at TIMESTAMP NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INTEGER,
|
||||
mining_speed_value NUMERIC(20,4),
|
||||
mining_speed_unit VARCHAR(8),
|
||||
bonus_speed_value NUMERIC(20,4),
|
||||
bonus_speed_unit VARCHAR(8),
|
||||
total_cost_amount NUMERIC(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
usd_reference_amount NUMERIC(20,8),
|
||||
reference_price_amount NUMERIC(20,8),
|
||||
reference_price_currency VARCHAR(10),
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
note TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_fetches_base_fetched
|
||||
ON miningcheck_fx_fetches(base_currency, fetched_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
fetch_id BIGINT NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value NUMERIC(20,10) NOT NULL,
|
||||
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_fetch
|
||||
ON miningcheck_fx_rates(fetch_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_currency
|
||||
ON miningcheck_fx_rates(currency_code);
|
||||
@@ -0,0 +1,223 @@
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_projects (
|
||||
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
project_name VARCHAR(160) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
baseline_measured_at DATETIME NOT NULL,
|
||||
baseline_coins_total DECIMAL(20,6) NOT NULL,
|
||||
daily_cost_amount DECIMAL(20,10) NOT NULL,
|
||||
daily_cost_currency VARCHAR(10) NOT NULL,
|
||||
report_currency VARCHAR(10) NULL,
|
||||
crypto_currency VARCHAR(10) NULL,
|
||||
display_timezone VARCHAR(64) NULL,
|
||||
fx_max_age_hours DECIMAL(10,2) NULL,
|
||||
module_theme_mode VARCHAR(16) NULL,
|
||||
module_theme_accent VARCHAR(16) NULL,
|
||||
preferred_currencies JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
runtime_months INT NOT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
base_price_amount DECIMAL(20,8) NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
note TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_cost_plans_project_start
|
||||
ON miningcheck_cost_plans(project_key, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
measured_at DATETIME NOT NULL,
|
||||
coins_total DECIMAL(20,6) NOT NULL,
|
||||
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
price_per_coin DECIMAL(20,8) NULL,
|
||||
price_currency VARCHAR(10) NULL,
|
||||
fx_fetch_id BIGINT UNSIGNED NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_project_measured_at
|
||||
ON miningcheck_measurements(project_key, measured_at);
|
||||
|
||||
CREATE INDEX idx_miningcheck_measurements_fx_fetch
|
||||
ON miningcheck_measurements(fx_fetch_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
measurement_id BIGINT UNSIGNED NOT NULL,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
quote_currency VARCHAR(10) NOT NULL,
|
||||
rate DECIMAL(20,10) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
|
||||
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
payout_at TIMESTAMP NOT NULL,
|
||||
coins_amount DECIMAL(20,6) NOT NULL,
|
||||
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
owner_sub VARCHAR(128) NOT NULL,
|
||||
measured_at TIMESTAMP NOT NULL,
|
||||
total_value_amount DECIMAL(20,8) NULL,
|
||||
total_value_currency VARCHAR(10) NULL,
|
||||
wallet_balance DECIMAL(28,10) NULL,
|
||||
wallet_currency VARCHAR(10) NOT NULL,
|
||||
balances_json JSON NULL,
|
||||
note TEXT NULL,
|
||||
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
ocr_raw_text MEDIUMTEXT NULL,
|
||||
ocr_confidence DECIMAL(6,4) NULL,
|
||||
ocr_flags JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
base_price_amount DECIMAL(20,8) NOT NULL,
|
||||
base_price_currency VARCHAR(10) NOT NULL,
|
||||
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
target_amount_fiat DECIMAL(20,2) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
|
||||
x_field VARCHAR(64) NOT NULL,
|
||||
y_field VARCHAR(64) NOT NULL,
|
||||
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
filters_json JSON NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
project_key VARCHAR(64) NOT NULL,
|
||||
miner_offer_id BIGINT UNSIGNED NULL,
|
||||
purchased_at TIMESTAMP NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
runtime_months INT NULL,
|
||||
mining_speed_value DECIMAL(20,4) NULL,
|
||||
mining_speed_unit VARCHAR(8) NULL,
|
||||
bonus_speed_value DECIMAL(20,4) NULL,
|
||||
bonus_speed_unit VARCHAR(8) NULL,
|
||||
total_cost_amount DECIMAL(20,8) NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL,
|
||||
usd_reference_amount DECIMAL(20,8) NULL,
|
||||
reference_price_amount DECIMAL(20,8) NULL,
|
||||
reference_price_currency VARCHAR(10) NULL,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
|
||||
note TEXT,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
fetch_id BIGINT UNSIGNED NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL,
|
||||
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
|
||||
KEY idx_miningcheck_fx_rates_currency (currency_code),
|
||||
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
INSERT INTO miningcheck_projects (project_key, project_name)
|
||||
VALUES ('doge-main', 'DOGE Mining Main')
|
||||
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
|
||||
|
||||
INSERT INTO miningcheck_settings (
|
||||
project_key,
|
||||
baseline_measured_at,
|
||||
baseline_coins_total,
|
||||
daily_cost_amount,
|
||||
daily_cost_currency,
|
||||
preferred_currencies
|
||||
)
|
||||
VALUES (
|
||||
'doge-main',
|
||||
'2026-03-16 01:32:00',
|
||||
27.617864,
|
||||
0.3123287671,
|
||||
'EUR',
|
||||
'["DOGE","USD","EUR"]'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
baseline_measured_at = VALUES(baseline_measured_at),
|
||||
baseline_coins_total = VALUES(baseline_coins_total),
|
||||
daily_cost_amount = VALUES(daily_cost_amount),
|
||||
daily_cost_currency = VALUES(daily_cost_currency),
|
||||
preferred_currencies = VALUES(preferred_currencies);
|
||||
|
||||
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
|
||||
VALUES
|
||||
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
|
||||
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
target_amount_fiat = VALUES(target_amount_fiat),
|
||||
currency = VALUES(currency),
|
||||
is_active = VALUES(is_active),
|
||||
sort_order = VALUES(sort_order);
|
||||
|
||||
INSERT INTO miningcheck_dashboard_definitions (
|
||||
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
|
||||
)
|
||||
VALUES
|
||||
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
|
||||
('doge-main', 'DOGE pro Tag', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
|
||||
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currencies":["EUR","USD"]}', 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
chart_type = VALUES(chart_type),
|
||||
x_field = VALUES(x_field),
|
||||
y_field = VALUES(y_field),
|
||||
aggregation = VALUES(aggregation),
|
||||
filters_json = VALUES(filters_json),
|
||||
is_active = VALUES(is_active);
|
||||
|
||||
INSERT INTO miningcheck_measurements (
|
||||
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
|
||||
)
|
||||
VALUES
|
||||
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
|
||||
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
|
||||
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
price_per_coin = VALUES(price_per_coin),
|
||||
price_currency = VALUES(price_currency),
|
||||
note = VALUES(note),
|
||||
source = VALUES(source);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,950 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
||||
use Modules\MiningChecker\Support\DebugTrace;
|
||||
|
||||
final class FxService
|
||||
{
|
||||
private ?MiningRepository $repository;
|
||||
private string $provider;
|
||||
private string $apiBaseUrl;
|
||||
private string $currenciesApiBaseUrl;
|
||||
private string $apiKey;
|
||||
private int $timeout;
|
||||
private int $cacheTtl;
|
||||
private bool $autoFetchOnMiss;
|
||||
private array $memoryCache = [];
|
||||
private array $snapshotCache = [];
|
||||
private ?DebugTrace $debug;
|
||||
|
||||
public function __construct(
|
||||
?MiningRepository $repository = null,
|
||||
string $apiBaseUrl = 'https://currencyapi.net',
|
||||
string $currenciesApiBaseUrl = 'https://currencyapi.net',
|
||||
int $timeout = 10,
|
||||
int $cacheTtl = 21600,
|
||||
bool $autoFetchOnMiss = false,
|
||||
string $provider = 'currencyapi',
|
||||
string $apiKey = '',
|
||||
?DebugTrace $debug = null
|
||||
)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->provider = trim(strtolower($provider)) !== '' ? trim(strtolower($provider)) : 'currencyapi';
|
||||
$this->apiBaseUrl = rtrim($apiBaseUrl, '/');
|
||||
$this->currenciesApiBaseUrl = rtrim($currenciesApiBaseUrl, '/');
|
||||
$this->apiKey = trim($apiKey);
|
||||
$this->timeout = max(2, $timeout);
|
||||
$this->cacheTtl = max(60, $cacheTtl);
|
||||
$this->autoFetchOnMiss = $autoFetchOnMiss;
|
||||
$this->debug = $debug;
|
||||
}
|
||||
|
||||
public function convert(?float $amount, ?string $from, ?string $to): ?float
|
||||
{
|
||||
return $this->convertAt($amount, $from, $to, null, null, null);
|
||||
}
|
||||
|
||||
public function convertAt(?float $amount, ?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
|
||||
{
|
||||
if ($amount === null || $from === null || $to === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && $normalizedFetchId !== null) {
|
||||
$snapshot = $this->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]);
|
||||
if (is_array($snapshot)) {
|
||||
$resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to)));
|
||||
if ($resolved !== null) {
|
||||
return $amount * $resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($shared !== null && method_exists($shared, 'convert')) {
|
||||
$converted = $shared->convert($amount, $from, $to, $at, $windowMinutes);
|
||||
return is_numeric($converted) ? (float) $converted : null;
|
||||
}
|
||||
|
||||
$rate = $this->rateAt($from, $to, $at, $windowMinutes, $normalizedFetchId);
|
||||
return $rate === null ? null : $amount * $rate;
|
||||
}
|
||||
|
||||
public function rate(?string $from, ?string $to): ?float
|
||||
{
|
||||
return $this->rateAt($from, $to, null, null, null);
|
||||
}
|
||||
|
||||
public function rateAt(?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
|
||||
{
|
||||
$base = strtoupper(trim((string) $from));
|
||||
$target = strtoupper(trim((string) $to));
|
||||
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
|
||||
|
||||
if ($base === '' || $target === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($base === $target) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && $normalizedFetchId !== null) {
|
||||
$snapshot = $this->snapshotByFetchId($normalizedFetchId, $base, [$target]);
|
||||
if (is_array($snapshot)) {
|
||||
$resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target);
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($shared !== null && method_exists($shared, 'findRate')) {
|
||||
$resolved = $shared->findRate($from, $to, $at, $windowMinutes);
|
||||
return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null;
|
||||
}
|
||||
|
||||
$cacheKey = implode(':', [$base, $target, $at ?? '', (string) ($windowMinutes ?? 0), (string) ($normalizedFetchId ?? 0)]);
|
||||
if (array_key_exists($cacheKey, $this->memoryCache)) {
|
||||
return $this->memoryCache[$cacheKey];
|
||||
}
|
||||
|
||||
$stored = $this->storedRate($base, $target);
|
||||
if ($stored !== null) {
|
||||
$this->memoryCache[$cacheKey] = $stored;
|
||||
return $stored;
|
||||
}
|
||||
|
||||
$cached = $this->readFileCache($cacheKey);
|
||||
if ($cached !== null) {
|
||||
$this->memoryCache[$cacheKey] = $cached;
|
||||
return $cached;
|
||||
}
|
||||
|
||||
if (!$this->autoFetchOnMiss) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rate = $this->fetchAndPersistRate($base, $target);
|
||||
$this->memoryCache[$cacheKey] = $rate;
|
||||
if ($rate !== null) {
|
||||
$this->writeFileCache($cacheKey, $rate);
|
||||
}
|
||||
|
||||
return $rate;
|
||||
}
|
||||
|
||||
public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array
|
||||
{
|
||||
if ($fetchId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cacheKey = $this->snapshotCacheKey('fetch', [
|
||||
$fetchId,
|
||||
strtoupper(trim((string) ($baseCurrency ?? ''))),
|
||||
$this->normalizeSymbolsForCache($symbols),
|
||||
]);
|
||||
if (array_key_exists($cacheKey, $this->snapshotCache)) {
|
||||
return $this->snapshotCache[$cacheKey];
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) {
|
||||
$snapshot = $shared->snapshotByFetchId($fetchId, $baseCurrency, $symbols);
|
||||
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
|
||||
}
|
||||
|
||||
return $this->snapshotCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function latestSnapshot(?string $baseCurrency = null, ?array $symbols = null): ?array
|
||||
{
|
||||
$cacheKey = $this->snapshotCacheKey('latest', [
|
||||
strtoupper(trim((string) ($baseCurrency ?? ''))),
|
||||
$this->normalizeSymbolsForCache($symbols),
|
||||
]);
|
||||
if (array_key_exists($cacheKey, $this->snapshotCache)) {
|
||||
return $this->snapshotCache[$cacheKey];
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'snapshot')) {
|
||||
$snapshot = $shared->snapshot($baseCurrency, null, $symbols, null);
|
||||
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
|
||||
}
|
||||
|
||||
return $this->snapshotCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function nearestSnapshot(?string $baseCurrency, string $at, ?array $symbols = null, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$cacheKey = $this->snapshotCacheKey('nearest', [
|
||||
strtoupper(trim((string) ($baseCurrency ?? ''))),
|
||||
trim($at),
|
||||
$windowMinutes ?? 0,
|
||||
$this->normalizeSymbolsForCache($symbols),
|
||||
]);
|
||||
if (array_key_exists($cacheKey, $this->snapshotCache)) {
|
||||
return $this->snapshotCache[$cacheKey];
|
||||
}
|
||||
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'nearestSnapshot')) {
|
||||
$snapshot = $shared->nearestSnapshot($baseCurrency, $at, $symbols, $windowMinutes);
|
||||
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
|
||||
}
|
||||
|
||||
return $this->snapshotCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'refreshLatestRates')) {
|
||||
return $shared->refreshLatestRates($currencies, $base);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
$targets = $currencies === null
|
||||
? null
|
||||
: array_values(array_unique(array_filter(array_map(
|
||||
static fn ($code): string => strtoupper(trim((string) $code)),
|
||||
$currencies
|
||||
), static fn (string $code): bool => $code !== '' && $code !== $normalizedBase)));
|
||||
|
||||
$payload = $this->fetchLatestPayload($normalizedBase, $targets);
|
||||
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
|
||||
$forwardRates = [];
|
||||
foreach ($rates as $target => $rate) {
|
||||
if (!is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === '' || $targetCode === $normalizedBase) {
|
||||
continue;
|
||||
}
|
||||
$forwardRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
|
||||
$updated = $this->persistRateSet($normalizedBase, $forwardRates, $rateDate);
|
||||
|
||||
return [
|
||||
'base' => $normalizedBase,
|
||||
'rate_date' => $rateDate,
|
||||
'updated_count' => count($updated),
|
||||
'rates' => $updated,
|
||||
];
|
||||
}
|
||||
|
||||
public function ensureFreshLatestRates(float $maxAgeHours = 3.0, string $base = 'USD'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'ensureFreshLatestRates')) {
|
||||
return $shared->ensureFreshLatestRates($maxAgeHours, $base, null);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
$maxAgeHours = $maxAgeHours > 0 ? $maxAgeHours : 3.0;
|
||||
|
||||
if ($this->repository === null) {
|
||||
return $this->refreshLatestRates(null, $normalizedBase);
|
||||
}
|
||||
|
||||
$latestFetch = $this->repository->getLatestFxFetch($normalizedBase);
|
||||
$latestFetchedAt = is_array($latestFetch) ? $this->parseStoredUtcTimestamp((string) ($latestFetch['fetched_at'] ?? '')) : null;
|
||||
$ageSeconds = $latestFetchedAt !== null ? (time() - $latestFetchedAt) : null;
|
||||
$maxAgeSeconds = (int) round($maxAgeHours * 3600);
|
||||
|
||||
if ($ageSeconds !== null && $ageSeconds >= 0 && $ageSeconds <= $maxAgeSeconds) {
|
||||
$this->debug?->add('fx.latest.reuse', [
|
||||
'base' => $normalizedBase,
|
||||
'fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
'age_seconds' => $ageSeconds,
|
||||
'max_age_seconds' => $maxAgeSeconds,
|
||||
]);
|
||||
|
||||
return [
|
||||
'base' => $normalizedBase,
|
||||
'rate_date' => $latestFetch['rate_date'] ?? null,
|
||||
'updated_count' => 0,
|
||||
'rates' => [],
|
||||
'reused' => true,
|
||||
'fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.refresh_required', [
|
||||
'base' => $normalizedBase,
|
||||
'previous_fetched_at' => $latestFetch['fetched_at'] ?? null,
|
||||
'age_seconds' => $ageSeconds,
|
||||
'max_age_seconds' => $maxAgeSeconds,
|
||||
]);
|
||||
|
||||
$result = $this->refreshLatestRates(null, $normalizedBase);
|
||||
$result['reused'] = false;
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function probeLatestRates(string $base = 'EUR'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'probeLatestRates')) {
|
||||
return $shared->probeLatestRates($base);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
return $this->fetchLatestProbe($normalizedBase);
|
||||
}
|
||||
|
||||
public function refreshCurrencyCatalog(): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'refreshCurrencyCatalog')) {
|
||||
return $shared->refreshCurrencyCatalog();
|
||||
}
|
||||
|
||||
$payload = $this->fetchCurrenciesPayload();
|
||||
$items = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : [];
|
||||
if ($items === []) {
|
||||
return [
|
||||
'synced_count' => 0,
|
||||
'currencies' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$synced = [];
|
||||
$sortOrder = 1000;
|
||||
|
||||
foreach ($items as $code => $name) {
|
||||
$normalizedCode = strtoupper(trim((string) $code));
|
||||
$normalizedName = trim((string) $name);
|
||||
if ($normalizedCode === '' || $normalizedName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currency = [
|
||||
'code' => substr($normalizedCode, 0, 10),
|
||||
'name' => function_exists('mb_substr') ? mb_substr($normalizedName, 0, 64) : substr($normalizedName, 0, 64),
|
||||
'symbol' => substr($normalizedCode, 0, 8),
|
||||
'is_active' => 1,
|
||||
'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0,
|
||||
'sort_order' => $this->catalogSortOrder($normalizedCode, $sortOrder),
|
||||
];
|
||||
|
||||
$synced[] = $currency;
|
||||
$sortOrder++;
|
||||
}
|
||||
|
||||
usort($synced, static function (array $left, array $right): int {
|
||||
return [$left['sort_order'], $left['code']] <=> [$right['sort_order'], $right['code']];
|
||||
});
|
||||
|
||||
return [
|
||||
'synced_count' => count($synced),
|
||||
'currencies' => $synced,
|
||||
];
|
||||
}
|
||||
|
||||
public function probeCurrencyCatalog(): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'probeCurrencyCatalog')) {
|
||||
return $shared->probeCurrencyCatalog();
|
||||
}
|
||||
|
||||
return $this->fetchCurrenciesProbe();
|
||||
}
|
||||
|
||||
private function fetchAndPersistRate(string $base, string $target): ?float
|
||||
{
|
||||
$payload = $this->fetchLatestPayload($base, [$target]);
|
||||
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
$rate = $rates[$target] ?? null;
|
||||
if (!is_numeric($rate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$numericRate = (float) $rate;
|
||||
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
|
||||
$this->persistRateSet($base, [$target => $numericRate], $rateDate);
|
||||
return $numericRate;
|
||||
}
|
||||
|
||||
private function fetchLatestPayload(string $base, ?array $targets = null): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = $this->buildLatestUrl($base, $targets);
|
||||
if ($url === null) {
|
||||
$this->debug?->add('fx.latest.skip', ['reason' => 'missing_url_or_key', 'base' => $base]);
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.request', [
|
||||
'base' => $base,
|
||||
'url' => $this->maskUrl($url),
|
||||
'targets' => $targets,
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$this->debug?->add('fx.latest.response', [
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_bytes' => is_string($response) ? strlen($response) : 0,
|
||||
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
|
||||
]);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
return $this->normalizePayload($payload, $base, $targets);
|
||||
}
|
||||
|
||||
private function fetchLatestProbe(string $base): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return ['ok' => false, 'message' => 'curl_init ist nicht verfuegbar.'];
|
||||
}
|
||||
|
||||
$url = $this->buildLatestUrl($base, null);
|
||||
if ($url === null) {
|
||||
return ['ok' => false, 'message' => 'FX-URL oder API-Key fehlt.'];
|
||||
}
|
||||
|
||||
$this->debug?->add('fx.latest.probe.request', [
|
||||
'base' => $base,
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
|
||||
$body = is_string($response) ? substr($response, $headerSize) : '';
|
||||
|
||||
$result = [
|
||||
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
|
||||
'url' => $this->maskUrl($url),
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_headers' => $rawHeaders,
|
||||
'response_body' => substr($body, 0, 4000),
|
||||
];
|
||||
|
||||
$this->debug?->add('fx.latest.probe.response', $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function fetchCurrenciesPayload(): array
|
||||
{
|
||||
if (!function_exists('curl_init') || $this->apiKey === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'%s/api/v2/currencies?output=json&key=%s',
|
||||
$this->currenciesApiBaseUrl,
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
|
||||
$this->debug?->add('fx.currencies.request', [
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$this->debug?->add('fx.currencies.response', [
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_bytes' => is_string($response) ? strlen($response) : 0,
|
||||
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
|
||||
]);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
throw new \RuntimeException('Waehrungskatalog konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function fetchCurrenciesProbe(): array
|
||||
{
|
||||
if (!function_exists('curl_init') || $this->apiKey === '') {
|
||||
return ['ok' => false, 'message' => 'curl_init oder API-Key fehlt.'];
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'%s/api/v2/currencies?output=json&key=%s',
|
||||
$this->currenciesApiBaseUrl,
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
|
||||
$this->debug?->add('fx.currencies.probe.request', [
|
||||
'url' => $this->maskUrl($url),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
|
||||
$body = is_string($response) ? substr($response, $headerSize) : '';
|
||||
|
||||
$result = [
|
||||
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
|
||||
'url' => $this->maskUrl($url),
|
||||
'http_status' => $httpStatus,
|
||||
'curl_error' => $curlError,
|
||||
'response_headers' => $rawHeaders,
|
||||
'response_body' => substr($body, 0, 4000),
|
||||
];
|
||||
|
||||
$this->debug?->add('fx.currencies.probe.response', $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function storedRate(string $base, string $target): ?float
|
||||
{
|
||||
if ($this->repository === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$direct = $this->repository->getLatestFxRate($base, $target);
|
||||
if (is_array($direct) && is_numeric($direct['rate'] ?? null)) {
|
||||
return (float) $direct['rate'];
|
||||
}
|
||||
|
||||
$inverse = $this->repository->getLatestFxRate($target, $base);
|
||||
if (is_array($inverse) && is_numeric($inverse['rate'] ?? null) && (float) $inverse['rate'] > 0) {
|
||||
return 1 / (float) $inverse['rate'];
|
||||
}
|
||||
|
||||
$measurementRate = $this->repository->getLatestMeasurementRate($base, $target);
|
||||
if (is_array($measurementRate) && is_numeric($measurementRate['rate'] ?? null)) {
|
||||
return (float) $measurementRate['rate'];
|
||||
}
|
||||
|
||||
$inverseMeasurementRate = $this->repository->getLatestMeasurementRate($target, $base);
|
||||
if (
|
||||
is_array($inverseMeasurementRate) &&
|
||||
is_numeric($inverseMeasurementRate['rate'] ?? null) &&
|
||||
(float) $inverseMeasurementRate['rate'] > 0
|
||||
) {
|
||||
return 1 / (float) $inverseMeasurementRate['rate'];
|
||||
}
|
||||
|
||||
foreach (['USD', 'EUR'] as $viaBase) {
|
||||
if ($base === $viaBase || $target === $viaBase) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fromVia = $this->repository->getLatestFxRate($viaBase, $base);
|
||||
$toVia = $this->repository->getLatestFxRate($viaBase, $target);
|
||||
if (
|
||||
is_array($fromVia) && is_numeric($fromVia['rate'] ?? null) &&
|
||||
is_array($toVia) && is_numeric($toVia['rate'] ?? null) &&
|
||||
(float) $fromVia['rate'] > 0
|
||||
) {
|
||||
return (float) $toVia['rate'] / (float) $fromVia['rate'];
|
||||
}
|
||||
|
||||
$fromViaInverse = $this->repository->getLatestFxRate($base, $viaBase);
|
||||
$toViaInverse = $this->repository->getLatestFxRate($target, $viaBase);
|
||||
if (
|
||||
is_array($fromViaInverse) && is_numeric($fromViaInverse['rate'] ?? null) &&
|
||||
is_array($toViaInverse) && is_numeric($toViaInverse['rate'] ?? null) &&
|
||||
(float) $toViaInverse['rate'] > 0
|
||||
) {
|
||||
return (1 / (float) $fromViaInverse['rate']) / (1 / (float) $toViaInverse['rate']);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function persistRateSet(string $base, array $rates, string $rateDate): array
|
||||
{
|
||||
$normalizedBase = strtoupper($base);
|
||||
$normalizedRates = [];
|
||||
foreach ($rates as $target => $rate) {
|
||||
if (!is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedTarget = strtoupper((string) $target);
|
||||
$normalizedRates[$normalizedTarget] = (float) $rate;
|
||||
$this->memoryCache[$normalizedBase . ':' . $normalizedTarget] = (float) $rate;
|
||||
$this->writeFileCache($normalizedBase . ':' . $normalizedTarget, (float) $rate);
|
||||
}
|
||||
|
||||
if ($this->repository === null) {
|
||||
$result = [];
|
||||
foreach ($normalizedRates as $target => $rate) {
|
||||
$result[] = [
|
||||
'base_currency' => $normalizedBase,
|
||||
'target_currency' => $target,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $this->provider,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
$saved = $this->repository->saveFxFetch($normalizedBase, $this->provider, $rateDate, $normalizedRates);
|
||||
return is_array($saved['rates'] ?? null) ? $saved['rates'] : [];
|
||||
} catch (\Throwable) {
|
||||
$result = [];
|
||||
foreach ($normalizedRates as $target => $rate) {
|
||||
$result[] = [
|
||||
'base_currency' => $normalizedBase,
|
||||
'target_currency' => $target,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $this->provider,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildLatestUrl(string $base, ?array $targets = null): ?string
|
||||
{
|
||||
if ($this->provider === 'currencyapi') {
|
||||
if ($this->apiKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s/api/v2/rates?base=%s&output=json&key=%s',
|
||||
$this->apiBaseUrl,
|
||||
rawurlencode($base),
|
||||
rawurlencode($this->apiKey)
|
||||
);
|
||||
}
|
||||
|
||||
$targets = $targets ?? $this->defaultCurrencies();
|
||||
return sprintf(
|
||||
'%s/latest?base=%s&symbols=%s',
|
||||
$this->apiBaseUrl,
|
||||
rawurlencode($base),
|
||||
rawurlencode(implode(',', $targets))
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizePayload(mixed $payload, string $base, ?array $targets = null): array
|
||||
{
|
||||
if (!is_array($payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->provider === 'currencyapi') {
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
|
||||
}
|
||||
|
||||
$allRates = $payload['rates'];
|
||||
$filteredRates = [];
|
||||
if ($targets === null) {
|
||||
foreach ($allRates as $target => $rate) {
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === $base || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$filteredRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
} else {
|
||||
foreach ($targets as $target) {
|
||||
$targetCode = strtoupper((string) $target);
|
||||
if ($targetCode === $base) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rate = $allRates[$targetCode] ?? null;
|
||||
if (is_numeric($rate)) {
|
||||
$filteredRates[$targetCode] = (float) $rate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'base' => strtoupper((string) ($payload['base'] ?? $base)),
|
||||
'date' => $payload['updated'] ?? null,
|
||||
'rates' => $filteredRates,
|
||||
];
|
||||
}
|
||||
|
||||
if (!is_array($payload['rates'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function extractProviderError(array $payload, string $fallback): string
|
||||
{
|
||||
foreach (['error', 'message', 'msg'] as $field) {
|
||||
$value = $payload[$field] ?? null;
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
$errors = $payload['errors'] ?? null;
|
||||
if (is_array($errors)) {
|
||||
$flat = [];
|
||||
array_walk_recursive($errors, static function ($value) use (&$flat): void {
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$flat[] = trim($value);
|
||||
}
|
||||
});
|
||||
if ($flat !== []) {
|
||||
return implode(' | ', array_values(array_unique($flat)));
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function defaultCurrencies(): array
|
||||
{
|
||||
return ['EUR', 'USD'];
|
||||
}
|
||||
|
||||
private function normalizeRateDate(mixed $value): string
|
||||
{
|
||||
if (is_int($value) || is_float($value) || (is_string($value) && ctype_digit(trim($value)))) {
|
||||
$timestamp = (int) $value;
|
||||
if ($timestamp > 0) {
|
||||
return date('Y-m-d', $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp !== false) {
|
||||
return date('Y-m-d', $timestamp);
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value, $matches) === 1) {
|
||||
return $matches[0];
|
||||
}
|
||||
}
|
||||
|
||||
return date('Y-m-d');
|
||||
}
|
||||
|
||||
private function parseStoredUtcTimestamp(string $value): ?int
|
||||
{
|
||||
$normalized = trim($value);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $normalized) === 1) {
|
||||
$date = new \DateTimeImmutable(str_replace(' ', 'T', $normalized), new \DateTimeZone('UTC'));
|
||||
} else {
|
||||
$date = new \DateTimeImmutable($normalized);
|
||||
}
|
||||
return $date->setTimezone(new \DateTimeZone('UTC'))->getTimestamp();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function catalogSortOrder(string $code, int $fallback): int
|
||||
{
|
||||
return match (strtoupper($code)) {
|
||||
'EUR' => 10,
|
||||
'USD' => 20,
|
||||
'DOGE' => 30,
|
||||
'BTC' => 40,
|
||||
'ETH' => 50,
|
||||
'USDT' => 60,
|
||||
'USDC' => 70,
|
||||
default => $fallback,
|
||||
};
|
||||
}
|
||||
|
||||
private function isCryptoCode(string $code): bool
|
||||
{
|
||||
return in_array(strtoupper($code), [
|
||||
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
|
||||
'SOL', 'USDC', 'USDT', 'XRP',
|
||||
], true);
|
||||
}
|
||||
|
||||
private function cacheFile(string $cacheKey): string
|
||||
{
|
||||
return rtrim(sys_get_temp_dir(), '/') . '/mining-checker-fx-' . md5($cacheKey) . '.json';
|
||||
}
|
||||
|
||||
private function readFileCache(string $cacheKey): ?float
|
||||
{
|
||||
$file = $this->cacheFile($cacheKey);
|
||||
if (!is_file($file) || (time() - filemtime($file)) > $this->cacheTtl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) file_get_contents($file), true);
|
||||
$rate = $payload['rate'] ?? null;
|
||||
return is_numeric($rate) ? (float) $rate : null;
|
||||
}
|
||||
|
||||
private function writeFileCache(string $cacheKey, float $rate): void
|
||||
{
|
||||
@file_put_contents($this->cacheFile($cacheKey), json_encode([
|
||||
'rate' => $rate,
|
||||
'cached_at' => time(),
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
private function maskUrl(string $url): string
|
||||
{
|
||||
return preg_replace_callback('/([?&]key=)([^&]+)/i', static function (array $matches): string {
|
||||
$key = $matches[2] ?? '';
|
||||
if (strlen($key) <= 8) {
|
||||
return $matches[1] . $key;
|
||||
}
|
||||
|
||||
return $matches[1] . substr($key, 0, 6) . '...' . substr($key, -4);
|
||||
}, $url) ?: $url;
|
||||
}
|
||||
|
||||
private function sharedFxService(): ?object
|
||||
{
|
||||
if (!function_exists('modules') || !modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'service')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
return is_object($service) ? $service : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveRateFromSnapshot(array $snapshot, string $from, string $to): ?float
|
||||
{
|
||||
$base = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
|
||||
if ($base === '' || $from === '' || $to === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($base === $from && is_numeric($rates[$to] ?? null)) {
|
||||
return (float) $rates[$to];
|
||||
}
|
||||
|
||||
if ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) {
|
||||
return 1 / (float) $rates[$from];
|
||||
}
|
||||
|
||||
if (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) {
|
||||
return (float) $rates[$to] / (float) $rates[$from];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function snapshotCacheKey(string $prefix, array $parts): string
|
||||
{
|
||||
return $prefix . ':' . implode(':', array_map(static fn (mixed $part): string => (string) $part, $parts));
|
||||
}
|
||||
|
||||
private function normalizeSymbolsForCache(?array $symbols): string
|
||||
{
|
||||
if (!is_array($symbols) || $symbols === []) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $symbol): string => strtoupper(trim((string) $symbol)),
|
||||
$symbols
|
||||
))));
|
||||
sort($normalized);
|
||||
return implode(',', $normalized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\ModuleConfig;
|
||||
use Modules\MiningChecker\Support\ApiException;
|
||||
|
||||
final class OcrService
|
||||
{
|
||||
private ModuleConfig $config;
|
||||
|
||||
public function __construct(ModuleConfig $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function preview(array $file, array $input): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
throw new ApiException('Screenshot-Upload fehlt oder ist fehlerhaft.', 422);
|
||||
}
|
||||
|
||||
$mime = mime_content_type($file['tmp_name']) ?: '';
|
||||
if (!in_array($mime, ['image/png', 'image/jpeg', 'image/webp'], true)) {
|
||||
throw new ApiException('Nur PNG, JPEG und WEBP werden akzeptiert.', 422, ['mime' => $mime]);
|
||||
}
|
||||
|
||||
$projectKey = (string) ($input['project_key'] ?? $this->config->defaultProjectKey());
|
||||
$uploadDir = $this->resolveUploadDir($projectKey);
|
||||
|
||||
$extension = pathinfo((string) ($file['name'] ?? 'upload.png'), PATHINFO_EXTENSION) ?: 'png';
|
||||
$filename = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . strtolower($extension);
|
||||
$targetFile = $uploadDir . '/' . $filename;
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetFile)) {
|
||||
throw new ApiException('Bild konnte nicht gespeichert werden.', 500);
|
||||
}
|
||||
|
||||
$rawText = trim((string) ($input['ocr_hint_text'] ?? ''));
|
||||
$flags = [];
|
||||
|
||||
if ($rawText === '') {
|
||||
['text' => $rawText, 'flags' => $providerFlags] = $this->extractRawText($targetFile);
|
||||
$flags = array_merge($flags, $providerFlags);
|
||||
} else {
|
||||
$flags[] = 'ocr_hint_text_used';
|
||||
}
|
||||
|
||||
$parsed = $this->parseText(
|
||||
$rawText,
|
||||
(string) ($input['date_context'] ?? date('Y-m-d')),
|
||||
strtoupper(trim((string) ($input['wallet_currency_hint'] ?? '')))
|
||||
);
|
||||
$parsed['image_path'] = $targetFile;
|
||||
$parsed['raw_text'] = $rawText;
|
||||
$parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags'])));
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function resolveUploadDir(string $projectKey): string
|
||||
{
|
||||
$safeProjectKey = preg_replace('~[^a-zA-Z0-9_-]~', '-', $projectKey) ?: 'default';
|
||||
$candidates = [
|
||||
rtrim($this->config->uploadsDir(), '/') . '/' . $safeProjectKey,
|
||||
rtrim(sys_get_temp_dir(), '/') . '/mining-checker/uploads/' . $safeProjectKey,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->ensureWritableDirectory($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiException('Upload-Verzeichnis konnte nicht erstellt werden.', 500, [
|
||||
'candidates' => $candidates,
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureWritableDirectory(string $directory): bool
|
||||
{
|
||||
if (is_dir($directory)) {
|
||||
return is_writable($directory);
|
||||
}
|
||||
|
||||
return @mkdir($directory, 0775, true) || is_dir($directory);
|
||||
}
|
||||
|
||||
private function extractRawText(string $imagePath): array
|
||||
{
|
||||
$ocrConfig = $this->config->ocr();
|
||||
$providers = $ocrConfig['providers'] ?? ['tesseract'];
|
||||
$flags = [];
|
||||
|
||||
if (!is_array($providers) || $providers === []) {
|
||||
$providers = ['tesseract'];
|
||||
}
|
||||
|
||||
foreach ($providers as $provider) {
|
||||
$providerName = strtolower(trim((string) $provider));
|
||||
if ($providerName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($providerName === 'ocrspace') {
|
||||
$result = $this->runOcrSpace((array) ($ocrConfig['ocrspace'] ?? []), $imagePath);
|
||||
} elseif ($providerName === 'tesseract') {
|
||||
$result = $this->runTesseract((array) ($ocrConfig['tesseract'] ?? []), $imagePath);
|
||||
} else {
|
||||
$flags[] = 'ocr_provider_unsupported:' . $providerName;
|
||||
continue;
|
||||
}
|
||||
|
||||
$flags = array_merge($flags, $result['flags']);
|
||||
if (($result['text'] ?? '') !== '') {
|
||||
return [
|
||||
'text' => (string) $result['text'],
|
||||
'flags' => array_values(array_unique(array_merge(
|
||||
$flags,
|
||||
['ocr_provider:' . $providerName]
|
||||
))),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => array_values(array_unique(array_merge($flags, ['ocr_engine_missing']))),
|
||||
];
|
||||
}
|
||||
|
||||
private function runOcrSpace(array $providerConfig, string $imagePath): array
|
||||
{
|
||||
if (!function_exists('curl_init') || !class_exists(\CURLFile::class)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocr_transport_missing:curl'],
|
||||
];
|
||||
}
|
||||
|
||||
$url = trim((string) ($providerConfig['url'] ?? ''));
|
||||
if ($url === '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_url_missing'],
|
||||
];
|
||||
}
|
||||
|
||||
$apiKey = trim((string) ($providerConfig['api_key'] ?? ''));
|
||||
if ($apiKey === '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_api_key_missing'],
|
||||
];
|
||||
}
|
||||
|
||||
$postFields = [
|
||||
'file' => new \CURLFile($imagePath),
|
||||
'language' => (string) ($providerConfig['language'] ?? 'eng'),
|
||||
'OCREngine' => (string) ((int) ($providerConfig['engine'] ?? 2)),
|
||||
'scale' => (string) ($providerConfig['scale'] ?? 'true'),
|
||||
'detectOrientation' => (string) ($providerConfig['detect_orientation'] ?? 'true'),
|
||||
'isTable' => (string) ($providerConfig['is_table'] ?? 'false'),
|
||||
'isOverlayRequired' => 'false',
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postFields,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => max(5, (int) ($providerConfig['timeout'] ?? 25)),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'apikey: ' . $apiKey,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $curlError !== '') {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_request_failed'],
|
||||
];
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_invalid_response'],
|
||||
];
|
||||
}
|
||||
|
||||
$flags = [];
|
||||
$rawText = '';
|
||||
$parsedResults = $payload['ParsedResults'] ?? null;
|
||||
if (is_array($parsedResults)) {
|
||||
$texts = [];
|
||||
foreach ($parsedResults as $result) {
|
||||
if (!is_array($result)) {
|
||||
continue;
|
||||
}
|
||||
$fileExitCode = (string) ($result['FileParseExitCode'] ?? '');
|
||||
if ($fileExitCode !== '') {
|
||||
$flags[] = 'ocrspace_file_exit_code:' . $fileExitCode;
|
||||
}
|
||||
$parsedText = trim((string) ($result['ParsedText'] ?? ''));
|
||||
if ($parsedText !== '') {
|
||||
$texts[] = $parsedText;
|
||||
}
|
||||
$resultError = trim((string) ($result['ErrorMessage'] ?? ''));
|
||||
if ($resultError !== '') {
|
||||
$flags[] = 'ocrspace_result_error';
|
||||
}
|
||||
}
|
||||
$rawText = trim(implode("\n", $texts));
|
||||
}
|
||||
|
||||
$ocrExitCode = (string) ($payload['OCRExitCode'] ?? '');
|
||||
$isErroredOnProcessing = !empty($payload['IsErroredOnProcessing']);
|
||||
$errorMessage = trim((string) ($payload['ErrorMessage'] ?? ''));
|
||||
$errorDetails = trim((string) ($payload['ErrorDetails'] ?? ''));
|
||||
|
||||
if ($httpStatus >= 400) {
|
||||
$flags[] = 'ocrspace_http_error';
|
||||
}
|
||||
|
||||
if ($ocrExitCode !== '') {
|
||||
$flags[] = 'ocrspace_exit_code:' . $ocrExitCode;
|
||||
}
|
||||
|
||||
$flags[] = 'ocrspace_engine:' . (string) ((int) ($providerConfig['engine'] ?? 2));
|
||||
|
||||
if ($isErroredOnProcessing) {
|
||||
$flags[] = 'ocrspace_processing_error';
|
||||
}
|
||||
|
||||
if ($errorMessage !== '' || $errorDetails !== '') {
|
||||
$flags[] = 'ocrspace_error';
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => $rawText,
|
||||
'flags' => $rawText === '' ? array_values(array_unique(array_merge($flags, ['ocr_provider_empty:ocrspace']))) : array_values(array_unique($flags)),
|
||||
];
|
||||
}
|
||||
|
||||
private function runTesseract(array $providerConfig, string $imagePath): array
|
||||
{
|
||||
$binary = (string) ($providerConfig['binary'] ?? 'tesseract');
|
||||
if (!$this->binaryExists($binary)) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_provider_missing:tesseract'],
|
||||
];
|
||||
}
|
||||
|
||||
$language = (string) ($providerConfig['language'] ?? 'eng');
|
||||
$tmpBase = tempnam(sys_get_temp_dir(), 'mc-ocr-');
|
||||
if ($tmpBase === false) {
|
||||
return [
|
||||
'text' => '',
|
||||
'flags' => ['ocr_tempfile_failed:tesseract'],
|
||||
];
|
||||
}
|
||||
|
||||
@unlink($tmpBase);
|
||||
$command = sprintf(
|
||||
'%s %s %s -l %s 2>/dev/null',
|
||||
escapeshellcmd($binary),
|
||||
escapeshellarg($imagePath),
|
||||
escapeshellarg($tmpBase),
|
||||
escapeshellarg($language)
|
||||
);
|
||||
shell_exec($command);
|
||||
|
||||
$txtFile = $tmpBase . '.txt';
|
||||
$text = is_file($txtFile) ? (string) file_get_contents($txtFile) : '';
|
||||
@unlink($txtFile);
|
||||
return [
|
||||
'text' => trim($text),
|
||||
'flags' => trim($text) === '' ? ['ocr_provider_empty:tesseract'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
private function binaryExists(string $binary): bool
|
||||
{
|
||||
return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== '';
|
||||
}
|
||||
|
||||
private function parseText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
|
||||
{
|
||||
$measurement = $this->parseMeasurementText($rawText, $dateContext);
|
||||
$wallet = $this->parseWalletText($rawText, $dateContext, $walletCurrencyHint);
|
||||
|
||||
$isWallet = ($wallet['score'] ?? 0) > ($measurement['score'] ?? 0)
|
||||
&& (
|
||||
($wallet['suggested_wallet']['wallet_balance'] ?? null) !== null
|
||||
|| ($wallet['suggested_wallet']['total_value_amount'] ?? null) !== null
|
||||
);
|
||||
|
||||
return [
|
||||
'kind' => $isWallet ? 'wallet' : 'measurement',
|
||||
'suggested' => $measurement['suggested'],
|
||||
'suggested_wallet' => $wallet['suggested_wallet'],
|
||||
'confidence' => round((float) ($isWallet ? ($wallet['confidence'] ?? 0.0) : ($measurement['confidence'] ?? 0.0)), 4),
|
||||
'flags' => $isWallet ? $wallet['flags'] : $measurement['flags'],
|
||||
];
|
||||
}
|
||||
|
||||
private function parseMeasurementText(string $rawText, string $dateContext): array
|
||||
{
|
||||
$flags = [];
|
||||
$suggestedTime = null;
|
||||
$coinsTotal = null;
|
||||
$price = null;
|
||||
$currency = null;
|
||||
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
|
||||
$lines = array_values(array_filter(array_map(
|
||||
static fn (string $line): string => trim($line),
|
||||
preg_split('/\R/u', $rawText) ?: []
|
||||
), static fn (string $line): bool => $line !== ''));
|
||||
|
||||
if ($normalizedText === '') {
|
||||
$flags[] = 'ocr_raw_text_empty';
|
||||
}
|
||||
|
||||
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
|
||||
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
|
||||
}
|
||||
|
||||
preg_match_all('/\b\d+(?:[.,]\d+)?\b/', $normalizedText, $numberMatches);
|
||||
$decimalCandidates = [];
|
||||
foreach ($numberMatches[0] ?? [] as $candidate) {
|
||||
$normalized = (float) str_replace(',', '.', $candidate);
|
||||
if ($normalized <= 0) {
|
||||
continue;
|
||||
}
|
||||
$decimalCandidates[] = [
|
||||
'raw' => $candidate,
|
||||
'value' => $normalized,
|
||||
'precision' => str_contains($candidate, ',') || str_contains($candidate, '.')
|
||||
? strlen((string) preg_replace('/^\d+[.,]/', '', $candidate))
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/DOGE\s*\/\s*(USD|EUR|USDT|USDC|BTC|ETH|LTC)/i', $normalizedText, $pairMatch)) {
|
||||
$currency = strtoupper((string) $pairMatch[1]);
|
||||
} elseif (preg_match('/\b(EUR|USD|USDT|USDC|BTC|ETH|LTC)\b/i', $normalizedText, $currencyMatch)) {
|
||||
$currency = strtoupper((string) $currencyMatch[1]);
|
||||
} elseif (str_contains($normalizedText, '$')) {
|
||||
$currency = 'USD';
|
||||
} else {
|
||||
$flags[] = 'currency_missing';
|
||||
}
|
||||
|
||||
if (preg_match('/DOGE\s*\/\s*(?:USD|EUR|USDT|USDC|BTC|ETH|LTC)[^\d]{0,20}(\d+[.,]\d{3,8})/i', $normalizedText, $priceMatch)) {
|
||||
$price = round((float) str_replace(',', '.', $priceMatch[1]), 8);
|
||||
}
|
||||
|
||||
if ($coinsTotal === null) {
|
||||
foreach ($lines as $line) {
|
||||
if (!preg_match('/MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE/i', $line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/(\d+[.,]\d{4,8})/', $line, $lineCoinsMatch)) {
|
||||
$coinsTotal = round((float) str_replace(',', '.', $lineCoinsMatch[1]), 6);
|
||||
$flags[] = 'coins_from_balance_line';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($coinsTotal === null && preg_match('/(\d+[.,]\d{4,8})\s*(?:DOGE)?\s*(?:MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE)/i', $normalizedText, $coinsMatch)) {
|
||||
$coinsTotal = round((float) str_replace(',', '.', $coinsMatch[1]), 6);
|
||||
$flags[] = 'coins_from_balance_context';
|
||||
}
|
||||
|
||||
if ($coinsTotal === null) {
|
||||
$coinsCandidates = array_values(array_filter($decimalCandidates, static function (array $item) use ($price): bool {
|
||||
if ($item['precision'] < 4) {
|
||||
return false;
|
||||
}
|
||||
if ($item['value'] <= 0 || $item['value'] >= 1000000) {
|
||||
return false;
|
||||
}
|
||||
if ($price !== null && abs($item['value'] - $price) < 0.0000005) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
if ($coinsCandidates !== []) {
|
||||
usort($coinsCandidates, static function (array $a, array $b): int {
|
||||
return [$b['precision'], $b['value']] <=> [$a['precision'], $a['value']];
|
||||
});
|
||||
$coinsTotal = round((float) $coinsCandidates[0]['value'], 6);
|
||||
if (count($coinsCandidates) > 1) {
|
||||
$flags[] = 'coins_ambiguous';
|
||||
}
|
||||
} else {
|
||||
$flags[] = 'coins_missing';
|
||||
}
|
||||
}
|
||||
|
||||
$priceCandidates = array_values(array_filter(
|
||||
$decimalCandidates,
|
||||
static fn (array $item): bool => $item['value'] > 0 && $item['value'] < 1
|
||||
));
|
||||
|
||||
if ($price === null && $priceCandidates !== []) {
|
||||
usort($priceCandidates, static function (array $a, array $b): int {
|
||||
return [$b['precision'], $a['value']] <=> [$a['precision'], $b['value']];
|
||||
});
|
||||
$price = round((float) $priceCandidates[0]['value'], 8);
|
||||
if (count($priceCandidates) > 1 && count(array_filter($priceCandidates, static fn (array $item): bool => $item['precision'] >= 4)) > 1) {
|
||||
$flags[] = 'price_ambiguous';
|
||||
}
|
||||
}
|
||||
|
||||
if ($price === null && $coinsTotal !== null && preg_match('/~\s*(\d+[.,]\d+)\s*\$/', $normalizedText, $fiatMatch)) {
|
||||
$fiatValue = (float) str_replace(',', '.', $fiatMatch[1]);
|
||||
if ($fiatValue > 0) {
|
||||
$price = round($fiatValue / $coinsTotal, 8);
|
||||
$flags[] = 'price_derived_from_balance_value';
|
||||
$currency = $currency ?? 'USD';
|
||||
}
|
||||
}
|
||||
|
||||
$measurementIndicators = 0;
|
||||
$normalizedLower = strtolower($normalizedText);
|
||||
foreach (['mining-guthaben', 'mining guthaben', 'mining-balance', 'mining balance', 'doge /', 'bonus', 'verlauf'] as $indicator) {
|
||||
if (str_contains($normalizedLower, $indicator)) {
|
||||
$measurementIndicators++;
|
||||
}
|
||||
}
|
||||
|
||||
$matchedFields = 0;
|
||||
foreach ([$coinsTotal, $price, $currency] as $field) {
|
||||
if ($field !== null) {
|
||||
$matchedFields++;
|
||||
}
|
||||
}
|
||||
|
||||
$score = $matchedFields + min(3, $measurementIndicators);
|
||||
$confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $measurementIndicators) * 0.08) - (count($flags) * 0.04)));
|
||||
|
||||
return [
|
||||
'suggested' => [
|
||||
'measured_at' => $suggestedTime,
|
||||
'coins_total' => $coinsTotal,
|
||||
'price_per_coin' => $price,
|
||||
'price_currency' => $currency,
|
||||
'note' => null,
|
||||
'source' => 'image_ocr',
|
||||
],
|
||||
'confidence' => round($confidence, 4),
|
||||
'flags' => $flags,
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
private function parseWalletText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
|
||||
{
|
||||
$flags = [];
|
||||
$suggestedTime = null;
|
||||
$totalValueAmount = null;
|
||||
$totalValueCurrency = null;
|
||||
$walletBalance = null;
|
||||
$walletCurrency = $walletCurrencyHint !== '' ? $walletCurrencyHint : 'DOGE';
|
||||
$balances = [];
|
||||
|
||||
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
|
||||
$lines = array_values(array_filter(array_map(
|
||||
static fn (string $line): string => trim($line),
|
||||
preg_split('/\R/u', $rawText) ?: []
|
||||
), static fn (string $line): bool => $line !== ''));
|
||||
|
||||
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
|
||||
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
|
||||
}
|
||||
|
||||
if (
|
||||
preg_match('/GESAMTSALDO[^\d]{0,24}(\d+(?:[.,]\d+)?)\s*(USD|EUR|USDT|USDC|BTC|ETH|DOGE|CTC|HSH)/i', $normalizedText, $totalMatch)
|
||||
|| preg_match('/(\d+(?:[.,]\d+)?)\s*(USD|EUR)\b.*GESAMTSALDO/i', $normalizedText, $totalMatch)
|
||||
) {
|
||||
$totalValueAmount = round((float) str_replace(',', '.', $totalMatch[1]), 8);
|
||||
$totalValueCurrency = strtoupper((string) $totalMatch[2]);
|
||||
} else {
|
||||
$flags[] = 'wallet_total_missing';
|
||||
}
|
||||
|
||||
$assetRows = [];
|
||||
foreach ($lines as $lineIndex => $line) {
|
||||
if (!preg_match('/(\d+(?:[.,]\d+)?)\s*([A-Z]{2,10})\b/u', $line, $match)) {
|
||||
continue;
|
||||
}
|
||||
$amount = round((float) str_replace(',', '.', $match[1]), 10);
|
||||
$currency = strtoupper((string) $match[2]);
|
||||
if ($amount <= 0 || $currency === '' || in_array($currency, ['USD', 'EUR'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assetRows[] = [
|
||||
'index' => $lineIndex,
|
||||
'currency' => $currency,
|
||||
'balance' => $amount,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($assetRows as $assetIndex => $assetRow) {
|
||||
$currency = (string) $assetRow['currency'];
|
||||
$balanceAmount = (float) $assetRow['balance'];
|
||||
$startIndex = (int) $assetRow['index'];
|
||||
$endIndex = isset($assetRows[$assetIndex + 1]['index'])
|
||||
? (int) $assetRows[$assetIndex + 1]['index']
|
||||
: count($lines);
|
||||
|
||||
$usdCandidates = [];
|
||||
for ($i = $startIndex; $i < $endIndex; $i++) {
|
||||
if (!isset($lines[$i])) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match_all('/(\d+(?:[.,]\d+)?)\s*USD\b/u', $lines[$i], $usdMatches, PREG_SET_ORDER)) {
|
||||
foreach ($usdMatches as $usdMatch) {
|
||||
$usdCandidates[] = round((float) str_replace(',', '.', $usdMatch[1]), 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$priceAmount = $this->pickWalletUnitPrice($balanceAmount, $usdCandidates);
|
||||
$balances[$currency] = [
|
||||
'balance' => $balanceAmount,
|
||||
'price_amount' => $priceAmount,
|
||||
'price_currency' => $priceAmount !== null ? 'USD' : null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($walletCurrencyHint !== '' && array_key_exists($walletCurrencyHint, $balances)) {
|
||||
$walletCurrency = $walletCurrencyHint;
|
||||
$walletBalance = is_array($balances[$walletCurrencyHint])
|
||||
? (float) ($balances[$walletCurrencyHint]['balance'] ?? 0.0)
|
||||
: (float) $balances[$walletCurrencyHint];
|
||||
} elseif ($balances !== []) {
|
||||
foreach (['DOGE', 'BTC', 'ETH', 'CTC', 'HSH', 'LTC', 'USDT', 'USDC'] as $preferredCurrency) {
|
||||
if (array_key_exists($preferredCurrency, $balances)) {
|
||||
$walletCurrency = $preferredCurrency;
|
||||
$walletBalance = is_array($balances[$preferredCurrency])
|
||||
? (float) ($balances[$preferredCurrency]['balance'] ?? 0.0)
|
||||
: (float) $balances[$preferredCurrency];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($walletBalance === null) {
|
||||
$firstCurrency = array_key_first($balances);
|
||||
if (is_string($firstCurrency)) {
|
||||
$walletCurrency = $firstCurrency;
|
||||
$walletBalance = is_array($balances[$firstCurrency])
|
||||
? (float) ($balances[$firstCurrency]['balance'] ?? 0.0)
|
||||
: (float) $balances[$firstCurrency];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$flags[] = 'wallet_balance_missing';
|
||||
}
|
||||
|
||||
$walletIndicators = 0;
|
||||
$normalizedLower = strtolower($normalizedText);
|
||||
foreach (['wallets', 'gesamtsaldo', 'alle münzen', 'alle munzen', 'letzte transaktion'] as $indicator) {
|
||||
if (str_contains($normalizedLower, $indicator)) {
|
||||
$walletIndicators++;
|
||||
}
|
||||
}
|
||||
|
||||
$matchedFields = 0;
|
||||
foreach ([$totalValueAmount, $walletBalance, $walletCurrency] as $field) {
|
||||
if ($field !== null && $field !== '') {
|
||||
$matchedFields++;
|
||||
}
|
||||
}
|
||||
$score = $matchedFields + ($walletIndicators * 2);
|
||||
$confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $walletIndicators) * 0.12) - (count($flags) * 0.03)));
|
||||
|
||||
ksort($balances);
|
||||
|
||||
return [
|
||||
'suggested_wallet' => [
|
||||
'measured_at' => $suggestedTime,
|
||||
'total_value_amount' => $totalValueAmount,
|
||||
'total_value_currency' => $totalValueCurrency,
|
||||
'wallet_balance' => $walletBalance,
|
||||
'wallet_currency' => $walletCurrency,
|
||||
'balances_json' => $balances,
|
||||
'note' => null,
|
||||
'source' => 'image_ocr',
|
||||
],
|
||||
'confidence' => round($confidence, 4),
|
||||
'flags' => array_values(array_unique($flags)),
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<float|int> $candidates
|
||||
*/
|
||||
private function pickWalletUnitPrice(float $balance, array $candidates): ?float
|
||||
{
|
||||
$candidates = array_values(array_filter(array_map(
|
||||
static fn (mixed $value): float => round((float) $value, 8),
|
||||
$candidates
|
||||
), static fn (float $value): bool => $value > 0));
|
||||
|
||||
if ($balance <= 0 || $candidates === []) {
|
||||
return null;
|
||||
}
|
||||
if (count($candidates) === 1) {
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
$bestPrice = null;
|
||||
$bestError = null;
|
||||
$candidateCount = count($candidates);
|
||||
|
||||
for ($i = 0; $i < $candidateCount; $i++) {
|
||||
$priceCandidate = $candidates[$i];
|
||||
for ($j = 0; $j < $candidateCount; $j++) {
|
||||
if ($i === $j) {
|
||||
continue;
|
||||
}
|
||||
$totalCandidate = $candidates[$j];
|
||||
$estimatedTotal = $balance * $priceCandidate;
|
||||
$denominator = max(abs($totalCandidate), 0.00000001);
|
||||
$error = abs($estimatedTotal - $totalCandidate) / $denominator;
|
||||
if ($bestError === null || $error < $bestError) {
|
||||
$bestError = $error;
|
||||
$bestPrice = $priceCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestPrice !== null && $bestError !== null && $bestError <= 0.2) {
|
||||
return round($bestPrice, 8);
|
||||
}
|
||||
|
||||
return round($candidates[count($candidates) - 1], 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
final class SeedData
|
||||
{
|
||||
public static function projectKey(): string
|
||||
{
|
||||
return 'doge-main';
|
||||
}
|
||||
|
||||
public static function projectName(): string
|
||||
{
|
||||
return 'DOGE Mining Main';
|
||||
}
|
||||
|
||||
public static function settings(): array
|
||||
{
|
||||
return [
|
||||
'baseline_measured_at' => '2026-03-16 01:32:00',
|
||||
'baseline_coins_total' => 27.617864,
|
||||
'daily_cost_amount' => 0.3123287671,
|
||||
'daily_cost_currency' => 'EUR',
|
||||
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function currencies(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'EUR', 'name' => 'Euro', 'symbol' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
|
||||
['code' => 'USD', 'name' => 'US-Dollar', 'symbol' => 'USD', 'is_active' => 1, 'sort_order' => 20],
|
||||
['code' => 'DOGE', 'name' => 'Dogecoin', 'symbol' => 'DOGE', 'is_active' => 1, 'sort_order' => 100],
|
||||
['code' => 'BTC', 'name' => 'Bitcoin', 'symbol' => 'BTC', 'is_active' => 1, 'sort_order' => 110],
|
||||
['code' => 'ETH', 'name' => 'Ethereum', 'symbol' => 'ETH', 'is_active' => 1, 'sort_order' => 120],
|
||||
['code' => 'LTC', 'name' => 'Litecoin', 'symbol' => 'LTC', 'is_active' => 1, 'sort_order' => 130],
|
||||
['code' => 'USDT', 'name' => 'Tether', 'symbol' => 'USDT', 'is_active' => 1, 'sort_order' => 140],
|
||||
['code' => 'USDC', 'name' => 'USD Coin', 'symbol' => 'USDC', 'is_active' => 1, 'sort_order' => 150],
|
||||
];
|
||||
}
|
||||
|
||||
public static function measurements(): array
|
||||
{
|
||||
return [
|
||||
['measured_at' => '2026-03-16 01:32:00', 'coins_total' => 27.617864, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Basiswert', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 02:41:00', 'coins_total' => 33.751904, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 07:15:00', 'coins_total' => 34.825695, 'price_per_coin' => 0.10037, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 13:21:00', 'coins_total' => 36.328140, 'price_per_coin' => 0.10002, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-17 18:53:00', 'coins_total' => 37.682757, 'price_per_coin' => 0.10062, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 00:08:00', 'coins_total' => 38.934351, 'price_per_coin' => 0.10097, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 07:40:00', 'coins_total' => 40.782006, 'price_per_coin' => 0.10040, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 13:32:00', 'coins_total' => 42.223449, 'price_per_coin' => 0.09607, 'price_currency' => 'EUR', 'note' => 'Originaleingabe im Chat: 18.6.2026', 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-18 21:15:00', 'coins_total' => 44.191018, 'price_per_coin' => 0.09446, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-19 00:09:00', 'coins_total' => 44.908500, 'price_per_coin' => 0.09507, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
|
||||
['measured_at' => '2026-03-19 02:33:00', 'coins_total' => 45.546924, 'price_per_coin' => 0.09499, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 07:01:00', 'coins_total' => 46.694127, 'price_per_coin' => 0.09460, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 12:24:00', 'coins_total' => 48.056494, 'price_per_coin' => 0.09419, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
['measured_at' => '2026-03-19 21:39:00', 'coins_total' => 50.427943, 'price_per_coin' => 0.09361, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
|
||||
];
|
||||
}
|
||||
|
||||
public static function targets(): array
|
||||
{
|
||||
return [
|
||||
['label' => 'Ziel A', 'target_amount_fiat' => 10.82, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
|
||||
['label' => 'Ziel B', 'target_amount_fiat' => 19.50, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 20],
|
||||
];
|
||||
}
|
||||
|
||||
public static function dashboards(): array
|
||||
{
|
||||
return [
|
||||
['name' => 'Mining-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'coins_total', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
|
||||
['name' => 'Performance-Verlauf', 'chart_type' => 'area', 'x_field' => 'measured_date', 'y_field' => 'doge_per_day_interval', 'aggregation' => 'avg', 'filters' => [], 'is_active' => 1],
|
||||
['name' => 'Kurs-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'price_per_coin', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Domain;
|
||||
|
||||
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
||||
|
||||
final class SeedImporter
|
||||
{
|
||||
private MiningRepository $repository;
|
||||
|
||||
public function __construct(MiningRepository $repository)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
public function import(string $projectKey): array
|
||||
{
|
||||
$seedProjectKey = SeedData::projectKey();
|
||||
if ($projectKey !== $seedProjectKey) {
|
||||
return ['inserted' => 0, 'project_key' => $projectKey, 'warning' => 'Seed-Daten sind nur fuer doge-main definiert.'];
|
||||
}
|
||||
|
||||
$this->repository->ensureProject($projectKey, SeedData::projectName());
|
||||
$this->repository->saveSettings($projectKey, SeedData::settings());
|
||||
|
||||
$insertedMeasurements = 0;
|
||||
foreach (SeedData::measurements() as $measurement) {
|
||||
try {
|
||||
$this->repository->createMeasurement($projectKey, array_merge([
|
||||
'image_path' => null,
|
||||
'ocr_raw_text' => null,
|
||||
'ocr_confidence' => null,
|
||||
'ocr_flags' => null,
|
||||
], $measurement));
|
||||
$insertedMeasurements++;
|
||||
} catch (\Throwable $exception) {
|
||||
// Duplicate seeds are expected on repeated imports.
|
||||
}
|
||||
}
|
||||
|
||||
$targetCount = 0;
|
||||
foreach (SeedData::targets() as $target) {
|
||||
$this->repository->saveTarget($projectKey, $target);
|
||||
$targetCount++;
|
||||
}
|
||||
|
||||
$dashboardCount = 0;
|
||||
foreach (SeedData::dashboards() as $dashboard) {
|
||||
$this->repository->saveDashboard($projectKey, $dashboard);
|
||||
$dashboardCount++;
|
||||
}
|
||||
|
||||
return [
|
||||
'project_key' => $projectKey,
|
||||
'imported_measurements' => $insertedMeasurements,
|
||||
'historical_rows_total' => count(SeedData::measurements()),
|
||||
'targets_synced' => $targetCount,
|
||||
'dashboards_synced' => $dashboardCount,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Infrastructure;
|
||||
|
||||
use App\Database as AppDatabase;
|
||||
use Modules\MiningChecker\Support\ApiException;
|
||||
use PDO;
|
||||
|
||||
final class ConnectionFactory
|
||||
{
|
||||
public static function make(ModuleConfig $config): PDO
|
||||
{
|
||||
$moduleSettings = modules()->settings('mining-checker');
|
||||
$useSeparateDb = self::usesSeparateDatabase($moduleSettings);
|
||||
|
||||
if ($useSeparateDb) {
|
||||
$dbConfig = is_array($moduleSettings['db'] ?? null) ? $moduleSettings['db'] : [];
|
||||
if ($dbConfig === []) {
|
||||
throw new ApiException('Custom-Datenbank ist aktiviert, aber nicht vollstaendig konfiguriert.', 500);
|
||||
}
|
||||
self::assertSupportedDriver($dbConfig);
|
||||
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
|
||||
return AppDatabase::connectFromConfig($dbConfig);
|
||||
}
|
||||
return AppDatabase::createFromArray($dbConfig);
|
||||
}
|
||||
|
||||
$dbConfig = app()->config()->dbConfig;
|
||||
if ($dbConfig === []) {
|
||||
throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500);
|
||||
}
|
||||
|
||||
self::assertSupportedDriver($dbConfig);
|
||||
|
||||
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
|
||||
return AppDatabase::connectFromConfig($dbConfig);
|
||||
}
|
||||
|
||||
return AppDatabase::createFromArray($dbConfig);
|
||||
}
|
||||
|
||||
private static function usesSeparateDatabase(array $moduleSettings): bool
|
||||
{
|
||||
$raw = $moduleSettings['use_separate_db'] ?? false;
|
||||
if (is_bool($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim((string) $raw));
|
||||
return in_array($normalized, ['1', 'true', 'yes', 'on', 'custom'], true);
|
||||
}
|
||||
|
||||
private static function assertSupportedDriver(array $dbConfig): void
|
||||
{
|
||||
$driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? '')));
|
||||
if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:')) {
|
||||
throw new ApiException(
|
||||
'Mining-Checker unterstuetzt aktuell MySQL/MariaDB und PostgreSQL. Stelle den Driver auf mysql oder pgsql.',
|
||||
500,
|
||||
['driver' => $dbConfig['driver'] ?? 'unknown']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Infrastructure;
|
||||
|
||||
final class ModuleConfig
|
||||
{
|
||||
private array $config;
|
||||
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public static function load(string $moduleBasePath): self
|
||||
{
|
||||
$config = require $moduleBasePath . '/config/module.php';
|
||||
return new self(is_array($config) ? $config : []);
|
||||
}
|
||||
|
||||
public function defaultProjectKey(): string
|
||||
{
|
||||
return (string) ($this->config['default_project_key'] ?? 'doge-main');
|
||||
}
|
||||
|
||||
public function useProjectDatabase(): bool
|
||||
{
|
||||
return (bool) ($this->config['use_project_database'] ?? true);
|
||||
}
|
||||
|
||||
public function tablePrefix(): string
|
||||
{
|
||||
return (string) ($this->config['table_prefix'] ?? 'miningcheck_');
|
||||
}
|
||||
|
||||
public function uploadsDir(): string
|
||||
{
|
||||
return (string) ($this->config['uploads_dir'] ?? sys_get_temp_dir());
|
||||
}
|
||||
|
||||
public function uploadsPublicPrefix(): string
|
||||
{
|
||||
return rtrim((string) ($this->config['uploads_public_prefix'] ?? '/uploads'), '/');
|
||||
}
|
||||
|
||||
public function ocr(): array
|
||||
{
|
||||
return (array) ($this->config['ocr'] ?? []);
|
||||
}
|
||||
|
||||
public function fx(): array
|
||||
{
|
||||
return (array) ($this->config['fx'] ?? []);
|
||||
}
|
||||
|
||||
public function debug(): array
|
||||
{
|
||||
return (array) ($this->config['debug'] ?? []);
|
||||
}
|
||||
|
||||
public function debugDir(): string
|
||||
{
|
||||
$debug = $this->debug();
|
||||
return (string) ($debug['dir'] ?? (dirname($this->uploadsDir()) . '/debug'));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class ApiException extends RuntimeException
|
||||
{
|
||||
private int $statusCode;
|
||||
private array $context;
|
||||
|
||||
public function __construct(string $message, int $statusCode = 400, array $context = [])
|
||||
{
|
||||
parent::__construct($message);
|
||||
$this->statusCode = $statusCode;
|
||||
$this->context = $context;
|
||||
}
|
||||
|
||||
public function statusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function context(): array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class DebugState
|
||||
{
|
||||
private static array $trace = [];
|
||||
private static ?string $latestFilePath = null;
|
||||
|
||||
public static function replace(array $trace): void
|
||||
{
|
||||
self::$trace = $trace;
|
||||
}
|
||||
|
||||
public static function export(): array
|
||||
{
|
||||
return self::$trace;
|
||||
}
|
||||
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$trace = [];
|
||||
}
|
||||
|
||||
public static function setLatestFilePath(?string $filePath): void
|
||||
{
|
||||
self::$latestFilePath = $filePath;
|
||||
}
|
||||
|
||||
public static function latestFilePath(): ?string
|
||||
{
|
||||
return self::$latestFilePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class DebugTrace
|
||||
{
|
||||
private bool $enabled;
|
||||
private array $entries = [];
|
||||
private ?string $filePath;
|
||||
|
||||
public function __construct(bool $enabled = false, ?string $filePath = null)
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
$this->filePath = $enabled ? $filePath : null;
|
||||
DebugState::replace([]);
|
||||
if ($this->enabled && $this->filePath !== null) {
|
||||
$this->persist();
|
||||
}
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function add(string $event, array $context = []): void
|
||||
{
|
||||
if (!$this->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->entries[] = [
|
||||
'time' => date('c'),
|
||||
'event' => $event,
|
||||
'context' => $context,
|
||||
];
|
||||
DebugState::replace($this->entries);
|
||||
$this->persist();
|
||||
}
|
||||
|
||||
public function export(): array
|
||||
{
|
||||
return $this->enabled ? $this->entries : [];
|
||||
}
|
||||
|
||||
private function persist(): void
|
||||
{
|
||||
if (!$this->enabled || $this->filePath === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directory = dirname($this->filePath);
|
||||
if (!is_dir($directory)) {
|
||||
@mkdir($directory, 0775, true);
|
||||
}
|
||||
|
||||
@file_put_contents($this->filePath, json_encode($this->entries, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\MiningChecker\Support;
|
||||
|
||||
final class Http
|
||||
{
|
||||
public static function json(array $payload, int $statusCode = 200): never
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function input(): array
|
||||
{
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw ?: '[]', true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
return $_POST;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
(() => {
|
||||
const form = document.querySelector('[data-command-form]');
|
||||
const list = document.querySelector('[data-command-list]');
|
||||
if (!form) return;
|
||||
|
||||
const idInput = form.querySelector('input[name="id"]');
|
||||
const labelInput = form.querySelector('input[name="label"]');
|
||||
const commandInput = form.querySelector('textarea[name="command"]');
|
||||
const timeoutInput = form.querySelector('input[name="timeout_sec"]');
|
||||
const adminInput = form.querySelector('input[name="admin_only"]');
|
||||
const submitBtn = form.querySelector('[data-command-submit]');
|
||||
const cancelBtn = form.querySelector('[data-command-cancel]');
|
||||
const modal = document.querySelector('[data-command-modal]');
|
||||
const modalTitle = document.querySelector('[data-command-modal-title]');
|
||||
const closeBtn = document.querySelector('[data-command-close]');
|
||||
const newBtn = document.querySelector('[data-command-new]');
|
||||
const unsavedBar = document.querySelector('[data-command-unsaved]');
|
||||
const discardBtn = document.querySelector('[data-command-discard]');
|
||||
|
||||
let initialSnapshot = '';
|
||||
|
||||
const resetForm = () => {
|
||||
if (idInput) idInput.value = '';
|
||||
if (labelInput) labelInput.value = '';
|
||||
if (commandInput) commandInput.value = '';
|
||||
if (timeoutInput) timeoutInput.value = '';
|
||||
if (adminInput) adminInput.checked = false;
|
||||
if (submitBtn) submitBtn.textContent = 'Speichern';
|
||||
if (unsavedBar) unsavedBar.style.display = 'none';
|
||||
};
|
||||
|
||||
const snapshot = () => {
|
||||
return JSON.stringify({
|
||||
id: idInput ? idInput.value : '',
|
||||
label: labelInput ? labelInput.value : '',
|
||||
command: commandInput ? commandInput.value : '',
|
||||
timeout: timeoutInput ? timeoutInput.value : '',
|
||||
admin: adminInput ? adminInput.checked : false,
|
||||
});
|
||||
};
|
||||
|
||||
const isDirty = () => snapshot() !== initialSnapshot;
|
||||
|
||||
const openModal = () => {
|
||||
if (!modal) return;
|
||||
modal.classList.add('is-open');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
initialSnapshot = snapshot();
|
||||
};
|
||||
|
||||
const closeModal = (force = false) => {
|
||||
if (!modal) return;
|
||||
if (!force && isDirty()) {
|
||||
if (unsavedBar) unsavedBar.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
modal.classList.remove('is-open');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
if (unsavedBar) unsavedBar.style.display = 'none';
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-command-edit]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const item = btn.closest('.command-item');
|
||||
if (!item) return;
|
||||
if (idInput) idInput.value = item.dataset.commandId || '';
|
||||
if (labelInput) labelInput.value = item.dataset.label || '';
|
||||
if (commandInput) commandInput.value = item.dataset.command || '';
|
||||
if (timeoutInput) timeoutInput.value = item.dataset.timeout || '';
|
||||
if (adminInput) adminInput.checked = item.dataset.admin === '1';
|
||||
if (submitBtn) submitBtn.textContent = 'Aktualisieren';
|
||||
if (modalTitle) modalTitle.textContent = 'Befehl bearbeiten';
|
||||
const details = btn.closest('details');
|
||||
if (details) details.removeAttribute('open');
|
||||
openModal();
|
||||
});
|
||||
});
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
resetForm();
|
||||
});
|
||||
}
|
||||
|
||||
if (newBtn) {
|
||||
newBtn.addEventListener('click', () => {
|
||||
resetForm();
|
||||
if (modalTitle) modalTitle.textContent = 'Neuer Befehl';
|
||||
openModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => closeModal(false));
|
||||
}
|
||||
|
||||
if (discardBtn) {
|
||||
discardBtn.addEventListener('click', () => {
|
||||
resetForm();
|
||||
closeModal(true);
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('input', () => {
|
||||
if (unsavedBar && isDirty()) {
|
||||
unsavedBar.style.display = 'flex';
|
||||
}
|
||||
});
|
||||
|
||||
if (!list) return;
|
||||
|
||||
let dragging = null;
|
||||
|
||||
list.querySelectorAll('.command-item').forEach((item) => {
|
||||
item.addEventListener('dragstart', () => {
|
||||
dragging = item;
|
||||
item.classList.add('is-dragging');
|
||||
});
|
||||
item.addEventListener('dragend', () => {
|
||||
item.classList.remove('is-dragging');
|
||||
dragging = null;
|
||||
saveOrder();
|
||||
});
|
||||
});
|
||||
|
||||
list.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (!dragging) return;
|
||||
const after = getDragAfterElement(list, e.clientY);
|
||||
if (after == null) {
|
||||
list.appendChild(dragging);
|
||||
} else if (after !== dragging) {
|
||||
list.insertBefore(dragging, after);
|
||||
}
|
||||
});
|
||||
|
||||
const getDragAfterElement = (container, y) => {
|
||||
const elements = [...container.querySelectorAll('.command-item:not(.is-dragging)')];
|
||||
return elements.reduce(
|
||||
(closest, child) => {
|
||||
const box = child.getBoundingClientRect();
|
||||
const offset = y - box.top - box.height / 2;
|
||||
if (offset < 0 && offset > closest.offset) {
|
||||
return { offset, element: child };
|
||||
}
|
||||
return closest;
|
||||
},
|
||||
{ offset: Number.NEGATIVE_INFINITY, element: null }
|
||||
).element;
|
||||
};
|
||||
|
||||
const saveOrder = () => {
|
||||
const order = [...list.querySelectorAll('.command-item')].map((el) => el.dataset.commandId);
|
||||
fetch(window.location.pathname + '?reorder_json=1', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order }),
|
||||
cache: 'no-store',
|
||||
}).catch(() => {});
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,417 @@
|
||||
(() => {
|
||||
// Disable any beforeunload prompts on this page
|
||||
window.onbeforeunload = null;
|
||||
window.addEventListener(
|
||||
'beforeunload',
|
||||
(e) => {
|
||||
e.stopImmediatePropagation();
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
const tabBar = document.querySelector('[data-console-tab-bar]');
|
||||
const tabPanels = document.querySelector('[data-console-tab-panels]');
|
||||
if (!tabBar || !tabPanels) return;
|
||||
|
||||
const consoleFab = document.querySelector('[data-console-fab]');
|
||||
const consoleModal = document.querySelector('[data-console-modal]');
|
||||
const consoleClose = document.querySelector('[data-console-close]');
|
||||
|
||||
let tabCount = 0;
|
||||
const idleMs = 5 * 60 * 1000;
|
||||
const idleTimers = new Map();
|
||||
const storageKey = 'pi_control_console_tabs';
|
||||
|
||||
const saveTabs = () => {
|
||||
const tabs = [];
|
||||
tabBar.querySelectorAll('.console-tab').forEach((btn) => {
|
||||
const id = btn.dataset.tabId;
|
||||
const panel = tabPanels.querySelector(`.console-panel[data-tab-id="${id}"]`);
|
||||
const iframe = panel ? panel.querySelector('iframe') : null;
|
||||
if (iframe && iframe.src) {
|
||||
tabs.push({
|
||||
label: btn.firstChild ? btn.firstChild.textContent : btn.textContent,
|
||||
url: iframe.src,
|
||||
openedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
localStorage.setItem(storageKey, JSON.stringify(tabs));
|
||||
if (consoleFab) {
|
||||
consoleFab.classList.toggle('is-visible', tabs.length > 0);
|
||||
}
|
||||
};
|
||||
|
||||
const activateTab = (id) => {
|
||||
tabBar.querySelectorAll('.console-tab').forEach((btn) => {
|
||||
btn.classList.toggle('is-active', btn.dataset.tabId === id);
|
||||
});
|
||||
tabPanels.querySelectorAll('.console-panel').forEach((panel) => {
|
||||
panel.classList.toggle('is-active', panel.dataset.tabId === id);
|
||||
});
|
||||
};
|
||||
|
||||
const getActiveIframe = () => {
|
||||
const activePanel = tabPanels.querySelector('.console-panel.is-active');
|
||||
if (!activePanel) return null;
|
||||
return activePanel.querySelector('iframe');
|
||||
};
|
||||
|
||||
const trySendToIframe = (iframe, command) => {
|
||||
if (!iframe) return false;
|
||||
try {
|
||||
const win = iframe.contentWindow;
|
||||
if (!win) return false;
|
||||
const term = win.term || win.xterm || win.terminal;
|
||||
if (term && typeof term.write === 'function') {
|
||||
term.write(command + '\r\n');
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
if (!consoleModal) return;
|
||||
consoleModal.classList.add('is-open');
|
||||
consoleModal.setAttribute('aria-hidden', 'false');
|
||||
};
|
||||
const closeModal = () => {
|
||||
if (!consoleModal) return;
|
||||
consoleModal.classList.remove('is-open');
|
||||
consoleModal.setAttribute('aria-hidden', 'true');
|
||||
};
|
||||
|
||||
const openTab = (label, url, persist = true) => {
|
||||
const id = `tab-${++tabCount}`;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'console-tab';
|
||||
btn.textContent = label || 'Konsole';
|
||||
btn.dataset.tabId = id;
|
||||
btn.addEventListener('click', () => activateTab(id));
|
||||
|
||||
const closeBtn = document.createElement('span');
|
||||
closeBtn.className = 'console-tab-close';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const panel = tabPanels.querySelector(`.console-panel[data-tab-id="${id}"]`);
|
||||
if (panel) panel.remove();
|
||||
btn.remove();
|
||||
idleTimers.delete(id);
|
||||
saveTabs();
|
||||
const next = tabBar.querySelector('.console-tab');
|
||||
if (next) activateTab(next.dataset.tabId);
|
||||
});
|
||||
btn.appendChild(closeBtn);
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'console-panel';
|
||||
panel.dataset.tabId = id;
|
||||
panel.innerHTML = `<iframe src="${url}" title="${label || 'Konsole'}"></iframe>`;
|
||||
|
||||
tabBar.appendChild(btn);
|
||||
tabPanels.appendChild(panel);
|
||||
activateTab(id);
|
||||
if (persist) saveTabs();
|
||||
openModal();
|
||||
|
||||
const iframe = panel.querySelector('iframe');
|
||||
const markActive = () => {
|
||||
idleTimers.set(id, Date.now());
|
||||
};
|
||||
idleTimers.set(id, Date.now());
|
||||
|
||||
iframe.addEventListener('load', () => {
|
||||
try {
|
||||
const disableBeforeUnload = (win) => {
|
||||
if (!win) return;
|
||||
try {
|
||||
win.onbeforeunload = null;
|
||||
win.addEventListener(
|
||||
'beforeunload',
|
||||
(e) => {
|
||||
e.stopImmediatePropagation();
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
if (win.document) {
|
||||
win.document.onbeforeunload = null;
|
||||
win.document.addEventListener(
|
||||
'beforeunload',
|
||||
(e) => {
|
||||
e.stopImmediatePropagation();
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
disableBeforeUnload(iframe.contentWindow);
|
||||
setInterval(() => disableBeforeUnload(iframe.contentWindow), 2000);
|
||||
const doc = iframe.contentWindow.document;
|
||||
['keydown', 'mousedown', 'wheel', 'touchstart'].forEach((evt) => {
|
||||
doc.addEventListener(evt, markActive, { passive: true });
|
||||
});
|
||||
const observer = new MutationObserver(markActive);
|
||||
observer.observe(doc.body, { childList: true, subtree: true, characterData: true });
|
||||
} catch (e) {
|
||||
// cross-origin or blocked; rely on timer only
|
||||
}
|
||||
});
|
||||
|
||||
const idleCheck = setInterval(() => {
|
||||
const last = idleTimers.get(id) || 0;
|
||||
if (Date.now() - last > idleMs) {
|
||||
const panelEl = tabPanels.querySelector(`.console-panel[data-tab-id="${id}"]`);
|
||||
const btnEl = tabBar.querySelector(`.console-tab[data-tab-id="${id}"]`);
|
||||
if (panelEl) panelEl.remove();
|
||||
if (btnEl) btnEl.remove();
|
||||
idleTimers.delete(id);
|
||||
saveTabs();
|
||||
clearInterval(idleCheck);
|
||||
const next = tabBar.querySelector('.console-tab');
|
||||
if (next) activateTab(next.dataset.tabId);
|
||||
}
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
document.querySelectorAll('.console-launch').forEach((el) => {
|
||||
const url = el.dataset.url;
|
||||
const host = el.dataset.host || 'Konsole';
|
||||
if (url) {
|
||||
openTab(host, url);
|
||||
}
|
||||
el.remove();
|
||||
});
|
||||
|
||||
const showRestoreNotice = (text) => {
|
||||
const notice = document.querySelector('[data-console-notice]');
|
||||
if (!notice) return;
|
||||
notice.textContent = text;
|
||||
notice.style.display = 'block';
|
||||
};
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (raw) {
|
||||
const tabs = JSON.parse(raw);
|
||||
if (Array.isArray(tabs)) {
|
||||
const now = Date.now();
|
||||
tabs.forEach((t) => {
|
||||
if (t && t.url) {
|
||||
if (t.openedAt && now - t.openedAt > (10 * 60 * 1000)) {
|
||||
showRestoreNotice('Token abgelaufen, bitte Konsole neu öffnen.');
|
||||
return;
|
||||
}
|
||||
openTab(t.label || 'Konsole', t.url, false);
|
||||
}
|
||||
});
|
||||
if (consoleFab && tabs.length > 0) {
|
||||
consoleFab.classList.add('is-visible');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (consoleFab) {
|
||||
consoleFab.addEventListener('click', openModal);
|
||||
}
|
||||
if (consoleClose) {
|
||||
consoleClose.addEventListener('click', closeModal);
|
||||
}
|
||||
if (consoleModal) {
|
||||
consoleModal.addEventListener('click', (e) => {
|
||||
if (e.target === consoleModal) closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
const queueBody = document.querySelector('[data-queue-body]');
|
||||
const countdownEl = document.querySelector('[data-queue-countdown]');
|
||||
const refreshBtn = document.querySelector('[data-queue-refresh]');
|
||||
const queueBtn = document.querySelector('[data-queue-button]');
|
||||
const queueCount = document.querySelector('[data-queue-count]');
|
||||
const queueModal = document.querySelector('[data-queue-modal]');
|
||||
const queueClose = document.querySelector('[data-queue-close]');
|
||||
if (!queueBody || !countdownEl) return;
|
||||
|
||||
let remaining = 10;
|
||||
|
||||
const fetchQueue = async () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('queue_json', '1');
|
||||
try {
|
||||
const res = await fetch(url.toString(), { cache: 'no-store' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data && data.html) {
|
||||
queueBody.innerHTML = data.html;
|
||||
}
|
||||
if (queueCount && typeof data.count === 'number') {
|
||||
queueCount.textContent = String(data.count);
|
||||
queueCount.style.display = data.count > 0 ? 'inline-flex' : 'none';
|
||||
}
|
||||
remaining = data && data.next ? data.next : 10;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
fetchQueue();
|
||||
remaining = 10;
|
||||
}
|
||||
countdownEl.textContent = String(remaining);
|
||||
};
|
||||
|
||||
setInterval(tick, 1000);
|
||||
fetchQueue();
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
fetchQueue();
|
||||
remaining = 10;
|
||||
countdownEl.textContent = String(remaining);
|
||||
});
|
||||
}
|
||||
|
||||
if (queueBtn && queueModal) {
|
||||
queueBtn.addEventListener('click', () => {
|
||||
queueModal.classList.add('is-open');
|
||||
queueModal.setAttribute('aria-hidden', 'false');
|
||||
fetchQueue();
|
||||
});
|
||||
}
|
||||
if (queueClose && queueModal) {
|
||||
queueClose.addEventListener('click', () => {
|
||||
queueModal.classList.remove('is-open');
|
||||
queueModal.setAttribute('aria-hidden', 'true');
|
||||
});
|
||||
}
|
||||
if (queueModal) {
|
||||
queueModal.addEventListener('click', (e) => {
|
||||
if (e.target === queueModal) {
|
||||
queueModal.classList.remove('is-open');
|
||||
queueModal.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
queueBody.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-queue-action]');
|
||||
if (!btn) return;
|
||||
const runId = btn.getAttribute('data-run-id');
|
||||
const action = btn.getAttribute('data-queue-action');
|
||||
if (!runId || !action) return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('queue_action_json', '1');
|
||||
const formData = new FormData();
|
||||
formData.set('run_id', runId);
|
||||
formData.set('action', action);
|
||||
try {
|
||||
const res = await fetch(url.toString(), { method: 'POST', body: formData, cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
if (consoleNotice) {
|
||||
consoleNotice.textContent = data.error || 'Aktion fehlgeschlagen.';
|
||||
consoleNotice.style.display = 'block';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (consoleNotice) {
|
||||
consoleNotice.textContent = 'Aktion fehlgeschlagen.';
|
||||
consoleNotice.style.display = 'block';
|
||||
}
|
||||
}
|
||||
fetchQueue();
|
||||
remaining = 10;
|
||||
countdownEl.textContent = String(remaining);
|
||||
});
|
||||
|
||||
const consoleForm = document.querySelector('[data-console-form]');
|
||||
const consoleError = document.querySelector('[data-console-error]');
|
||||
const consoleNotice = document.querySelector('[data-console-notice]');
|
||||
const tokenEl = document.querySelector('[data-console-token]');
|
||||
if (consoleForm) {
|
||||
consoleForm.addEventListener('submit', (e) => e.preventDefault());
|
||||
|
||||
const presetSelect = consoleForm.querySelector('select[name="terminal_preset_id"]');
|
||||
const commandTextarea = consoleForm.querySelector('textarea[name="terminal_command_text"]');
|
||||
if (presetSelect && commandTextarea) {
|
||||
presetSelect.addEventListener('change', () => {
|
||||
const opt = presetSelect.selectedOptions[0];
|
||||
const cmd = opt && opt.dataset && opt.dataset.command ? opt.dataset.command : '';
|
||||
if (cmd) {
|
||||
commandTextarea.value = cmd;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const submitOpen = async () => {
|
||||
const formData = new FormData(consoleForm);
|
||||
if (presetSelect) formData.set('terminal_preset_id', '');
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('open_console_json', '1');
|
||||
const res = await fetch(url.toString(), { method: 'POST', body: formData, cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
if (consoleError) {
|
||||
consoleError.textContent = data.error || 'Fehler beim Öffnen der Konsole.';
|
||||
consoleError.style.display = 'block';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (consoleError) consoleError.style.display = 'none';
|
||||
if (consoleNotice) consoleNotice.style.display = 'none';
|
||||
if (tokenEl) tokenEl.textContent = data.token || '';
|
||||
if (data.url) openTab(data.host || 'Konsole', data.url);
|
||||
};
|
||||
|
||||
const submitRun = async () => {
|
||||
const formData = new FormData(consoleForm);
|
||||
if (presetSelect) formData.set('terminal_preset_id', '');
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('run_command_json', '1');
|
||||
const res = await fetch(url.toString(), { method: 'POST', body: formData, cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
if (consoleError) {
|
||||
consoleError.textContent = data.error || 'Fehler beim Ausführen.';
|
||||
consoleError.style.display = 'block';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (consoleError) consoleError.style.display = 'none';
|
||||
if (consoleNotice) {
|
||||
consoleNotice.textContent = data.notice || '';
|
||||
consoleNotice.style.display = 'block';
|
||||
}
|
||||
fetchQueue();
|
||||
remaining = 10;
|
||||
countdownEl.textContent = String(remaining);
|
||||
};
|
||||
|
||||
const openBtn = consoleForm.querySelector('[data-open-console]');
|
||||
const runBtn = consoleForm.querySelector('[data-run-command]');
|
||||
if (openBtn) {
|
||||
openBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
submitOpen();
|
||||
});
|
||||
}
|
||||
if (runBtn) {
|
||||
runBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
submitRun();
|
||||
});
|
||||
}
|
||||
// send-active removed
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,286 @@
|
||||
(() => {
|
||||
const form = document.querySelector('[data-host-form]');
|
||||
if (!form) return;
|
||||
|
||||
const idInput = form.querySelector('input[name="id"]');
|
||||
const nameInput = form.querySelector('input[name="name"]');
|
||||
const hostInput = form.querySelector('input[name="host"]');
|
||||
const portInput = form.querySelector('input[name="port"]');
|
||||
const userInput = form.querySelector('input[name="username"]');
|
||||
const authSelect = form.querySelector('select[name="auth_type"]');
|
||||
const keyInput = form.querySelector('input[name="key_path"]');
|
||||
const passInput = form.querySelector('input[name="password"]');
|
||||
const imageInput = form.querySelector('input[name="image_url"]');
|
||||
const submitBtn = form.querySelector('[data-host-submit]');
|
||||
const modal = document.querySelector('[data-host-modal]');
|
||||
const modalTitle = document.querySelector('[data-host-modal-title]');
|
||||
const closeBtn = document.querySelector('[data-host-close]');
|
||||
const newBtn = document.querySelector('[data-host-new]');
|
||||
const checkAllBtn = document.querySelector('[data-host-check-all]');
|
||||
const cancelBtn = form.querySelector('[data-host-cancel]');
|
||||
|
||||
let initialSnapshot = '';
|
||||
|
||||
const resetForm = () => {
|
||||
if (idInput) idInput.value = '';
|
||||
if (nameInput) nameInput.value = '';
|
||||
if (hostInput) hostInput.value = '';
|
||||
if (portInput) portInput.value = '22';
|
||||
if (userInput) userInput.value = '';
|
||||
if (authSelect) authSelect.value = 'key';
|
||||
if (keyInput) keyInput.value = '';
|
||||
if (passInput) passInput.value = '';
|
||||
if (imageInput) imageInput.value = '';
|
||||
if (submitBtn) submitBtn.textContent = 'Speichern';
|
||||
if (modal && modal.classList.contains('is-open')) {
|
||||
initialSnapshot = snapshot();
|
||||
}
|
||||
};
|
||||
|
||||
const snapshot = () => {
|
||||
return JSON.stringify({
|
||||
id: idInput ? idInput.value : '',
|
||||
name: nameInput ? nameInput.value : '',
|
||||
host: hostInput ? hostInput.value : '',
|
||||
port: portInput ? portInput.value : '',
|
||||
user: userInput ? userInput.value : '',
|
||||
auth: authSelect ? authSelect.value : '',
|
||||
key: keyInput ? keyInput.value : '',
|
||||
pass: passInput ? passInput.value : '',
|
||||
image: imageInput ? imageInput.value : '',
|
||||
});
|
||||
};
|
||||
|
||||
const isDirty = () => snapshot() !== initialSnapshot;
|
||||
|
||||
const openModal = () => {
|
||||
if (!modal) return;
|
||||
modal.classList.add('is-open');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
initialSnapshot = snapshot();
|
||||
};
|
||||
|
||||
const closeModal = (force = false) => {
|
||||
if (!modal) return;
|
||||
if (!force && isDirty()) {
|
||||
const ok = window.confirm('Änderungen nicht gespeichert. Ohne Speichern schließen?');
|
||||
if (!ok) return;
|
||||
}
|
||||
modal.classList.remove('is-open');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-host-edit]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const card = btn.closest('.host-card');
|
||||
if (!card) return;
|
||||
if (idInput) idInput.value = card.dataset.hostId || '';
|
||||
if (nameInput) nameInput.value = card.dataset.name || '';
|
||||
if (hostInput) hostInput.value = card.dataset.host || '';
|
||||
if (portInput) portInput.value = card.dataset.port || '22';
|
||||
if (userInput) userInput.value = card.dataset.username || '';
|
||||
if (authSelect) authSelect.value = card.dataset.auth || 'key';
|
||||
if (keyInput) keyInput.value = card.dataset.keyPath || '';
|
||||
if (passInput) passInput.value = '';
|
||||
if (imageInput) imageInput.value = card.dataset.imageUrl || '';
|
||||
if (submitBtn) submitBtn.textContent = 'Aktualisieren';
|
||||
if (modalTitle) modalTitle.textContent = 'Host bearbeiten';
|
||||
const details = btn.closest('details');
|
||||
if (details) details.removeAttribute('open');
|
||||
openModal();
|
||||
});
|
||||
});
|
||||
|
||||
if (newBtn) {
|
||||
newBtn.addEventListener('click', () => {
|
||||
resetForm();
|
||||
if (modalTitle) modalTitle.textContent = 'Neuer Host';
|
||||
openModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => closeModal(false));
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
resetForm();
|
||||
});
|
||||
}
|
||||
|
||||
const updateStatus = (card, status) => {
|
||||
const dot = card.querySelector('[data-host-status]');
|
||||
if (!dot) return;
|
||||
dot.classList.remove('status-ok', 'status-auth', 'status-down');
|
||||
if (status === 'ok') dot.classList.add('status-ok');
|
||||
else if (status === 'down') dot.classList.add('status-down');
|
||||
else dot.classList.add('status-auth');
|
||||
};
|
||||
|
||||
const fetchStatus = (card) => {
|
||||
const id = card.dataset.hostId;
|
||||
if (!id) return;
|
||||
fetch(`${window.location.pathname}?status_json=1&id=${encodeURIComponent(id)}`, { cache: 'no-store' })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data && data.ok) {
|
||||
updateStatus(card, data.status);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
document.querySelectorAll('.host-card').forEach((card) => {
|
||||
fetchStatus(card);
|
||||
});
|
||||
|
||||
const setUpdateUi = (card, data) => {
|
||||
const upd = card.querySelector('[data-update-badge]');
|
||||
const upg = card.querySelector('[data-upgrade-badge]');
|
||||
const time = card.querySelector('[data-update-time]');
|
||||
const updDebug = card.querySelector('[data-update-debug]');
|
||||
const upgDebug = card.querySelector('[data-upgrade-debug]');
|
||||
if (upd) {
|
||||
upd.classList.remove('badge-warn', 'badge-ok', 'badge-error');
|
||||
if (data.updates && data.updates.error) {
|
||||
upd.textContent = 'Updates: Fehler';
|
||||
upd.classList.add('badge-error');
|
||||
upd.setAttribute('title', data.updates.error);
|
||||
} else if (data.updates && typeof data.updates.count === 'number') {
|
||||
upd.textContent = `Updates: ${data.updates.count}`;
|
||||
upd.classList.toggle('badge-warn', data.updates.count > 0);
|
||||
upd.classList.toggle('badge-ok', data.updates.count === 0);
|
||||
if (data.updates.preview || data.updates.raw) {
|
||||
upd.setAttribute('title', data.updates.preview || data.updates.raw);
|
||||
}
|
||||
} else {
|
||||
upd.textContent = 'Updates: –';
|
||||
if (data.updates && (data.updates.preview || data.updates.raw)) {
|
||||
upd.setAttribute('title', data.updates.preview || data.updates.raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (upg) {
|
||||
upg.classList.remove('badge-warn', 'badge-ok', 'badge-error');
|
||||
if (data.os && data.os.error) {
|
||||
upg.textContent = 'OS: Fehler';
|
||||
upg.classList.add('badge-error');
|
||||
upg.setAttribute('title', data.os.error);
|
||||
} else if (data.os && typeof data.os.available === 'boolean') {
|
||||
upg.textContent = data.os.available ? 'OS: Upgrade verfügbar' : 'OS: OK';
|
||||
upg.classList.toggle('badge-warn', data.os.available);
|
||||
upg.classList.toggle('badge-ok', !data.os.available);
|
||||
if (data.os.raw) upg.setAttribute('title', data.os.raw);
|
||||
} else {
|
||||
upg.textContent = 'OS: –';
|
||||
if (data.os && data.os.raw) {
|
||||
upg.setAttribute('title', data.os.raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updDebug) {
|
||||
const raw = (data.updates && (data.updates.raw || data.updates.preview)) || '';
|
||||
updDebug.textContent = raw ? `Update Debug: ${raw}` : 'Update Debug: –';
|
||||
}
|
||||
if (upgDebug) {
|
||||
const raw = (data.os && data.os.raw) || '';
|
||||
upgDebug.textContent = raw ? `Upgrade Debug: ${raw}` : 'Upgrade Debug: –';
|
||||
}
|
||||
if (time && data.checked_at) {
|
||||
const dt = new Date(data.checked_at);
|
||||
time.textContent = isNaN(dt.getTime()) ? data.checked_at : dt.toLocaleString();
|
||||
}
|
||||
};
|
||||
|
||||
const checkHostUpdates = (card) => {
|
||||
const id = card.dataset.hostId;
|
||||
if (!id) return Promise.resolve();
|
||||
const btn = card.querySelector('[data-host-check]');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Prüfe...';
|
||||
}
|
||||
return fetch(`${window.location.pathname}?update_json=1&id=${encodeURIComponent(id)}`, { cache: 'no-store' })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data && data.ok) {
|
||||
setUpdateUi(card, data);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Updates prüfen';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-host-check]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const card = btn.closest('.host-card');
|
||||
if (card) checkHostUpdates(card);
|
||||
});
|
||||
});
|
||||
|
||||
if (checkAllBtn) {
|
||||
checkAllBtn.addEventListener('click', async () => {
|
||||
const cards = Array.from(document.querySelectorAll('.host-card'));
|
||||
for (const card of cards) {
|
||||
await checkHostUpdates(card);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const applyStoredUpdate = (card) => {
|
||||
const checkedAt = card.dataset.updateChecked || '';
|
||||
const updateCount = card.dataset.updateCount;
|
||||
const updateError = card.dataset.updateError || '';
|
||||
const upgradeAvailable = card.dataset.upgradeAvailable;
|
||||
const upgradeRaw = card.dataset.upgradeRaw || '';
|
||||
const upgradeError = card.dataset.upgradeError || '';
|
||||
|
||||
const payload = {
|
||||
updates: {},
|
||||
os: {},
|
||||
checked_at: checkedAt || '',
|
||||
};
|
||||
if (updateError) {
|
||||
payload.updates.error = updateError;
|
||||
} else if (updateCount !== undefined && updateCount !== '') {
|
||||
payload.updates.count = Number(updateCount);
|
||||
payload.updates.preview = card.dataset.updatePreview || '';
|
||||
payload.updates.raw = card.dataset.updatePreview || '';
|
||||
} else {
|
||||
payload.updates.preview = card.dataset.updatePreview || '';
|
||||
payload.updates.raw = card.dataset.updatePreview || '';
|
||||
}
|
||||
|
||||
if (upgradeError) {
|
||||
payload.os.error = upgradeError;
|
||||
} else if (upgradeAvailable !== undefined && upgradeAvailable !== '') {
|
||||
payload.os.available = upgradeAvailable === '1' || upgradeAvailable === 'true';
|
||||
payload.os.raw = upgradeRaw;
|
||||
} else {
|
||||
payload.os.raw = upgradeRaw;
|
||||
}
|
||||
|
||||
setUpdateUi(card, payload);
|
||||
};
|
||||
|
||||
const isStale = (checkedAt) => {
|
||||
if (!checkedAt) return true;
|
||||
const dt = new Date(checkedAt);
|
||||
if (isNaN(dt.getTime())) return true;
|
||||
const ageMs = Date.now() - dt.getTime();
|
||||
return ageMs > 24 * 60 * 60 * 1000;
|
||||
};
|
||||
|
||||
document.querySelectorAll('.host-card').forEach((card) => {
|
||||
applyStoredUpdate(card);
|
||||
if (isStale(card.dataset.updateChecked || '')) {
|
||||
checkHostUpdates(card);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,279 @@
|
||||
.form-card { padding: 14px; }
|
||||
|
||||
.notice-card {
|
||||
padding: 12px 14px;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
.form-grid { display: grid; gap: 12px; }
|
||||
.form-field { display: grid; gap: 6px; }
|
||||
.form-field input,
|
||||
.form-field select,
|
||||
.form-field textarea { width: 100%; }
|
||||
|
||||
.icon-button {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-2);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.icon-button:hover { background: var(--panel); }
|
||||
|
||||
.console-tabs {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #0b0f17;
|
||||
}
|
||||
.console-tab-bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px;
|
||||
background: #0f1624;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.console-tab {
|
||||
background: transparent;
|
||||
color: #c9d3e3;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.console-tab.is-active {
|
||||
border-color: rgba(255,255,255,0.2);
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #ffffff;
|
||||
}
|
||||
.console-tab-panels {
|
||||
min-height: 420px;
|
||||
}
|
||||
.console-tab-close {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.12);
|
||||
color: #ffffff;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.console-tab-close:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
.console-panel { display: none; }
|
||||
.console-panel.is-active { display: block; }
|
||||
.console-panel iframe {
|
||||
width: 100%;
|
||||
height: 520px;
|
||||
border: 0;
|
||||
background: #0b0f17;
|
||||
}
|
||||
|
||||
.host-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.host-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
overflow: visible;
|
||||
display: grid;
|
||||
grid-template-rows: 120px 1fr;
|
||||
box-shadow: var(--shadow);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.host-card-image {
|
||||
background: linear-gradient(135deg, #2b3a67 0%, #3b2f5c 45%, #1c2b3f 100%);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
}
|
||||
.host-card-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(10, 16, 28, 0.35);
|
||||
}
|
||||
.host-card-body {
|
||||
padding: 12px 14px 14px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.host-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.host-card-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
}
|
||||
.status-ok { background: #31c48d; }
|
||||
.status-auth { background: #fbbf24; }
|
||||
.status-down { background: #ef4444; }
|
||||
|
||||
.host-update-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.update-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-2);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-ok {
|
||||
border-color: rgba(49,196,141,0.5);
|
||||
background: rgba(49,196,141,0.15);
|
||||
}
|
||||
.badge-warn {
|
||||
border-color: rgba(251,191,36,0.6);
|
||||
background: rgba(251,191,36,0.2);
|
||||
}
|
||||
.badge-error {
|
||||
border-color: rgba(239,68,68,0.6);
|
||||
background: rgba(239,68,68,0.15);
|
||||
}
|
||||
|
||||
.action-menu {
|
||||
position: relative;
|
||||
}
|
||||
.action-menu summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.action-menu summary::-webkit-details-marker { display: none; }
|
||||
.action-menu[open] summary {
|
||||
background: var(--panel);
|
||||
}
|
||||
.action-menu-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
min-width: 160px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
z-index: 200;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.action-menu-panel form { margin: 0; }
|
||||
|
||||
.host-unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #f3c3b8;
|
||||
background: #fff5f3;
|
||||
color: #7a2114;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.host-debug {
|
||||
margin: 6px 0 0;
|
||||
padding: 6px 8px;
|
||||
background: #0b1020;
|
||||
color: #c7d2fe;
|
||||
border-radius: 8px;
|
||||
font-size: 0.7rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.command-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.command-item {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.command-item.is-dragging {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.command-drag {
|
||||
cursor: grab;
|
||||
color: var(--muted);
|
||||
font-size: 1.1rem;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.command-body code {
|
||||
display: inline-block;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.queue-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.queue-badge {
|
||||
display: inline-flex;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: #ff5a3c;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(1100px, 96vw);
|
||||
}
|
||||
.modal-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
<?php
|
||||
use App\ModuleConfigException;
|
||||
|
||||
$moduleName = 'pi_control';
|
||||
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
|
||||
|
||||
$mm->registerFunction($moduleName, 'table', function (string $name): string {
|
||||
$prefix = 'picontrol_';
|
||||
$sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return $prefix . $sanitized;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): \PDO {
|
||||
$settings = modules()->settings($moduleName);
|
||||
$useSeparate = !empty($settings['use_separate_db']);
|
||||
|
||||
if ($useSeparate) {
|
||||
// Uses module-specific DB config
|
||||
$module = modules()->get($moduleName);
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
return modules()->modulePdo($moduleName, $fallback);
|
||||
}
|
||||
|
||||
$base = app()->basePdo();
|
||||
if (!$base) {
|
||||
throw new ModuleConfigException(
|
||||
$moduleName,
|
||||
'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eigene Modul-DB konfigurieren.'
|
||||
);
|
||||
}
|
||||
|
||||
return $base;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'redis', function () use ($moduleName) {
|
||||
$settings = modules()->settings($moduleName);
|
||||
$redis = (array)($settings['redis'] ?? []);
|
||||
$host = (string)($redis['host'] ?? ($settings['redis.host'] ?? getenv('PI_CONTROL_REDIS_HOST') ?: 'redis'));
|
||||
$port = (int)($redis['port'] ?? ($settings['redis.port'] ?? (getenv('PI_CONTROL_REDIS_PORT') !== false ? (int)getenv('PI_CONTROL_REDIS_PORT') : 6379)));
|
||||
$password = (string)($redis['password'] ?? ($settings['redis.password'] ?? getenv('PI_CONTROL_REDIS_PASSWORD') ?: ''));
|
||||
$db = (int)($redis['db'] ?? ($settings['redis.db'] ?? (getenv('PI_CONTROL_REDIS_DB') !== false ? (int)getenv('PI_CONTROL_REDIS_DB') : 0)));
|
||||
|
||||
return new \App\RedisClient($host, $port, $password !== '' ? $password : null, $db);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName): void {
|
||||
$pdo = module_fn($moduleName, 'pdo');
|
||||
$table = fn(string $name) => module_fn($moduleName, 'table', $name);
|
||||
|
||||
$driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
|
||||
$hostTable = $table('hosts');
|
||||
$cmdTable = $table('commands');
|
||||
$runTable = $table('runs');
|
||||
$sessionTable = $table('sessions');
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$hostTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
username VARCHAR(120) NOT NULL,
|
||||
auth_type VARCHAR(20) NOT NULL DEFAULT 'key',
|
||||
key_path TEXT NULL,
|
||||
password TEXT NULL,
|
||||
image_url TEXT NULL,
|
||||
update_checked_at TIMESTAMP NULL,
|
||||
update_count INTEGER NULL,
|
||||
update_preview TEXT NULL,
|
||||
update_error TEXT NULL,
|
||||
upgrade_available BOOLEAN NULL,
|
||||
upgrade_raw TEXT NULL,
|
||||
upgrade_error TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$cmdTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
label VARCHAR(160) NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
admin_only BOOLEAN NOT NULL DEFAULT false,
|
||||
timeout_sec INTEGER NULL,
|
||||
sort_order INTEGER NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$runTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
host_id INTEGER NULL,
|
||||
command_id INTEGER NULL,
|
||||
command_text TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
output TEXT NULL,
|
||||
error TEXT NULL,
|
||||
exit_code INTEGER NULL,
|
||||
timeout_sec INTEGER NULL,
|
||||
created_by VARCHAR(120) NULL,
|
||||
started_at TIMESTAMP NULL,
|
||||
finished_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$sessionTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
host_id INTEGER NOT NULL,
|
||||
provider VARCHAR(20) NOT NULL DEFAULT 'ttyd',
|
||||
command_text TEXT NULL,
|
||||
created_by VARCHAR(120) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
last_used_at TIMESTAMP NULL
|
||||
)");
|
||||
} else {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$hostTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
username VARCHAR(120) NOT NULL,
|
||||
auth_type VARCHAR(20) NOT NULL DEFAULT 'key',
|
||||
key_path TEXT NULL,
|
||||
password TEXT NULL,
|
||||
image_url TEXT NULL,
|
||||
update_checked_at DATETIME NULL,
|
||||
update_count INTEGER NULL,
|
||||
update_preview TEXT NULL,
|
||||
update_error TEXT NULL,
|
||||
upgrade_available INTEGER NULL,
|
||||
upgrade_raw TEXT NULL,
|
||||
upgrade_error TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$cmdTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label VARCHAR(160) NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
admin_only INTEGER NOT NULL DEFAULT 0,
|
||||
timeout_sec INTEGER NULL,
|
||||
sort_order INTEGER NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$runTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NULL,
|
||||
command_id INTEGER NULL,
|
||||
command_text TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
output TEXT NULL,
|
||||
error TEXT NULL,
|
||||
exit_code INTEGER NULL,
|
||||
timeout_sec INTEGER NULL,
|
||||
created_by VARCHAR(120) NULL,
|
||||
started_at DATETIME NULL,
|
||||
finished_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$sessionTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
host_id INTEGER NOT NULL,
|
||||
provider VARCHAR(20) NOT NULL DEFAULT 'ttyd',
|
||||
command_text TEXT NULL,
|
||||
created_by VARCHAR(120) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
last_used_at DATETIME NULL
|
||||
)");
|
||||
}
|
||||
|
||||
// Schema migrations for existing tables
|
||||
if ($driver === 'pgsql') {
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS image_url TEXT NULL");
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS update_checked_at TIMESTAMP NULL");
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS update_count INTEGER NULL");
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS update_preview TEXT NULL");
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS update_error TEXT NULL");
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS upgrade_available BOOLEAN NULL");
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS upgrade_raw TEXT NULL");
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS upgrade_error TEXT NULL");
|
||||
$pdo->exec("ALTER TABLE {$cmdTable} ADD COLUMN IF NOT EXISTS timeout_sec INTEGER NULL");
|
||||
$pdo->exec("ALTER TABLE {$cmdTable} ADD COLUMN IF NOT EXISTS sort_order INTEGER NULL");
|
||||
$pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS error TEXT NULL");
|
||||
$pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS exit_code INTEGER NULL");
|
||||
$pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS timeout_sec INTEGER NULL");
|
||||
$pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS started_at TIMESTAMP NULL");
|
||||
$pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS finished_at TIMESTAMP NULL");
|
||||
$pdo->exec("ALTER TABLE {$sessionTable} ADD COLUMN IF NOT EXISTS command_text TEXT NULL");
|
||||
} else {
|
||||
$columns = [];
|
||||
$stmt = $pdo->query('PRAGMA table_info(' . $hostTable . ')');
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) {
|
||||
$columns[$col['name']] = true;
|
||||
}
|
||||
if (empty($columns['image_url'])) {
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN image_url TEXT NULL");
|
||||
}
|
||||
if (empty($columns['update_checked_at'])) {
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN update_checked_at DATETIME NULL");
|
||||
}
|
||||
if (empty($columns['update_count'])) {
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN update_count INTEGER NULL");
|
||||
}
|
||||
if (empty($columns['update_preview'])) {
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN update_preview TEXT NULL");
|
||||
}
|
||||
if (empty($columns['update_error'])) {
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN update_error TEXT NULL");
|
||||
}
|
||||
if (empty($columns['upgrade_available'])) {
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN upgrade_available INTEGER NULL");
|
||||
}
|
||||
if (empty($columns['upgrade_raw'])) {
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN upgrade_raw TEXT NULL");
|
||||
}
|
||||
if (empty($columns['upgrade_error'])) {
|
||||
$pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN upgrade_error TEXT NULL");
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
$stmt = $pdo->query('PRAGMA table_info(' . $cmdTable . ')');
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) {
|
||||
$columns[$col['name']] = true;
|
||||
}
|
||||
if (empty($columns['timeout_sec'])) {
|
||||
$pdo->exec("ALTER TABLE {$cmdTable} ADD COLUMN timeout_sec INTEGER NULL");
|
||||
}
|
||||
if (empty($columns['sort_order'])) {
|
||||
$pdo->exec("ALTER TABLE {$cmdTable} ADD COLUMN sort_order INTEGER NULL");
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
$stmt = $pdo->query('PRAGMA table_info(' . $runTable . ')');
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) {
|
||||
$columns[$col['name']] = true;
|
||||
}
|
||||
if (empty($columns['error'])) {
|
||||
$pdo->exec("ALTER TABLE {$runTable} ADD COLUMN error TEXT NULL");
|
||||
}
|
||||
if (empty($columns['exit_code'])) {
|
||||
$pdo->exec("ALTER TABLE {$runTable} ADD COLUMN exit_code INTEGER NULL");
|
||||
}
|
||||
if (empty($columns['timeout_sec'])) {
|
||||
$pdo->exec("ALTER TABLE {$runTable} ADD COLUMN timeout_sec INTEGER NULL");
|
||||
}
|
||||
if (empty($columns['started_at'])) {
|
||||
$pdo->exec("ALTER TABLE {$runTable} ADD COLUMN started_at DATETIME NULL");
|
||||
}
|
||||
if (empty($columns['finished_at'])) {
|
||||
$pdo->exec("ALTER TABLE {$runTable} ADD COLUMN finished_at DATETIME NULL");
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
$stmt = $pdo->query('PRAGMA table_info(' . $sessionTable . ')');
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) {
|
||||
$columns[$col['name']] = true;
|
||||
}
|
||||
if (empty($columns['command_text'])) {
|
||||
$pdo->exec("ALTER TABLE {$sessionTable} ADD COLUMN command_text TEXT NULL");
|
||||
}
|
||||
}
|
||||
|
||||
// Seed default commands (only when empty)
|
||||
$count = (int)$pdo->query('SELECT COUNT(*) FROM ' . $cmdTable)->fetchColumn();
|
||||
if ($count === 0) {
|
||||
$defaults = [
|
||||
['Speicherplatz auf Dateisystem', 'df -T -h', false],
|
||||
['Netzwerkdaten', 'ip -s addr show', false],
|
||||
['CPU-Information', 'cat /proc/cpuinfo', false],
|
||||
['Informationen über USB-Bus', 'lsusb', false],
|
||||
['Uptime', 'uptime -p', false],
|
||||
['Scannen Sie einen I2C-Bus nach Geräten', '/usr/sbin/i2cdetect -y 1', true],
|
||||
['Prozesse (Gib zum Beenden \"q\" ein)', 'top', false],
|
||||
['SSH status', 'systemctl status ssh', false],
|
||||
['Konfigurationsprogramm in Raspberry OS', 'sudo raspi-config', true],
|
||||
['Pinbelegung', 'pinout', false],
|
||||
['Exportiere GPIO (Für Nutzung vorbereiten)', 'echo 22 > /sys/class/gpio/export', true],
|
||||
['Lese GPIO-Wert', 'echo in > /sys/class/gpio/gpio22/direction && cat /sys/class/gpio/gpio22/value', true],
|
||||
['Schreibe \"Low\" GPIO Wert', 'echo out > /sys/class/gpio/gpio22/direction && echo 0 > /sys/class/gpio/gpio22/value', true],
|
||||
['Schreibe \"High\" GPIO Wert', 'echo out > /sys/class/gpio/gpio22/direction && echo 1 > /sys/class/gpio/gpio22/value', true],
|
||||
['Export entfernen (Ressource freigeben)', 'echo 22 > /sys/class/gpio/unexport', true],
|
||||
['Alternative Funktionen von GPIO', 'raspi-gpio funcs', false],
|
||||
['Gerät herunterfahren', 'sudo systemctl poweroff', true],
|
||||
['Gerät neu starten', 'sudo systemctl reboot', true],
|
||||
['Aktualisiere die Betriebssystem-Pakete', 'sudo apt update && sudo apt full-upgrade', true],
|
||||
['Befehle, die zuvor ausgeführt wurden (Quellsprache)', 'history 30', false],
|
||||
['Liste der zuletzt angemeldeten Nutzer', 'last -30 -F', false],
|
||||
['Liste der aktuell angemeldeten Nutzer', 'w', false],
|
||||
['Aktualisierung', 'sudo apt update && sudo apt upgrade -y && sudo apt-get autoremove --purge && sudo apt-get clean && sudo rm -rf /var/lib/apt/lists/* && sudo apt-get update && sudo apt-get remove texlive-*-doc', true],
|
||||
['Update OS', 'sudo apt update && sudo apt full-upgrade -y && sudo apt-get autoremove --purge && sudo apt-get clean && sudo rm -rf /var/lib/apt/lists/* && sudo apt-get update && sudo apt-get remove texlive-*-doc', true],
|
||||
['Paketlisten laden', 'sudo apt-get update; sudo apt-get dist-upgrade -y;exit', true],
|
||||
['Gerät herunterfahren', 'sudo /sbin/shutdown -h now', true],
|
||||
['Gerät neu starten', 'sudo /sbin/reboot', true],
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO ' . $cmdTable . ' (label, command, admin_only) VALUES (:label, :command, :admin_only)'
|
||||
);
|
||||
foreach ($defaults as [$label, $command, $adminOnly]) {
|
||||
$stmt->bindValue(':label', $label, PDO::PARAM_STR);
|
||||
$stmt->bindValue(':command', $command, PDO::PARAM_STR);
|
||||
$stmt->bindValue(':admin_only', (bool)$adminOnly, PDO::PARAM_BOOL);
|
||||
$stmt->execute();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Pi Control",
|
||||
"description": "Verwaltung und Steuerung von Raspberry Pis per SSH, Presets und Konsole.",
|
||||
"actions": [
|
||||
{ "label": "Setup", "href": "/modules/setup/pi_control", "variant": "secondary" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Ueberblick", "href": "/module/pi_control", "match_prefixes": ["/module/pi_control"] },
|
||||
{ "label": "Hosts", "href": "/module/pi_control/hosts", "match_prefixes": ["/module/pi_control/hosts"] },
|
||||
{ "label": "Befehle", "href": "/module/pi_control/commands", "match_prefixes": ["/module/pi_control/commands"] },
|
||||
{ "label": "Konsole", "href": "/module/pi_control/console", "match_prefixes": ["/module/pi_control/console"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"title": "Pi Control",
|
||||
"version": "0.1.0",
|
||||
"description": "Verwaltung und Steuerung von Raspberry Pis (SSH/Commands/Presets).",
|
||||
"setup": {
|
||||
"fields": [
|
||||
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Base-DB genutzt." },
|
||||
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" },
|
||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
|
||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
|
||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
|
||||
{ "name": "ttyd_url", "label": "ttyd URL", "type": "text", "required": false, "help": "z.B. https://staging.nexus.kusche.berlin/ttyd" },
|
||||
{ "name": "terminal_token_ttl", "label": "Token TTL (Minuten)", "type": "number", "required": false, "help": "Gültigkeit der Konsole-Token, z.B. 10" },
|
||||
{ "name": "terminal_shared_secret", "label": "Terminal Shared Secret", "type": "password", "required": false, "help": "Zusätzliche Absicherung für terminal_info (Header X-Terminal-Secret)" },
|
||||
{ "name": "terminal_tmux_session", "label": "tmux Session-Name", "type": "text", "required": false, "help": "Session-Name für bestehende Konsole (Standard: nexus)" },
|
||||
{ "name": "terminal_strict_hostkey", "label": "Strict Host-Key Checking", "type": "checkbox", "required": false, "help": "Aktiviert StrictHostKeyChecking (accept-new) statt Insecure." },
|
||||
{ "name": "exec_default_timeout", "label": "Command-Timeout (Sek.)", "type": "number", "required": false, "help": "Default-Timeout für Befehle, z.B. 300" },
|
||||
{ "name": "settings_reload_sec", "label": "Settings Reload (Sek.)", "type": "number", "required": false, "help": "Wie oft der Worker Settings neu lädt (Standard 30s)" },
|
||||
{ "name": "redis.host", "label": "Redis Host", "type": "text", "required": false, "help": "Service-Name, z.B. redis" },
|
||||
{ "name": "redis.port", "label": "Redis Port", "type": "number", "required": false, "help": "Standard 6379" },
|
||||
{ "name": "redis.password", "label": "Redis Passwort", "type": "password", "required": false },
|
||||
{ "name": "redis.db", "label": "Redis DB", "type": "number", "required": false, "help": "Standard 0" },
|
||||
{ "name": "redis.queue", "label": "Redis Queue", "type": "text", "required": false, "help": "z.B. pi_control:queue" }
|
||||
]
|
||||
},
|
||||
"db_defaults": {
|
||||
"driver": "pgsql",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"dbname": "",
|
||||
"schema": "public",
|
||||
"user": "",
|
||||
"password": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
$file = (string)($_GET['file'] ?? '');
|
||||
$base = realpath(__DIR__ . '/../assets');
|
||||
$map = [
|
||||
'pi_control.css' => $base . '/pi_control.css',
|
||||
'console.js' => $base . '/console.js',
|
||||
'hosts.js' => $base . '/hosts.js',
|
||||
'commands.js' => $base . '/commands.js',
|
||||
];
|
||||
|
||||
if (!isset($map[$file])) {
|
||||
http_response_code(404);
|
||||
exit('Not found');
|
||||
}
|
||||
|
||||
$path = $map[$file];
|
||||
if (!$base || !is_file($path) || !str_starts_with($path, $base)) {
|
||||
http_response_code(404);
|
||||
exit('Not found');
|
||||
}
|
||||
|
||||
$ext = pathinfo($path, PATHINFO_EXTENSION);
|
||||
if ($ext === 'css') {
|
||||
header('Content-Type: text/css; charset=utf-8');
|
||||
} elseif ($ext === 'js') {
|
||||
header('Content-Type: application/javascript; charset=utf-8');
|
||||
} else {
|
||||
header('Content-Type: application/octet-stream');
|
||||
}
|
||||
|
||||
readfile($path);
|
||||
exit;
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
$pdo = module_fn('pi_control', 'pdo');
|
||||
module_fn('pi_control', 'ensure_schema');
|
||||
$table = fn(string $name) => module_fn('pi_control', 'table', $name);
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/pi_control/asset?file=pi_control.css');
|
||||
$assets->addScript('/module/pi_control/asset?file=commands.js', 'footer', true);
|
||||
}
|
||||
|
||||
$notice = null;
|
||||
$error = null;
|
||||
|
||||
if (isset($_GET['reorder_json'])) {
|
||||
require_admin();
|
||||
$payload = json_decode(file_get_contents('php://input'), true);
|
||||
$order = is_array($payload['order'] ?? null) ? $payload['order'] : [];
|
||||
if ($order) {
|
||||
$stmt = $pdo->prepare('UPDATE ' . $table('commands') . ' SET sort_order = :sort_order WHERE id = :id');
|
||||
$pos = 1;
|
||||
foreach ($order as $id) {
|
||||
$stmt->execute([
|
||||
'sort_order' => $pos++,
|
||||
'id' => (int)$id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
require_admin();
|
||||
$deleteId = (int)($_POST['delete_id'] ?? 0);
|
||||
$editId = (int)($_POST['id'] ?? 0);
|
||||
$label = trim((string)($_POST['label'] ?? ''));
|
||||
$command = trim((string)($_POST['command'] ?? ''));
|
||||
$adminOnly = !empty($_POST['admin_only']) ? 1 : 0;
|
||||
$timeoutSec = (int)($_POST['timeout_sec'] ?? 0);
|
||||
|
||||
if ($deleteId > 0) {
|
||||
$stmt = $pdo->prepare('DELETE FROM ' . $table('commands') . ' WHERE id = :id');
|
||||
$stmt->execute(['id' => $deleteId]);
|
||||
$notice = 'Befehl gelöscht.';
|
||||
} else {
|
||||
if ($label === '' || $command === '') {
|
||||
$error = 'Bitte Label und Command angeben.';
|
||||
} else {
|
||||
if ($editId > 0) {
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE ' . $table('commands') . ' SET label = :label, command = :command, admin_only = :admin_only, timeout_sec = :timeout_sec WHERE id = :id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $editId,
|
||||
'label' => $label,
|
||||
'command' => $command,
|
||||
'admin_only' => $adminOnly,
|
||||
'timeout_sec' => $timeoutSec > 0 ? $timeoutSec : null,
|
||||
]);
|
||||
$notice = 'Befehl aktualisiert.';
|
||||
} else {
|
||||
$nextSort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order), 0) + 1 FROM ' . $table('commands'))->fetchColumn();
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO ' . $table('commands') . ' (label, command, admin_only, timeout_sec, sort_order) VALUES (:label, :command, :admin_only, :timeout_sec, :sort_order)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'label' => $label,
|
||||
'command' => $command,
|
||||
'admin_only' => $adminOnly,
|
||||
'timeout_sec' => $timeoutSec > 0 ? $timeoutSec : null,
|
||||
'sort_order' => $nextSort,
|
||||
]);
|
||||
$notice = 'Befehl gespeichert.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY COALESCE(sort_order, id) ASC, id ASC')->fetchAll(PDO::FETCH_ASSOC);
|
||||
?>
|
||||
<?= module_shell_header('pi_control', [
|
||||
'title' => 'Befehle',
|
||||
]) ?>
|
||||
<div class="module-flow">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">Befehle</h2>
|
||||
<p>Verwalte vordefinierte SSH-Befehle.</p>
|
||||
</div>
|
||||
<button class="module-button module-button--primary" type="button" data-command-new>+ Neuer Befehl</button>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="setup-db-message setup-db-message--error" style="margin-top:16px;">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="setup-db-message setup-db-message--success" style="margin-top:16px;">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="module-box" style="margin-top:16px;">
|
||||
<strong>Vorhandene Befehle</strong>
|
||||
<?php if (!$commands): ?>
|
||||
<div class="muted" style="margin-top:.75rem;">Keine Befehle vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<ul class="command-list" data-command-list style="margin-top:.75rem;">
|
||||
<?php foreach ($commands as $c): ?>
|
||||
<li class="command-item" draggable="true"
|
||||
data-command-id="<?= e((string)$c['id']) ?>"
|
||||
data-label="<?= e((string)($c['label'] ?? '')) ?>"
|
||||
data-command="<?= e((string)($c['command'] ?? '')) ?>"
|
||||
data-timeout="<?= e((string)($c['timeout_sec'] ?? '')) ?>"
|
||||
data-admin="<?= !empty($c['admin_only']) ? '1' : '0' ?>">
|
||||
<div class="command-drag">⋮⋮</div>
|
||||
<div class="command-body">
|
||||
<div class="command-title">
|
||||
<strong><?= e($c['label'] ?? '') ?></strong>
|
||||
<?php if (!empty($c['admin_only'])): ?>
|
||||
<span class="pill">Admin</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="muted" style="margin-top:4px;">
|
||||
<code><?= e($c['command'] ?? '') ?></code>
|
||||
</div>
|
||||
<div class="muted" style="margin-top:4px;">
|
||||
Timeout: <?= !empty($c['timeout_sec']) ? e((string)$c['timeout_sec']) . 's' : 'default' ?>
|
||||
</div>
|
||||
</div>
|
||||
<details class="action-menu">
|
||||
<summary>☰</summary>
|
||||
<div class="action-menu-panel">
|
||||
<button class="nav-link" type="button" data-command-edit>Bearbeiten</button>
|
||||
<form method="post" onsubmit="return confirm('Befehl wirklich löschen?')">
|
||||
<input type="hidden" name="delete_id" value="<?= e((string)$c['id']) ?>">
|
||||
<button class="nav-link" type="submit">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<p class="muted" style="margin-top:.5rem;">Reihenfolge per Drag & Drop ändern.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal" data-command-modal aria-hidden="true">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<strong data-command-modal-title>Neuer Befehl</strong>
|
||||
<button class="icon-button" type="button" data-command-close>×</button>
|
||||
</div>
|
||||
<form method="post" class="form-grid" style="margin-top:.75rem;" data-command-form>
|
||||
<input type="hidden" name="id" value="">
|
||||
<input type="text" name="label" placeholder="Label" required>
|
||||
<textarea name="command" rows="4" placeholder="Command" required></textarea>
|
||||
<input type="number" name="timeout_sec" placeholder="Timeout (Sek., optional)">
|
||||
<label class="muted" style="display:flex; gap:8px; align-items:center;">
|
||||
<input type="checkbox" name="admin_only" value="1">
|
||||
Nur Admin
|
||||
</label>
|
||||
<div class="host-unsaved" data-command-unsaved style="display:none;">
|
||||
<span class="muted">Änderungen nicht gespeichert.</span>
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||
<button class="cta-button" type="submit" data-command-submit>Speichern</button>
|
||||
<button class="nav-link" type="button" data-command-discard>Änderungen verwerfen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||
<button class="cta-button" type="submit" data-command-submit>Speichern</button>
|
||||
<button class="nav-link" type="button" data-command-cancel>Zurücksetzen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,527 @@
|
||||
<?php
|
||||
$pdo = module_fn('pi_control', 'pdo');
|
||||
module_fn('pi_control', 'ensure_schema');
|
||||
$table = fn(string $name) => module_fn('pi_control', 'table', $name);
|
||||
|
||||
$notice = null;
|
||||
$error = null;
|
||||
$terminalNotice = null;
|
||||
$terminalError = null;
|
||||
$terminalUrl = null;
|
||||
$terminalToken = null;
|
||||
$terminalHostLabel = null;
|
||||
|
||||
$settings = modules()->settings('pi_control');
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/pi_control/asset?file=pi_control.css');
|
||||
$assets->addScript('/module/pi_control/asset?file=console.js', 'footer', true);
|
||||
}
|
||||
$ttydUrl = trim((string)($settings['ttyd_url'] ?? '/ttyd'));
|
||||
$defaultProvider = 'ttyd';
|
||||
$defaultTimeout = (int)($settings['exec_default_timeout'] ?? (getenv('PI_CONTROL_EXEC_DEFAULT_TIMEOUT') !== false ? (int)getenv('PI_CONTROL_EXEC_DEFAULT_TIMEOUT') : 300));
|
||||
$defaultTimeout = $defaultTimeout > 0 ? $defaultTimeout : 300;
|
||||
$queueName = (string)($settings['redis']['queue'] ?? ($settings['redis.queue'] ?? (getenv('PI_CONTROL_REDIS_QUEUE') ?: 'pi_control:queue')));
|
||||
$tokenTtl = (int)($settings['terminal_token_ttl'] ?? 10);
|
||||
$tokenTtl = $tokenTtl > 0 ? $tokenTtl : 10;
|
||||
|
||||
$hosts = $pdo->query('SELECT * FROM ' . $table('hosts') . ' ORDER BY name ASC')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY COALESCE(sort_order, id) ASC, id ASC')->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$renderRuns = function (array $runs): string {
|
||||
ob_start();
|
||||
if (!$runs) {
|
||||
echo '<tr><td colspan="8" class="muted">Noch keine Runs.</td></tr>';
|
||||
return ob_get_clean();
|
||||
}
|
||||
foreach ($runs as $r) {
|
||||
$out = (string)($r['output'] ?? '');
|
||||
$err = (string)($r['error'] ?? '');
|
||||
$snippet = $out !== '' ? $out : $err;
|
||||
if (strlen($snippet) > 140) {
|
||||
$snippet = substr($snippet, 0, 140) . '…';
|
||||
}
|
||||
echo '<tr>';
|
||||
echo '<td>' . e((string)$r['id']) . '</td>';
|
||||
echo '<td>' . e((string)($r['status'] ?? '')) . '</td>';
|
||||
echo '<td>' . e((string)($r['host_name'] ?? $r['host_addr'] ?? '')) . '</td>';
|
||||
echo '<td class="muted" style="max-width:360px;"><code>' . e((string)($r['command_text'] ?? '')) . '</code></td>';
|
||||
echo '<td>' . e((string)($r['created_by'] ?? '')) . '</td>';
|
||||
echo '<td class="muted" style="max-width:240px;"><code>' . e($snippet) . '</code></td>';
|
||||
echo '<td>' . (!empty($r['timeout_sec']) ? e((string)$r['timeout_sec']) . 's' : 'default') . '</td>';
|
||||
$status = (string)($r['status'] ?? '');
|
||||
echo '<td>';
|
||||
if ($status === 'queued') {
|
||||
echo '<button class="nav-link" type="button" data-queue-action="cancel" data-run-id="' . e((string)$r['id']) . '">Stoppen</button>';
|
||||
echo '<button class="nav-link" type="button" data-queue-action="delete" data-run-id="' . e((string)$r['id']) . '">Löschen</button>';
|
||||
} elseif ($status === 'running') {
|
||||
echo '<span class="muted">läuft</span>';
|
||||
} else {
|
||||
echo '<button class="nav-link" type="button" data-queue-action="delete" data-run-id="' . e((string)$r['id']) . '">Löschen</button>';
|
||||
}
|
||||
echo '</td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
return ob_get_clean();
|
||||
};
|
||||
|
||||
if (isset($_GET['queue_json'])) {
|
||||
$runs = $pdo->query(
|
||||
'SELECT r.*, h.name AS host_name, h.host AS host_addr
|
||||
FROM ' . $table('runs') . ' r
|
||||
LEFT JOIN ' . $table('hosts') . ' h ON h.id = r.host_id
|
||||
ORDER BY r.id DESC LIMIT 20'
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
$count = (int)$pdo->query(
|
||||
"SELECT COUNT(*) FROM " . $table('runs') . " WHERE status IN ('queued','running','cancel_requested')"
|
||||
)->fetchColumn();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'next' => 10,
|
||||
'count' => $count,
|
||||
'html' => $renderRuns($runs),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_GET['queue_action_json'])) {
|
||||
$runId = (int)($_POST['run_id'] ?? 0);
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
$error = null;
|
||||
|
||||
if ($runId <= 0 || !in_array($action, ['cancel', 'delete'], true)) {
|
||||
$error = 'Ungültige Anfrage.';
|
||||
} else {
|
||||
$stmt = $pdo->prepare('SELECT * FROM ' . $table('runs') . ' WHERE id = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $runId]);
|
||||
$run = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$run) {
|
||||
$error = 'Run nicht gefunden.';
|
||||
} else {
|
||||
$status = (string)($run['status'] ?? '');
|
||||
$driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$nowExpr = $driver === 'pgsql' ? 'NOW()' : "DATETIME('now')";
|
||||
|
||||
if ($action === 'cancel') {
|
||||
if ($status !== 'queued') {
|
||||
$error = 'Nur queued Runs können gestoppt werden.';
|
||||
} else {
|
||||
$pdo->exec('UPDATE ' . $table('runs') . ' SET status = \'canceled\', finished_at = ' . $nowExpr . ' WHERE id = ' . (int)$runId);
|
||||
try {
|
||||
$redis = module_fn('pi_control', 'redis');
|
||||
$payload = json_encode(['run_id' => $runId]);
|
||||
$redis->command(['LREM', $queueName, '0', $payload]);
|
||||
} catch (\Throwable $e) {
|
||||
// ignore redis cleanup errors
|
||||
}
|
||||
}
|
||||
} elseif ($action === 'delete') {
|
||||
if ($status === 'running') {
|
||||
$error = 'Laufende Runs können nicht gelöscht werden.';
|
||||
} else {
|
||||
$pdo->prepare('DELETE FROM ' . $table('runs') . ' WHERE id = :id')->execute(['id' => $runId]);
|
||||
try {
|
||||
$redis = module_fn('pi_control', 'redis');
|
||||
$payload = json_encode(['run_id' => $runId]);
|
||||
$redis->command(['LREM', $queueName, '0', $payload]);
|
||||
} catch (\Throwable $e) {
|
||||
// ignore redis cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'ok' => $error === null,
|
||||
'error' => $error,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_GET['open_console_json'])) {
|
||||
$hostId = (int)($_POST['terminal_host_id'] ?? 0);
|
||||
$presetId = (int)($_POST['terminal_preset_id'] ?? 0);
|
||||
$rawCommand = trim((string)($_POST['terminal_command_text'] ?? ''));
|
||||
$terminalError = null;
|
||||
$terminalUrl = null;
|
||||
$terminalToken = null;
|
||||
$terminalHostLabel = null;
|
||||
|
||||
if ($hostId <= 0) {
|
||||
$terminalError = 'Bitte einen Host wählen.';
|
||||
} elseif ($ttydUrl === '') {
|
||||
$terminalError = 'ttyd URL fehlt. Bitte im Setup setzen.';
|
||||
} else {
|
||||
$presetCommand = '';
|
||||
if ($presetId > 0) {
|
||||
foreach ($commands as $c) {
|
||||
if ((int)$c['id'] === $presetId) {
|
||||
if (!auth_is_admin() && !empty($c['admin_only'])) {
|
||||
$terminalError = 'Diese Vorlage ist nur für Admins.';
|
||||
} else {
|
||||
$presetCommand = (string)$c['command'];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$terminalError) {
|
||||
$driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$expiresSql = $driver === 'pgsql'
|
||||
? "NOW() + INTERVAL '{$tokenTtl} minutes'"
|
||||
: "DATETIME('now', '+{$tokenTtl} minutes')";
|
||||
|
||||
$token = bin2hex(random_bytes(24));
|
||||
$terminalToken = $token;
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO ' . $table('sessions') . ' (token, host_id, provider, command_text, created_by, expires_at)
|
||||
VALUES (:token, :host_id, :provider, :command_text, :created_by, ' . $expiresSql . ')'
|
||||
);
|
||||
$commandToRun = $presetCommand !== '' ? $presetCommand : $rawCommand;
|
||||
$stmt->execute([
|
||||
'token' => $token,
|
||||
'host_id' => $hostId,
|
||||
'provider' => 'ttyd',
|
||||
'command_text' => $commandToRun !== '' ? $commandToRun : null,
|
||||
'created_by' => auth_display_name() ?: null,
|
||||
]);
|
||||
|
||||
$sep = str_contains($ttydUrl, '?') ? '&' : '?';
|
||||
$terminalUrl = rtrim($ttydUrl, '/') . '/' . $sep . 'arg=' . rawurlencode($token);
|
||||
foreach ($hosts as $h) {
|
||||
if ((int)$h['id'] === $hostId) {
|
||||
$terminalHostLabel = (string)($h['name'] ?? $h['host']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'ok' => $terminalError === null,
|
||||
'error' => $terminalError,
|
||||
'url' => $terminalUrl,
|
||||
'token' => $terminalToken,
|
||||
'host' => $terminalHostLabel,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_GET['run_command_json'])) {
|
||||
$hostId = (int)($_POST['terminal_host_id'] ?? 0);
|
||||
$commandId = (int)($_POST['terminal_preset_id'] ?? 0);
|
||||
$rawCommand = trim((string)($_POST['terminal_command_text'] ?? ''));
|
||||
$timeoutSec = $defaultTimeout;
|
||||
$error = null;
|
||||
$notice = null;
|
||||
|
||||
if ($hostId <= 0) {
|
||||
$error = 'Bitte einen Host wählen.';
|
||||
} elseif ($commandId <= 0 && $rawCommand === '') {
|
||||
$error = 'Bitte einen Befehl wählen oder einen Befehl eingeben.';
|
||||
} else {
|
||||
$selectedCommand = '';
|
||||
if ($commandId > 0) {
|
||||
foreach ($commands as $c) {
|
||||
if ((int)$c['id'] === $commandId) {
|
||||
if (!auth_is_admin() && !empty($c['admin_only'])) {
|
||||
$error = 'Dieser Befehl ist nur für Admins.';
|
||||
} else {
|
||||
$selectedCommand = (string)$c['command'];
|
||||
if (!empty($c['timeout_sec'])) {
|
||||
$timeoutSec = (int)$c['timeout_sec'];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$error) {
|
||||
$commandText = $selectedCommand !== '' ? $selectedCommand : $rawCommand;
|
||||
$driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
if ($driver === 'pgsql') {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO ' . $table('runs') . ' (host_id, command_id, command_text, status, timeout_sec, created_by)
|
||||
VALUES (:host_id, :command_id, :command_text, :status, :timeout_sec, :created_by)
|
||||
RETURNING id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'host_id' => $hostId,
|
||||
'command_id' => $commandId > 0 ? $commandId : null,
|
||||
'command_text' => $commandText,
|
||||
'status' => 'queued',
|
||||
'timeout_sec' => $timeoutSec,
|
||||
'created_by' => auth_display_name() ?: null,
|
||||
]);
|
||||
$runId = (int)$stmt->fetchColumn();
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO ' . $table('runs') . ' (host_id, command_id, command_text, status, timeout_sec, created_by)
|
||||
VALUES (:host_id, :command_id, :command_text, :status, :timeout_sec, :created_by)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'host_id' => $hostId,
|
||||
'command_id' => $commandId > 0 ? $commandId : null,
|
||||
'command_text' => $commandText,
|
||||
'status' => 'queued',
|
||||
'timeout_sec' => $timeoutSec,
|
||||
'created_by' => auth_display_name() ?: null,
|
||||
]);
|
||||
$runId = (int)$pdo->lastInsertId();
|
||||
}
|
||||
try {
|
||||
$redis = module_fn('pi_control', 'redis');
|
||||
$payload = json_encode(['run_id' => $runId], JSON_THROW_ON_ERROR);
|
||||
$redis->command(['RPUSH', $queueName, $payload]);
|
||||
$notice = 'Befehl wurde in die Queue gestellt.';
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->exec('UPDATE ' . $table('runs') . ' SET status = \'queue_error\' WHERE id = ' . (int)$runId);
|
||||
$notice = 'Befehl gespeichert, aber Queue nicht erreichbar.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'ok' => $error === null,
|
||||
'error' => $error,
|
||||
'notice' => $notice,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_GET['send_active_json'])) {
|
||||
$hostId = (int)($_POST['terminal_host_id'] ?? 0);
|
||||
$commandId = (int)($_POST['terminal_preset_id'] ?? 0);
|
||||
$rawCommand = trim((string)($_POST['terminal_command_text'] ?? ''));
|
||||
$error = null;
|
||||
$notice = null;
|
||||
|
||||
if ($hostId <= 0) {
|
||||
$error = 'Bitte einen Host wählen.';
|
||||
} elseif ($commandId <= 0 && $rawCommand === '') {
|
||||
$error = 'Bitte einen Befehl wählen oder einen Befehl eingeben.';
|
||||
} else {
|
||||
$selectedCommand = '';
|
||||
if ($commandId > 0) {
|
||||
foreach ($commands as $c) {
|
||||
if ((int)$c['id'] === $commandId) {
|
||||
if (!auth_is_admin() && !empty($c['admin_only'])) {
|
||||
$error = 'Dieser Befehl ist nur für Admins.';
|
||||
} else {
|
||||
$selectedCommand = (string)$c['command'];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$error) {
|
||||
$commandText = $selectedCommand !== '' ? $selectedCommand : $rawCommand;
|
||||
$stmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $hostId]);
|
||||
$host = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$host) {
|
||||
$error = 'Host nicht gefunden.';
|
||||
} else {
|
||||
$settings = modules()->settings('pi_control');
|
||||
$strictHostKey = !empty($settings['terminal_strict_hostkey']) || getenv('PI_CONTROL_STRICT_HOSTKEY') === '1';
|
||||
[$ok, $sendError] = sendToActiveConsole($host, $commandText, $strictHostKey);
|
||||
if ($ok) {
|
||||
$notice = 'Befehl wurde in der bestehenden Konsole ausgeführt.';
|
||||
} else {
|
||||
$error = $sendError ?: 'Bestehende Konsole nicht verfügbar.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'ok' => $error === null,
|
||||
'error' => $error,
|
||||
'notice' => $notice,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Form submits are handled via AJAX to avoid reloads.
|
||||
|
||||
$runs = $pdo->query(
|
||||
'SELECT r.*, h.name AS host_name, h.host AS host_addr
|
||||
FROM ' . $table('runs') . ' r
|
||||
LEFT JOIN ' . $table('hosts') . ' h ON h.id = r.host_id
|
||||
ORDER BY r.id DESC LIMIT 20'
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
function sendToActiveConsole(array $host, string $command, bool $strictHostKey): array
|
||||
{
|
||||
$hostAddr = (string)($host['host'] ?? '');
|
||||
$user = (string)($host['username'] ?? '');
|
||||
$port = (int)($host['port'] ?? 22);
|
||||
$authType = (string)($host['auth_type'] ?? 'key');
|
||||
$keyPath = (string)($host['key_path'] ?? '');
|
||||
$password = (string)($host['password'] ?? '');
|
||||
|
||||
if ($hostAddr === '' || $user === '') {
|
||||
return [false, 'Hostdaten unvollständig.'];
|
||||
}
|
||||
|
||||
$opts = $strictHostKey
|
||||
? '-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts'
|
||||
: '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
|
||||
|
||||
$target = escapeshellarg($user . '@' . $hostAddr);
|
||||
$cmdB64 = base64_encode($command);
|
||||
$remote = 'CMD_B64="$0"; CMD="$(printf "%s" "$CMD_B64" | base64 -d)"; ' .
|
||||
'command -v tmux >/dev/null 2>&1 || exit 2; ' .
|
||||
'tmux has-session -t nexus 2>/dev/null || exit 3; ' .
|
||||
'tmux send-keys -t nexus "$CMD" C-m';
|
||||
|
||||
$cmd = 'ssh ' . $opts . ' -p ' . (int)$port . ' ';
|
||||
if ($authType === 'key' && $keyPath !== '') {
|
||||
$cmd .= '-i ' . escapeshellarg($keyPath) . ' ';
|
||||
}
|
||||
$cmd .= $target . ' -- /bin/bash -lc ' . escapeshellarg($remote) . ' ' . escapeshellarg($cmdB64);
|
||||
if ($authType === 'pass' && $password !== '') {
|
||||
$cmd = 'sshpass -p ' . escapeshellarg($password) . ' ' . $cmd;
|
||||
}
|
||||
|
||||
$descriptors = [
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
$process = proc_open($cmd, $descriptors, $pipes);
|
||||
if (!is_resource($process)) {
|
||||
return [false, 'proc_open failed'];
|
||||
}
|
||||
$out = stream_get_contents($pipes[1]);
|
||||
$err = stream_get_contents($pipes[2]);
|
||||
$status = proc_get_status($process);
|
||||
$exitCode = (int)($status['exitcode'] ?? 1);
|
||||
proc_close($process);
|
||||
|
||||
if ($exitCode === 0) {
|
||||
return [true, null];
|
||||
}
|
||||
if ($exitCode === 2) {
|
||||
return [false, 'tmux ist auf dem Host nicht installiert.'];
|
||||
}
|
||||
if ($exitCode === 3) {
|
||||
return [false, 'Keine aktive Konsole gefunden.'];
|
||||
}
|
||||
$msg = trim($err !== '' ? $err : $out);
|
||||
return [false, $msg !== '' ? $msg : 'Befehl konnte nicht gesendet werden.'];
|
||||
}
|
||||
?>
|
||||
<?= module_shell_header('pi_control', [
|
||||
'title' => 'Konsole',
|
||||
]) ?>
|
||||
<div class="module-flow">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">Konsole</h2>
|
||||
<p>Wähle einen Host und führe einen Befehl aus.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="setup-db-message setup-db-message--error" style="margin-top:16px;">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="setup-db-message setup-db-message--success" style="margin-top:16px;">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="module-box" style="margin-top:16px;">
|
||||
<strong>Live-Konsole</strong>
|
||||
<div class="setup-db-message setup-db-message--error" style="margin-top:1rem; display:none;" data-console-error></div>
|
||||
<div class="setup-db-message setup-db-message--success" style="margin-top:1rem; display:none;" data-console-notice></div>
|
||||
<form method="post" class="form-grid" style="margin-top:.75rem;" data-console-form>
|
||||
<label class="form-field">
|
||||
<span class="muted">Host</span>
|
||||
<select name="terminal_host_id" required title="Pi auswählen, zu dem die Konsole verbindet.">
|
||||
<option value="">Host wählen</option>
|
||||
<?php foreach ($hosts as $h): ?>
|
||||
<option value="<?= e((string)$h['id']) ?>"><?= e($h['name'] ?? $h['host']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="muted">Vorlage</span>
|
||||
<select name="terminal_preset_id" title="Optional: Vorlage direkt in der Konsole ausführen.">
|
||||
<option value="">Vorlage auswählen (optional)</option>
|
||||
<?php foreach ($commands as $c): ?>
|
||||
<?php if (!auth_is_admin() && !empty($c['admin_only'])) { continue; } ?>
|
||||
<option value="<?= e((string)$c['id']) ?>" data-command="<?= e((string)($c['command'] ?? '')) ?>">
|
||||
<?= e($c['label'] ?? '') ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="muted">Direkter Befehl</span>
|
||||
<textarea name="terminal_command_text" rows="2" placeholder="Optional: Befehl direkt ausführen"></textarea>
|
||||
</label>
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||
<button class="cta-button" type="button" data-open-console>Neue Konsole öffnen</button>
|
||||
<button class="nav-link" type="button" data-run-command>Im Hintergrund ausführen</button>
|
||||
<button class="icon-button queue-button" type="button" data-queue-button>
|
||||
Queue
|
||||
<span class="queue-badge" data-queue-count>0</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="muted" style="margin-top:.5rem;">
|
||||
Token: <code data-console-token></code>
|
||||
</div>
|
||||
|
||||
<?php if ($terminalUrl): ?>
|
||||
<div class="console-launch"
|
||||
data-url="<?= e($terminalUrl) ?>"
|
||||
data-host="<?= e($terminalHostLabel ?: 'Konsole') ?>"
|
||||
data-token="<?= e($terminalToken ?: '') ?>"></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="modal" data-queue-modal aria-hidden="true">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<strong>Queue</strong>
|
||||
<div class="modal-actions">
|
||||
<span class="muted">Update in <span data-queue-countdown>10</span>s</span>
|
||||
<button class="icon-button" type="button" data-queue-refresh title="Jetzt aktualisieren">↻</button>
|
||||
<button class="icon-button" type="button" data-queue-close title="Schließen">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:.75rem; overflow:auto;">
|
||||
<table class="table" style="min-width:720px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Status</th>
|
||||
<th>Host</th>
|
||||
<th>Command</th>
|
||||
<th>Von</th>
|
||||
<th>Output</th>
|
||||
<th>Timeout</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-queue-body>
|
||||
<?= $renderRuns($runs) ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,479 @@
|
||||
<?php
|
||||
$pdo = module_fn('pi_control', 'pdo');
|
||||
module_fn('pi_control', 'ensure_schema');
|
||||
$table = fn(string $name) => module_fn('pi_control', 'table', $name);
|
||||
$assets = app()->assets();
|
||||
if ($assets) {
|
||||
$assets->addStyle('/module/pi_control/asset?file=pi_control.css');
|
||||
$assets->addScript('/module/pi_control/asset?file=hosts.js', 'footer', true);
|
||||
}
|
||||
|
||||
$notice = null;
|
||||
$error = null;
|
||||
|
||||
if (isset($_GET['status_json'])) {
|
||||
require_admin();
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$stmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$host = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$host) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => 'not_found']);
|
||||
exit;
|
||||
}
|
||||
$settings = modules()->settings('pi_control');
|
||||
$strictHostKey = !empty($settings['terminal_strict_hostkey']);
|
||||
$reachable = hostReachable((string)($host['host'] ?? ''), (int)($host['port'] ?? 22));
|
||||
$authOk = $reachable ? hostAuthOk($host, $strictHostKey) : false;
|
||||
$status = !$reachable ? 'down' : ($authOk ? 'ok' : 'auth');
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => true, 'status' => $status]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_GET['update_json'])) {
|
||||
require_admin();
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$stmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$host = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$host) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => 'not_found']);
|
||||
exit;
|
||||
}
|
||||
$settings = modules()->settings('pi_control');
|
||||
$strictHostKey = !empty($settings['terminal_strict_hostkey']);
|
||||
|
||||
$updateCmd = <<<'SH'
|
||||
if ! command -v apt-get >/dev/null 2>&1; then echo "__ERR__NO_APT"; exit 2; fi;
|
||||
if sudo -n apt update -qq >/dev/null 2>&1; then echo "__APT_UPDATE__=1"; else echo "__APT_UPDATE__=0"; fi;
|
||||
count=$(apt-get -s dist-upgrade 2>/dev/null | grep -c "^Inst ");
|
||||
echo "__COUNT__=$count"
|
||||
SH;
|
||||
$upgradeCmd = <<<'SH'
|
||||
id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")";
|
||||
current="$(. /etc/os-release 2>/dev/null && echo "${VERSION_CODENAME:-}")";
|
||||
if [ "$id" != "debian" ] || [ -z "$current" ]; then echo "__UPGRADE__=0"; exit 0; fi;
|
||||
latest="$( (command -v curl >/dev/null 2>&1 && curl -fsSL https://deb.debian.org/debian/dists/stable/Release) || (command -v wget >/dev/null 2>&1 && wget -qO- https://deb.debian.org/debian/dists/stable/Release) )";
|
||||
latest="$(printf "%s" "$latest" | awk -F": " "/^Codename:/{print $2}")";
|
||||
if [ -z "$latest" ]; then echo "__UPGRADE__=0"; echo "__RAW__=NO_FETCH"; exit 0; fi;
|
||||
if [ "$current" != "$latest" ]; then echo "__UPGRADE__=1"; else echo "__UPGRADE__=0"; fi
|
||||
SH;
|
||||
|
||||
[$updExit, $updOut, $updErr] = runSshCommandCapture($host, $updateCmd, $strictHostKey, 20);
|
||||
$updOutStr = (string)$updOut;
|
||||
$updErrStr = (string)$updErr;
|
||||
$updateCount = null;
|
||||
$updatePreview = '';
|
||||
$updateErr = str_contains($updOutStr, '__ERR__NO_APT');
|
||||
if (preg_match('/^__COUNT__=(\d+)$/m', $updOutStr, $m)) {
|
||||
$updateCount = (int)$m[1];
|
||||
}
|
||||
$updatePreview = trim($updOutStr);
|
||||
if (strlen($updatePreview) > 1200) {
|
||||
$updatePreview = substr($updatePreview, 0, 1200);
|
||||
}
|
||||
|
||||
[$upgExit, $upgOut, $upgErr] = runSshCommandCapture($host, $upgradeCmd, $strictHostKey, 25);
|
||||
$upgOutStr = (string)$upgOut;
|
||||
$upgErrStr = (string)$upgErr;
|
||||
$upgradeAvailable = null;
|
||||
$upgradeErr = str_contains($upgOutStr, '__ERR__');
|
||||
if ($upgExit === 0 && !$upgradeErr) {
|
||||
if (preg_match('/^__UPGRADE__=(0|1)$/m', $upgOutStr, $m)) {
|
||||
$upgradeAvailable = $m[1] === '1';
|
||||
}
|
||||
}
|
||||
|
||||
$driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$nowExpr = $driver === 'pgsql' ? 'NOW()' : "DATETIME('now')";
|
||||
$updCountVal = $updateCount;
|
||||
$updErrVal = (!$updateErr && $updateCount !== null) ? null : trim($updErrStr ?: $updOutStr);
|
||||
$upgAvailVal = $upgradeAvailable;
|
||||
$upgErrVal = $upgExit === 0 && !$upgradeErr ? null : trim($upgErrStr ?: $upgOutStr);
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE ' . $table('hosts') . ' SET update_checked_at = ' . $nowExpr . ',
|
||||
update_count = :update_count,
|
||||
update_preview = :update_preview,
|
||||
update_error = :update_error,
|
||||
upgrade_available = :upgrade_available,
|
||||
upgrade_raw = :upgrade_raw,
|
||||
upgrade_error = :upgrade_error
|
||||
WHERE id = :id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'update_count' => $updCountVal,
|
||||
'update_preview' => $updatePreview !== '' ? $updatePreview : null,
|
||||
'update_error' => $updErrVal,
|
||||
'upgrade_available' => $upgAvailVal === null ? null : ($upgAvailVal ? 1 : 0),
|
||||
'upgrade_raw' => $upgExit === 0 ? trim($upgOutStr) : null,
|
||||
'upgrade_error' => $upgErrVal,
|
||||
'id' => $id,
|
||||
]);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'updates' => [
|
||||
'count' => $updateCount,
|
||||
'preview' => $updatePreview,
|
||||
'raw' => $updatePreview,
|
||||
'error' => (!$updateErr && $updateCount !== null) ? '' : trim($updErrStr ?: $updOutStr),
|
||||
],
|
||||
'os' => [
|
||||
'available' => $upgradeAvailable,
|
||||
'raw' => $upgExit === 0 ? trim($upgOutStr) : '',
|
||||
'error' => $upgExit === 0 && !$upgradeErr ? '' : trim($upgErrStr ?: $upgOutStr),
|
||||
],
|
||||
'checked_at' => date('c'),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
require_admin();
|
||||
$deleteId = (int)($_POST['delete_id'] ?? 0);
|
||||
$editId = (int)($_POST['id'] ?? 0);
|
||||
$name = trim((string)($_POST['name'] ?? ''));
|
||||
$host = trim((string)($_POST['host'] ?? ''));
|
||||
$port = (int)($_POST['port'] ?? 22);
|
||||
$username = trim((string)($_POST['username'] ?? ''));
|
||||
$authType = trim((string)($_POST['auth_type'] ?? 'key'));
|
||||
$keyPath = trim((string)($_POST['key_path'] ?? ''));
|
||||
$password = trim((string)($_POST['password'] ?? ''));
|
||||
$imageUrl = trim((string)($_POST['image_url'] ?? ''));
|
||||
|
||||
if ($deleteId > 0) {
|
||||
$stmt = $pdo->prepare('DELETE FROM ' . $table('hosts') . ' WHERE id = :id');
|
||||
$stmt->execute(['id' => $deleteId]);
|
||||
$notice = 'Host gelöscht.';
|
||||
} else {
|
||||
if ($name === '' || $host === '' || $username === '') {
|
||||
$error = 'Bitte Name, Host und Benutzer angeben.';
|
||||
} else {
|
||||
if ($editId > 0) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $editId]);
|
||||
$existing = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
$passwordToStore = $password !== '' ? $password : ($existing['password'] ?? null);
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE ' . $table('hosts') . ' SET name = :name, host = :host, port = :port, username = :username, auth_type = :auth_type, key_path = :key_path, password = :password, image_url = :image_url WHERE id = :id'
|
||||
);
|
||||
$stmt->execute([
|
||||
'id' => $editId,
|
||||
'name' => $name,
|
||||
'host' => $host,
|
||||
'port' => $port > 0 ? $port : 22,
|
||||
'username' => $username,
|
||||
'auth_type' => $authType !== '' ? $authType : 'key',
|
||||
'key_path' => $keyPath !== '' ? $keyPath : null,
|
||||
'password' => $passwordToStore,
|
||||
'image_url' => $imageUrl !== '' ? $imageUrl : null,
|
||||
]);
|
||||
$notice = 'Host aktualisiert.';
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO ' . $table('hosts') . ' (name, host, port, username, auth_type, key_path, password, image_url) VALUES (:name, :host, :port, :username, :auth_type, :key_path, :password, :image_url)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'name' => $name,
|
||||
'host' => $host,
|
||||
'port' => $port > 0 ? $port : 22,
|
||||
'username' => $username,
|
||||
'auth_type' => $authType !== '' ? $authType : 'key',
|
||||
'key_path' => $keyPath !== '' ? $keyPath : null,
|
||||
'password' => $password !== '' ? $password : null,
|
||||
'image_url' => $imageUrl !== '' ? $imageUrl : null,
|
||||
]);
|
||||
$notice = 'Host gespeichert.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$hosts = $pdo->query('SELECT * FROM ' . $table('hosts') . ' ORDER BY id DESC')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$settings = modules()->settings('pi_control');
|
||||
$strictHostKey = !empty($settings['terminal_strict_hostkey']);
|
||||
|
||||
function hostReachable(string $host, int $port): bool
|
||||
{
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
$fp = @fsockopen($host, $port, $errno, $errstr, 1.2);
|
||||
if ($fp) {
|
||||
fclose($fp);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function runSshCommand(string $cmd, int $timeoutSec): int
|
||||
{
|
||||
$descriptors = [
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
$process = proc_open($cmd, $descriptors, $pipes);
|
||||
if (!is_resource($process)) {
|
||||
return 255;
|
||||
}
|
||||
stream_set_blocking($pipes[1], false);
|
||||
stream_set_blocking($pipes[2], false);
|
||||
$start = time();
|
||||
while (true) {
|
||||
$status = proc_get_status($process);
|
||||
if (!$status['running']) {
|
||||
$code = (int)$status['exitcode'];
|
||||
proc_close($process);
|
||||
return $code;
|
||||
}
|
||||
if (time() - $start > $timeoutSec) {
|
||||
proc_terminate($process, 9);
|
||||
proc_close($process);
|
||||
return 124;
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
}
|
||||
|
||||
function runSshCommandCapture(array $host, string $command, bool $strictHostKey, int $timeoutSec): array
|
||||
{
|
||||
$hostAddr = (string)($host['host'] ?? '');
|
||||
$user = (string)($host['username'] ?? '');
|
||||
$port = (int)($host['port'] ?? 22);
|
||||
$authType = (string)($host['auth_type'] ?? 'key');
|
||||
$keyPath = (string)($host['key_path'] ?? '');
|
||||
$password = (string)($host['password'] ?? '');
|
||||
|
||||
$opts = $strictHostKey
|
||||
? '-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts'
|
||||
: '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
|
||||
$opts .= ' -o ConnectTimeout=6 -o NumberOfPasswordPrompts=1 -o LogLevel=ERROR -o RequestTTY=no';
|
||||
|
||||
$target = escapeshellarg($user . '@' . $hostAddr);
|
||||
$remote = '/bin/sh -c ' . escapeshellarg($command);
|
||||
$cmd = 'ssh -T ' . $opts . ' -p ' . (int)$port . ' ';
|
||||
if ($authType === 'key' && $keyPath !== '') {
|
||||
$cmd .= '-i ' . escapeshellarg($keyPath) . ' -o BatchMode=yes ';
|
||||
} elseif ($authType === 'key') {
|
||||
$cmd .= '-o BatchMode=yes ';
|
||||
}
|
||||
$cmd .= $target . ' -- ' . $remote;
|
||||
if ($authType === 'pass' && $password !== '') {
|
||||
$cmd = 'sshpass -p ' . escapeshellarg($password) . ' ' . $cmd;
|
||||
}
|
||||
|
||||
$descriptors = [
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
$process = proc_open($cmd, $descriptors, $pipes);
|
||||
if (!is_resource($process)) {
|
||||
return [255, '', 'proc_open failed'];
|
||||
}
|
||||
stream_set_blocking($pipes[1], false);
|
||||
stream_set_blocking($pipes[2], false);
|
||||
$out = '';
|
||||
$err = '';
|
||||
$start = time();
|
||||
while (true) {
|
||||
$status = proc_get_status($process);
|
||||
$out .= stream_get_contents($pipes[1]);
|
||||
$err .= stream_get_contents($pipes[2]);
|
||||
if (!$status['running']) {
|
||||
$exit = (int)$status['exitcode'];
|
||||
proc_close($process);
|
||||
return [$exit, $out, $err];
|
||||
}
|
||||
if (time() - $start > $timeoutSec) {
|
||||
proc_terminate($process, 9);
|
||||
proc_close($process);
|
||||
return [124, $out, $err];
|
||||
}
|
||||
usleep(100000);
|
||||
}
|
||||
}
|
||||
|
||||
function hostAuthOk(array $host, bool $strictHostKey): bool
|
||||
{
|
||||
$hostAddr = (string)($host['host'] ?? '');
|
||||
$user = (string)($host['username'] ?? '');
|
||||
$port = (int)($host['port'] ?? 22);
|
||||
$authType = (string)($host['auth_type'] ?? 'key');
|
||||
$keyPath = (string)($host['key_path'] ?? '');
|
||||
$password = (string)($host['password'] ?? '');
|
||||
if ($hostAddr === '' || $user === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$opts = $strictHostKey
|
||||
? '-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts'
|
||||
: '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
|
||||
$opts .= ' -o ConnectTimeout=2 -o NumberOfPasswordPrompts=1';
|
||||
|
||||
$target = escapeshellarg($user . '@' . $hostAddr);
|
||||
$cmd = 'ssh ' . $opts . ' -p ' . (int)$port . ' ';
|
||||
if ($authType === 'key' && $keyPath !== '') {
|
||||
$cmd .= '-i ' . escapeshellarg($keyPath) . ' -o BatchMode=yes ';
|
||||
} elseif ($authType === 'key') {
|
||||
$cmd .= '-o BatchMode=yes ';
|
||||
}
|
||||
$cmd .= $target . ' -- true';
|
||||
|
||||
if ($authType === 'pass') {
|
||||
if ($password === '') {
|
||||
return false;
|
||||
}
|
||||
$cmd = 'sshpass -p ' . escapeshellarg($password) . ' ' . $cmd;
|
||||
}
|
||||
|
||||
$exitCode = runSshCommand($cmd, 3);
|
||||
return $exitCode === 0;
|
||||
}
|
||||
?>
|
||||
<?= module_shell_header('pi_control', [
|
||||
'title' => 'Hosts',
|
||||
]) ?>
|
||||
<div class="module-flow">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">Hosts</h2>
|
||||
<p>Verwalte die Raspberry Pis, die du steuern möchtest.</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||
<button class="module-button module-button--secondary module-button--small" type="button" data-host-check-all>Alle Hosts prüfen</button>
|
||||
<button class="module-button module-button--primary" type="button" data-host-new>+ Neuer Host</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="setup-db-message setup-db-message--error" style="margin-top:16px;">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="setup-db-message setup-db-message--success" style="margin-top:16px;">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="module-box-grid module-box-grid--panels" style="margin-top:16px;">
|
||||
<div class="module-box form-card">
|
||||
<strong>Registrierte Hosts</strong>
|
||||
<?php if (!$hosts): ?>
|
||||
<div class="muted" style="margin-top:.75rem;">Keine Hosts vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<div class="host-grid" style="margin-top:.75rem;">
|
||||
<?php foreach ($hosts as $h): ?>
|
||||
<div class="host-card"
|
||||
data-host-id="<?= e((string)$h['id']) ?>"
|
||||
data-name="<?= e((string)($h['name'] ?? '')) ?>"
|
||||
data-host="<?= e((string)($h['host'] ?? '')) ?>"
|
||||
data-port="<?= e((string)($h['port'] ?? 22)) ?>"
|
||||
data-username="<?= e((string)($h['username'] ?? '')) ?>"
|
||||
data-auth="<?= e((string)($h['auth_type'] ?? 'key')) ?>"
|
||||
data-key-path="<?= e((string)($h['key_path'] ?? '')) ?>"
|
||||
data-image-url="<?= e((string)($h['image_url'] ?? '')) ?>"
|
||||
data-update-checked="<?= e((string)($h['update_checked_at'] ?? '')) ?>"
|
||||
data-update-count="<?= e((string)($h['update_count'] ?? '')) ?>"
|
||||
data-update-error="<?= e((string)($h['update_error'] ?? '')) ?>"
|
||||
data-update-preview="<?= e((string)($h['update_preview'] ?? '')) ?>"
|
||||
data-upgrade-available="<?= e((string)($h['upgrade_available'] ?? '')) ?>"
|
||||
data-upgrade-raw="<?= e((string)($h['upgrade_raw'] ?? '')) ?>"
|
||||
data-upgrade-error="<?= e((string)($h['upgrade_error'] ?? '')) ?>">
|
||||
<div class="host-card-image"<?= !empty($h['image_url']) ? ' style="background-image:url(' . e((string)$h['image_url']) . ')"' : '' ?>>
|
||||
<div class="host-card-overlay"></div>
|
||||
</div>
|
||||
<div class="host-card-body">
|
||||
<div class="host-card-header">
|
||||
<div class="host-card-title">
|
||||
<span class="status-dot status-auth" data-host-status></span>
|
||||
<strong><?= e((string)($h['name'] ?? '')) ?></strong>
|
||||
</div>
|
||||
<details class="action-menu">
|
||||
<summary>☰</summary>
|
||||
<div class="action-menu-panel">
|
||||
<button class="nav-link" type="button" data-host-edit>Bearbeiten</button>
|
||||
<form method="post" onsubmit="return confirm('Host wirklich löschen?')">
|
||||
<input type="hidden" name="delete_id" value="<?= e((string)$h['id']) ?>">
|
||||
<button class="nav-link" type="submit">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="muted"><?= e((string)($h['host'] ?? '')) ?>:<?= e((string)($h['port'] ?? 22)) ?></div>
|
||||
<div class="muted"><?= e((string)($h['username'] ?? '')) ?> · <?= e((string)($h['auth_type'] ?? '')) ?></div>
|
||||
<div class="host-update-row">
|
||||
<span class="update-badge" data-update-badge>Updates: –</span>
|
||||
<span class="update-badge" data-upgrade-badge>OS: –</span>
|
||||
<span class="muted" data-update-time>Nie geprüft</span>
|
||||
</div>
|
||||
<pre class="host-debug" data-update-debug>Update Debug: –</pre>
|
||||
<pre class="host-debug" data-upgrade-debug>Upgrade Debug: –</pre>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<button class="nav-link" type="button" data-host-check>Updates prüfen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal" data-host-modal aria-hidden="true">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<strong data-host-modal-title>Neuer Host</strong>
|
||||
<button class="icon-button" type="button" data-host-close>×</button>
|
||||
</div>
|
||||
<form method="post" class="form-grid" style="margin-top:.75rem;" data-host-form>
|
||||
<input type="hidden" name="id" value="">
|
||||
<label class="form-field">
|
||||
<span class="muted">Name</span>
|
||||
<input type="text" name="name" placeholder="z.B. Wohnzimmer-Pi" required title="Eindeutiger Anzeigename für diesen Pi.">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="muted">Host / IP</span>
|
||||
<input type="text" name="host" placeholder="z.B. 192.168.178.14 oder pi.local" required title="Hostname oder IP im Heimnetzwerk.">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="muted">SSH Port</span>
|
||||
<input type="number" name="port" placeholder="22" value="22" title="Standard ist 22.">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="muted">SSH Benutzer</span>
|
||||
<input type="text" name="username" placeholder="z.B. pi" required title="Benutzername auf dem Pi.">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="muted">Auth-Typ</span>
|
||||
<select name="auth_type" title="key = SSH-Key, password = Passwort">
|
||||
<option value="key">key (SSH-Key)</option>
|
||||
<option value="pass">pass (Passwort)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="muted">Key-Pfad (optional)</span>
|
||||
<input type="text" name="key_path" placeholder="/home/app/.ssh/id_rsa" title="Pfad zum Private-Key im Webserver-Container.">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="muted">Passwort (optional)</span>
|
||||
<input type="password" name="password" placeholder="SSH-Passwort" title="Nur nutzen, wenn Auth-Typ = pass.">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="muted">Bild-URL (optional)</span>
|
||||
<input type="text" name="image_url" placeholder="https://..." title="Optionales Bild für die Host-Karte.">
|
||||
</label>
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||
<button class="cta-button" type="submit" data-host-submit>Speichern</button>
|
||||
<button class="nav-link" type="button" data-host-cancel>Zurücksetzen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
$pdo = module_fn('pi_control', 'pdo');
|
||||
module_fn('pi_control', 'ensure_schema');
|
||||
$table = fn(string $name) => module_fn('pi_control', 'table', $name);
|
||||
|
||||
$hostCount = (int)$pdo->query('SELECT COUNT(*) FROM ' . $table('hosts'))->fetchColumn();
|
||||
$cmdCount = (int)$pdo->query('SELECT COUNT(*) FROM ' . $table('commands'))->fetchColumn();
|
||||
$runCount = (int)$pdo->query('SELECT COUNT(*) FROM ' . $table('runs'))->fetchColumn();
|
||||
?>
|
||||
<?= module_shell_header('pi_control', [
|
||||
'title' => 'Raspberry Pi Steuerung',
|
||||
]) ?>
|
||||
<div class="module-flow">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">Raspberry Pi Steuerung</h2>
|
||||
<p>SSH Hosts verwalten, Befehle definieren und Aktionen ausführen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="module-box-grid module-box-grid--stats">
|
||||
<div class="module-box-soft">
|
||||
<strong>Hosts</strong>
|
||||
<div class="muted" style="margin-top:.35rem;">Registriert: <?= e((string)$hostCount) ?></div>
|
||||
<div style="margin-top:.75rem;"><a class="module-button module-button--secondary module-button--small" href="/module/pi_control/hosts">Hosts verwalten</a></div>
|
||||
</div>
|
||||
<div class="module-box-soft">
|
||||
<strong>Befehle</strong>
|
||||
<div class="muted" style="margin-top:.35rem;">Presets: <?= e((string)$cmdCount) ?></div>
|
||||
<div style="margin-top:.75rem;"><a class="module-button module-button--secondary module-button--small" href="/module/pi_control/commands">Befehle verwalten</a></div>
|
||||
</div>
|
||||
<div class="module-box-soft">
|
||||
<strong>Konsole</strong>
|
||||
<div class="muted" style="margin-top:.35rem;">Runs: <?= e((string)$runCount) ?></div>
|
||||
<div style="margin-top:.75rem;"><a class="module-button module-button--secondary module-button--small" href="/module/pi_control/console">Konsole öffnen</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$pdo = module_fn('pi_control', 'pdo');
|
||||
module_fn('pi_control', 'ensure_schema');
|
||||
$table = fn(string $name) => module_fn('pi_control', 'table', $name);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$settings = modules()->settings('pi_control');
|
||||
$sharedSecret = trim((string)($settings['terminal_shared_secret'] ?? ''));
|
||||
if ($sharedSecret !== '') {
|
||||
$provided = trim((string)($_SERVER['HTTP_X_TERMINAL_SECRET'] ?? ''));
|
||||
if (!hash_equals($sharedSecret, $provided)) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['ok' => false, 'error' => 'unauthorized']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$token = '';
|
||||
if (!empty($_GET['token'])) {
|
||||
$token = trim((string)$_GET['token']);
|
||||
} elseif (!empty($_SERVER['HTTP_X_TERMINAL_TOKEN'])) {
|
||||
$token = trim((string)$_SERVER['HTTP_X_TERMINAL_TOKEN']);
|
||||
}
|
||||
|
||||
if ($token === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'missing_token']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$nowSql = $driver === 'pgsql' ? 'NOW()' : "DATETIME('now')";
|
||||
|
||||
$sessionStmt = $pdo->prepare(
|
||||
'SELECT * FROM ' . $table('sessions') . ' WHERE token = :token AND expires_at > ' . $nowSql . ' LIMIT 1'
|
||||
);
|
||||
$sessionStmt->execute(['token' => $token]);
|
||||
$session = $sessionStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$session) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['ok' => false, 'error' => 'invalid_or_expired']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$hostStmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1');
|
||||
$hostStmt->execute(['id' => (int)$session['host_id']]);
|
||||
$host = $hostStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$host) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['ok' => false, 'error' => 'host_not_found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo->prepare('UPDATE ' . $table('sessions') . ' SET last_used_at = ' . $nowSql . ' WHERE id = :id')
|
||||
->execute(['id' => (int)$session['id']]);
|
||||
|
||||
$commandText = (string)($session['command_text'] ?? '');
|
||||
if ($commandText !== '') {
|
||||
$pdo->prepare('UPDATE ' . $table('sessions') . ' SET command_text = NULL WHERE id = :id')
|
||||
->execute(['id' => (int)$session['id']]);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'host' => [
|
||||
'name' => (string)($host['name'] ?? ''),
|
||||
'host' => (string)($host['host'] ?? ''),
|
||||
'port' => (int)($host['port'] ?? 22),
|
||||
'username' => (string)($host['username'] ?? ''),
|
||||
'auth_type' => (string)($host['auth_type'] ?? 'key'),
|
||||
'key_path' => (string)($host['key_path'] ?? ''),
|
||||
'password' => (string)($host['password'] ?? ''),
|
||||
],
|
||||
'command' => $commandText,
|
||||
'strict_hostkey' => !empty($settings['terminal_strict_hostkey']),
|
||||
'tmux_session' => (string)($settings['terminal_tmux_session'] ?? ''),
|
||||
]);
|
||||
exit;
|
||||
@@ -0,0 +1,351 @@
|
||||
.pihole-page .card { background: var(--panel); }
|
||||
|
||||
.pihole-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pihole-stat {
|
||||
padding: 16px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.pihole-stat-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.pihole-stat-sub {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pihole-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pihole-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pihole-inline {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pihole-inline input {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.pihole-instance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-instance {
|
||||
padding: 16px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.pihole-instance-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pihole-instance-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-instance-value {
|
||||
font-weight: 700;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pihole-status {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background: rgba(0, 179, 164, 0.15);
|
||||
color: #0a6b63;
|
||||
}
|
||||
|
||||
.pihole-status.is-disabled {
|
||||
background: rgba(255, 90, 61, 0.15);
|
||||
color: #a83a28;
|
||||
}
|
||||
|
||||
.pihole-status.is-partial {
|
||||
background: rgba(255, 166, 0, 0.18);
|
||||
color: #8a5a00;
|
||||
}
|
||||
|
||||
.pihole-split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pihole-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-list-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.pihole-list-row strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pihole-update {
|
||||
margin-top: 10px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pihole-error {
|
||||
margin-top: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: #a83a28;
|
||||
}
|
||||
|
||||
.pihole-blocked {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-blocked-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 90, 61, 0.08);
|
||||
}
|
||||
|
||||
.pihole-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pihole-page.is-busy {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pihole-busy-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(10, 14, 24, 0.28);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.pihole-busy-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pihole-busy-card {
|
||||
min-width: min(320px, 92vw);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.pihole-busy-card span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pihole-console-modal {
|
||||
width: min(720px, 96vw);
|
||||
}
|
||||
|
||||
.pihole-console-body {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 48vh;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.pihole-console-line {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.pihole-console-line span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pihole-console-line strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pihole-console-line.is-success {
|
||||
border-color: rgba(0, 179, 164, 0.22);
|
||||
background: rgba(0, 179, 164, 0.08);
|
||||
}
|
||||
|
||||
.pihole-console-line.is-error {
|
||||
border-color: rgba(255, 90, 61, 0.24);
|
||||
background: rgba(255, 90, 61, 0.08);
|
||||
}
|
||||
|
||||
.pihole-instance-card {
|
||||
padding: 16px;
|
||||
background: var(--panel-2);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pihole-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pihole-test-result {
|
||||
margin-top: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pihole-test-result.is-ok { color: #0a6b63; }
|
||||
.pihole-test-result.is-auth { color: #a83a28; }
|
||||
.pihole-test-result.is-unreachable { color: #a83a28; }
|
||||
.pihole-test-result.is-invalid { color: #8a5a00; }
|
||||
.pihole-test-result.is-error { color: #a83a28; }
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pihole-instance-form {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.pihole-instance-form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pihole-form-field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.pihole-checkbox-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pihole-checkbox-field input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pihole-modal-card {
|
||||
width: min(760px, 96vw);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pihole-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
border-radius: 10px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.pihole-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.pihole-inline {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pihole-inline input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pihole-instance-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
(() => {
|
||||
const page = document.querySelector('[data-pihole-page]');
|
||||
if (!page) return;
|
||||
|
||||
const pageType = page.dataset.piholePage || 'dashboard';
|
||||
const configuredRefreshSeconds = Number(page.dataset.refreshSeconds || 0);
|
||||
const defaultRefreshSeconds = pageType === 'dashboard' ? 1 : 5;
|
||||
const refreshSeconds = Number.isFinite(configuredRefreshSeconds) && configuredRefreshSeconds >= 0
|
||||
? configuredRefreshSeconds
|
||||
: defaultRefreshSeconds;
|
||||
|
||||
const fmt = new Intl.NumberFormat('de-DE');
|
||||
const fmtDate = (ts) => {
|
||||
if (!ts) return '–';
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleString('de-DE');
|
||||
};
|
||||
|
||||
let refreshTimer = null;
|
||||
let loadInFlight = false;
|
||||
let actionInFlight = false;
|
||||
let actionConsoleApi = null;
|
||||
let actionConsoleBody = null;
|
||||
let actionConsoleClose = null;
|
||||
|
||||
const apiCall = async (action, payload = {}) => {
|
||||
const res = await fetch(`/module/pihole/api?action=${encodeURIComponent(action)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
if (data && data.results && typeof data.results === 'object') {
|
||||
const failures = Object.values(data.results).filter((row) => row && row.ok === false);
|
||||
if (failures.length) {
|
||||
const first = failures[0];
|
||||
throw new Error(first.error || 'action_failed');
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const setText = (el, value) => {
|
||||
if (!el) return;
|
||||
el.textContent = value;
|
||||
};
|
||||
|
||||
const ensureActionConsole = () => {
|
||||
if (actionConsoleApi && actionConsoleBody && actionConsoleClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = 'modal';
|
||||
root.dataset.piholeConsoleModal = '1';
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
root.innerHTML = `
|
||||
<div class="modal-card pihole-console-modal" role="dialog" aria-modal="true" aria-labelledby="pihole-console-title">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<strong id="pihole-console-title">Pi-hole Aktion</strong>
|
||||
<div class="muted">Status und Rueckmeldungen zur laufenden Aktion.</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="module-button module-button--secondary module-button--small" type="button" data-pihole-console-clear>Leeren</button>
|
||||
<button class="icon-button" type="button" data-pihole-console-close aria-label="Konsole schliessen">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pihole-console-body" data-pihole-console-body></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(root);
|
||||
|
||||
actionConsoleBody = root.querySelector('[data-pihole-console-body]');
|
||||
actionConsoleClose = root.querySelector('[data-pihole-console-close]');
|
||||
const clearBtn = root.querySelector('[data-pihole-console-clear]');
|
||||
|
||||
actionConsoleApi = window.NexusModal && typeof window.NexusModal.create === 'function'
|
||||
? window.NexusModal.create(root, { initialFocus: '[data-pihole-console-close]' })
|
||||
: {
|
||||
open() {
|
||||
root.classList.add('is-open');
|
||||
root.setAttribute('aria-hidden', 'false');
|
||||
},
|
||||
close() {
|
||||
root.classList.remove('is-open');
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
},
|
||||
};
|
||||
|
||||
if (actionConsoleClose) {
|
||||
actionConsoleClose.addEventListener('click', () => {
|
||||
if (!actionInFlight) {
|
||||
actionConsoleApi.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
if (actionConsoleBody) {
|
||||
actionConsoleBody.innerHTML = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const appendActionLog = (message, tone = 'info') => {
|
||||
ensureActionConsole();
|
||||
if (!actionConsoleBody) {
|
||||
return;
|
||||
}
|
||||
const row = document.createElement('div');
|
||||
row.className = `pihole-console-line is-${tone}`;
|
||||
row.innerHTML = `<span>${new Date().toLocaleTimeString('de-DE')}</span><strong>${message}</strong>`;
|
||||
actionConsoleBody.appendChild(row);
|
||||
actionConsoleBody.scrollTop = actionConsoleBody.scrollHeight;
|
||||
};
|
||||
|
||||
const setActionLock = (locked, message = 'Bitte warten ...') => {
|
||||
actionInFlight = locked;
|
||||
page.classList.toggle('is-busy', locked);
|
||||
ensureActionConsole();
|
||||
if (actionConsoleClose) {
|
||||
actionConsoleClose.disabled = locked;
|
||||
}
|
||||
|
||||
page.querySelectorAll('button, input, select, textarea').forEach((el) => {
|
||||
const formControl = el;
|
||||
if (locked) {
|
||||
formControl.dataset.piholeWasDisabled = formControl.disabled ? 'true' : 'false';
|
||||
formControl.disabled = true;
|
||||
return;
|
||||
}
|
||||
formControl.disabled = formControl.dataset.piholeWasDisabled === 'true';
|
||||
delete formControl.dataset.piholeWasDisabled;
|
||||
});
|
||||
};
|
||||
|
||||
const statusLabel = (status) => {
|
||||
if (status === 'enabled') return 'Aktiv';
|
||||
if (status === 'disabled') return 'Deaktiviert';
|
||||
if (status === 'partial') return 'Teilweise';
|
||||
return 'Unbekannt';
|
||||
};
|
||||
|
||||
const setStatusBadge = (el, status) => {
|
||||
if (!el) return;
|
||||
el.textContent = statusLabel(status);
|
||||
el.classList.remove('is-disabled', 'is-partial');
|
||||
if (status === 'disabled') el.classList.add('is-disabled');
|
||||
if (status === 'partial') el.classList.add('is-partial');
|
||||
};
|
||||
|
||||
const mapToList = (map, limit = 10) => {
|
||||
if (!map || typeof map !== 'object') return [];
|
||||
const entries = Object.entries(map)
|
||||
.filter(([, value]) => typeof value === 'number' || typeof value === 'string')
|
||||
.map(([key, value]) => [key, Number(value)])
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
return entries.slice(0, limit);
|
||||
};
|
||||
|
||||
const renderList = (container, map, emptyText) => {
|
||||
if (!container) return;
|
||||
const rows = mapToList(map, 10);
|
||||
container.innerHTML = '';
|
||||
if (!rows.length) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'muted';
|
||||
div.textContent = emptyText || 'Keine Daten';
|
||||
container.appendChild(div);
|
||||
return;
|
||||
}
|
||||
rows.forEach(([label, value]) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'pihole-list-row';
|
||||
row.innerHTML = `<strong>${label}</strong><span>${fmt.format(value)}</span>`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const renderBlocked = (container, list) => {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
if (!Array.isArray(list) || !list.length) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'muted';
|
||||
div.textContent = 'Keine Daten';
|
||||
container.appendChild(div);
|
||||
return;
|
||||
}
|
||||
list.slice(0, 20).forEach((entry) => {
|
||||
const domain = entry.domain || entry;
|
||||
const instance = entry.instance || '';
|
||||
const row = document.createElement('div');
|
||||
row.className = 'pihole-blocked-row';
|
||||
row.innerHTML = `<span>${domain}</span><span class="muted">${instance}</span>`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const renderDashboardData = (data) => {
|
||||
const summary = data.aggregate?.summary || {};
|
||||
setText(document.querySelector('[data-summary-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
|
||||
setText(document.querySelector('[data-summary-blocked]'), fmt.format(Number(summary.ads_blocked_today || 0)));
|
||||
setText(document.querySelector('[data-summary-percent]'), `${Number(summary.ads_percentage_today || 0).toFixed(2)}%`);
|
||||
setText(document.querySelector('[data-summary-clients]'), fmt.format(Number(summary.unique_clients || 0)));
|
||||
setStatusBadge(document.querySelector('[data-summary-status]'), summary.status || 'unknown');
|
||||
setText(document.querySelector('[data-summary-last-refresh]'), `Letztes Update: ${fmtDate(data.ts)}`);
|
||||
|
||||
renderInstances(data.instances || {});
|
||||
renderList(document.querySelector('[data-query-types]'), data.aggregate?.query_types, 'Keine Daten');
|
||||
renderList(document.querySelector('[data-forward-destinations]'), data.aggregate?.forward_destinations, 'Keine Daten');
|
||||
renderList(document.querySelector('[data-top-ads]'), data.aggregate?.top_ads, 'Keine Daten');
|
||||
renderList(document.querySelector('[data-top-queries]'), data.aggregate?.top_queries, 'Keine Daten');
|
||||
renderList(document.querySelector('[data-top-clients]'), data.aggregate?.query_sources, 'Keine Daten');
|
||||
renderBlocked(document.querySelector('[data-recent-blocked]'), data.aggregate?.recent_blocked);
|
||||
};
|
||||
|
||||
const dashboardFingerprint = (data) => {
|
||||
const aggregate = data?.aggregate?.summary || {};
|
||||
const instanceSummary = Object.values(data?.instances || {}).map((entry) => ({
|
||||
id: entry?.meta?.id || '',
|
||||
blocked_domains: Number(entry?.summary?.blocked_domains || 0),
|
||||
ads_blocked_today: Number(entry?.summary?.ads_blocked_today || 0),
|
||||
unique_domains: Number(entry?.summary?.unique_domains || 0),
|
||||
}));
|
||||
|
||||
return JSON.stringify({
|
||||
blocked_domains: Number(aggregate.blocked_domains || 0),
|
||||
ads_blocked_today: Number(aggregate.ads_blocked_today || 0),
|
||||
unique_domains: Number(aggregate.unique_domains || 0),
|
||||
instances: instanceSummary,
|
||||
});
|
||||
};
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
const monitorGravityProgress = async (baselineData, instanceLabel) => {
|
||||
const baselineFingerprint = dashboardFingerprint(baselineData || {});
|
||||
const maxPolls = 24;
|
||||
const pollDelayMs = 5000;
|
||||
|
||||
appendActionLog(`Pi-hole meldet keinen Live-Fortschritt. Pruefe jetzt zyklisch auf erkennbare Aenderungen fuer ${instanceLabel}.`, 'info');
|
||||
|
||||
for (let attempt = 1; attempt <= maxPolls; attempt += 1) {
|
||||
await delay(pollDelayMs);
|
||||
appendActionLog(`Pruefung ${attempt}/${maxPolls}: aktuelle Statusdaten werden geladen ...`, 'info');
|
||||
|
||||
try {
|
||||
const data = await apiCall('dashboard');
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error || 'API error');
|
||||
}
|
||||
|
||||
renderDashboardData(data);
|
||||
const nextFingerprint = dashboardFingerprint(data);
|
||||
if (nextFingerprint !== baselineFingerprint) {
|
||||
appendActionLog(`Erkennbare Aenderung gefunden. Listen-Update fuer ${instanceLabel} scheint abgeschlossen zu sein.`, 'success');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attempt % 3 === 0) {
|
||||
appendActionLog('Noch keine erkennbare Aenderung in den Pi-hole Statusdaten.', 'info');
|
||||
}
|
||||
} catch (err) {
|
||||
appendActionLog(`Statuspruefung fehlgeschlagen: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
appendActionLog(`Innerhalb des Beobachtungsfensters wurde keine erkennbare Aenderung gefunden. Das Update fuer ${instanceLabel} kann trotzdem weiterlaufen oder bereits ohne sichtbare Statistikaenderung beendet worden sein.`, 'error');
|
||||
return false;
|
||||
};
|
||||
|
||||
const renderInstances = (instances) => {
|
||||
const holder = document.querySelector('[data-instance-cards]');
|
||||
const tpl = document.querySelector('#pihole-instance-template');
|
||||
if (!holder || !tpl) return;
|
||||
holder.innerHTML = '';
|
||||
|
||||
Object.values(instances).forEach((entry) => {
|
||||
const node = tpl.content.cloneNode(true);
|
||||
const root = node.querySelector('[data-instance]');
|
||||
if (!root) return;
|
||||
root.dataset.instance = entry.meta.id;
|
||||
|
||||
const summary = entry.summary || {};
|
||||
setText(root.querySelector('[data-instance-name]'), entry.meta.name || entry.meta.id);
|
||||
setText(root.querySelector('[data-instance-url]'), entry.meta.url || '');
|
||||
setText(root.querySelector('[data-instance-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
|
||||
setText(root.querySelector('[data-instance-ads]'), fmt.format(Number(summary.ads_blocked_today || 0)));
|
||||
const percent = summary.ads_percentage_today || summary.ads_percentage || 0;
|
||||
setText(root.querySelector('[data-instance-percent]'), `${Number(percent).toFixed(2)}%`);
|
||||
setStatusBadge(root.querySelector('[data-instance-status]'), summary.status || 'unknown');
|
||||
|
||||
const actions = root.querySelectorAll('[data-instance]');
|
||||
actions.forEach((btn) => {
|
||||
if (btn.dataset.instance === '') {
|
||||
btn.dataset.instance = entry.meta.id;
|
||||
}
|
||||
});
|
||||
const customInput = root.querySelector('[data-custom-minutes]');
|
||||
if (customInput) {
|
||||
customInput.dataset.customMinutes = entry.meta.id;
|
||||
}
|
||||
|
||||
const updateEl = root.querySelector('[data-instance-update]');
|
||||
if (entry.updates && entry.updates.available) {
|
||||
updateEl.textContent = 'Updates verfuegbar';
|
||||
} else {
|
||||
updateEl.textContent = 'Keine Updates erkannt';
|
||||
}
|
||||
|
||||
const errorEl = root.querySelector('[data-instance-errors]');
|
||||
if (errorEl) {
|
||||
if (Array.isArray(entry.errors) && entry.errors.length) {
|
||||
const lines = entry.errors.map((err) => {
|
||||
const code = err.http_code ? `HTTP ${err.http_code}` : 'HTTP ?';
|
||||
const scope = err.scope || 'request';
|
||||
const msg = err.error || 'error';
|
||||
const hint = err.hint ? `, Hint: ${typeof err.hint === 'string' ? err.hint : JSON.stringify(err.hint)}` : '';
|
||||
return `${scope}: ${msg} (${code}${hint})`;
|
||||
});
|
||||
errorEl.textContent = `API Fehler: ${lines.join(' | ')}`;
|
||||
} else {
|
||||
errorEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
holder.appendChild(node);
|
||||
});
|
||||
};
|
||||
|
||||
const bindActionButtons = () => {
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
const instance = btn.dataset.instance || 'all';
|
||||
const minutes = btn.dataset.minutes;
|
||||
const scope = btn.closest('.pihole-instance') || document;
|
||||
const customInput = scope.querySelector(`[data-custom-minutes="${instance}"]`);
|
||||
let payload = { instance };
|
||||
let baselineData = null;
|
||||
|
||||
if (action === 'disable') {
|
||||
payload.minutes = Number(minutes || 0);
|
||||
}
|
||||
if (action === 'disable-custom') {
|
||||
payload.minutes = Number(customInput?.value || 0);
|
||||
}
|
||||
|
||||
try {
|
||||
const actionLabel = action === 'enable'
|
||||
? 'Pi-hole wird aktiviert ...'
|
||||
: action === 'disable' || action === 'disable-custom'
|
||||
? 'Pi-hole wird deaktiviert ...'
|
||||
: action === 'gravity'
|
||||
? 'Listen werden aktualisiert ...'
|
||||
: 'Aktion wird ausgefuehrt ...';
|
||||
ensureActionConsole();
|
||||
if (actionConsoleBody) {
|
||||
actionConsoleBody.innerHTML = '';
|
||||
}
|
||||
actionConsoleApi.open();
|
||||
appendActionLog(actionLabel, 'info');
|
||||
setActionLock(true, actionLabel);
|
||||
|
||||
baselineData = action === 'gravity' ? await apiCall('dashboard').catch(() => null) : null;
|
||||
|
||||
if (action === 'enable') {
|
||||
appendActionLog(`Aktiviere ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info');
|
||||
await apiCall('enable', payload);
|
||||
} else if (action === 'disable' || action === 'disable-custom') {
|
||||
if (!payload.minutes || payload.minutes <= 0) {
|
||||
appendActionLog('Fehler: Bitte Minuten angeben.', 'error');
|
||||
return;
|
||||
}
|
||||
appendActionLog(`Deaktiviere ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`} fuer ${payload.minutes} Minuten.`, 'info');
|
||||
await apiCall('disable', payload);
|
||||
} else if (action === 'gravity') {
|
||||
appendActionLog(`Starte Listen-Update fuer ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info');
|
||||
await apiCall('gravity', payload);
|
||||
const status = document.querySelector('[data-list-update-status]');
|
||||
if (status) status.textContent = 'Listen-Update gestartet.';
|
||||
} else if (action === 'update') {
|
||||
appendActionLog(`Starte Pi-hole-Update fuer ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info');
|
||||
await apiCall('update', payload);
|
||||
}
|
||||
appendActionLog('Aktion abgeschlossen. Dashboard wird aktualisiert.', 'success');
|
||||
if (action === 'gravity') {
|
||||
await monitorGravityProgress(baselineData, instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`);
|
||||
} else {
|
||||
await loadDashboard();
|
||||
appendActionLog('Anzeige erfolgreich aktualisiert.', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
appendActionLog(`Aktion fehlgeschlagen: ${err.message}`, 'error');
|
||||
if (action === 'gravity' && /timed out/i.test(err.message)) {
|
||||
appendActionLog('Der Request ist in Nexus abgelaufen. Pi-hole kann intern trotzdem weiterarbeiten. Starte weiterfuehrende Statuspruefung.', 'info');
|
||||
await monitorGravityProgress(baselineData, instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`);
|
||||
}
|
||||
} finally {
|
||||
setActionLock(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const bindForms = () => {
|
||||
const domainForm = document.querySelector('[data-domain-form]');
|
||||
if (domainForm) {
|
||||
domainForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(domainForm);
|
||||
const type = String(formData.get('type') || 'block');
|
||||
const domain = String(formData.get('domain') || '').trim();
|
||||
const status = document.querySelector('[data-domain-status]');
|
||||
if (!domain) return;
|
||||
try {
|
||||
await apiCall('domain_add', { type, domain, instance: 'primary' });
|
||||
if (status) status.textContent = `Domain hinzugefuegt: ${domain}`;
|
||||
domainForm.reset();
|
||||
} catch (err) {
|
||||
if (status) status.textContent = `Fehler: ${err.message}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const adlistForm = document.querySelector('[data-adlist-form]');
|
||||
if (adlistForm) {
|
||||
adlistForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(adlistForm);
|
||||
const url = String(formData.get('url') || '').trim();
|
||||
const status = document.querySelector('[data-adlist-status]');
|
||||
if (!url) return;
|
||||
try {
|
||||
await apiCall('adlist_add', { url, instance: 'primary' });
|
||||
if (status) status.textContent = `Adlist hinzugefuegt: ${url}`;
|
||||
adlistForm.reset();
|
||||
} catch (err) {
|
||||
if (status) status.textContent = `Fehler: ${err.message}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadDashboard = async () => {
|
||||
if (loadInFlight || actionInFlight) return;
|
||||
loadInFlight = true;
|
||||
try {
|
||||
const data = await apiCall('dashboard');
|
||||
if (!data.ok) throw new Error(data.error || 'API error');
|
||||
renderDashboardData(data);
|
||||
const existing = page.querySelector('[data-pihole-load-error]');
|
||||
if (existing) existing.remove();
|
||||
} catch (err) {
|
||||
let message = page.querySelector('[data-pihole-load-error]');
|
||||
if (!message) {
|
||||
message = document.createElement('div');
|
||||
message.className = 'card';
|
||||
message.style.marginTop = '1rem';
|
||||
message.dataset.piholeLoadError = '1';
|
||||
page.appendChild(message);
|
||||
}
|
||||
message.textContent = `Fehler beim Laden der Pi-hole Daten: ${err.message}`;
|
||||
} finally {
|
||||
loadInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshTimer !== null) {
|
||||
window.clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
stopAutoRefresh();
|
||||
if (refreshSeconds <= 0 || document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
refreshTimer = window.setInterval(loadDashboard, refreshSeconds * 1000);
|
||||
};
|
||||
|
||||
bindActionButtons();
|
||||
bindForms();
|
||||
loadDashboard();
|
||||
startAutoRefresh();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
loadDashboard();
|
||||
startAutoRefresh();
|
||||
return;
|
||||
}
|
||||
stopAutoRefresh();
|
||||
});
|
||||
window.addEventListener('beforeunload', stopAutoRefresh);
|
||||
})();
|
||||
@@ -0,0 +1,141 @@
|
||||
(() => {
|
||||
const modal = document.querySelector('[data-instance-modal]');
|
||||
const form = document.querySelector('[data-instance-form]');
|
||||
|
||||
const apiCall = async (action, payload = {}) => {
|
||||
const res = await fetch(`/module/pihole/api?action=${encodeURIComponent(action)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
document.addEventListener('click', async (event) => {
|
||||
const testBtn = event.target.closest('[data-instance-test]');
|
||||
if (!testBtn) return;
|
||||
|
||||
const card = testBtn.closest('[data-instance-id]');
|
||||
if (!card) return;
|
||||
|
||||
const instanceId = card.dataset.instanceId || '';
|
||||
const resultEl = card.querySelector('[data-instance-result]');
|
||||
if (resultEl) {
|
||||
resultEl.classList.remove('is-ok', 'is-auth', 'is-unreachable', 'is-invalid', 'is-error');
|
||||
resultEl.textContent = 'Teste Verbindung...';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiCall('test', { instance: instanceId });
|
||||
if (resultEl) {
|
||||
const statusClass = res.status ? `is-${res.status}` : 'is-ok';
|
||||
resultEl.classList.add(statusClass);
|
||||
resultEl.textContent = res.message || 'Verbindung OK.';
|
||||
}
|
||||
} catch (err) {
|
||||
if (resultEl) {
|
||||
const msg = err.message || 'Fehler';
|
||||
resultEl.classList.add('is-error');
|
||||
resultEl.textContent = `Test fehlgeschlagen: ${msg}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!modal || !form) return;
|
||||
|
||||
const title = document.querySelector('[data-instance-modal-title]');
|
||||
const closeBtn = document.querySelector('[data-instance-close]');
|
||||
const newBtn = document.querySelector('[data-instance-new]');
|
||||
const cancelBtn = document.querySelector('[data-instance-cancel]');
|
||||
|
||||
const currentIdInput = form.querySelector('input[name="current_id"]');
|
||||
const idInput = form.querySelector('input[name="instance_id"]');
|
||||
const nameInput = form.querySelector('input[name="name"]');
|
||||
const urlInput = form.querySelector('input[name="url"]');
|
||||
const passwordInput = form.querySelector('input[name="password"]');
|
||||
const primaryInput = form.querySelector('input[name="is_primary"]');
|
||||
|
||||
const resetForm = () => {
|
||||
if (currentIdInput) currentIdInput.value = '';
|
||||
if (idInput) idInput.value = '';
|
||||
if (nameInput) nameInput.value = '';
|
||||
if (urlInput) urlInput.value = '';
|
||||
if (passwordInput) passwordInput.value = '';
|
||||
if (primaryInput) primaryInput.checked = false;
|
||||
};
|
||||
|
||||
const modalApi = window.NexusModal && typeof window.NexusModal.create === 'function'
|
||||
? window.NexusModal.create(modal, { initialFocus: 'input[name="instance_id"]' })
|
||||
: null;
|
||||
|
||||
const openModal = () => {
|
||||
if (modalApi) {
|
||||
modalApi.open();
|
||||
return;
|
||||
}
|
||||
modal.classList.add('is-open');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
if (idInput) {
|
||||
window.setTimeout(() => idInput.focus(), 20);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (modalApi) {
|
||||
modalApi.close();
|
||||
return;
|
||||
}
|
||||
modal.classList.remove('is-open');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-instance-edit]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const card = btn.closest('[data-instance-id]');
|
||||
if (!card) return;
|
||||
if (currentIdInput) currentIdInput.value = card.dataset.instanceId || '';
|
||||
if (idInput) idInput.value = card.dataset.instanceId || '';
|
||||
if (nameInput) nameInput.value = card.dataset.name || '';
|
||||
if (urlInput) urlInput.value = card.dataset.url || '';
|
||||
if (passwordInput) passwordInput.value = '';
|
||||
if (primaryInput) primaryInput.checked = card.dataset.primary === '1';
|
||||
if (title) title.textContent = 'Instanz bearbeiten';
|
||||
openModal();
|
||||
});
|
||||
});
|
||||
|
||||
if (newBtn) {
|
||||
newBtn.addEventListener('click', () => {
|
||||
resetForm();
|
||||
if (title) title.textContent = 'Neue Instanz';
|
||||
openModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => closeModal());
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
resetForm();
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (!modalApi) {
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && modal.classList.contains('is-open')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
116
docs/Umsetzungsanweisung/Old-Nexus/modules/pihole/bootstrap.php
Normal file
116
docs/Umsetzungsanweisung/Old-Nexus/modules/pihole/bootstrap.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
use App\ModuleConfigException;
|
||||
|
||||
$moduleName = 'pihole';
|
||||
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
|
||||
|
||||
$mm->registerFunction($moduleName, 'settings', function () use ($moduleName): array {
|
||||
return modules()->settings($moduleName);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'instances', function () use ($moduleName): array {
|
||||
$settings = modules()->settings($moduleName);
|
||||
$apiPath = trim((string)($settings['api_path'] ?? '/admin/api.php'));
|
||||
if ($apiPath === '') {
|
||||
$apiPath = '/admin/api.php';
|
||||
}
|
||||
if ($apiPath[0] !== '/') {
|
||||
$apiPath = '/' . $apiPath;
|
||||
}
|
||||
|
||||
$timeout = (int)($settings['api_timeout_sec'] ?? 8);
|
||||
if ($timeout <= 0) {
|
||||
$timeout = 8;
|
||||
}
|
||||
|
||||
$verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true;
|
||||
|
||||
$normalizeSecret = static function (array $row): string {
|
||||
$password = trim((string)($row['password'] ?? ''));
|
||||
if ($password !== '') {
|
||||
return $password;
|
||||
}
|
||||
|
||||
return trim((string)($row['token'] ?? ''));
|
||||
};
|
||||
|
||||
$instances = [];
|
||||
|
||||
$rawJson = trim((string)($settings['instances_json'] ?? ''));
|
||||
if ($rawJson !== '') {
|
||||
$decoded = json_decode($rawJson, true);
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$id = trim((string)($row['id'] ?? ''));
|
||||
$url = trim((string)($row['url'] ?? ''));
|
||||
if ($id === '' || $url === '') {
|
||||
continue;
|
||||
}
|
||||
$instances[$id] = [
|
||||
'id' => $id,
|
||||
'name' => trim((string)($row['name'] ?? '')) ?: $id,
|
||||
'url' => rtrim($url, '/'),
|
||||
'password' => $normalizeSecret($row),
|
||||
'api_path' => $apiPath,
|
||||
'timeout' => $timeout,
|
||||
'verify_tls' => $verifyTls,
|
||||
'is_primary' => !empty($row['is_primary']),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($instances)) {
|
||||
foreach (['primary', 'secondary'] as $key) {
|
||||
$urlKey = $key . '_url';
|
||||
$tokenKey = $key . '_token';
|
||||
$nameKey = $key . '_name';
|
||||
$url = trim((string)($settings[$urlKey] ?? ''));
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
$instances[$key] = [
|
||||
'id' => $key,
|
||||
'name' => trim((string)($settings[$nameKey] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'),
|
||||
'url' => rtrim($url, '/'),
|
||||
'password' => trim((string)($settings[$tokenKey] ?? '')),
|
||||
'api_path' => $apiPath,
|
||||
'timeout' => $timeout,
|
||||
'verify_tls' => $verifyTls,
|
||||
'is_primary' => $key === 'primary',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$primaryId = trim((string)($settings['primary_id'] ?? ''));
|
||||
if ($primaryId !== '' && isset($instances[$primaryId])) {
|
||||
foreach ($instances as $id => &$row) {
|
||||
$row['is_primary'] = ($id === $primaryId);
|
||||
}
|
||||
unset($row);
|
||||
} else {
|
||||
$hasPrimary = false;
|
||||
foreach ($instances as $row) {
|
||||
if (!empty($row['is_primary'])) {
|
||||
$hasPrimary = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasPrimary && $instances) {
|
||||
$firstKey = array_key_first($instances);
|
||||
if ($firstKey !== null) {
|
||||
$instances[$firstKey]['is_primary'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $instances;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'lists_primary_only', function () use ($moduleName): bool {
|
||||
$settings = modules()->settings($moduleName);
|
||||
return !empty($settings['lists_primary_only']);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Pi-hole",
|
||||
"description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.",
|
||||
"actions": [
|
||||
{ "label": "Setup", "href": "/modules/setup/pihole", "variant": "ghost" },
|
||||
{ "label": "Instanzen", "href": "/module/pihole/instances", "variant": "secondary" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Dashboard", "href": "/module/pihole" },
|
||||
{ "label": "Listen", "href": "/module/pihole/lists" },
|
||||
{ "label": "Queries", "href": "/module/pihole/queries" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"title": "Pi-hole",
|
||||
"version": "0.1.0",
|
||||
"description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.",
|
||||
"setup": {
|
||||
"fields": [
|
||||
{
|
||||
"name": "api_timeout_sec",
|
||||
"label": "API Timeout Standard (Sekunden)",
|
||||
"type": "number",
|
||||
"help": "Timeout fuer normale Pi-hole API-Abfragen wie Dashboard und Queries. Standard: 8 Sekunden.",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "action_timeout_sec",
|
||||
"label": "API Timeout Aktionen (Sekunden)",
|
||||
"type": "number",
|
||||
"help": "Timeout fuer laengere Aktionen wie Listen- oder Pi-hole-Updates. Standard: 120 Sekunden.",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "dashboard_refresh_sec",
|
||||
"label": "Refresh Dashboard (Sekunden)",
|
||||
"type": "number",
|
||||
"help": "Automatische Aktualisierung fuer das Dashboard. Standard: 1 Sekunde. 0 deaktiviert den Auto-Refresh.",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "lists_refresh_sec",
|
||||
"label": "Refresh Listen (Sekunden)",
|
||||
"type": "number",
|
||||
"help": "Automatische Aktualisierung fuer die Listen-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "queries_refresh_sec",
|
||||
"label": "Refresh Queries (Sekunden)",
|
||||
"type": "number",
|
||||
"help": "Automatische Aktualisierung fuer die Queries-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1154
docs/Umsetzungsanweisung/Old-Nexus/modules/pihole/pages/api.php
Normal file
1154
docs/Umsetzungsanweisung/Old-Nexus/modules/pihole/pages/api.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
$file = (string)($_GET['file'] ?? '');
|
||||
$base = realpath(__DIR__ . '/../assets');
|
||||
$map = [
|
||||
'pihole.css' => $base . '/pihole.css',
|
||||
'pihole.js' => $base . '/pihole.js',
|
||||
'pihole_instances.js' => $base . '/pihole_instances.js',
|
||||
];
|
||||
|
||||
if (!isset($map[$file])) {
|
||||
http_response_code(404);
|
||||
exit('Not found');
|
||||
}
|
||||
|
||||
$path = $map[$file];
|
||||
if (!$base || !is_file($path) || !str_starts_with($path, $base)) {
|
||||
http_response_code(404);
|
||||
exit('Not found');
|
||||
}
|
||||
|
||||
$ext = pathinfo($path, PATHINFO_EXTENSION);
|
||||
if ($ext === 'css') {
|
||||
header('Content-Type: text/css; charset=utf-8');
|
||||
} elseif ($ext === 'js') {
|
||||
header('Content-Type: application/javascript; charset=utf-8');
|
||||
} else {
|
||||
header('Content-Type: application/octet-stream');
|
||||
}
|
||||
|
||||
readfile($path);
|
||||
exit;
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
$assets = app()->assets();
|
||||
$assets->addStyle('/module/pihole/asset?file=pihole.css');
|
||||
$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true);
|
||||
|
||||
$settings = modules()->settings('pihole');
|
||||
$instances = module_fn('pihole', 'instances');
|
||||
$hasConfig = !empty($instances);
|
||||
$refreshSeconds = (int)($settings['dashboard_refresh_sec'] ?? 1);
|
||||
if ($refreshSeconds < 0) {
|
||||
$refreshSeconds = 1;
|
||||
}
|
||||
?>
|
||||
<?= module_shell_header('pihole', [
|
||||
'title' => 'Pi-hole Dashboard',
|
||||
'description' => 'Status, Blockings, Usage und Steuerung fuer beide Instanzen.',
|
||||
]) ?>
|
||||
<div class="module-flow pihole-page" data-pihole-page="dashboard" data-refresh-seconds="<?= e((string)$refreshSeconds) ?>">
|
||||
|
||||
<section class="module-box">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Hosts</strong>
|
||||
<a class="module-button module-button--secondary module-button--small" href="/module/pihole/instances">Instanzen verwalten</a>
|
||||
</div>
|
||||
<?php if (!$instances): ?>
|
||||
<div class="muted" style="margin-top:.75rem;">Keine Pi-hole Instanzen vorhanden. Bitte zuerst hinzufuegen.</div>
|
||||
<div style="margin-top:.75rem;"><a class="module-button module-button--primary" href="/module/pihole/instances">+ Neue Instanz</a></div>
|
||||
<?php else: ?>
|
||||
<div class="pihole-list" style="margin-top:1rem;">
|
||||
<?php foreach ($instances as $instance): ?>
|
||||
<div class="pihole-list-row">
|
||||
<div>
|
||||
<strong><?= e((string)($instance['name'] ?? $instance['id'] ?? '')) ?></strong>
|
||||
<div class="muted"><?= e((string)($instance['url'] ?? '')) ?></div>
|
||||
</div>
|
||||
<?php if (!empty($instance['is_primary'])): ?>
|
||||
<span class="pihole-status">Primaer</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<?php if (!$hasConfig): ?>
|
||||
<?php return; ?>
|
||||
<?php else: ?>
|
||||
<div class="module-box-grid module-box-grid--stats pihole-grid">
|
||||
<div class="module-box-soft pihole-stat">
|
||||
<div class="muted">DNS Queries (heute)</div>
|
||||
<div class="pihole-stat-value" data-summary-dns>–</div>
|
||||
</div>
|
||||
<div class="module-box-soft pihole-stat">
|
||||
<div class="muted">Ads geblockt</div>
|
||||
<div class="pihole-stat-value" data-summary-blocked>–</div>
|
||||
<div class="pihole-stat-sub" data-summary-percent>–</div>
|
||||
</div>
|
||||
<div class="module-box-soft pihole-stat">
|
||||
<div class="muted">Unique Clients</div>
|
||||
<div class="pihole-stat-value" data-summary-clients>–</div>
|
||||
</div>
|
||||
<div class="module-box-soft pihole-stat">
|
||||
<div class="muted">Status</div>
|
||||
<div class="pihole-stat-value" data-summary-status>–</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="module-box">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Blocker steuern (alle Instanzen)</strong>
|
||||
<span class="muted" data-summary-last-refresh>Letztes Update: –</span>
|
||||
</div>
|
||||
<div class="pihole-actions" data-global-actions>
|
||||
<button class="cta-button" data-action="enable" data-instance="all">Aktivieren</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="5" data-instance="all">5 Min</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="10" data-instance="all">10 Min</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="20" data-instance="all">20 Min</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="30" data-instance="all">30 Min</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="60" data-instance="all">60 Min</button>
|
||||
<div class="pihole-inline">
|
||||
<input type="number" min="1" max="1440" placeholder="Minuten" data-custom-minutes="all">
|
||||
<button class="nav-link" data-action="disable-custom" data-instance="all">Custom</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="module-box">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Instanzen</strong>
|
||||
<span class="muted">Einzeln steuerbar & getrennte Updates</span>
|
||||
</div>
|
||||
<div class="pihole-instance-grid" data-instance-cards></div>
|
||||
</section>
|
||||
|
||||
<section class="module-box">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Usage (Aggregiert)</strong>
|
||||
<span class="muted">Query-Typen und Weiterleitungen</span>
|
||||
</div>
|
||||
<div class="pihole-split" style="margin-top:1rem;">
|
||||
<div>
|
||||
<div class="muted">Query-Typen</div>
|
||||
<div class="pihole-list" data-query-types></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted">Forward Destinations</div>
|
||||
<div class="pihole-list" data-forward-destinations></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<template id="pihole-instance-template">
|
||||
<div class="module-box-soft pihole-instance" data-instance="">
|
||||
<div class="pihole-instance-header">
|
||||
<div>
|
||||
<strong data-instance-name></strong>
|
||||
<div class="muted" data-instance-url></div>
|
||||
</div>
|
||||
<div class="pihole-status" data-instance-status>–</div>
|
||||
</div>
|
||||
<div class="pihole-instance-stats">
|
||||
<div><span class="muted">DNS heute</span><div class="pihole-instance-value" data-instance-dns>–</div></div>
|
||||
<div><span class="muted">Ads geblockt</span><div class="pihole-instance-value" data-instance-ads>–</div></div>
|
||||
<div><span class="muted">% Blocked</span><div class="pihole-instance-value" data-instance-percent>–</div></div>
|
||||
</div>
|
||||
<div class="pihole-actions" data-instance-actions>
|
||||
<button class="cta-button" data-action="enable" data-instance="">Aktivieren</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="5" data-instance="">5 Min</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="10" data-instance="">10 Min</button>
|
||||
<button class="nav-link" data-action="disable" data-minutes="30" data-instance="">30 Min</button>
|
||||
<div class="pihole-inline">
|
||||
<input type="number" min="1" max="1440" placeholder="Minuten" data-custom-minutes="">
|
||||
<button class="nav-link" data-action="disable-custom" data-instance="">Custom</button>
|
||||
</div>
|
||||
<button class="nav-link" data-action="update" data-instance="">Pi-hole Update</button>
|
||||
</div>
|
||||
<div class="pihole-update" data-instance-update></div>
|
||||
<div class="pihole-error" data-instance-errors></div>
|
||||
</div>
|
||||
</template>
|
||||
<?= module_shell_footer() ?>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user