first commit

This commit is contained in:
Mateusz Gruszczyński 2025-05-18 19:30:50 +02:00
commit e382fab275
8 changed files with 627 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
venv
config.py

Binary file not shown.

173
app.py Normal file
View File

@ -0,0 +1,173 @@
from flask import Flask, request, jsonify, render_template
from influxdb import InfluxDBClient
from flask_cors import CORS
from config import get_influx_client
app = Flask(__name__)
CORS(app)
client = get_influx_client()
def escape_identifier(identifier):
"""Escapuje identyfikatory dla InfluxDB."""
return f'"{identifier.replace("\\", "\\\\").replace("\"", "\\\"")}"'
def escape_value(value):
"""Escapuje wartości dla zapytań InfluxDB."""
if isinstance(value, str):
escaped = value.replace("'", "''")
return f"'{escaped}'"
return str(value)
@app.route('/')
def index():
return render_template("index.html")
@app.route('/databases', methods=['GET'])
def get_databases():
databases = client.get_list_database()
return jsonify([db['name'] for db in databases])
@app.route('/series', methods=['GET'])
def get_series():
db = request.args.get('db')
if not db:
return jsonify({'error': 'Baza danych nie została wybrana'}), 400
client.switch_database(db)
series_info = []
#print("DEBUG: Pobieram listę measurementów...")
measurements = client.query("SHOW MEASUREMENTS")
measurement_names = [m['name'] for m in measurements.get_points()]
#print("DEBUG: Measurementy:", measurement_names)
for measurement in measurement_names:
if measurement == "results":
print("DEBUG: Pomijam 'results'")
continue
#print(f"DEBUG: SHOW SERIES FROM \"{measurement}\"")
try:
result = client.query(f'SHOW SERIES FROM "{measurement}"')
for s in result.get_points():
tags = {k: v for k, v in s.items() if k != 'time'}
if not tags:
continue
series_id = f"{measurement},{','.join([f'{k}={v}' for k, v in tags.items()])}"
#print(f"DEBUG: Series = {series_id}")
series_info.append({
'series': series_id,
'measurement': measurement,
'tags': tags
})
except Exception as e:
print(f"DEBUG: Błąd w {measurement}: {str(e)}")
print(f"DEBUG: Zwracam do frontu {len(series_info)} serii")
return jsonify(series_info)
@app.route('/delete', methods=['POST'])
def delete_series():
data = request.json
db = data.get('db')
if not db:
return jsonify({'error': 'Baza danych nie została wybrana'}), 400
client.switch_database(db)
deleted_count = 0
for series in data['series']:
try:
parts = series.split(',', 1)
measurement = parts[0].strip()
tags_part = parts[1] if len(parts) > 1 else ''
# Raw tag parsing
tags = {}
for tag in tags_part.split(','):
if '=' in tag:
k, v = tag.split('=', 1)
tags[k.strip()] = v.strip()
# Pobierz tylko tagi dostępne w measurement
tag_keys_query = client.query(f'SHOW TAG KEYS FROM "{measurement}"')
tag_keys = {row['tagKey'] for row in tag_keys_query.get_points()}
# Build WHERE clause tylko z tagów
where_parts = []
for k, v in tags.items():
if k not in tag_keys:
print(f"Pomijam '{k}', bo nie jest tagiem w '{measurement}'")
continue
v_escaped = v.replace("'", "\\'")
where_parts.append(f'"{k}" = \'{v_escaped}\'')
# Escape measurement for InfluxQL
measurement_escaped = measurement.replace('"', '\\"')
query = f'DROP SERIES FROM "{measurement_escaped}" WHERE {" AND ".join(where_parts)}'
print(f"DEBUG: Final query to run: {query}")
client.query(query)
deleted_count += 1
except Exception as e:
print(f"Błąd usuwania serii {series}: {str(e)}")
continue
return jsonify({'status': 'success', 'deleted': deleted_count})
@app.route('/delete_range', methods=['POST'])
def delete_range():
data = request.json
db = data.get('db')
series = data.get('series')
time_from = data.get('from')
time_to = data.get('to')
if not all([db, series, time_from, time_to]):
return jsonify({'error': 'Brakuje wymaganych parametrów'}), 400
client.switch_database(db)
try:
# Parsowanie measurementu i tagów z serii
parts = series.split(',', 1)
measurement = parts[0].strip()
tags_part = parts[1] if len(parts) > 1 else ''
tags = {}
for tag in tags_part.split(','):
if '=' in tag:
k, v = tag.split('=', 1)
tags[k.strip()] = v.strip()
# Pobierz dozwolone tagi z InfluxDB
tag_keys_query = client.query(f'SHOW TAG KEYS FROM "{measurement}"')
tag_keys = {row['tagKey'] for row in tag_keys_query.get_points()}
# Buduj WHERE tylko z tagów i czasu
where_parts = [f"time >= '{time_from}'", f"time <= '{time_to}'"]
for k, v in tags.items():
if k not in tag_keys:
print(f"Pomijam '{k}', bo nie jest tagiem w '{measurement}'")
continue
v_escaped = v.replace("'", "\\'")
where_parts.append(f'"{k}" = \'{v_escaped}\'')
measurement_escaped = measurement.replace('"', '\\"')
query = f'DELETE FROM "{measurement_escaped}" WHERE {" AND ".join(where_parts)}'
print(f"DEBUG: DELETE query: {query}")
client.query(query)
return jsonify({'status': 'success'})
except Exception as e:
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=9999, debug=True)

9
config.example.py Normal file
View File

@ -0,0 +1,9 @@
from influxdb import InfluxDBClient
def get_influx_client():
return InfluxDBClient(
host='stats.mngmnt.r.local',
port=8086,
#username='admin',
#password='admin'
)

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
flask
flask-cors
influxdb

289
static/script.js Normal file
View File

@ -0,0 +1,289 @@
let currentSeriesData = [];
let darkMode = true;
function toggleTheme() {
darkMode = !darkMode;
document.body.classList.toggle('bg-dark', darkMode);
document.body.classList.toggle('text-light', darkMode);
document.querySelector('.theme-switch').className = `bi ${darkMode ? 'bi-moon-stars-fill' : 'bi-sun-fill'} theme-switch`;
document.querySelectorAll('.form-select, .form-control').forEach(el => {
el.classList.toggle('bg-dark', darkMode);
el.classList.toggle('text-light', darkMode);
el.classList.toggle('border-secondary', darkMode);
});
}
async function fetchDatabases() {
const res = await fetch('/databases');
const dbs = await res.json();
const select = document.getElementById('db-select');
select.innerHTML = '<option value="">Wybierz bazę danych...</option>';
dbs.forEach(db => {
const option = document.createElement('option');
option.value = db;
option.textContent = db;
select.appendChild(option);
});
const params = new URLSearchParams(window.location.search);
const dbParam = params.get('db');
if (dbParam && dbs.includes(dbParam)) {
select.value = dbParam;
fetchSeries();
}
}
async function fetchSeries() {
const db = document.getElementById('db-select').value;
if (!db) return;
const params = new URLSearchParams(window.location.search);
params.set('db', db);
window.history.replaceState({}, '', `${location.pathname}?${params}`);
showMessage('Ładowanie serii...', 'info');
const res = await fetch(`/series?db=${db}`);
const series = await res.json();
currentSeriesData = series;
renderSeriesTable(series);
showMessage('', 'info');
}
function renderSeriesTable(series) {
const container = document.getElementById('series-list');
container.innerHTML = '';
if (!Array.isArray(series) || series.length === 0) {
container.innerHTML = `<tr><td colspan="3" class="text-center">Brak serii w wybranej bazie danych</td></tr>`;
return;
}
const baseNames = new Map();
series.forEach(s => {
const entity = s.tags?.entity_id;
if (!entity) return;
const base = entity.replace(/_\d+$/, '');
if (baseNames.has(base)) baseNames.get(base).push(s.series);
else baseNames.set(base, [s.series]);
});
const duplicates = new Set();
for (const [_, entries] of baseNames.entries()) {
if (entries.length > 1) entries.forEach(ser => duplicates.add(ser));
}
series.forEach((s, i) => {
const id = `series-${i}`;
const isDuplicate = duplicates.has(s.series);
const row = document.createElement('tr');
row.className = isDuplicate ? 'table-warning' : '';
row.innerHTML = `
<td><input class="form-check-input" type="checkbox" value="${s.series}" id="${id}" onchange="updateCount()"></td>
<td class="series-item">
<label class="form-check-label" for="${id}">${s.series}</label>
${isDuplicate ? '<div class="text-warning small">Duplikat entity_id</div>' : ''}
</td>
<td>
<button class="btn btn-sm btn-outline-danger me-1" onclick="deleteSingleSeries('${s.series}')"><i class="bi bi-trash"></i></button>
<button class="btn btn-sm btn-outline-warning" onclick="openTimeDeleteModal('${s.series}')"><i class="bi bi-clock-history"></i></button>
</td>
`;
container.appendChild(row);
});
updateCount();
}
function filterSeries() {
const term = document.getElementById('series-filter').value.toLowerCase();
const filtered = currentSeriesData.filter(s => s.series.toLowerCase().includes(term));
renderSeriesTable(filtered);
}
function updateCount() {
const count = document.querySelectorAll('input[type="checkbox"]:checked').length;
document.getElementById('selected-count').innerText = `Zaznaczono: ${count}`;
}
function toggleSelectAll() {
const checkboxes = document.querySelectorAll('input[type="checkbox"]:not(#select-all)');
const selectAll = document.getElementById('select-all').checked;
checkboxes.forEach(checkbox => checkbox.checked = selectAll);
updateCount();
}
function showMessage(message, type) {
const el = document.getElementById('info-message');
el.textContent = message;
el.className = `alert alert-${type} ${message ? 'd-block' : 'd-none'}`;
}
async function deleteSelected() {
const checkboxes = document.querySelectorAll('input[type="checkbox"]:checked:not(#select-all)');
if (checkboxes.length === 0) return showMessage('Nie zaznaczono żadnych serii', 'warning');
const series = Array.from(checkboxes).map(cb => cb.value);
const db = document.getElementById('db-select').value;
const preview = series.map(s => `DROP SERIES FROM "${s.split(',')[0]}" WHERE ...`).join('\n');
document.getElementById('confirmMessage').textContent = `Czy na pewno chcesz usunąć ${series.length} serii?`;
document.getElementById('queryPreview').textContent = preview;
const modal = new bootstrap.Modal(document.getElementById('confirmModal'));
modal.show();
document.getElementById('confirmDeleteBtn').onclick = async () => {
modal.hide();
await deleteSeries(series);
};
}
async function deleteSingleSeries(seriesId) {
document.getElementById('confirmMessage').textContent = `Czy na pewno chcesz usunąć serię:`;
document.getElementById('queryPreview').textContent = `DROP SERIES FROM "${seriesId.split(',')[0]}" WHERE ...`;
const modal = new bootstrap.Modal(document.getElementById('confirmModal'));
modal.show();
document.getElementById('confirmDeleteBtn').onclick = async () => {
modal.hide();
await deleteSeries([seriesId]);
};
}
async function deleteSeries(series) {
const db = document.getElementById('db-select').value;
showMessage('Usuwanie serii...', 'info');
const res = await fetch('/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db, series })
});
const data = await res.json();
if (data.status === 'success') {
showMessage(`Usunięto ${data.deleted} serii`, 'success');
setTimeout(fetchSeries, 1000);
} else {
showMessage('Wystąpił błąd podczas usuwania serii', 'danger');
}
}
fetchDatabases();
let currentSeriesForTimeDelete = null;
function openTimeDeleteModal(seriesId) {
currentSeriesForTimeDelete = seriesId;
document.getElementById('time-from').value = '';
document.getElementById('time-to').value = '';
document.getElementById('time-query-preview').textContent = '';
const modal = new bootstrap.Modal(document.getElementById('timeDeleteModal'));
modal.show();
}
async function confirmTimeDelete() {
const from = document.getElementById('time-from').value;
const to = document.getElementById('time-to').value;
const db = document.getElementById('db-select').value;
const series = currentSeriesForTimeDelete;
if (!from || !to || !series || !db) {
showMessage('Uzupełnij wszystkie pola i wybierz bazę danych', 'warning');
return;
}
showMessage('Usuwanie danych z serii...', 'info');
const res = await fetch('/delete_range', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db, series, from, to })
});
const data = await res.json();
if (data.status === 'success') {
showMessage('Dane zostały usunięte', 'success');
setTimeout(fetchSeries, 1000);
} else {
showMessage(`Błąd: ${data.error}`, 'danger');
}
}
function setPredefinedRange(value) {
if (!value) return;
const now = new Date();
const to = now.toISOString().slice(0, 16);
let fromDate = new Date(now);
if (value.endsWith('h')) {
fromDate.setHours(fromDate.getHours() - parseInt(value));
} else if (value.endsWith('d')) {
fromDate.setDate(fromDate.getDate() - parseInt(value));
}
const from = fromDate.toISOString().slice(0, 16);
document.getElementById('time-from').value = from;
document.getElementById('time-to').value = to;
}
function setPredefinedRange(value) {
if (!value) return;
const now = new Date();
const to = now.toISOString().slice(0, 16);
let fromDate = new Date(now);
if (value.endsWith('h')) {
fromDate.setHours(fromDate.getHours() - parseInt(value));
} else if (value.endsWith('d')) {
fromDate.setDate(fromDate.getDate() - parseInt(value));
} else if (value === 'before-current-year') {
const start = new Date(now.getFullYear(), 0, 1);
document.getElementById('time-from').value = '2000-01-01T00:00';
document.getElementById('time-to').value = start.toISOString().slice(0, 16);
return;
} else if (value === 'keep-2y') {
fromDate.setFullYear(fromDate.getFullYear() - 2);
} else if (value === 'keep-3y') {
fromDate.setFullYear(fromDate.getFullYear() - 3);
}
const from = fromDate.toISOString().slice(0, 16);
document.getElementById('time-from').value = from;
document.getElementById('time-to').value = to;
}
async function deleteRangeForSelected() {
const checkboxes = document.querySelectorAll('input[type="checkbox"]:checked:not(#select-all)');
if (checkboxes.length === 0) {
showMessage('Zaznacz przynajmniej jedną serię', 'warning');
return;
}
const fromInput = document.getElementById('time-from').value;
const toInput = document.getElementById('time-to').value;
const db = document.getElementById('db-select').value;
if (!fromInput || !toInput || !db) {
showMessage('Wybierz zakres czasu i bazę danych', 'warning');
return;
}
const from = new Date(fromInput).toISOString();
const to = new Date(toInput).toISOString();
const series = Array.from(checkboxes).map(cb => cb.value);
showMessage('Usuwanie danych z wielu serii...', 'info');
for (const s of series) {
await fetch('/delete_range', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ db, series: s, from, to })
});
}
showMessage('Dane zostały usunięte', 'success');
setTimeout(fetchSeries, 1000);
}
function openTimeDeleteModalForMany() {
currentSeriesForTimeDelete = null; // żeby nie używać pojedynczej serii
document.getElementById('time-from').value = '';
document.getElementById('time-to').value = '';
document.getElementById('time-query-preview').textContent = '';
const modal = new bootstrap.Modal(document.getElementById('timeDeleteModal'));
modal.show();
}

19
static/styles.css Normal file
View File

@ -0,0 +1,19 @@
body {
padding-top: 20px;
padding-bottom: 20px;
}
.table-container {
max-height: 70vh;
overflow-y: auto;
}
.series-item {
font-family: monospace;
word-break: break-all;
}
.theme-switch {
cursor: pointer;
font-size: 1.5rem;
}

132
templates/index.html Normal file
View File

@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<title>InfluxDB Series Manager</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-night.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
</head>
<body class="bg-dark text-light">
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">InfluxDB Series Manager</h2>
<i class="bi bi-moon-stars-fill theme-switch" onclick="toggleTheme()"></i>
</div>
<div class="card bg-dark border-secondary mb-4">
<div class="card-body">
<div class="mb-3">
<label for="db-select" class="form-label">Wybierz bazę danych</label>
<select id="db-select" class="form-select bg-dark text-light border-secondary" onchange="fetchSeries()">
<option value="">Wybierz bazę danych...</option>
</select>
</div>
<div class="mb-3">
<label for="series-filter" class="form-label">Filtruj serie</label>
<input type="text" id="series-filter" class="form-control bg-dark text-light border-secondary" placeholder="Wpisz nazwę serii..." oninput="filterSeries()">
</div>
<div class="d-flex align-items-center mb-3">
<button class="btn btn-danger me-3" onclick="deleteSelected()">
<i class="bi bi-trash"></i> Usuń zaznaczone
</button>
<button class="btn btn-warning me-3" onclick="openTimeDeleteModalForMany()">
<i class="bi bi-clock-history"></i> Usuń dane z zaznaczonych wg zakresu
</button>
<button style="display:none">
<i class="bi bi-trash"></i> Usuń zaznaczone
</button>
<span class="text-muted" id="selected-count">Zaznaczono: 0</span>
</div>
<div class="alert alert-info d-none" id="info-message"></div>
</div>
</div>
<div class="card bg-dark border-secondary">
<div class="card-body table-container">
<table class="table table-dark table-striped table-hover">
<thead>
<tr>
<th width="50px"><input type="checkbox" class="form-check-input" id="select-all" onchange="toggleSelectAll()"></th>
<th>Seria</th>
<th width="100px">Akcje</th>
</tr>
</thead>
<tbody id="series-list">
<tr><td colspan="3" class="text-center py-4">Wybierz bazę danych, aby załadować serie...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal potwierdzenia -->
<div class="modal fade" id="confirmModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header">
<h5 class="modal-title">Potwierdzenie usunięcia</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p id="confirmMessage"></p>
<pre class="bg-black p-2 text-light small" id="queryPreview"></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">Usuń</button>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='script.js') }}"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Modal usuwania danych z zakresu czasu -->
<div class="modal fade" id="timeDeleteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header">
<h5 class="modal-title">Usuń dane z zakresu czasu</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label>Zakres czasu:</label>
<select class="form-select mb-2" onchange="setPredefinedRange(this.value)">
<option value="">-- wybierz --</option>
<option value="1h">Ostatnia godzina</option>
<option value="1d">Ostatni dzień</option>
<option value="7d">Ostatni tydzień</option>
<option value="30d">Ostatni miesiąc</option>
<option value="365d">Ostatni rok</option>
<option value="before-current-year">Wszystko poza bieżącym rokiem</option>
<option value="keep-2y">Pozostaw 2 ostatnie lata</option>
<option value="keep-3y">Pozostaw 3 ostatnie lata</option>
</select>
</div>
<label for="time-from">Od (możesz też ustawić ręcznie):</label>
<input type="datetime-local" id="time-from" class="form-control mb-2">
<label for="time-to">Do:</label>
<input type="datetime-local" id="time-to" class="form-control mb-2">
<p class="small text-muted" id="time-query-preview"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Anuluj</button>
<button type="button" class="btn btn-danger me-2" onclick="confirmTimeDelete()">Usuń dane tej serii</button>
<button type="button" class="btn btn-warning" onclick="deleteRangeForSelected()">Usuń z zaznaczonych</button>
</div>
</div>
</div>
</div>
</body>
</html>