487 lines
26 KiB
Plaintext
487 lines
26 KiB
Plaintext
<!DOCTYPE html>
|
|
<html lang="en" x-data="{ darkMode: localStorage.getItem('darkMode') === 'true' || (!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches) }"
|
|
:class="{ 'dark': darkMode }"
|
|
class="transition-colors duration-500 ease-in-out">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="csrf-token" content="<%= csrfToken %>">
|
|
<title>Ceph Max Storage Calculator</title>
|
|
<!-- HTMX for interactive elements -->
|
|
<script src="https://unpkg.com/htmx.org@1.9.4"></script>
|
|
<!-- Tailwind CSS for modern design -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
darkMode: 'class',
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
dark: {
|
|
bg: '#1a1a1a',
|
|
card: '#2d2d2d',
|
|
border: '#404040',
|
|
text: '#e5e5e5',
|
|
input: '#404040',
|
|
hover: '#505050'
|
|
}
|
|
},
|
|
transitionProperty: {
|
|
'all': 'all',
|
|
'colors': 'color, background-color, border-color, text-decoration-color, fill, stroke',
|
|
'opacity': 'opacity',
|
|
'shadow': 'box-shadow',
|
|
'transform': 'transform',
|
|
},
|
|
transitionDuration: {
|
|
'200': '200ms',
|
|
'300': '300ms',
|
|
'500': '500ms',
|
|
'700': '700ms',
|
|
},
|
|
transitionTimingFunction: {
|
|
'ease-in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<!-- Alpine.js for reactive user interface -->
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.0/dist/cdn.min.js"></script>
|
|
<script src="/static/js/script.js"></script>
|
|
</head>
|
|
<body class="bg-gray-100 dark:bg-dark-bg min-h-screen transition-all duration-500 ease-in-out" x-data="cephCalculator()">
|
|
<div class="w-full px-4 py-8 bg-gray-100 dark:bg-dark-bg min-h-screen transition-all duration-500 ease-in-out">
|
|
<div class="max-w-7xl mx-auto">
|
|
<!-- Logo -->
|
|
<div class="flex justify-center mb-8">
|
|
<img src="/static/img/tk-logo.png" alt="Thomas-Krenn.AG Logo" class="h-25 w-auto">
|
|
</div>
|
|
|
|
<header class="mb-8">
|
|
<h1 class="text-3xl font-bold text-center text-[#333333] dark:text-[#F7941D]">Ceph Max Storage Capacity Calculator</h1>
|
|
<p class="text-gray-600 dark:text-gray-400 text-center mt-2">Calculate the maximum storage usage of your Ceph cluster</p>
|
|
</header>
|
|
|
|
<main class="bg-white dark:bg-dark-card rounded-lg shadow-lg p-6">
|
|
<!-- Configuration Section -->
|
|
<div class="mb-8">
|
|
<h2 class="text-xl font-semibold mb-4 text-[#F7941D] dark:text-[#F7941D]">Configuration</h2>
|
|
|
|
<!-- Replication Type Selection -->
|
|
<div class="mb-6">
|
|
<label class="block text-[#333333] dark:text-dark-text mb-2">Replication Type</label>
|
|
<div class="flex space-x-4">
|
|
<button @click="replicationType = 'replication'"
|
|
:class="replicationType === 'replication' ? 'bg-[#F7941D] text-white' : 'bg-gray-200 text-[#333333] hover:bg-[#F7941D] hover:text-white'"
|
|
class="px-4 py-2 rounded-lg transition-colors duration-200">
|
|
Replication
|
|
</button>
|
|
<button @click="replicationType = 'erasure_coding'"
|
|
:class="replicationType === 'erasure_coding' ? 'bg-[#F7941D] text-white' : 'bg-gray-200 text-[#333333] hover:bg-[#F7941D] hover:text-white'"
|
|
class="px-4 py-2 rounded-lg transition-colors duration-200">
|
|
Erasure Coding
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Replication Parameters -->
|
|
<div x-show="replicationType === 'replication'" class="space-y-4">
|
|
<div>
|
|
<label class="block text-[#333333] dark:text-dark-text mb-2">Number of Replicas</label>
|
|
<input type="number" x-model="replicas" min="1"
|
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 focus:border-[#F7941D] focus:ring-[#F7941D]">
|
|
</div>
|
|
<div>
|
|
<label class="block text-[#333333] dark:text-dark-text mb-2">Minimum Size</label>
|
|
<input type="number" x-model="minSize" min="1"
|
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 focus:border-[#F7941D] focus:ring-[#F7941D]">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Erasure Coding Parameters -->
|
|
<div x-show="replicationType === 'erasure_coding'" class="space-y-4">
|
|
<div>
|
|
<label class="block text-[#333333] dark:text-dark-text mb-2">Data Chunks (k)</label>
|
|
<input type="number" x-model="k" min="1"
|
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 focus:border-[#F7941D] focus:ring-[#F7941D]">
|
|
</div>
|
|
<div>
|
|
<label class="block text-[#333333] dark:text-dark-text mb-2">Coding Chunks (m)</label>
|
|
<input type="number" x-model="m" min="1"
|
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 focus:border-[#F7941D] focus:ring-[#F7941D]">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Storage Unit Selection -->
|
|
<div class="mt-6">
|
|
<label class="block text-[#333333] dark:text-dark-text mb-2">Storage Unit</label>
|
|
<div class="flex space-x-4">
|
|
<button @click="storageUnit = 'TB'"
|
|
:class="storageUnit === 'TB' ? 'bg-[#F7941D] text-white' : 'bg-gray-200 text-[#333333] hover:bg-[#F7941D] hover:text-white'"
|
|
class="px-4 py-2 rounded-lg transition-colors duration-200">
|
|
TB
|
|
</button>
|
|
<button @click="storageUnit = 'GB'"
|
|
:class="storageUnit === 'GB' ? 'bg-[#F7941D] text-white' : 'bg-gray-200 text-[#333333] hover:bg-[#F7941D] hover:text-white'"
|
|
class="px-4 py-2 rounded-lg transition-colors duration-200">
|
|
GB
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Nodes Configuration -->
|
|
<div class="mb-8">
|
|
<h2 class="text-xl font-semibold mb-4 text-[#F7941D] dark:text-[#F7941D]">Nodes Configuration</h2>
|
|
<div class="space-y-4">
|
|
<template x-for="(node, index) in nodes" :key="index">
|
|
<div class="bg-gray-50 dark:bg-dark-input p-4 rounded-lg">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-medium text-[#333333] dark:text-dark-text">Node <span x-text="index + 1"></span></h3>
|
|
<button @click="removeNode(index)" x-show="nodes.length > 1"
|
|
class="text-red-600 hover:text-red-800">
|
|
Remove Node
|
|
</button>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="block text-[#333333] dark:text-dark-text mb-2">Number of OSDs</label>
|
|
<input type="number" x-model="node.osd_count" min="1"
|
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 focus:border-[#F7941D] focus:ring-[#F7941D]">
|
|
</div>
|
|
<div>
|
|
<label class="block text-[#333333] dark:text-dark-text mb-2">OSD Size</label>
|
|
<div class="flex">
|
|
<input type="number" x-model="node.osd_size_gb" min="1" step="0.1"
|
|
class="w-full px-4 py-2 rounded-l-lg border border-gray-300 focus:border-[#F7941D] focus:ring-[#F7941D]">
|
|
<span class="inline-flex items-center px-4 py-2 bg-gray-200 text-[#333333] rounded-r-lg border border-l-0 border-gray-300">
|
|
<span x-text="storageUnit"></span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<button @click="addNode()"
|
|
class="mt-4 px-6 py-2 bg-[#F7941D] text-white rounded-lg hover:bg-[#e88616] transition-colors duration-200">
|
|
Add Node
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex flex-wrap gap-4 mb-8">
|
|
<button @click="calculateCapacity()"
|
|
class="px-6 py-2 bg-[#F7941D] text-white rounded-lg hover:bg-[#e88616] transition-colors duration-200 flex items-center">
|
|
<span x-show="!isCalculating">Calculate</span>
|
|
<span x-show="isCalculating">Calculating...</span>
|
|
</button>
|
|
<button @click="generatePDF()"
|
|
class="px-6 py-2 border border-[#F7941D] text-[#F7941D] rounded-lg hover:bg-[#F7941D] hover:text-white transition-colors duration-200"
|
|
x-bind:disabled="!result.raw_total">
|
|
Generate PDF Report
|
|
</button>
|
|
<button @click="exportConfig()"
|
|
class="px-6 py-2 border border-[#F7941D] text-[#F7941D] rounded-lg hover:bg-[#F7941D] hover:text-white transition-colors duration-200">
|
|
Export Configuration
|
|
</button>
|
|
<label class="px-6 py-2 border border-[#F7941D] text-[#F7941D] rounded-lg hover:bg-[#F7941D] hover:text-white transition-colors duration-200 cursor-pointer">
|
|
Import Configuration
|
|
<input type="file" @change="importConfig($event)" class="hidden" accept=".json">
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Results -->
|
|
<div x-show="result.raw_total" class="space-y-6">
|
|
<h2 class="text-xl font-semibold text-[#F7941D] dark:text-[#F7941D]">Results</h2>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
<div class="p-3">
|
|
<p class="text-gray-600 dark:text-gray-400 mb-1">Total Raw Storage:</p>
|
|
<p class="text-2xl font-bold text-[#333333] dark:text-dark-text">
|
|
<span x-text="storageUnit === 'TB' ? `${result.raw_total} TB` : `${result.raw_total} GB`"></span>
|
|
</p>
|
|
</div>
|
|
<div class="p-3 bg-gray-50 dark:bg-dark-input rounded-lg">
|
|
<p class="text-gray-600 dark:text-gray-400 mb-1">Maximum Recommended Usage:</p>
|
|
<p class="text-2xl font-bold text-[#F7941D] dark:text-[#F7941D]" x-text="`${result.max_usage_percent}%`"></p>
|
|
<p class="text-xl font-semibold text-[#333333] dark:text-dark-text mt-2">
|
|
<span x-text="storageUnit === 'TB' ? `${result.max_usage_tb} TB` : `${result.max_usage_gb} GB`"></span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fault Tolerance -->
|
|
<div class="mt-8">
|
|
<h2 class="text-xl font-semibold mb-4 text-[#F7941D] dark:text-[#F7941D]">Fault Tolerance</h2>
|
|
<div class="space-y-4">
|
|
<div class="p-4 rounded-lg" :class="result.node_failure_tolerance ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'">
|
|
<p class="font-semibold mb-2" :class="result.node_failure_tolerance ? 'text-green-700' : 'text-red-700'">
|
|
Node Fault Tolerance: <span x-text="result.node_failure_tolerance ? 'Available' : 'Not Available'"></span>
|
|
</p>
|
|
<p class="text-gray-600" x-text="result.node_failure_info"></p>
|
|
</div>
|
|
<div class="p-4 rounded-lg" :class="result.osd_failure_tolerance ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'">
|
|
<p class="font-semibold mb-2" :class="result.osd_failure_tolerance ? 'text-green-700' : 'text-red-700'">
|
|
OSD Fault Tolerance: <span x-text="result.osd_failure_tolerance ? 'Available' : 'Limited'"></span>
|
|
</p>
|
|
<p class="text-gray-600" x-text="result.osd_failure_info"></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recommendations -->
|
|
<div class="mt-8">
|
|
<h2 class="text-xl font-semibold mb-4 text-[#F7941D] dark:text-[#F7941D]">Recommendations</h2>
|
|
<div class="bg-gray-50 dark:bg-dark-input p-6 rounded-lg border border-gray-200 dark:border-dark-border">
|
|
<ul class="list-disc pl-5 space-y-2 text-[#333333] dark:text-dark-text">
|
|
<li>Ensure that after node failures, enough capacity remains to avoid reaching the 'full ratio'.</li>
|
|
<li>For replication, the number of nodes should be greater than the replication factor.</li>
|
|
<li>For erasure coding, the number of nodes should be greater than k+m (data chunks + coding chunks).</li>
|
|
<li>Recovery performance depends on network and storage speed.</li>
|
|
<li>Note that after a node failure, rebalancing the cluster takes longer the larger the failed node is.</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<footer class="mt-12 text-center text-gray-500 dark:text-gray-400 text-sm">
|
|
<p>Ceph Storage Calculator © 2025 Thomas-Krenn.AG</p>
|
|
</footer>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function cephCalculator() {
|
|
return {
|
|
replicationType: 'replication',
|
|
replicas: 3,
|
|
minSize: 2,
|
|
k: 4,
|
|
m: 2,
|
|
storageUnit: 'TB',
|
|
nodes: [
|
|
{ osd_count: 4, osd_size_gb: 1 },
|
|
{ osd_count: 4, osd_size_gb: 1 },
|
|
{ osd_count: 4, osd_size_gb: 1 }
|
|
],
|
|
result: {
|
|
raw_total: 0,
|
|
max_usage_percent: 0,
|
|
max_usage_gb: 0,
|
|
max_usage_tb: 0,
|
|
node_failure_tolerance: false,
|
|
node_failure_info: '',
|
|
osd_failure_tolerance: false,
|
|
osd_failure_info: '',
|
|
multi_failure_tolerance: false,
|
|
max_failure_nodes: 0
|
|
},
|
|
isCalculating: false,
|
|
|
|
addNode() {
|
|
this.nodes.push({ osd_count: 3, osd_size_gb: 2000 });
|
|
},
|
|
|
|
removeNode(index) {
|
|
if (this.nodes.length > 1) {
|
|
this.nodes.splice(index, 1);
|
|
}
|
|
},
|
|
|
|
getOsdSizeInGB(size) {
|
|
return this.storageUnit === 'TB' ? size * 1024 : size;
|
|
},
|
|
|
|
calculateCapacity() {
|
|
this.isCalculating = true;
|
|
|
|
// Validate Erasure Coding parameters
|
|
if (this.replicationType === 'erasure_coding') {
|
|
if (parseInt(this.k) <= 0 || parseInt(this.m) <= 0) {
|
|
alert('Für Erasure Coding müssen k und m größer als 0 sein.');
|
|
this.isCalculating = false;
|
|
return;
|
|
}
|
|
if (parseInt(this.k) + parseInt(this.m) <= 0) {
|
|
alert('Die Summe von k und m muss größer als 0 sein.');
|
|
this.isCalculating = false;
|
|
return;
|
|
}
|
|
if (this.nodes.length <= parseInt(this.k)) {
|
|
alert('Die Anzahl der Nodes muss größer als k sein.');
|
|
this.isCalculating = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Validate OSD sizes
|
|
for (let node of this.nodes) {
|
|
if (parseFloat(node.osd_size_gb) <= 0) {
|
|
alert('Die OSD-Größe muss größer als 0 sein.');
|
|
this.isCalculating = false;
|
|
return;
|
|
}
|
|
if (parseInt(node.osd_count) <= 0) {
|
|
alert('Die Anzahl der OSDs muss größer als 0 sein.');
|
|
this.isCalculating = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Get CSRF token
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
|
|
|
// Prepare data for sending
|
|
const data = {
|
|
replication_type: this.replicationType,
|
|
replicas: parseInt(this.replicas),
|
|
min_size: parseInt(this.minSize),
|
|
k: parseInt(this.k),
|
|
m: parseInt(this.m),
|
|
nodes: this.nodes.map(node => ({
|
|
osd_count: parseInt(node.osd_count),
|
|
osd_size_gb: this.getOsdSizeInGB(parseFloat(node.osd_size_gb))
|
|
})),
|
|
storage_unit: this.storageUnit
|
|
};
|
|
|
|
// Send request to server
|
|
fetch('/calculate', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken
|
|
},
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error('Calculation failed');
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(result => {
|
|
this.result = result;
|
|
this.isCalculating = false;
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
this.isCalculating = false;
|
|
alert('Error during calculation: ' + error.message);
|
|
});
|
|
},
|
|
|
|
exportConfig() {
|
|
const data = {
|
|
replication_type: this.replicationType,
|
|
replicas: this.replicas,
|
|
min_size: this.minSize,
|
|
k: this.k,
|
|
m: this.m,
|
|
nodes: this.nodes,
|
|
storage_unit: this.storageUnit,
|
|
result: this.result
|
|
};
|
|
|
|
const dataStr = JSON.stringify(data, null, 2);
|
|
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
|
|
|
|
const exportFileDefaultName = 'ceph-config.json';
|
|
|
|
const linkElement = document.createElement('a');
|
|
linkElement.setAttribute('href', dataUri);
|
|
linkElement.setAttribute('download', exportFileDefaultName);
|
|
linkElement.click();
|
|
},
|
|
|
|
importConfig(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
try {
|
|
const config = JSON.parse(e.target.result);
|
|
|
|
// Update local state with imported configuration
|
|
this.replicationType = config.replication_type || 'replication';
|
|
this.replicas = config.replicas || 3;
|
|
this.minSize = config.min_size || 2;
|
|
this.k = config.k || 4;
|
|
this.m = config.m || 2;
|
|
this.nodes = config.nodes || [{ osd_count: 3, osd_size_gb: 2000 }];
|
|
this.storageUnit = config.storage_unit || 'TB';
|
|
|
|
// If results are included, update those too
|
|
if (config.result && config.result.raw_total) {
|
|
this.result = config.result;
|
|
}
|
|
|
|
alert('Configuration successfully imported!');
|
|
} catch (error) {
|
|
console.error('Error importing configuration:', error);
|
|
alert('Error importing configuration. Please check the file format.');
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
},
|
|
|
|
generatePDF() {
|
|
// Get CSRF token
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
|
|
|
// Prepare data for sending
|
|
const data = {
|
|
replication_type: this.replicationType,
|
|
replicas: parseInt(this.replicas),
|
|
min_size: parseInt(this.minSize),
|
|
k: parseInt(this.k),
|
|
m: parseInt(this.m),
|
|
nodes: this.nodes.map(node => ({
|
|
osd_count: parseInt(node.osd_count),
|
|
osd_size_gb: this.getOsdSizeInGB(parseFloat(node.osd_size_gb))
|
|
})),
|
|
storage_unit: this.storageUnit,
|
|
result: this.result
|
|
};
|
|
|
|
// Send request to server
|
|
fetch('/generate-pdf', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken
|
|
},
|
|
body: JSON.stringify(data)
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error('PDF generation failed');
|
|
}
|
|
return response.blob();
|
|
})
|
|
.then(blob => {
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.style.display = 'none';
|
|
a.href = url;
|
|
a.download = 'ceph-report.pdf';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error:', error);
|
|
alert('Error generating PDF: ' + error.message);
|
|
});
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|