commit e382fab275f6ee669744fbc563cae0d5b816c69b Author: Mateusz Gruszczyński Date: Sun May 18 19:30:50 2025 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54d14d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +venv +config.py \ No newline at end of file diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..8af9a71 Binary files /dev/null and b/__pycache__/config.cpython-313.pyc differ diff --git a/app.py b/app.py new file mode 100644 index 0000000..b2025cd --- /dev/null +++ b/app.py @@ -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) diff --git a/config.example.py b/config.example.py new file mode 100644 index 0000000..85aca1e --- /dev/null +++ b/config.example.py @@ -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' + ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d84ca83 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask +flask-cors +influxdb diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..3298c8e --- /dev/null +++ b/static/script.js @@ -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 = ''; + 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 = `Brak serii w wybranej bazie danych`; + 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 = ` + + + + ${isDuplicate ? '
Duplikat entity_id
' : ''} + + + + + + `; + 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(); +} diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..481279a --- /dev/null +++ b/static/styles.css @@ -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; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..9b45dc2 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,132 @@ + + + + + InfluxDB Series Manager + + + + + + +
+
+

InfluxDB Series Manager

+ +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + + + Zaznaczono: 0 +
+ +
+
+
+ +
+
+ + + + + + + + + + + +
SeriaAkcje
Wybierz bazę danych, aby załadować serie...
+
+
+
+ + + + + + + + + + + \ No newline at end of file