Port backend to Node.js
This commit is contained in:
11
views/errors/404.ejs
Normal file
11
views/errors/404.ejs
Normal file
@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Not Found</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<a href="/">Back to home</a>
|
||||
</body>
|
||||
</html>
|
||||
11
views/errors/500.ejs
Normal file
11
views/errors/500.ejs
Normal file
@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Error</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>500 - Server Error</h1>
|
||||
<a href="/">Back to home</a>
|
||||
</body>
|
||||
</html>
|
||||
486
views/index.ejs
Normal file
486
views/index.ejs
Normal file
@ -0,0 +1,486 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user