BAMS initial project structure
This commit is contained in:
354
cockpit/bams.js
Normal file
354
cockpit/bams.js
Normal file
@@ -0,0 +1,354 @@
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
const API_BASE = "http://localhost:8080/api/v1";
|
||||
let currentTab = "dashboard";
|
||||
|
||||
// Initialize
|
||||
$(document).ready(function() {
|
||||
setupTabs();
|
||||
loadDashboard();
|
||||
setupEventHandlers();
|
||||
});
|
||||
|
||||
function setupTabs() {
|
||||
$("a[data-tab]").on("click", function(e) {
|
||||
e.preventDefault();
|
||||
const tab = $(this).data("tab");
|
||||
switchTab(tab);
|
||||
});
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
$(".tab-content").hide();
|
||||
$(".nav li").removeClass("active");
|
||||
$(`#${tab}`).show();
|
||||
$(`a[data-tab="${tab}"]`).parent().addClass("active");
|
||||
currentTab = tab;
|
||||
|
||||
// Load tab-specific data
|
||||
switch(tab) {
|
||||
case "dashboard":
|
||||
loadDashboard();
|
||||
break;
|
||||
case "storage":
|
||||
loadRepositories();
|
||||
break;
|
||||
case "tape":
|
||||
loadTapeLibrary();
|
||||
break;
|
||||
case "iscsi":
|
||||
loadiSCSITargets();
|
||||
break;
|
||||
case "bacula":
|
||||
loadBaculaStatus();
|
||||
break;
|
||||
case "logs":
|
||||
loadLogs();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function apiCall(endpoint, method, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
url: `${API_BASE}${endpoint}`,
|
||||
method: method || "GET",
|
||||
contentType: "application/json",
|
||||
success: resolve,
|
||||
error: function(xhr, status, error) {
|
||||
reject(new Error(error || xhr.responseText));
|
||||
}
|
||||
};
|
||||
if (data) {
|
||||
options.data = JSON.stringify(data);
|
||||
}
|
||||
$.ajax(options);
|
||||
});
|
||||
}
|
||||
|
||||
function loadDashboard() {
|
||||
apiCall("/dashboard")
|
||||
.then(data => {
|
||||
// Update disk stats
|
||||
const disk = data.disk || {};
|
||||
$("#disk-stats").html(`
|
||||
<p><strong>Repositories:</strong> ${disk.repositories || 0}</p>
|
||||
<p><strong>Total:</strong> ${formatBytes(disk.total_capacity || 0)}</p>
|
||||
<p><strong>Used:</strong> ${formatBytes(disk.used_capacity || 0)}</p>
|
||||
`);
|
||||
|
||||
// Update tape stats
|
||||
const tape = data.tape || {};
|
||||
$("#tape-stats").html(`
|
||||
<p><strong>Status:</strong> ${tape.library_status || "unknown"}</p>
|
||||
<p><strong>Active Drives:</strong> ${tape.drives_active || 0}</p>
|
||||
<p><strong>Total Slots:</strong> ${tape.total_slots || 0}</p>
|
||||
`);
|
||||
|
||||
// Update iSCSI stats
|
||||
const iscsi = data.iscsi || {};
|
||||
$("#iscsi-stats").html(`
|
||||
<p><strong>Targets:</strong> ${iscsi.targets || 0}</p>
|
||||
<p><strong>Sessions:</strong> ${iscsi.sessions || 0}</p>
|
||||
`);
|
||||
|
||||
// Update Bacula stats
|
||||
const bacula = data.bacula || {};
|
||||
$("#bacula-stats").html(`
|
||||
<p><strong>Status:</strong> ${bacula.status || "unknown"}</p>
|
||||
`);
|
||||
|
||||
// Update alerts
|
||||
const alerts = data.alerts || [];
|
||||
if (alerts.length > 0) {
|
||||
let alertsHtml = "<ul>";
|
||||
alerts.forEach(alert => {
|
||||
alertsHtml += `<li class="alert alert-${alert.level || 'warning'}">${alert.message}</li>`;
|
||||
});
|
||||
alertsHtml += "</ul>";
|
||||
$("#alerts").html(alertsHtml);
|
||||
} else {
|
||||
$("#alerts").html("<p>No alerts</p>");
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to load dashboard:", err);
|
||||
$("#disk-stats, #tape-stats, #iscsi-stats, #bacula-stats").html("<p>Error loading data</p>");
|
||||
});
|
||||
}
|
||||
|
||||
function loadRepositories() {
|
||||
apiCall("/disk/repositories")
|
||||
.then(repos => {
|
||||
let html = "";
|
||||
if (repos.length === 0) {
|
||||
html = "<tr><td colspan='6'>No repositories</td></tr>";
|
||||
} else {
|
||||
repos.forEach(repo => {
|
||||
html += `
|
||||
<tr>
|
||||
<td>${repo.name}</td>
|
||||
<td>${repo.type}</td>
|
||||
<td>${repo.size}</td>
|
||||
<td>${repo.used || "N/A"}</td>
|
||||
<td><span class="label label-${repo.status === 'active' ? 'success' : 'default'}">${repo.status}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteRepository('${repo.id}')">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
$("#repositories-table").html(html);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to load repositories:", err);
|
||||
$("#repositories-table").html("<tr><td colspan='6'>Error loading repositories</td></tr>");
|
||||
});
|
||||
}
|
||||
|
||||
function loadTapeLibrary() {
|
||||
Promise.all([
|
||||
apiCall("/tape/library"),
|
||||
apiCall("/tape/drives"),
|
||||
apiCall("/tape/slots")
|
||||
]).then(([library, drives, slots]) => {
|
||||
// Library status
|
||||
$("#library-status").html(`
|
||||
<p><strong>Status:</strong> ${library.status}</p>
|
||||
<p><strong>Model:</strong> ${library.model || "N/A"}</p>
|
||||
<p><strong>Total Slots:</strong> ${library.total_slots}</p>
|
||||
<p><strong>Active Drives:</strong> ${library.active_drives}</p>
|
||||
`);
|
||||
|
||||
// Drives
|
||||
let drivesHtml = "";
|
||||
if (drives.length === 0) {
|
||||
drivesHtml = "<p>No drives detected</p>";
|
||||
} else {
|
||||
drives.forEach(drive => {
|
||||
drivesHtml += `
|
||||
<div class="drive-item">
|
||||
<strong>${drive.id}</strong>: ${drive.status}
|
||||
${drive.loaded_tape ? ` (Tape: ${drive.barcode || drive.loaded_tape})` : ""}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
$("#drives-list").html(drivesHtml);
|
||||
|
||||
// Slots
|
||||
let slotsHtml = "<table class='table'><thead><tr><th>Slot</th><th>Barcode</th><th>Status</th></tr></thead><tbody>";
|
||||
if (slots.length === 0) {
|
||||
slotsHtml += "<tr><td colspan='3'>No slots</td></tr>";
|
||||
} else {
|
||||
slots.forEach(slot => {
|
||||
slotsHtml += `
|
||||
<tr>
|
||||
<td>${slot.number}</td>
|
||||
<td>${slot.barcode || "N/A"}</td>
|
||||
<td>${slot.status}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
slotsHtml += "</tbody></table>";
|
||||
$("#slots-list").html(slotsHtml);
|
||||
}).catch(err => {
|
||||
console.error("Failed to load tape library:", err);
|
||||
});
|
||||
}
|
||||
|
||||
function loadiSCSITargets() {
|
||||
Promise.all([
|
||||
apiCall("/iscsi/targets"),
|
||||
apiCall("/iscsi/sessions")
|
||||
]).then(([targets, sessions]) => {
|
||||
// Targets
|
||||
let targetsHtml = "";
|
||||
if (targets.length === 0) {
|
||||
targetsHtml = "<tr><td colspan='5'>No targets</td></tr>";
|
||||
} else {
|
||||
targets.forEach(target => {
|
||||
targetsHtml += `
|
||||
<tr>
|
||||
<td>${target.iqn}</td>
|
||||
<td>${target.portals.join(", ") || "N/A"}</td>
|
||||
<td>${target.initiators.join(", ") || "N/A"}</td>
|
||||
<td><span class="label label-${target.status === 'active' ? 'success' : 'default'}">${target.status}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="applyTarget('${target.id}')">Apply</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteTarget('${target.id}')">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
$("#targets-table").html(targetsHtml);
|
||||
|
||||
// Sessions
|
||||
let sessionsHtml = "";
|
||||
if (sessions.length === 0) {
|
||||
sessionsHtml = "<tr><td colspan='4'>No active sessions</td></tr>";
|
||||
} else {
|
||||
sessions.forEach(session => {
|
||||
sessionsHtml += `
|
||||
<tr>
|
||||
<td>${session.target_iqn}</td>
|
||||
<td>${session.initiator_iqn}</td>
|
||||
<td>${session.ip}</td>
|
||||
<td>${session.state}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
$("#sessions-table").html(sessionsHtml);
|
||||
}).catch(err => {
|
||||
console.error("Failed to load iSCSI targets:", err);
|
||||
});
|
||||
}
|
||||
|
||||
function loadBaculaStatus() {
|
||||
apiCall("/bacula/status")
|
||||
.then(status => {
|
||||
$("#bacula-status").html(`
|
||||
<p><strong>Status:</strong> <span class="label label-${status.status === 'running' ? 'success' : 'danger'}">${status.status}</span></p>
|
||||
${status.version ? `<p><strong>Version:</strong> ${status.version}</p>` : ""}
|
||||
${status.pid ? `<p><strong>PID:</strong> ${status.pid}</p>` : ""}
|
||||
`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to load Bacula status:", err);
|
||||
});
|
||||
}
|
||||
|
||||
function loadLogs() {
|
||||
const service = $("#log-service").val();
|
||||
apiCall(`/logs/${service}?lines=100`)
|
||||
.then(logs => {
|
||||
let logsHtml = "";
|
||||
logs.forEach(entry => {
|
||||
logsHtml += `[${entry.timestamp}] ${entry.level}: ${entry.message}\n`;
|
||||
});
|
||||
$("#logs-content").text(logsHtml);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to load logs:", err);
|
||||
$("#logs-content").text("Error loading logs: " + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function setupEventHandlers() {
|
||||
$("#inventory-btn").on("click", function() {
|
||||
apiCall("/tape/inventory", "POST")
|
||||
.then(() => {
|
||||
alert("Inventory started");
|
||||
loadTapeLibrary();
|
||||
})
|
||||
.catch(err => alert("Failed to start inventory: " + err.message));
|
||||
});
|
||||
|
||||
$("#bacula-inventory-btn").on("click", function() {
|
||||
apiCall("/bacula/inventory", "POST")
|
||||
.then(() => alert("Inventory started"))
|
||||
.catch(err => alert("Failed to start inventory: " + err.message));
|
||||
});
|
||||
|
||||
$("#bacula-restart-btn").on("click", function() {
|
||||
if (confirm("Restart Bacula Storage Daemon?")) {
|
||||
apiCall("/bacula/restart", "POST")
|
||||
.then(() => {
|
||||
alert("Restart initiated");
|
||||
setTimeout(loadBaculaStatus, 2000);
|
||||
})
|
||||
.catch(err => alert("Failed to restart: " + err.message));
|
||||
}
|
||||
});
|
||||
|
||||
$("#refresh-logs-btn").on("click", loadLogs);
|
||||
$("#log-service").on("change", loadLogs);
|
||||
|
||||
$("#download-bundle-btn").on("click", function() {
|
||||
window.location.href = `${API_BASE}/diagnostics/bundle`;
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
|
||||
// Global functions for inline handlers
|
||||
window.deleteRepository = function(id) {
|
||||
if (confirm("Delete this repository?")) {
|
||||
apiCall(`/disk/repositories/${id}`, "DELETE")
|
||||
.then(() => {
|
||||
alert("Repository deleted");
|
||||
loadRepositories();
|
||||
})
|
||||
.catch(err => alert("Failed to delete: " + err.message));
|
||||
}
|
||||
};
|
||||
|
||||
window.applyTarget = function(id) {
|
||||
apiCall(`/iscsi/targets/${id}/apply`, "POST")
|
||||
.then(() => alert("Target configuration applied"))
|
||||
.catch(err => alert("Failed to apply: " + err.message));
|
||||
};
|
||||
|
||||
window.deleteTarget = function(id) {
|
||||
if (confirm("Delete this iSCSI target?")) {
|
||||
apiCall(`/iscsi/targets/${id}`, "DELETE")
|
||||
.then(() => {
|
||||
alert("Target deleted");
|
||||
loadiSCSITargets();
|
||||
})
|
||||
.catch(err => alert("Failed to delete: " + err.message));
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
250
cockpit/index.html
Normal file
250
cockpit/index.html
Normal file
@@ -0,0 +1,250 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>BAMS - Backup Appliance Management System</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="../base1/cockpit.css" type="text/css" rel="stylesheet">
|
||||
<script src="../base1/jquery.js"></script>
|
||||
<script src="../base1/cockpit.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<nav class="navbar navbar-default">
|
||||
<div class="container-fluid">
|
||||
<div class="navbar-header">
|
||||
<a class="navbar-brand" href="#">BAMS</a>
|
||||
</div>
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="active"><a href="#dashboard" data-tab="dashboard">Dashboard</a></li>
|
||||
<li><a href="#storage" data-tab="storage">Storage</a></li>
|
||||
<li><a href="#tape" data-tab="tape">Tape Library</a></li>
|
||||
<li><a href="#iscsi" data-tab="iscsi">iSCSI Targets</a></li>
|
||||
<li><a href="#bacula" data-tab="bacula">Bacula</a></li>
|
||||
<li><a href="#logs" data-tab="logs">Logs</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div id="dashboard" class="tab-content active">
|
||||
<div class="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Disk Storage</div>
|
||||
<div class="panel-body">
|
||||
<div id="disk-stats">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Tape Library</div>
|
||||
<div class="panel-body">
|
||||
<div id="tape-stats">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">iSCSI</div>
|
||||
<div class="panel-body">
|
||||
<div id="iscsi-stats">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Bacula SD</div>
|
||||
<div class="panel-body">
|
||||
<div id="bacula-stats">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Alerts</div>
|
||||
<div class="panel-body">
|
||||
<div id="alerts">
|
||||
<p>No alerts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="storage" class="tab-content" style="display: none;">
|
||||
<div class="page-header">
|
||||
<h1>Storage Repositories</h1>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<button class="btn btn-primary" id="create-repo-btn">Create Repository</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Used</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="repositories-table">
|
||||
<tr><td colspan="6">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tape" class="tab-content" style="display: none;">
|
||||
<div class="page-header">
|
||||
<h1>Tape Library Management</h1>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
Library Status
|
||||
<button class="btn btn-sm btn-default pull-right" id="inventory-btn">Run Inventory</button>
|
||||
</div>
|
||||
<div class="panel-body" id="library-status">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Tape Drives</div>
|
||||
<div class="panel-body" id="drives-list">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Slots</div>
|
||||
<div class="panel-body" id="slots-list">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="iscsi" class="tab-content" style="display: none;">
|
||||
<div class="page-header">
|
||||
<h1>iSCSI Target Management</h1>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<button class="btn btn-primary" id="create-target-btn">Create Target</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IQN</th>
|
||||
<th>Portals</th>
|
||||
<th>Initiators</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="targets-table">
|
||||
<tr><td colspan="5">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Active Sessions</div>
|
||||
<div class="panel-body">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Target IQN</th>
|
||||
<th>Initiator IQN</th>
|
||||
<th>IP Address</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessions-table">
|
||||
<tr><td colspan="4">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bacula" class="tab-content" style="display: none;">
|
||||
<div class="page-header">
|
||||
<h1>Bacula Integration</h1>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Storage Daemon Status</div>
|
||||
<div class="panel-body" id="bacula-status">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Actions</div>
|
||||
<div class="panel-body">
|
||||
<button class="btn btn-default" id="bacula-inventory-btn">Run Inventory</button>
|
||||
<button class="btn btn-default" id="bacula-restart-btn">Restart SD</button>
|
||||
<button class="btn btn-default" id="bacula-config-btn">Generate Config</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="logs" class="tab-content" style="display: none;">
|
||||
<div class="page-header">
|
||||
<h1>Logs & Diagnostics</h1>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<select id="log-service" class="form-control" style="display: inline-block; width: 200px;">
|
||||
<option value="bams">BAMS</option>
|
||||
<option value="scst">SCST</option>
|
||||
<option value="iscsi">iSCSI</option>
|
||||
<option value="bacula">Bacula</option>
|
||||
</select>
|
||||
<button class="btn btn-default" id="refresh-logs-btn">Refresh</button>
|
||||
<button class="btn btn-default" id="download-bundle-btn">Download Support Bundle</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<pre id="logs-content" style="max-height: 600px; overflow-y: auto;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="bams.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
19
cockpit/manifest.json
Normal file
19
cockpit/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"name": "BAMS",
|
||||
"displayName": "Backup Appliance Management System",
|
||||
"description": "Manage disk repositories, tape libraries, iSCSI targets, and Bacula integration",
|
||||
"author": "BAMS Team",
|
||||
"license": "GPL-3.0",
|
||||
"url": "https://github.com/bams/bams",
|
||||
"tools": {
|
||||
"bams": {
|
||||
"label": "BAMS",
|
||||
"path": "index.html"
|
||||
}
|
||||
},
|
||||
"requires": {
|
||||
"cockpit": ">= 300"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user