mining
This commit is contained in:
@@ -543,6 +543,11 @@
|
|||||||
daily_cost_currency: 'EUR',
|
daily_cost_currency: 'EUR',
|
||||||
report_currency: 'EUR',
|
report_currency: 'EUR',
|
||||||
crypto_currency: 'DOGE',
|
crypto_currency: 'DOGE',
|
||||||
|
crypto_base_price_amount: '5.49',
|
||||||
|
crypto_base_price_currency: 'USD',
|
||||||
|
min_offer_runtime_months: 24,
|
||||||
|
target_offer_hashrate_kh: 50,
|
||||||
|
target_offer_runtime_months: 36,
|
||||||
preferred_currencies: ['DOGE', 'USD', 'EUR'],
|
preferred_currencies: ['DOGE', 'USD', 'EUR'],
|
||||||
cost_plans: [],
|
cost_plans: [],
|
||||||
currencies: [],
|
currencies: [],
|
||||||
@@ -1049,6 +1054,11 @@
|
|||||||
baseline_coins_total: '',
|
baseline_coins_total: '',
|
||||||
report_currency: 'EUR',
|
report_currency: 'EUR',
|
||||||
crypto_currency: 'DOGE',
|
crypto_currency: 'DOGE',
|
||||||
|
crypto_base_price_amount: '5.49',
|
||||||
|
crypto_base_price_currency: 'USD',
|
||||||
|
min_offer_runtime_months: '24',
|
||||||
|
target_offer_hashrate_kh: '50',
|
||||||
|
target_offer_runtime_months: '36',
|
||||||
});
|
});
|
||||||
const [offerPreviewForm, setOfferPreviewForm] = useState({
|
const [offerPreviewForm, setOfferPreviewForm] = useState({
|
||||||
crypto_base_price_amount: '',
|
crypto_base_price_amount: '',
|
||||||
@@ -1173,7 +1183,11 @@
|
|||||||
const currentWalletWithdrawals = walletWithdrawalRows.length ? walletWithdrawalRows : (Array.isArray(currentSettings.wallet_withdrawals) ? currentSettings.wallet_withdrawals : []);
|
const currentWalletWithdrawals = walletWithdrawalRows.length ? walletWithdrawalRows : (Array.isArray(currentSettings.wallet_withdrawals) ? currentSettings.wallet_withdrawals : []);
|
||||||
const currentWalletSnapshots = Array.isArray(payload?.wallet_snapshots) ? payload.wallet_snapshots : [];
|
const currentWalletSnapshots = Array.isArray(payload?.wallet_snapshots) ? payload.wallet_snapshots : [];
|
||||||
const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : [];
|
const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : [];
|
||||||
|
const currentSummaryMinerOffers = Array.isArray(payload?.summary?.miner_offers) ? payload.summary.miner_offers : [];
|
||||||
const currentMiningCurrency = String((latest && latest.coin_currency) || currentSettings.crypto_currency || 'DOGE').toUpperCase();
|
const currentMiningCurrency = String((latest && latest.coin_currency) || currentSettings.crypto_currency || 'DOGE').toUpperCase();
|
||||||
|
const minOfferRuntimeMonths = Number(currentSettings.min_offer_runtime_months) > 0 ? Number(currentSettings.min_offer_runtime_months) : 24;
|
||||||
|
const targetOfferHashrateKh = Number(currentSettings.target_offer_hashrate_kh) > 0 ? Number(currentSettings.target_offer_hashrate_kh) : 50;
|
||||||
|
const targetOfferRuntimeMonths = Number(currentSettings.target_offer_runtime_months) > 0 ? Number(currentSettings.target_offer_runtime_months) : 36;
|
||||||
const overviewPerDayLabel = `${currentMiningCurrency} pro Tag`;
|
const overviewPerDayLabel = `${currentMiningCurrency} pro Tag`;
|
||||||
const latestMeasurementDate = latest && latest.measured_at ? parseStoredUtcDate(latest.measured_at) : null;
|
const latestMeasurementDate = latest && latest.measured_at ? parseStoredUtcDate(latest.measured_at) : null;
|
||||||
const latestMeasurementAgeMs = latestMeasurementDate ? (Date.now() - latestMeasurementDate.getTime()) : null;
|
const latestMeasurementAgeMs = latestMeasurementDate ? (Date.now() - latestMeasurementDate.getTime()) : null;
|
||||||
@@ -1273,13 +1287,47 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number.isFinite(runtimeMonths) && runtime !== runtimeMonths) {
|
if (Number.isFinite(runtimeMonths) && runtime < runtimeMonths) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
const scenarioMinerOffers = filteredMinerOffers;
|
const scenarioMinerOffers = filteredMinerOffers;
|
||||||
|
const preferredMinerOffer = currentSummaryMinerOffers
|
||||||
|
.filter((offer) => String(offer.payment_type || '').toLowerCase() === 'crypto')
|
||||||
|
.map((offer) => {
|
||||||
|
const offerHashrateKh = Number(offer.offer_hashrate_mh) * 1000;
|
||||||
|
const runtime = Number(offer.runtime_months);
|
||||||
|
return {
|
||||||
|
...offer,
|
||||||
|
__runtimeDelta: Math.abs(runtime - targetOfferRuntimeMonths),
|
||||||
|
__hashrateDelta: Math.abs(offerHashrateKh - targetOfferHashrateKh),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.__runtimeDelta - right.__runtimeDelta || left.__hashrateDelta - right.__hashrateDelta)[0] || null;
|
||||||
|
const preferredOfferRequiredCoins = preferredMinerOffer
|
||||||
|
? convertCurrencyValue(
|
||||||
|
preferredMinerOffer.effective_price_amount ?? preferredMinerOffer.crypto_display_price_amount,
|
||||||
|
preferredMinerOffer.effective_price_currency || preferredMinerOffer.crypto_display_price_currency || currentMiningCurrency,
|
||||||
|
currentMiningCurrency
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const preferredOfferCurrentCoins = latest ? Number(latest.coins_total_visible ?? latest.coins_total) : null;
|
||||||
|
const preferredOfferRemainingCoins = Number.isFinite(preferredOfferRequiredCoins) && Number.isFinite(preferredOfferCurrentCoins)
|
||||||
|
? Math.max(0, preferredOfferRequiredCoins - preferredOfferCurrentCoins)
|
||||||
|
: null;
|
||||||
|
const preferredOfferRemainingDays = preferredOfferRemainingCoins !== null
|
||||||
|
&& Number(latest?.doge_per_day_interval) > 0
|
||||||
|
? (preferredOfferRemainingCoins / Number(latest.doge_per_day_interval))
|
||||||
|
: null;
|
||||||
|
const preferredOfferEta = preferredOfferRemainingDays !== null && latest?.measured_at
|
||||||
|
? (
|
||||||
|
preferredOfferRemainingDays <= 0
|
||||||
|
? latest.measured_at
|
||||||
|
: new Date(parseStoredUtcDate(latest.measured_at).getTime() + (preferredOfferRemainingDays * 86400000)).toISOString()
|
||||||
|
)
|
||||||
|
: null;
|
||||||
const speedUnits = ['kH/s', 'MH/s'];
|
const speedUnits = ['kH/s', 'MH/s'];
|
||||||
const fiatCurrencies = currencies.filter((currency) => !currency.is_crypto);
|
const fiatCurrencies = currencies.filter((currency) => !currency.is_crypto);
|
||||||
const cryptoCurrencies = currencies.filter((currency) => !!currency.is_crypto);
|
const cryptoCurrencies = currencies.filter((currency) => !!currency.is_crypto);
|
||||||
@@ -1791,7 +1839,31 @@
|
|||||||
baseline_coins_total: normalized.settings.baseline_coins_total || '',
|
baseline_coins_total: normalized.settings.baseline_coins_total || '',
|
||||||
report_currency: normalized.settings.report_currency || 'EUR',
|
report_currency: normalized.settings.report_currency || 'EUR',
|
||||||
crypto_currency: normalized.settings.crypto_currency || 'DOGE',
|
crypto_currency: normalized.settings.crypto_currency || 'DOGE',
|
||||||
|
crypto_base_price_amount: normalized.settings.crypto_base_price_amount !== null && normalized.settings.crypto_base_price_amount !== undefined
|
||||||
|
? String(normalized.settings.crypto_base_price_amount)
|
||||||
|
: '5.49',
|
||||||
|
crypto_base_price_currency: normalized.settings.crypto_base_price_currency || 'USD',
|
||||||
|
min_offer_runtime_months: normalized.settings.min_offer_runtime_months !== null && normalized.settings.min_offer_runtime_months !== undefined
|
||||||
|
? String(normalized.settings.min_offer_runtime_months)
|
||||||
|
: '24',
|
||||||
|
target_offer_hashrate_kh: normalized.settings.target_offer_hashrate_kh !== null && normalized.settings.target_offer_hashrate_kh !== undefined
|
||||||
|
? String(normalized.settings.target_offer_hashrate_kh)
|
||||||
|
: '50',
|
||||||
|
target_offer_runtime_months: normalized.settings.target_offer_runtime_months !== null && normalized.settings.target_offer_runtime_months !== undefined
|
||||||
|
? String(normalized.settings.target_offer_runtime_months)
|
||||||
|
: '36',
|
||||||
});
|
});
|
||||||
|
setOfferPreviewForm((current) => ({
|
||||||
|
crypto_base_price_amount: normalized.settings.crypto_base_price_amount !== null && normalized.settings.crypto_base_price_amount !== undefined
|
||||||
|
? String(normalized.settings.crypto_base_price_amount)
|
||||||
|
: (current.crypto_base_price_amount || '5.49'),
|
||||||
|
crypto_base_price_currency: normalized.settings.crypto_base_price_currency || current.crypto_base_price_currency || 'USD',
|
||||||
|
fiat_base_price_amount: current.fiat_base_price_amount || '',
|
||||||
|
}));
|
||||||
|
setMinerOfferFilters((previous) => ({
|
||||||
|
...previous,
|
||||||
|
runtime_months: previous.runtime_months || String(normalized.settings.min_offer_runtime_months ?? 24),
|
||||||
|
}));
|
||||||
setFxSelection(Array.isArray(normalized.settings.preferred_currencies) && normalized.settings.preferred_currencies.length
|
setFxSelection(Array.isArray(normalized.settings.preferred_currencies) && normalized.settings.preferred_currencies.length
|
||||||
? normalized.settings.preferred_currencies
|
? normalized.settings.preferred_currencies
|
||||||
: ['DOGE', 'USD', 'EUR']);
|
: ['DOGE', 'USD', 'EUR']);
|
||||||
@@ -2296,6 +2368,11 @@
|
|||||||
daily_cost_currency: currentSettings.daily_cost_currency,
|
daily_cost_currency: currentSettings.daily_cost_currency,
|
||||||
report_currency: settingsForm.report_currency || 'EUR',
|
report_currency: settingsForm.report_currency || 'EUR',
|
||||||
crypto_currency: settingsForm.crypto_currency || 'DOGE',
|
crypto_currency: settingsForm.crypto_currency || 'DOGE',
|
||||||
|
crypto_base_price_amount: settingsForm.crypto_base_price_amount || '5.49',
|
||||||
|
crypto_base_price_currency: settingsForm.crypto_base_price_currency || 'USD',
|
||||||
|
min_offer_runtime_months: Number(settingsForm.min_offer_runtime_months) || 24,
|
||||||
|
target_offer_hashrate_kh: Number(settingsForm.target_offer_hashrate_kh) || 50,
|
||||||
|
target_offer_runtime_months: Number(settingsForm.target_offer_runtime_months) || 36,
|
||||||
preferred_currencies: Array.isArray(currentSettings.preferred_currencies)
|
preferred_currencies: Array.isArray(currentSettings.preferred_currencies)
|
||||||
? currentSettings.preferred_currencies
|
? currentSettings.preferred_currencies
|
||||||
: fxSelection,
|
: fxSelection,
|
||||||
@@ -3223,6 +3300,21 @@
|
|||||||
yLabel: `Kurs ${reportCurrency}`,
|
yLabel: `Kurs ${reportCurrency}`,
|
||||||
})),
|
})),
|
||||||
]),
|
]),
|
||||||
|
preferredMinerOffer ? h(StatCard, {
|
||||||
|
key: 'preferred-offer-target',
|
||||||
|
label: `Zielminer ${fmtNumber(targetOfferHashrateKh, 0)} kH/s · ${targetOfferRuntimeMonths} Monate`,
|
||||||
|
value: preferredOfferRequiredCoins !== null ? `${fmtNumber(preferredOfferRequiredCoins, 4)} ${currentMiningCurrency}` : 'n/a',
|
||||||
|
sub: [
|
||||||
|
preferredMinerOffer.usd_display_price_amount !== null ? `≈ ${fmtMoney(preferredMinerOffer.usd_display_price_amount, 'USD')}` : null,
|
||||||
|
preferredOfferRemainingDays !== null
|
||||||
|
? `Rest ${fmtNumber(preferredOfferRemainingDays, 2)} Tage`
|
||||||
|
: 'Resttage n/a',
|
||||||
|
preferredOfferRemainingCoins !== null
|
||||||
|
? `Rest-${currentMiningCurrency} ${fmtNumber(preferredOfferRemainingCoins, 4)}`
|
||||||
|
: null,
|
||||||
|
preferredOfferEta ? `ETA ${fmtDate(preferredOfferEta)}` : null,
|
||||||
|
].filter(Boolean).join(' · '),
|
||||||
|
}) : null,
|
||||||
panel('Zielmonitor', `Rest-${currentCoinCurrency} und Resttage werden gegen den letzten verfuegbaren Kurs je Zielwaehrung berechnet.`,
|
panel('Zielmonitor', `Rest-${currentCoinCurrency} und Resttage werden gegen den letzten verfuegbaren Kurs je Zielwaehrung berechnet.`,
|
||||||
h('div', { className: 'mc-target-grid' },
|
h('div', { className: 'mc-target-grid' },
|
||||||
currentTargets.map((target, index) => h('div', {
|
currentTargets.map((target, index) => h('div', {
|
||||||
@@ -3642,9 +3734,9 @@
|
|||||||
]),
|
]),
|
||||||
panel('Miner-Angebote', selectedOfferType === 'crypto'
|
panel('Miner-Angebote', selectedOfferType === 'crypto'
|
||||||
? (currentWalletMiningBalance !== null
|
? (currentWalletMiningBalance !== null
|
||||||
? `Crypto-Angebote sind aktiv. Angezeigt werden nur Angebote, die dein NC-Wallet aktuell tragen kann (${fmtNumber(currentWalletMiningBalance, 8)} ${currentMiningCurrency} Gegenwert).`
|
? `Crypto-Angebote sind aktiv. Standardmäßig werden Laufzeiten ab ${fmtNumber(minOfferRuntimeMonths, 0)} Monaten berücksichtigt und nur Angebote gezeigt, die dein NC-Wallet aktuell tragen kann (${fmtNumber(currentWalletMiningBalance, 8)} ${currentMiningCurrency} Gegenwert).`
|
||||||
: 'Crypto-Angebote sind aktiv. Solange kein Wallet-Bestand erkannt wird, bleiben die Krypto-Angebote sichtbar.')
|
: `Crypto-Angebote sind aktiv. Standardmäßig werden Laufzeiten ab ${fmtNumber(minOfferRuntimeMonths, 0)} Monaten berücksichtigt. Solange kein Wallet-Bestand erkannt wird, bleiben die Krypto-Angebote sichtbar.`)
|
||||||
: 'Fiat-Angebote sind aktiv. Die Berechnung startet erst, wenn du eine Geschwindigkeit eingibst.',
|
: `Fiat-Angebote sind aktiv. Standardmäßig werden Laufzeiten ab ${fmtNumber(minOfferRuntimeMonths, 0)} Monaten berücksichtigt. Die Berechnung startet erst, wenn du eine Geschwindigkeit eingibst.`,
|
||||||
[
|
[
|
||||||
h('div', { key: 'filters', className: 'mc-filter-grid' }, [
|
h('div', { key: 'filters', className: 'mc-filter-grid' }, [
|
||||||
selectedOfferType === 'crypto'
|
selectedOfferType === 'crypto'
|
||||||
@@ -3688,9 +3780,9 @@
|
|||||||
? inputField('Max. DOGE', 'number', minerOfferFilters.max_doge, (value) => setMinerOfferFilters({ ...minerOfferFilters, max_doge: value }), '0.0001')
|
? inputField('Max. DOGE', 'number', minerOfferFilters.max_doge, (value) => setMinerOfferFilters({ ...minerOfferFilters, max_doge: value }), '0.0001')
|
||||||
: null,
|
: null,
|
||||||
inputField(`Max. Basispreis (${reportCurrency})`, 'number', minerOfferFilters.price_max, (value) => setMinerOfferFilters({ ...minerOfferFilters, price_max: value }), '0.0001'),
|
inputField(`Max. Basispreis (${reportCurrency})`, 'number', minerOfferFilters.price_max, (value) => setMinerOfferFilters({ ...minerOfferFilters, price_max: value }), '0.0001'),
|
||||||
selectField('Laufzeit', minerOfferFilters.runtime_months, [{ value: '', label: 'Alle Laufzeiten' }].concat(Array.from(new Set(evaluatedMinerOffers.map((offer) => String(offer.runtime_months || '')).filter(Boolean))).sort((a, b) => Number(a) - Number(b)).map((value) => ({
|
selectField('Min. Laufzeit', minerOfferFilters.runtime_months, [{ value: '', label: 'Alle Laufzeiten' }].concat(Array.from(new Set(evaluatedMinerOffers.map((offer) => String(offer.runtime_months || '')).filter(Boolean))).sort((a, b) => Number(a) - Number(b)).map((value) => ({
|
||||||
value,
|
value,
|
||||||
label: `${value} Monate`,
|
label: `ab ${value} Monate`,
|
||||||
}))), (value) => setMinerOfferFilters({ ...minerOfferFilters, runtime_months: value })),
|
}))), (value) => setMinerOfferFilters({ ...minerOfferFilters, runtime_months: value })),
|
||||||
]),
|
]),
|
||||||
visibleOfferBasisHistory.length ? h('div', { key: 'basis-history', className: 'mc-mini-grid' }, visibleOfferBasisHistory.map((entry) => h('div', {
|
visibleOfferBasisHistory.length ? h('div', { key: 'basis-history', className: 'mc-mini-grid' }, visibleOfferBasisHistory.map((entry) => h('div', {
|
||||||
@@ -4138,7 +4230,7 @@
|
|||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
h('div', { className: 'mc-stack' }, [
|
h('div', { className: 'mc-stack' }, [
|
||||||
panel('Basis-Settings', 'Baseline bleibt als Referenzwert mit Datum und Uhrzeit bestehen.', h('form', {
|
panel('Basis-Settings', 'Baseline bleibt als Referenzwert mit Datum und Uhrzeit bestehen. Angebotsbasis und Zielminer steuern die automatische Miner-Kalkulation auf der Übersicht.', h('form', {
|
||||||
className: 'mc-form',
|
className: 'mc-form',
|
||||||
onSubmit: submitSettings,
|
onSubmit: submitSettings,
|
||||||
}, [
|
}, [
|
||||||
@@ -4146,6 +4238,11 @@
|
|||||||
inputField('Baseline Coins', 'number', settingsForm.baseline_coins_total, (value) => setSettingsForm({ ...settingsForm, baseline_coins_total: value }), '0.000001'),
|
inputField('Baseline Coins', 'number', settingsForm.baseline_coins_total, (value) => setSettingsForm({ ...settingsForm, baseline_coins_total: value }), '0.000001'),
|
||||||
selectField('Standard-FIAT-Währung', settingsForm.report_currency || 'EUR', selectableFiatCurrencies.map((currency) => currency.code), (value) => setSettingsForm({ ...settingsForm, report_currency: value })),
|
selectField('Standard-FIAT-Währung', settingsForm.report_currency || 'EUR', selectableFiatCurrencies.map((currency) => currency.code), (value) => setSettingsForm({ ...settingsForm, report_currency: value })),
|
||||||
selectField('Standard-Krypto-Währung', settingsForm.crypto_currency || 'DOGE', selectableCryptoCurrencies.map((currency) => currency.code), (value) => setSettingsForm({ ...settingsForm, crypto_currency: value })),
|
selectField('Standard-Krypto-Währung', settingsForm.crypto_currency || 'DOGE', selectableCryptoCurrencies.map((currency) => currency.code), (value) => setSettingsForm({ ...settingsForm, crypto_currency: value })),
|
||||||
|
inputField('Krypto-Basispreis 50 kH/s · 3 Monate', 'number', settingsForm.crypto_base_price_amount, (value) => setSettingsForm({ ...settingsForm, crypto_base_price_amount: value }), '0.000001'),
|
||||||
|
selectField('Basiswaehrung Krypto', settingsForm.crypto_base_price_currency || 'USD', Array.from(new Set(['USD', String(settingsForm.crypto_currency || 'DOGE').toUpperCase()])), (value) => setSettingsForm({ ...settingsForm, crypto_base_price_currency: value || 'USD' })),
|
||||||
|
inputField('Min. Mietlaufzeit in Monaten', 'number', settingsForm.min_offer_runtime_months, (value) => setSettingsForm({ ...settingsForm, min_offer_runtime_months: value }), '1'),
|
||||||
|
inputField('Zielminer Hashrate in kH/s', 'number', settingsForm.target_offer_hashrate_kh, (value) => setSettingsForm({ ...settingsForm, target_offer_hashrate_kh: value }), '1'),
|
||||||
|
inputField('Zielminer Laufzeit in Monaten', 'number', settingsForm.target_offer_runtime_months, (value) => setSettingsForm({ ...settingsForm, target_offer_runtime_months: value }), '1'),
|
||||||
h('button', {
|
h('button', {
|
||||||
type: 'submit',
|
type: 'submit',
|
||||||
className: 'mc-button mc-button--primary',
|
className: 'mc-button mc-button--primary',
|
||||||
|
|||||||
@@ -112,3 +112,15 @@ Pro Abruf entsteht genau ein Datensatz in `fx-rates` mit Basiswaehrung, Provider
|
|||||||
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
## Miner-Basis und Zielminer
|
||||||
|
|
||||||
|
Die Angebotsberechnung fuer Krypto-Miner nutzt eine konfigurierbare Basis fuer `50 kH/s` und `3 Monate`. Standard ist `5.49 USD`, kann aber in den Modul-Settings angepasst werden.
|
||||||
|
|
||||||
|
Zusaetzlich koennen in den Settings diese Leitwerte hinterlegt werden:
|
||||||
|
|
||||||
|
- minimale gewuenschte Mietlaufzeit in Monaten
|
||||||
|
- Zielminer-Hashrate in `kH/s`
|
||||||
|
- Zielminer-Laufzeit in Monaten
|
||||||
|
|
||||||
|
Die Uebersicht berechnet daraus automatisch den theoretischen Preis des Zielminers, den benoetigten Krypto-Gegenwert und die Resttage bis zur theoretischen Anmietung auf Basis des letzten Uploads.
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ Aus [README.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/docs/READ
|
|||||||
- Keycloak bleibt das Auth-System
|
- Keycloak bleibt das Auth-System
|
||||||
- das globale Desktop-Debug ist als Shell-Werkzeug unter `system/shell/desktop-debug/` definiert
|
- das globale Desktop-Debug ist als Shell-Werkzeug unter `system/shell/desktop-debug/` definiert
|
||||||
- der Gitea-Deploy-Status liegt als optionales Addon unter `system/addons/gitea-deploy-status/`
|
- der Gitea-Deploy-Status liegt als optionales Addon unter `system/addons/gitea-deploy-status/`
|
||||||
|
- fuer die Staging-PostgreSQL-Instanz mit Portainer-Stack und Bind-Mount gibt es ein zentrales Recovery-Runbook unter `docs/POSTGRES_STAGING_RECOVERY.md`
|
||||||
|
|
||||||
### Module
|
### Module
|
||||||
|
|
||||||
|
|||||||
171
docs/POSTGRES_STAGING_RECOVERY.md
Normal file
171
docs/POSTGRES_STAGING_RECOVERY.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# PostgreSQL Staging Recovery
|
||||||
|
|
||||||
|
Runbook fuer den Fall, dass die Staging-PostgreSQL-Instanz per Portainer-Stack nicht mehr sauber startet und WAL- oder Checkpoint-Schaeden meldet.
|
||||||
|
|
||||||
|
Stand dieses Runbooks:
|
||||||
|
|
||||||
|
- Datum: `2026-08-20`
|
||||||
|
- Zielsystem: `staging.desktop.kusche.berlin`
|
||||||
|
- PostgreSQL-Image: `postgres:15-alpine`
|
||||||
|
- Datenpfad als Bind-Mount: `/mnt/db/postgres/nexus/staging:/var/lib/postgresql/data`
|
||||||
|
- Init-SQL als Bind-Mount: `/mnt/docker/containerdata/stackfiles/int/nexus/init-staging.sql:/docker-entrypoint-initdb.d/init.sql:ro`
|
||||||
|
|
||||||
|
## Typische Fehlersymptome
|
||||||
|
|
||||||
|
Beispielhafte Logmeldungen:
|
||||||
|
|
||||||
|
- `invalid resource manager ID in primary checkpoint record`
|
||||||
|
- `PANIC: could not locate a valid checkpoint record`
|
||||||
|
- `database system was interrupted`
|
||||||
|
|
||||||
|
Die Meldung `chmod: /var/run/postgresql: Operation not permitted` kann daneben auftreten, war in diesem Fall aber nicht die eigentliche Ursache des Startfehlers.
|
||||||
|
|
||||||
|
## Wichtige Grundregeln
|
||||||
|
|
||||||
|
- die bestehende Datenbasis nicht ungesichert loeschen
|
||||||
|
- in Portainer kein Volume-Reset oder `down -v` ausloesen
|
||||||
|
- vor jeder Reparatur ein Dateisystem-Backup des kompletten Bind-Mounts anlegen
|
||||||
|
- `pg_resetwal` nur als Rettungsmassnahme verwenden
|
||||||
|
- nach erfolgreichem Start immer sofort einen logischen Dump erzeugen
|
||||||
|
- eine mit `pg_resetwal` gerettete Datenbank nicht dauerhaft als Zielzustand weiterbetreiben
|
||||||
|
|
||||||
|
## Verwendete Sicherungen
|
||||||
|
|
||||||
|
Im dokumentierten Staging-Fall wurden diese Sicherungen erzeugt:
|
||||||
|
|
||||||
|
- Roh-Backup des Datenverzeichnisses: `~/pg-backups/postgres-staging-backup-2026-08-20.tar.gz`
|
||||||
|
- logischer Dump: `~/pg-backups/staging-rettung.dump`
|
||||||
|
|
||||||
|
## Sofortmassnahmen bei WAL- oder Checkpoint-Schaeden
|
||||||
|
|
||||||
|
### 1. Stack stoppen
|
||||||
|
|
||||||
|
In Portainer den betroffenen Staging-Stack vollstaendig stoppen, damit PostgreSQL nicht parallel auf das Datenverzeichnis schreibt.
|
||||||
|
|
||||||
|
### 2. Bind-Mount sichern
|
||||||
|
|
||||||
|
Auf dem Docker-Host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/pg-backups
|
||||||
|
tar czf ~/pg-backups/postgres-staging-backup-2026-08-20.tar.gz -C /mnt/db/postgres/nexus staging
|
||||||
|
ls -lh ~/pg-backups/postgres-staging-backup-2026-08-20.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Diagnosecontainer mit passender PostgreSQL-Major starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -it \
|
||||||
|
-v /mnt/db/postgres/nexus/staging:/var/lib/postgresql/data \
|
||||||
|
postgres:15-alpine \
|
||||||
|
sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_controldata /var/lib/postgresql/data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `pg_resetwal` erst als Dry-Run
|
||||||
|
|
||||||
|
`pg_resetwal` muss als PostgreSQL-Superuser ausgefuehrt werden. Deshalb Diagnosecontainer als User `postgres` starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -it \
|
||||||
|
--user postgres \
|
||||||
|
-v /mnt/db/postgres/nexus/staging:/var/lib/postgresql/data \
|
||||||
|
postgres:15-alpine \
|
||||||
|
sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_resetwal -n /var/lib/postgresql/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn der Dry-Run plausibel aussieht, kann die eigentliche WAL-Ruecksetzung ausgefuehrt werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_resetwal -f /var/lib/postgresql/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach den Container verlassen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Stack wieder starten
|
||||||
|
|
||||||
|
In Portainer den Staging-Stack wieder starten und pruefen, ob PostgreSQL ohne neue Fehler hochkommt.
|
||||||
|
|
||||||
|
### 6. Sofort einen logischen Dump erzeugen
|
||||||
|
|
||||||
|
Containernamen und Umgebungsvariablen pruefen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps
|
||||||
|
docker exec -it <postgres-container> env | grep POSTGRES
|
||||||
|
```
|
||||||
|
|
||||||
|
Im dokumentierten Fall waren die Werte:
|
||||||
|
|
||||||
|
- Datenbank: `nexus_staging`
|
||||||
|
- User: `rootNexusStaging`
|
||||||
|
|
||||||
|
In den laufenden Container wechseln:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it <postgres-container> sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_dump -Fc -U rootNexusStaging nexus_staging > /tmp/staging-rettung.dump
|
||||||
|
ls -lh /tmp/staging-rettung.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
Zurueck auf dem Host den Dump herauskopieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker cp <postgres-container>:/tmp/staging-rettung.dump ~/pg-backups/staging-rettung.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zielzustand nach erfolgreicher Rettung
|
||||||
|
|
||||||
|
Nach einem erfolgreichen `pg_resetwal` ist die Instanz nur wieder startbar gemacht. Der saubere Zielzustand ist:
|
||||||
|
|
||||||
|
1. frisches leeres PostgreSQL-Datenverzeichnis erzeugen
|
||||||
|
2. neue saubere Staging-Instanz initialisieren
|
||||||
|
3. `staging-rettung.dump` in die neue Instanz zurueckspielen
|
||||||
|
4. erst danach die alte reparierte Datenbasis ersetzen oder archivieren
|
||||||
|
|
||||||
|
## Restore in eine frische Staging-Instanz
|
||||||
|
|
||||||
|
Der empfohlene Ablauf ist:
|
||||||
|
|
||||||
|
1. aktuelle gerettete Datenbasis zusaetzlich archivieren
|
||||||
|
2. neues leeres Datenverzeichnis anlegen, z. B. `/mnt/db/postgres/nexus/staging-fresh`
|
||||||
|
3. Portainer-Stack oder Compose temporaer auf das neue Datenverzeichnis zeigen lassen
|
||||||
|
4. Stack starten, damit PostgreSQL sauber initialisiert
|
||||||
|
5. Dump `~/pg-backups/staging-rettung.dump` in die neue Instanz kopieren
|
||||||
|
6. mit `pg_restore` zurueckspielen
|
||||||
|
7. Anwendung gegen die frische Instanz testen
|
||||||
|
8. erst nach erfolgreichem Test das alte Datenverzeichnis abloesen oder umbenennen
|
||||||
|
|
||||||
|
Beispiel fuer den Restore in einen laufenden Container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker cp ~/pg-backups/staging-rettung.dump <postgres-container>:/tmp/staging-rettung.dump
|
||||||
|
docker exec -it <postgres-container> sh
|
||||||
|
pg_restore --clean --if-exists -U rootNexusStaging -d nexus_staging /tmp/staging-rettung.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
Je nach Zielzustand kann statt `--clean --if-exists` auch ein Restore in eine frisch erzeugte leere Datenbank sinnvoller sein.
|
||||||
|
|
||||||
|
## Ablage im Projekt
|
||||||
|
|
||||||
|
Dieses Runbook dokumentiert den echten Staging-Vorfall vom `2026-08-20` und gilt als Referenz fuer weitere PostgreSQL-Wiederherstellungen auf diesem Projektstand.
|
||||||
@@ -19,6 +19,9 @@ Zentraler Dokumentationsindex fuer das Projekt.
|
|||||||
- [Umsetzungsanweisung/START_HIER.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/docs/Umsetzungsanweisung/START_HIER.md)
|
- [Umsetzungsanweisung/START_HIER.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/docs/Umsetzungsanweisung/START_HIER.md)
|
||||||
Einstieg in die fachlichen Umsetzungsanweisungen und deren Reihenfolge.
|
Einstieg in die fachlichen Umsetzungsanweisungen und deren Reihenfolge.
|
||||||
|
|
||||||
|
- [POSTGRES_STAGING_RECOVERY.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/docs/POSTGRES_STAGING_RECOVERY.md)
|
||||||
|
Runbook fuer WAL-/Checkpoint-Schaeden und Wiederherstellung der Staging-PostgreSQL-Instanz.
|
||||||
|
|
||||||
## Technische Projektstruktur
|
## Technische Projektstruktur
|
||||||
|
|
||||||
- `public/` Web-Root mit Desktop-Shell auf `/`
|
- `public/` Web-Root mit Desktop-Shell auf `/`
|
||||||
@@ -70,3 +73,4 @@ Aktuell wichtig:
|
|||||||
- `Systemtools` und installierbare `Module` werden getrennt behandelt; Systemtools sind nicht Teil der Benutzer-Installationsauswahl
|
- `Systemtools` und installierbare `Module` werden getrennt behandelt; Systemtools sind nicht Teil der Benutzer-Installationsauswahl
|
||||||
- das `Cron Tool` ist als globales Systemtool vorhanden und sammelt Modul-Cronjobs automatisch ueber Manifest-Metadaten
|
- das `Cron Tool` ist als globales Systemtool vorhanden und sammelt Modul-Cronjobs automatisch ueber Manifest-Metadaten
|
||||||
- Widgets und Cron-Endpunkte sollen von Modulen bevorzugt direkt in `module.json` beschrieben werden
|
- Widgets und Cron-Endpunkte sollen von Modulen bevorzugt direkt in `module.json` beschrieben werden
|
||||||
|
- das PostgreSQL-Staging-Recovery-Runbook fuer Portainer-Stacks mit Bind-Mount liegt zentral unter `docs/POSTGRES_STAGING_RECOVERY.md`
|
||||||
|
|||||||
Reference in New Issue
Block a user