git add templatesgit add templates OBSLUGA DEMONAgit add templates
This commit is contained in:
parent
cae5ed787d
commit
aa0a0a0025
18
alters.txt
18
alters.txt
@ -6,3 +6,21 @@ ALTER TABLE host ADD COLUMN auto_backup_enabled BOOLEAN DEFAULT 1;
|
|||||||
|
|
||||||
ALTER TABLE user_settings DROP COLUMN deploy_interval;
|
ALTER TABLE user_settings DROP COLUMN deploy_interval;
|
||||||
ALTER TABLE user_settings DROP COLUMN backup_interval;
|
ALTER TABLE user_settings DROP COLUMN backup_interval;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE host
|
||||||
|
ADD COLUMN use_daemon BOOLEAN NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE host
|
||||||
|
ALTER COLUMN use_daemon DROP DEFAULT;
|
||||||
|
|
||||||
|
ALTER TABLE host
|
||||||
|
ADD COLUMN daemon_url VARCHAR(255);
|
||||||
|
|
||||||
|
ALTER TABLE host
|
||||||
|
ADD COLUMN daemon_token VARCHAR(255);
|
||||||
|
|
||||||
|
ALTER TABLE Host ADD COLUMN preferred_hostfile_id INTEGER;
|
||||||
|
ALTER TABLE Host
|
||||||
|
ADD COLUMN preferred_hostfile_id INTEGER
|
||||||
|
REFERENCES host_file(id);
|
345
app.py
345
app.py
@ -44,6 +44,14 @@ class Host(db.Model):
|
|||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
auto_deploy_enabled = db.Column(db.Boolean, default=True)
|
auto_deploy_enabled = db.Column(db.Boolean, default=True)
|
||||||
auto_backup_enabled = db.Column(db.Boolean, default=True)
|
auto_backup_enabled = db.Column(db.Boolean, default=True)
|
||||||
|
preferred_hostfile_id = db.Column(db.Integer, db.ForeignKey('host_file.id'), nullable=True)
|
||||||
|
preferred_hostfile = db.relationship('HostFile', foreign_keys=[preferred_hostfile_id])
|
||||||
|
|
||||||
|
# Funkcja wspolpracy z hosts_daemon
|
||||||
|
use_daemon = db.Column(db.Boolean, default=False) # <--- NOWE
|
||||||
|
daemon_url = db.Column(db.String(255), nullable=True) # <--- NOWE
|
||||||
|
daemon_token = db.Column(db.String(255), nullable=True) # <--- NOWE
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def resolved_hostname(self):
|
def resolved_hostname(self):
|
||||||
try:
|
try:
|
||||||
@ -170,12 +178,33 @@ def get_statistics(user_id):
|
|||||||
|
|
||||||
def automated_backup_for_host(host):
|
def automated_backup_for_host(host):
|
||||||
try:
|
try:
|
||||||
ssh = open_ssh_connection(host)
|
if host.use_daemon and host.type == 'linux':
|
||||||
sftp = ssh.open_sftp()
|
import requests
|
||||||
with sftp.open('/etc/hosts', 'r') as remote_file:
|
# pobieramy /etc/hosts z demona:
|
||||||
content = remote_file.read().decode('utf-8')
|
url = host.daemon_url.rstrip('/') + '/hosts'
|
||||||
sftp.close()
|
# Zmiana: jeśli demon wymaga nagłówka Bearer:
|
||||||
ssh.close()
|
headers = {"Authorization": host.daemon_token}
|
||||||
|
|
||||||
|
resp = requests.get(url, headers=headers, timeout=10, verify=False)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise Exception(f"Daemon GET error: {resp.status_code} - {resp.text}")
|
||||||
|
data = resp.json()
|
||||||
|
content = data.get("hosts", "")
|
||||||
|
else:
|
||||||
|
# standard:
|
||||||
|
if host.type == 'mikrotik':
|
||||||
|
ssh = open_ssh_connection(host)
|
||||||
|
stdin, stdout, stderr = ssh.exec_command("/ip dns static export")
|
||||||
|
content = stdout.read().decode('utf-8')
|
||||||
|
ssh.close()
|
||||||
|
else:
|
||||||
|
ssh = open_ssh_connection(host)
|
||||||
|
sftp = ssh.open_sftp()
|
||||||
|
with sftp.open('/etc/hosts', 'r') as remote_file:
|
||||||
|
content = remote_file.read().decode('utf-8')
|
||||||
|
sftp.close()
|
||||||
|
ssh.close()
|
||||||
|
|
||||||
backup = Backup(
|
backup = Backup(
|
||||||
user_id=host.user_id,
|
user_id=host.user_id,
|
||||||
host_id=host.id,
|
host_id=host.id,
|
||||||
@ -184,13 +213,14 @@ def automated_backup_for_host(host):
|
|||||||
)
|
)
|
||||||
db.session.add(backup)
|
db.session.add(backup)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
log_entry = DeployLog(
|
|
||||||
details=f'[BACKUP] Automatic backup created for host {host.hostname}',
|
log_entry = DeployLog(details=f'[BACKUP] Automatic backup created for host {host.hostname}',
|
||||||
user_id=host.user_id
|
user_id=host.user_id)
|
||||||
)
|
|
||||||
db.session.add(log_entry)
|
db.session.add(log_entry)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
print(f'Automated backup for host {host.hostname} created successfully.')
|
print(f'Automated backup for host {host.hostname} created successfully.')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'Error creating automated backup for host {host.hostname}: {str(e)}')
|
print(f'Error creating automated backup for host {host.hostname}: {str(e)}')
|
||||||
|
|
||||||
@ -356,6 +386,10 @@ def change_password():
|
|||||||
def add_server():
|
def add_server():
|
||||||
if 'user_id' not in session:
|
if 'user_id' not in session:
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
# Pobieramy wszystkie HostFile tego użytkownika, np. do wyświetlenia w <select>
|
||||||
|
user_hostfiles = HostFile.query.filter_by(user_id=session['user_id']).all()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
hostname = request.form['hostname']
|
hostname = request.form['hostname']
|
||||||
username = request.form['username']
|
username = request.form['username']
|
||||||
@ -369,8 +403,27 @@ def add_server():
|
|||||||
port = int(port_str)
|
port = int(port_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
port = 22
|
port = 22
|
||||||
|
|
||||||
|
# Czy ma używać demona?
|
||||||
|
use_daemon = bool(request.form.get('use_daemon'))
|
||||||
|
daemon_url = request.form.get('daemon_url', '').strip()
|
||||||
|
daemon_token = request.form.get('daemon_token', '').strip()
|
||||||
|
|
||||||
|
# Jeśli auth_method == 'ssh_key'
|
||||||
stored_key = private_key if (auth_method == 'ssh_key' and private_key) else None
|
stored_key = private_key if (auth_method == 'ssh_key' and private_key) else None
|
||||||
stored_passphrase = key_passphrase if (auth_method == 'ssh_key' and key_passphrase) else None
|
stored_passphrase = key_passphrase if (auth_method == 'ssh_key' and key_passphrase) else None
|
||||||
|
|
||||||
|
# Obsługa preferowanego pliku hosts
|
||||||
|
preferred_file_id_str = request.form.get('preferred_hostfile_id', '').strip()
|
||||||
|
if preferred_file_id_str == '':
|
||||||
|
chosen_file_id = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
chosen_file_id = int(preferred_file_id_str)
|
||||||
|
except ValueError:
|
||||||
|
chosen_file_id = None
|
||||||
|
|
||||||
|
# Tworzymy nowy obiekt Host
|
||||||
host = Host(
|
host = Host(
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
username=username,
|
username=username,
|
||||||
@ -380,14 +433,25 @@ def add_server():
|
|||||||
private_key=stored_key,
|
private_key=stored_key,
|
||||||
key_passphrase=stored_passphrase,
|
key_passphrase=stored_passphrase,
|
||||||
port=port,
|
port=port,
|
||||||
user_id=session['user_id']
|
user_id=session['user_id'],
|
||||||
|
|
||||||
|
# Obsługa demona tylko jeśli host_type=='linux' i checkbox zaznaczony
|
||||||
|
use_daemon=use_daemon if host_type == 'linux' else False,
|
||||||
|
daemon_url=daemon_url if (use_daemon and host_type == 'linux') else None,
|
||||||
|
daemon_token=daemon_token if (use_daemon and host_type == 'linux') else None,
|
||||||
|
|
||||||
|
# Nowe pole preferowanego pliku
|
||||||
|
preferred_hostfile_id=chosen_file_id
|
||||||
)
|
)
|
||||||
|
|
||||||
db.session.add(host)
|
db.session.add(host)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Host added successfully', 'success')
|
flash('Host added successfully', 'success')
|
||||||
# Po dodaniu możesz przekierować do listy serwerów lub pozostawić na formularzu
|
|
||||||
return redirect(url_for('server_list'))
|
return redirect(url_for('server_list'))
|
||||||
return render_template('add_server.html')
|
|
||||||
|
# GET -> wyświetlamy formularz add_server, przekazując listę user_hostfiles
|
||||||
|
return render_template('add_server.html', user_hostfiles=user_hostfiles)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/delete-server/<int:id>')
|
@app.route('/delete-server/<int:id>')
|
||||||
def delete_server(id):
|
def delete_server(id):
|
||||||
@ -417,27 +481,63 @@ def edit_server(id):
|
|||||||
if not host or host.user_id != session['user_id']:
|
if not host or host.user_id != session['user_id']:
|
||||||
flash('Server not found or unauthorized', 'danger')
|
flash('Server not found or unauthorized', 'danger')
|
||||||
return redirect(url_for('server_list'))
|
return redirect(url_for('server_list'))
|
||||||
|
|
||||||
|
# Lista plików usera do <select>:
|
||||||
|
user_hostfiles = HostFile.query.filter_by(user_id=session['user_id']).all()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
host.hostname = request.form['hostname']
|
host.hostname = request.form['hostname']
|
||||||
host.username = request.form['username']
|
host.username = request.form['username']
|
||||||
host.password = request.form['password'] or ''
|
new_password = request.form['password'] or ''
|
||||||
|
if new_password:
|
||||||
|
host.password = new_password
|
||||||
|
|
||||||
port_str = request.form.get('port', '22')
|
port_str = request.form.get('port', '22')
|
||||||
try:
|
try:
|
||||||
host.port = int(port_str)
|
host.port = int(port_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
host.port = 22
|
host.port = 22
|
||||||
|
|
||||||
host.type = request.form.get('host_type', 'linux')
|
host.type = request.form.get('host_type', 'linux')
|
||||||
host.auth_method = request.form.get('auth_method', 'password')
|
host.auth_method = request.form.get('auth_method', 'password')
|
||||||
|
|
||||||
new_private_key = request.form.get('private_key', '').strip()
|
new_private_key = request.form.get('private_key', '').strip()
|
||||||
new_passphrase = request.form.get('key_passphrase', '').strip()
|
new_passphrase = request.form.get('key_passphrase', '').strip()
|
||||||
if host.auth_method == 'ssh_key' and new_private_key:
|
if host.auth_method == 'ssh_key' and new_private_key:
|
||||||
host.private_key = new_private_key
|
host.private_key = new_private_key
|
||||||
if host.auth_method == 'ssh_key' and new_passphrase:
|
if host.auth_method == 'ssh_key' and new_passphrase:
|
||||||
host.key_passphrase = new_passphrase
|
host.key_passphrase = new_passphrase
|
||||||
|
|
||||||
|
# Demon:
|
||||||
|
use_daemon = bool(request.form.get('use_daemon'))
|
||||||
|
daemon_url = request.form.get('daemon_url', '').strip()
|
||||||
|
daemon_token = request.form.get('daemon_token', '').strip()
|
||||||
|
if host.type == 'linux' and use_daemon:
|
||||||
|
host.use_daemon = True
|
||||||
|
host.daemon_url = daemon_url
|
||||||
|
host.daemon_token = daemon_token
|
||||||
|
else:
|
||||||
|
host.use_daemon = False
|
||||||
|
host.daemon_url = None
|
||||||
|
host.daemon_token = None
|
||||||
|
|
||||||
|
# Nowe pole: preferred_hostfile_id
|
||||||
|
preferred_file_id_str = request.form.get('preferred_hostfile_id', '').strip()
|
||||||
|
if preferred_file_id_str == '':
|
||||||
|
host.preferred_hostfile_id = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
host.preferred_hostfile_id = int(preferred_file_id_str)
|
||||||
|
except ValueError:
|
||||||
|
host.preferred_hostfile_id = None
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Server updated successfully', 'success')
|
flash('Server updated successfully', 'success')
|
||||||
return redirect(url_for('server_list'))
|
return redirect(url_for('server_list'))
|
||||||
return render_template('edit_server.html', host=host)
|
|
||||||
|
# GET -> renderuj z user_hostfiles
|
||||||
|
return render_template('edit_server.html', host=host, user_hostfiles=user_hostfiles)
|
||||||
|
|
||||||
|
|
||||||
# -------------------
|
# -------------------
|
||||||
# TESTOWANIE POŁĄCZENIA SSH DLA HOSTA
|
# TESTOWANIE POŁĄCZENIA SSH DLA HOSTA
|
||||||
@ -446,16 +546,50 @@ def edit_server(id):
|
|||||||
def test_server_connection(id):
|
def test_server_connection(id):
|
||||||
if 'user_id' not in session:
|
if 'user_id' not in session:
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
host = db.session.get(Host, id)
|
host = db.session.get(Host, id)
|
||||||
if not host or host.user_id != session['user_id']:
|
if not host or host.user_id != session['user_id']:
|
||||||
flash('Host not found or unauthorized', 'danger')
|
flash('Host not found or unauthorized', 'danger')
|
||||||
return redirect(url_for('server_list'))
|
return redirect(url_for('server_list'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ssh = open_ssh_connection(host)
|
if host.use_daemon and host.type == 'linux':
|
||||||
ssh.close()
|
# Połączenie przez demon (self-signed certy, verify=False)
|
||||||
flash(f'SSH connection to {host.hostname} successful.', 'success')
|
import requests
|
||||||
|
headers = {"Authorization": host.daemon_token}
|
||||||
|
|
||||||
|
# Najpierw sprawdzenie /health
|
||||||
|
health_url = host.daemon_url.rstrip('/') + '/health'
|
||||||
|
resp = requests.get(health_url, headers=headers, verify=False, timeout=5)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
flash(f'Demon connection successful (health OK) for {host.hostname}', 'success')
|
||||||
|
else:
|
||||||
|
raise Exception(f"Demon health check returned {resp.status_code}")
|
||||||
|
|
||||||
|
# Dodatkowe pobranie /system-info
|
||||||
|
sysinfo_url = host.daemon_url.rstrip('/') + '/system-info'
|
||||||
|
sysinfo_resp = requests.get(sysinfo_url, headers=headers, verify=False, timeout=5)
|
||||||
|
if sysinfo_resp.status_code == 200:
|
||||||
|
info = sysinfo_resp.json()
|
||||||
|
# Wyświetlamy kilka przykładowych danych w flash:
|
||||||
|
msg = (f"System-info for {host.hostname}: "
|
||||||
|
f"CPU={info.get('cpu_percent')}%, "
|
||||||
|
f"MEM={info.get('memory_percent')}%, "
|
||||||
|
f"DISK={info.get('disk_percent')}%, "
|
||||||
|
f"UPTIME={info.get('uptime_seconds')}s")
|
||||||
|
flash(msg, 'info')
|
||||||
|
else:
|
||||||
|
raise Exception(f"Demon system-info returned {sysinfo_resp.status_code}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Standardowe sprawdzenie przez SSH
|
||||||
|
ssh = open_ssh_connection(host)
|
||||||
|
ssh.close()
|
||||||
|
flash(f'SSH connection to {host.hostname} successful.', 'success')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'SSH connection to {host.hostname} failed: {str(e)}', 'danger')
|
flash(f'Connection failed for {host.hostname}: {str(e)}', 'danger')
|
||||||
|
|
||||||
return redirect(url_for('server_list'))
|
return redirect(url_for('server_list'))
|
||||||
|
|
||||||
# -------------------
|
# -------------------
|
||||||
@ -476,15 +610,29 @@ def clear_single_server(host_id):
|
|||||||
if not host or host.user_id != session['user_id']:
|
if not host or host.user_id != session['user_id']:
|
||||||
flash('Host not found or unauthorized', 'danger')
|
flash('Host not found or unauthorized', 'danger')
|
||||||
return redirect(url_for('clear_servers'))
|
return redirect(url_for('clear_servers'))
|
||||||
|
|
||||||
default_content = ensure_local_defaults("")
|
default_content = ensure_local_defaults("")
|
||||||
try:
|
try:
|
||||||
if host.type == 'linux':
|
if host.use_daemon and host.type == 'linux':
|
||||||
clear_linux(host, default_content)
|
import requests
|
||||||
|
url = host.daemon_url.rstrip('/') + '/hosts'
|
||||||
|
headers = {"Authorization": host.daemon_token}
|
||||||
|
# Zakładamy, że demon potrafi zastąpić /etc/hosts treścią "default_content"
|
||||||
|
resp = requests.post(url, json={"hosts": default_content},
|
||||||
|
headers=headers, verify=False, timeout=10)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise Exception(f"Daemon update error: {resp.status_code} - {resp.text}")
|
||||||
|
|
||||||
elif host.type == 'mikrotik':
|
elif host.type == 'mikrotik':
|
||||||
clear_mikrotik(host)
|
clear_mikrotik(host)
|
||||||
|
else:
|
||||||
|
# standard linux
|
||||||
|
clear_linux(host, default_content)
|
||||||
|
|
||||||
flash(f'Cleared host: {host.hostname}', 'success')
|
flash(f'Cleared host: {host.hostname}', 'success')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Error clearing host {host.hostname}: {str(e)}', 'danger')
|
flash(f'Error clearing host {host.hostname}: {str(e)}', 'danger')
|
||||||
|
|
||||||
return redirect(url_for('clear_all_server'))
|
return redirect(url_for('clear_all_server'))
|
||||||
|
|
||||||
@app.route('/clear-all-server', methods=['GET', 'POST'])
|
@app.route('/clear-all-server', methods=['GET', 'POST'])
|
||||||
@ -492,23 +640,37 @@ def clear_all_server():
|
|||||||
if 'user_id' not in session:
|
if 'user_id' not in session:
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
hosts = Host.query.filter_by(user_id=session['user_id']).all()
|
hosts = Host.query.filter_by(user_id=session['user_id']).all()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
linux_clear = request.form.get('linux')
|
linux_clear = request.form.get('linux')
|
||||||
mikrotik_clear = request.form.get('mikrotik')
|
mikrotik_clear = request.form.get('mikrotik')
|
||||||
default_content = ensure_local_defaults("")
|
default_content = ensure_local_defaults("")
|
||||||
|
|
||||||
for h in hosts:
|
for h in hosts:
|
||||||
try:
|
try:
|
||||||
if h.type == 'linux' and linux_clear:
|
if h.type == 'linux' and linux_clear:
|
||||||
clear_linux(h, default_content)
|
if h.use_daemon:
|
||||||
|
import requests
|
||||||
|
url = h.daemon_url.rstrip('/') + '/hosts'
|
||||||
|
headers = {"Authorization": h.daemon_token}
|
||||||
|
resp = requests.post(url, json={"hosts": default_content},
|
||||||
|
headers=headers, verify=False, timeout=10)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise Exception(f"Daemon update error: {resp.status_code} - {resp.text}")
|
||||||
|
else:
|
||||||
|
clear_linux(h, default_content)
|
||||||
flash(f'Cleared Linux host: {h.hostname}', 'success')
|
flash(f'Cleared Linux host: {h.hostname}', 'success')
|
||||||
|
|
||||||
elif h.type == 'mikrotik' and mikrotik_clear:
|
elif h.type == 'mikrotik' and mikrotik_clear:
|
||||||
clear_mikrotik(h)
|
clear_mikrotik(h)
|
||||||
flash(f'Cleared Mikrotik host: {h.hostname}', 'success')
|
flash(f'Cleared Mikrotik host: {h.hostname}', 'success')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Error clearing host {h.hostname}: {str(e)}', 'danger')
|
flash(f'Error clearing host {h.hostname}: {str(e)}', 'danger')
|
||||||
return redirect(url_for('clear_all_server'))
|
|
||||||
return render_template('clear_servers.html', hosts=hosts)
|
|
||||||
|
|
||||||
|
return redirect(url_for('clear_all_server'))
|
||||||
|
|
||||||
|
return render_template('clear_servers.html', hosts=hosts)
|
||||||
|
|
||||||
# -------------------
|
# -------------------
|
||||||
# ZARZĄDZANIE PLIKAMI HOSTS (WIELOKROTNE PLIKI)
|
# ZARZĄDZANIE PLIKAMI HOSTS (WIELOKROTNE PLIKI)
|
||||||
@ -580,16 +742,39 @@ def deploy_hosts_file(file_id):
|
|||||||
flash('File not found or unauthorized', 'danger')
|
flash('File not found or unauthorized', 'danger')
|
||||||
return redirect(url_for('list_hosts_files'))
|
return redirect(url_for('list_hosts_files'))
|
||||||
hosts = Host.query.filter_by(user_id=session['user_id']).all()
|
hosts = Host.query.filter_by(user_id=session['user_id']).all()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
selected_host_ids = request.form.getlist('hosts')
|
selected_host_ids = request.form.getlist('hosts')
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
if str(host.id) in selected_host_ids:
|
if str(host.id) in selected_host_ids:
|
||||||
try:
|
try:
|
||||||
if host.type == 'linux':
|
# Przygotuj zawartość do wgrania
|
||||||
|
adjusted_content = ensure_local_defaults(file.content)
|
||||||
|
wrapped_content = wrap_content_with_comments(adjusted_content)
|
||||||
|
|
||||||
|
if host.use_daemon and host.type == 'linux':
|
||||||
|
import requests
|
||||||
|
url = host.daemon_url.rstrip('/') + '/hosts'
|
||||||
|
headers = {"Authorization": host.daemon_token}
|
||||||
|
resp = requests.post(url, json={"hosts": wrapped_content},
|
||||||
|
headers=headers, timeout=10, verify=False)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise Exception(f"Daemon POST error: {resp.status_code} - {resp.text}")
|
||||||
|
db.session.add(DeployLog(
|
||||||
|
details=f'[LINUX/DAEMON] Deployed file "{file.title}" to {host.hostname} for user {session["user_id"]}',
|
||||||
|
user_id=session['user_id']
|
||||||
|
))
|
||||||
|
elif host.type == 'mikrotik':
|
||||||
|
# Mikrotik
|
||||||
|
wrapped_mikro = wrap_mikrotik_content(file.content)
|
||||||
|
deploy_mikrotik(host, wrapped_mikro)
|
||||||
|
db.session.add(DeployLog(
|
||||||
|
details=f'[MIKROTIK] Deployed file "{file.title}" to {host.hostname} for user {session["user_id"]}',
|
||||||
|
user_id=session['user_id']
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# Standard Linux (SSH)
|
||||||
ssh = open_ssh_connection(host)
|
ssh = open_ssh_connection(host)
|
||||||
# Używamy file.content, nie hosts_content
|
|
||||||
adjusted_content = ensure_local_defaults(file.content)
|
|
||||||
wrapped_content = wrap_content_with_comments(adjusted_content)
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmpf:
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmpf:
|
||||||
tmpf.write(wrapped_content)
|
tmpf.write(wrapped_content)
|
||||||
tmp_file_path = tmpf.name
|
tmp_file_path = tmpf.name
|
||||||
@ -602,18 +787,13 @@ def deploy_hosts_file(file_id):
|
|||||||
details=f'[LINUX] Deployed file "{file.title}" to {host.hostname} for user {session["user_id"]}',
|
details=f'[LINUX] Deployed file "{file.title}" to {host.hostname} for user {session["user_id"]}',
|
||||||
user_id=session['user_id']
|
user_id=session['user_id']
|
||||||
))
|
))
|
||||||
elif host.type == 'mikrotik':
|
|
||||||
wrapped_content = wrap_mikrotik_content(file.content)
|
|
||||||
deploy_mikrotik(host, wrapped_content)
|
|
||||||
db.session.add(DeployLog(
|
|
||||||
details=f'[MIKROTIK] Deployed file "{file.title}" to {host.hostname} for user {session["user_id"]}',
|
|
||||||
user_id=session['user_id']
|
|
||||||
))
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Deployed file "{file.title}" to {host.hostname}', 'success')
|
flash(f'Deployed file "{file.title}" to {host.hostname}', 'success')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Error deploying file "{file.title}" to {host.hostname}: {str(e)}', 'danger')
|
flash(f'Error deploying file "{file.title}" to {host.hostname}: {str(e)}', 'danger')
|
||||||
return redirect(url_for('list_hosts_files'))
|
return redirect(url_for('list_hosts_files'))
|
||||||
|
|
||||||
return render_template('deploy_hosts_file.html', file=file, hosts=hosts)
|
return render_template('deploy_hosts_file.html', file=file, hosts=hosts)
|
||||||
|
|
||||||
# -------------------
|
# -------------------
|
||||||
@ -628,13 +808,24 @@ def server_backup(host_id):
|
|||||||
flash('Host not found or unauthorized', 'danger')
|
flash('Host not found or unauthorized', 'danger')
|
||||||
return redirect(url_for('server_list'))
|
return redirect(url_for('server_list'))
|
||||||
try:
|
try:
|
||||||
if host.type == 'mikrotik':
|
if host.use_daemon and host.type == 'linux':
|
||||||
|
import requests
|
||||||
|
url = host.daemon_url.rstrip('/') + '/hosts'
|
||||||
|
headers = {"Authorization": host.daemon_token}
|
||||||
|
resp = requests.get(url, headers=headers, timeout=10, verify=False)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise Exception(f"Daemon GET error: {resp.status_code} - {resp.text}")
|
||||||
|
data = resp.json()
|
||||||
|
content = data.get("hosts", "")
|
||||||
|
description = f'Backup (daemon) from {host.hostname}'
|
||||||
|
elif host.type == 'mikrotik':
|
||||||
ssh = open_ssh_connection(host)
|
ssh = open_ssh_connection(host)
|
||||||
stdin, stdout, stderr = ssh.exec_command("/ip dns static export")
|
stdin, stdout, stderr = ssh.exec_command("/ip dns static export")
|
||||||
content = stdout.read().decode('utf-8')
|
content = stdout.read().decode('utf-8')
|
||||||
ssh.close()
|
ssh.close()
|
||||||
description = f'Backup (mikrotik) from {host.hostname}'
|
description = f'Backup (mikrotik) from {host.hostname}'
|
||||||
else:
|
else:
|
||||||
|
# Standard Linux (SSH)
|
||||||
ssh = open_ssh_connection(host)
|
ssh = open_ssh_connection(host)
|
||||||
sftp = ssh.open_sftp()
|
sftp = ssh.open_sftp()
|
||||||
with sftp.open('/etc/hosts', 'r') as remote_file:
|
with sftp.open('/etc/hosts', 'r') as remote_file:
|
||||||
@ -642,6 +833,7 @@ def server_backup(host_id):
|
|||||||
sftp.close()
|
sftp.close()
|
||||||
ssh.close()
|
ssh.close()
|
||||||
description = f'Backup from {host.hostname}'
|
description = f'Backup from {host.hostname}'
|
||||||
|
|
||||||
backup = Backup(
|
backup = Backup(
|
||||||
user_id=session['user_id'],
|
user_id=session['user_id'],
|
||||||
host_id=host.id,
|
host_id=host.id,
|
||||||
@ -743,13 +935,24 @@ def backup_all():
|
|||||||
hosts = Host.query.filter_by(user_id=user_id).all()
|
hosts = Host.query.filter_by(user_id=user_id).all()
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
try:
|
try:
|
||||||
if host.type == 'mikrotik':
|
if host.use_daemon and host.type == 'linux':
|
||||||
|
import requests
|
||||||
|
url = host.daemon_url.rstrip('/') + '/hosts'
|
||||||
|
headers = {"Authorization": host.daemon_token}
|
||||||
|
resp = requests.get(url, headers=headers, timeout=10, verify=False)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise Exception(f"Daemon GET error: {resp.status_code} - {resp.text}")
|
||||||
|
data = resp.json()
|
||||||
|
content = data.get("hosts", "")
|
||||||
|
description = f'Backup (daemon) from {host.hostname}'
|
||||||
|
elif host.type == 'mikrotik':
|
||||||
ssh = open_ssh_connection(host)
|
ssh = open_ssh_connection(host)
|
||||||
stdin, stdout, stderr = ssh.exec_command("/ip dns static export")
|
stdin, stdout, stderr = ssh.exec_command("/ip dns static export")
|
||||||
content = stdout.read().decode('utf-8')
|
content = stdout.read().decode('utf-8')
|
||||||
ssh.close()
|
ssh.close()
|
||||||
description = f'Backup (mikrotik) from {host.hostname}'
|
description = f'Backup (mikrotik) from {host.hostname}'
|
||||||
else:
|
else:
|
||||||
|
# Standard Linux (SSH)
|
||||||
ssh = open_ssh_connection(host)
|
ssh = open_ssh_connection(host)
|
||||||
sftp = ssh.open_sftp()
|
sftp = ssh.open_sftp()
|
||||||
with sftp.open('/etc/hosts', 'r') as remote_file:
|
with sftp.open('/etc/hosts', 'r') as remote_file:
|
||||||
@ -757,6 +960,7 @@ def backup_all():
|
|||||||
sftp.close()
|
sftp.close()
|
||||||
ssh.close()
|
ssh.close()
|
||||||
description = f'Backup from {host.hostname}'
|
description = f'Backup from {host.hostname}'
|
||||||
|
|
||||||
backup = Backup(
|
backup = Backup(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
host_id=host.id,
|
host_id=host.id,
|
||||||
@ -767,6 +971,7 @@ def backup_all():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Error creating backup for host {host.hostname}: {str(e)}', 'danger')
|
flash(f'Error creating backup for host {host.hostname}: {str(e)}', 'danger')
|
||||||
|
|
||||||
flash('Backup for all hosts created successfully.', 'success')
|
flash('Backup for all hosts created successfully.', 'success')
|
||||||
return redirect(url_for('backups'))
|
return redirect(url_for('backups'))
|
||||||
|
|
||||||
@ -934,25 +1139,59 @@ def delete_backup(backup_id):
|
|||||||
# -------------------
|
# -------------------
|
||||||
def deploy_user(user_id):
|
def deploy_user(user_id):
|
||||||
user_settings = UserSettings.query.filter_by(user_id=user_id).first()
|
user_settings = UserSettings.query.filter_by(user_id=user_id).first()
|
||||||
hostfile = HostFile.query.filter_by(user_id=user_id).first()
|
# domyślny plik "Default Hosts" (np. szukasz title=="Default Hosts")
|
||||||
if not hostfile:
|
default_file = HostFile.query.filter_by(user_id=user_id, title="Default Hosts").first()
|
||||||
|
if not default_file:
|
||||||
|
# jeśli nie ma w ogóle, nic nie robimy
|
||||||
return
|
return
|
||||||
|
|
||||||
hosts_content = hostfile.content
|
# ewentualnie regex
|
||||||
|
regex_lines = ""
|
||||||
if user_settings and user_settings.regex_deploy_enabled:
|
if user_settings and user_settings.regex_deploy_enabled:
|
||||||
regex_lines = generate_regex_hosts(user_id)
|
regex_lines = generate_regex_hosts(user_id)
|
||||||
else:
|
|
||||||
regex_lines = ""
|
|
||||||
|
|
||||||
final_content = regex_lines + hosts_content
|
|
||||||
hosts = Host.query.filter_by(user_id=user_id).all()
|
hosts = Host.query.filter_by(user_id=user_id).all()
|
||||||
|
|
||||||
for h in hosts:
|
for h in hosts:
|
||||||
# Tylko dla serwerów z włączonym auto_deploy
|
|
||||||
if not h.auto_deploy_enabled:
|
if not h.auto_deploy_enabled:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Który plik wgrywać?
|
||||||
|
if h.preferred_hostfile_id:
|
||||||
|
chosen_file = HostFile.query.filter_by(id=h.preferred_hostfile_id, user_id=user_id).first()
|
||||||
|
if not chosen_file:
|
||||||
|
# fallback do default
|
||||||
|
chosen_file = default_file
|
||||||
|
else:
|
||||||
|
chosen_file = default_file
|
||||||
|
|
||||||
|
# final_content
|
||||||
|
final_content = regex_lines + chosen_file.content
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if h.type == 'linux':
|
if h.type == 'mikrotik':
|
||||||
|
wrapped_content = wrap_mikrotik_content(final_content)
|
||||||
|
deploy_mikrotik(h, wrapped_content)
|
||||||
|
db.session.add(DeployLog(
|
||||||
|
details=f'[MIKROTIK] Updated {h.hostname} for user {user_id}',
|
||||||
|
user_id=user_id
|
||||||
|
))
|
||||||
|
elif h.use_daemon and h.type == 'linux':
|
||||||
|
# Demon
|
||||||
|
import requests
|
||||||
|
adjusted_content = ensure_local_defaults(final_content)
|
||||||
|
wrapped_content = wrap_content_with_comments(adjusted_content)
|
||||||
|
url = h.daemon_url.rstrip('/') + '/hosts'
|
||||||
|
headers = {"Authorization": h.daemon_token}
|
||||||
|
resp = requests.post(url, json={"hosts": wrapped_content}, headers=headers, timeout=10)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise Exception(f"Daemon POST error: {resp.status_code} - {resp.text}")
|
||||||
|
|
||||||
|
db.session.add(DeployLog(
|
||||||
|
details=f'[LINUX/DAEMON] Updated {h.hostname} for user {user_id}',
|
||||||
|
user_id=user_id
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# standard linux - SSH
|
||||||
ssh = open_ssh_connection(h)
|
ssh = open_ssh_connection(h)
|
||||||
adjusted_content = ensure_local_defaults(final_content)
|
adjusted_content = ensure_local_defaults(final_content)
|
||||||
wrapped_content = wrap_content_with_comments(adjusted_content)
|
wrapped_content = wrap_content_with_comments(adjusted_content)
|
||||||
@ -964,14 +1203,16 @@ def deploy_user(user_id):
|
|||||||
sftp.close()
|
sftp.close()
|
||||||
ssh.close()
|
ssh.close()
|
||||||
os.remove(tmp_file_path)
|
os.remove(tmp_file_path)
|
||||||
db.session.add(DeployLog(details=f'[LINUX] Updated {h.hostname} for user {user_id}', user_id=user_id))
|
db.session.add(DeployLog(
|
||||||
elif h.type == 'mikrotik':
|
details=f'[LINUX] Updated {h.hostname} for user {user_id}',
|
||||||
wrapped_content = wrap_mikrotik_content(final_content)
|
user_id=user_id
|
||||||
deploy_mikrotik(h, wrapped_content)
|
))
|
||||||
db.session.add(DeployLog(details=f'[MIKROTIK] Updated {h.hostname} for user {user_id}', user_id=user_id))
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.add(DeployLog(details=f'Failed to update {h.hostname}: {str(e)} for user {user_id}'))
|
db.session.add(DeployLog(
|
||||||
|
details=f'Failed to update {h.hostname}: {str(e)} for user {user_id}'
|
||||||
|
))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@ -5,4 +5,5 @@ paramiko
|
|||||||
apscheduler
|
apscheduler
|
||||||
gunicorn
|
gunicorn
|
||||||
waitress
|
waitress
|
||||||
croniter
|
croniter
|
||||||
|
requests
|
@ -1,14 +1,23 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Dodaj serwer - /etc/hosts Manager{% endblock %}
|
{% block title %}Dodaj serwer - /etc/hosts Manager{% endblock %}
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<style>
|
<style>
|
||||||
.tooltip-inner {
|
.tooltip-inner {
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
/* Kontenery domyślnie ukryte; pokażemy je przez JavaScript. */
|
||||||
|
#hostPortFields,
|
||||||
|
#userPassFields,
|
||||||
|
#sshKeyFields,
|
||||||
|
#daemonFields {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@ -16,44 +25,93 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" action="{{ url_for('add_server') }}">
|
<form method="POST" action="{{ url_for('add_server') }}">
|
||||||
|
|
||||||
|
<!-- 1) Wybór platformy (Linux / Mikrotik) -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="hostname" class="form-label">Nazwa hosta (IP lub domena)</label>
|
<label for="host_type" class="form-label">Platforma (system)</label>
|
||||||
<input type="text" name="hostname" id="hostname" class="form-control" required>
|
<select name="host_type" id="host_type" class="form-select" required onchange="toggleSystemOptions()">
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="username" class="form-label">Użytkownik SSH</label>
|
|
||||||
<input type="text" name="username" id="username" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label">Hasło SSH</label>
|
|
||||||
<input type="password" name="password" id="password" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="port" class="form-label">Port SSH</label>
|
|
||||||
<input type="text" name="port" id="port" class="form-control" value="22">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="host_type" class="form-label">Typ</label>
|
|
||||||
<select name="host_type" id="host_type" class="form-select" required>
|
|
||||||
<option value="linux">Linux</option>
|
<option value="linux">Linux</option>
|
||||||
<option value="mikrotik">Mikrotik</option>
|
<option value="mikrotik">Mikrotik</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 2) Wybór metody uwierzytelniania -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="auth_method" class="form-label">Metoda uwierzytelniania</label>
|
<label for="auth_method" class="form-label">Metoda uwierzytelniania</label>
|
||||||
<select name="auth_method" id="auth_method" class="form-select">
|
<select name="auth_method" id="auth_method" class="form-select" onchange="toggleAuthFields()">
|
||||||
<option value="password">Hasło</option>
|
<option value="password">Hasło</option>
|
||||||
<option value="ssh_key">Klucz SSH</option>
|
<option value="ssh_key">Klucz SSH</option>
|
||||||
|
<option value="global_key">Globalny klucz SSH</option>
|
||||||
|
<option value="daemon">Demon (Linux)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
|
||||||
<label for="private_key" class="form-label">Klucz prywatny (jeśli używasz klucza SSH)</label>
|
<!-- Pola nazwy hosta i portu -->
|
||||||
<textarea name="private_key" id="private_key" rows="4" class="form-control"></textarea>
|
<div id="hostPortFields">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="hostname" class="form-label">Nazwa hosta (IP lub domena)</label>
|
||||||
|
<input type="text" name="hostname" id="hostname" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="port" class="form-label">Port</label>
|
||||||
|
<input type="text" name="port" id="port" class="form-control" value="22">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
|
||||||
<label for="key_passphrase" class="form-label">Hasło do klucza (jeśli klucz jest zaszyfrowany)</label>
|
<!-- Pola user + hasło (dla password / ssh_key / global_key) -->
|
||||||
<input type="password" name="key_passphrase" id="key_passphrase" class="form-control">
|
<div id="userPassFields">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Użytkownik</label>
|
||||||
|
<input type="text" name="username" id="username" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="passwordField">
|
||||||
|
<label for="password" class="form-label">Hasło</label>
|
||||||
|
<input type="password" name="password" id="password" class="form-control">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pola klucza prywatnego (dla ssh_key) -->
|
||||||
|
<div id="sshKeyFields">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="private_key" class="form-label">Klucz prywatny</label>
|
||||||
|
<textarea name="private_key" id="private_key" rows="4" class="form-control"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="key_passphrase" class="form-label">Hasło do klucza</label>
|
||||||
|
<input type="password" name="key_passphrase" id="key_passphrase" class="form-control">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pola dla demona (tylko jeśli auth_method=daemon i platforma=linux) -->
|
||||||
|
<div id="daemonFields">
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="use_daemon" id="use_daemon" value="1">
|
||||||
|
<label class="form-check-label" for="use_daemon">
|
||||||
|
Korzystaj z demona (zamiast SSH) [Tylko Linux]
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="daemon_url" class="form-label">URL demona</label>
|
||||||
|
<input type="text" name="daemon_url" id="daemon_url" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="daemon_token" class="form-label">Token autoryzacyjny demona</label>
|
||||||
|
<input type="text" name="daemon_token" id="daemon_token" class="form-control">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nowe: wybór preferowanego pliku /etc/hosts -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="preferred_hostfile_id" class="form-label">Domyślny plik /etc/hosts</label>
|
||||||
|
<select name="preferred_hostfile_id" id="preferred_hostfile_id" class="form-select">
|
||||||
|
<!-- Opcja pusta => None => "Default" -->
|
||||||
|
<option value="">(Default - brak)</option>
|
||||||
|
{% for hf in user_hostfiles %}
|
||||||
|
<option value="{{ hf.id }}">{{ hf.title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Dodaj serwer</button>
|
<button type="submit" class="btn btn-primary">Dodaj serwer</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -61,6 +119,89 @@
|
|||||||
|
|
||||||
<div class="mt-3 text-center">
|
<div class="mt-3 text-center">
|
||||||
<a href="{{ url_for('server_list') }}" class="btn btn-secondary">Lista serwerów</a>
|
<a href="{{ url_for('server_list') }}" class="btn btn-secondary">Lista serwerów</a>
|
||||||
<a href="{{ url_for('import_servers') }}" class="btn btn-secondary">Importuj serwery z CSV</a>
|
<a href="{{ url_for('import_servers') }}" class="btn btn-secondary">Importuj z CSV</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{{ super() }}
|
||||||
|
<script>
|
||||||
|
function toggleSystemOptions() {
|
||||||
|
const hostType = document.getElementById('host_type').value;
|
||||||
|
const authMethodSelect = document.getElementById('auth_method');
|
||||||
|
// Znajdź opcję 'daemon':
|
||||||
|
const daemonOption = Array.from(authMethodSelect.options).find(opt => opt.value === 'daemon');
|
||||||
|
|
||||||
|
// Jeśli to Mikrotik, ukrywamy opcję demona
|
||||||
|
if (hostType === 'mikrotik') {
|
||||||
|
if (daemonOption) {
|
||||||
|
daemonOption.style.display = 'none';
|
||||||
|
if (authMethodSelect.value === 'daemon') {
|
||||||
|
authMethodSelect.value = 'password';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Linux -> z powrotem pokazujemy 'daemon'
|
||||||
|
if (daemonOption) {
|
||||||
|
daemonOption.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toggleAuthFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAuthFields() {
|
||||||
|
const hostType = document.getElementById('host_type').value;
|
||||||
|
const authMethod = document.getElementById('auth_method').value;
|
||||||
|
|
||||||
|
// Kontenery:
|
||||||
|
const hostPortFields = document.getElementById('hostPortFields');
|
||||||
|
const userPassFields = document.getElementById('userPassFields');
|
||||||
|
const passwordField = document.getElementById('passwordField');
|
||||||
|
const sshKeyFields = document.getElementById('sshKeyFields');
|
||||||
|
const daemonFields = document.getElementById('daemonFields');
|
||||||
|
const useDaemon = document.getElementById('use_daemon');
|
||||||
|
|
||||||
|
// Ukrywamy wszystkie na start
|
||||||
|
hostPortFields.style.display = 'none';
|
||||||
|
userPassFields.style.display = 'none';
|
||||||
|
passwordField.style.display = 'none';
|
||||||
|
sshKeyFields.style.display = 'none';
|
||||||
|
daemonFields.style.display = 'none';
|
||||||
|
|
||||||
|
if (useDaemon) {
|
||||||
|
useDaemon.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jeżeli authMethod != 'daemon', to normalnie pokazujemy host/port
|
||||||
|
if (authMethod !== 'daemon') {
|
||||||
|
hostPortFields.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// W zależności od authMethod
|
||||||
|
if (authMethod === 'password') {
|
||||||
|
userPassFields.style.display = 'block';
|
||||||
|
passwordField.style.display = 'block';
|
||||||
|
|
||||||
|
} else if (authMethod === 'ssh_key') {
|
||||||
|
userPassFields.style.display = 'block';
|
||||||
|
sshKeyFields.style.display = 'block';
|
||||||
|
|
||||||
|
} else if (authMethod === 'global_key') {
|
||||||
|
userPassFields.style.display = 'block';
|
||||||
|
|
||||||
|
} else if (authMethod === 'daemon') {
|
||||||
|
// Tylko Linux
|
||||||
|
if (hostType === 'linux') {
|
||||||
|
daemonFields.style.display = 'block';
|
||||||
|
if (useDaemon) {
|
||||||
|
useDaemon.checked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
toggleSystemOptions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Edytuj server - /etc/hosts Manager{% endblock %}
|
{% block title %}Edytuj serwer - /etc/hosts Manager{% endblock %}
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<style>
|
<style>
|
||||||
@ -7,8 +7,17 @@
|
|||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Domyślnie ukrywamy te kontenery. JS je włącza zależnie od opcji. */
|
||||||
|
#hostPortFields,
|
||||||
|
#userPassFields,
|
||||||
|
#sshKeyFields,
|
||||||
|
#daemonFields {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@ -16,44 +25,100 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" action="{{ url_for('edit_server', id=host.id) }}">
|
<form method="POST" action="{{ url_for('edit_server', id=host.id) }}">
|
||||||
|
|
||||||
|
<!-- 1) Platforma (Linux / Mikrotik) -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="hostname" class="form-label">Nazwa hosta (IP lub domena)</label>
|
<label for="host_type" class="form-label">Platforma (system)</label>
|
||||||
<input type="text" name="hostname" id="hostname" class="form-control" value="{{ host.hostname }}" required>
|
<select name="host_type" id="host_type" class="form-select" required onchange="toggleSystemOptions()">
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="username" class="form-label">Użytkownik SSH</label>
|
|
||||||
<input type="text" name="username" id="username" class="form-control" value="{{ host.username }}" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label">Hasło (pozostaw puste, aby nie zmieniać)</label>
|
|
||||||
<input type="password" name="password" id="password" class="form-control" placeholder="Wprowadź nowe hasło">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="port" class="form-label">Port SSH</label>
|
|
||||||
<input type="text" name="port" id="port" class="form-control" value="{{ host.port }}">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="host_type" class="form-label">Typ</label>
|
|
||||||
<select name="host_type" id="host_type" class="form-select" required>
|
|
||||||
<option value="linux" {% if host.type == 'linux' %}selected{% endif %}>Linux</option>
|
<option value="linux" {% if host.type == 'linux' %}selected{% endif %}>Linux</option>
|
||||||
<option value="mikrotik" {% if host.type == 'mikrotik' %}selected{% endif %}>Mikrotik</option>
|
<option value="mikrotik" {% if host.type == 'mikrotik' %}selected{% endif %}>Mikrotik</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 2) Metoda uwierzytelniania -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="auth_method" class="form-label">Metoda uwierzytelniania</label>
|
<label for="auth_method" class="form-label">Metoda uwierzytelniania</label>
|
||||||
<select name="auth_method" id="auth_method" class="form-select">
|
<select name="auth_method" id="auth_method" class="form-select" onchange="toggleAuthFields()">
|
||||||
<option value="password" {% if host.auth_method == 'password' %}selected{% endif %}>Hasło</option>
|
<option value="password" {% if host.auth_method == 'password' %}selected{% endif %}>Hasło</option>
|
||||||
<option value="ssh_key" {% if host.auth_method == 'ssh_key' %}selected{% endif %}>Klucz SSH</option>
|
<option value="ssh_key" {% if host.auth_method == 'ssh_key' %}selected{% endif %}>Klucz SSH</option>
|
||||||
|
<option value="global_key" {% if host.auth_method == 'global_key' %}selected{% endif %}>Globalny klucz SSH</option>
|
||||||
|
<option value="daemon" {% if host.use_daemon and host.type == 'linux' %}selected{% endif %}>Demon (Linux)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
|
||||||
<label for="private_key" class="form-label">Klucz prywatny (jeśli używasz klucza SSH)</label>
|
<!-- Kontener host + port -->
|
||||||
<textarea name="private_key" id="private_key" rows="4" class="form-control">{{ host.private_key }}</textarea>
|
<div id="hostPortFields">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="hostname" class="form-label">Nazwa hosta (IP lub domena)</label>
|
||||||
|
<input type="text" name="hostname" id="hostname" class="form-control" value="{{ host.hostname }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="port" class="form-label">Port</label>
|
||||||
|
<input type="text" name="port" id="port" class="form-control" value="{{ host.port }}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
|
||||||
<label for="key_passphrase" class="form-label">Hasło do klucza (jeśli klucz jest zaszyfrowany)</label>
|
<!-- Kontener user + hasło -->
|
||||||
<input type="password" name="key_passphrase" id="key_passphrase" class="form-control" value="{{ host.key_passphrase }}">
|
<div id="userPassFields">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Użytkownik</label>
|
||||||
|
<input type="text" name="username" id="username" class="form-control" value="{{ host.username }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="passwordField">
|
||||||
|
<label for="password" class="form-label">Hasło (pozostaw puste, aby nie zmieniać)</label>
|
||||||
|
<input type="password" name="password" id="password" class="form-control" placeholder="Wprowadź nowe hasło">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Kontener klucza SSH (ssh_key) -->
|
||||||
|
<div id="sshKeyFields">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="private_key" class="form-label">Klucz prywatny</label>
|
||||||
|
<textarea name="private_key" id="private_key" rows="4" class="form-control">{{ host.private_key }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="key_passphrase" class="form-label">Hasło do klucza</label>
|
||||||
|
<input type="password" name="key_passphrase" id="key_passphrase" class="form-control" value="{{ host.key_passphrase }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kontener demona -->
|
||||||
|
<div id="daemonFields">
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="use_daemon" id="use_daemon" value="1"
|
||||||
|
{% if host.use_daemon %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="use_daemon">
|
||||||
|
Korzystaj z demona (zamiast SSH) [Tylko Linux]
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="daemon_url" class="form-label">URL demona</label>
|
||||||
|
<input type="text" name="daemon_url" id="daemon_url" class="form-control" value="{{ host.daemon_url }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="daemon_token" class="form-label">Token autoryzacyjny demona</label>
|
||||||
|
<input type="text" name="daemon_token" id="daemon_token" class="form-control" value="{{ host.daemon_token }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nowe: wybór pliku /etc/hosts (preferred_hostfile_id) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="preferred_hostfile_id" class="form-label">Domyślny plik /etc/hosts dla tego serwera</label>
|
||||||
|
<select name="preferred_hostfile_id" id="preferred_hostfile_id" class="form-select">
|
||||||
|
<!-- pusta wartość => None => "Default" -->
|
||||||
|
<option value=""
|
||||||
|
{% if not host.preferred_hostfile_id %}selected{% endif %}>
|
||||||
|
(Default - brak)
|
||||||
|
</option>
|
||||||
|
{% for hf in user_hostfiles %}
|
||||||
|
<option value="{{ hf.id }}"
|
||||||
|
{% if host.preferred_hostfile_id == hf.id %}selected{% endif %}>
|
||||||
|
{{ hf.title }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Zapisz zmiany</button>
|
<button type="submit" class="btn btn-primary">Zapisz zmiany</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -61,7 +126,84 @@
|
|||||||
|
|
||||||
<div class="mt-3 text-center">
|
<div class="mt-3 text-center">
|
||||||
<a href="{{ url_for('server_list') }}" class="btn btn-secondary">Lista serwerów</a>
|
<a href="{{ url_for('server_list') }}" class="btn btn-secondary">Lista serwerów</a>
|
||||||
<a href="{{ url_for('import_servers') }}" class="btn btn-secondary">Importuj serwery z CSV</a>
|
<a href="{{ url_for('import_servers') }}" class="btn btn-secondary">Importuj z CSV</a>
|
||||||
<a href="{{ url_for('export_servers_to_csv') }}" class="btn btn-secondary">Eksportuj serwery do CSV</a>
|
<a href="{{ url_for('export_servers_to_csv') }}" class="btn btn-secondary">Eksportuj do CSV</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{{ super() }}
|
||||||
|
<script>
|
||||||
|
function toggleSystemOptions() {
|
||||||
|
const hostType = document.getElementById('host_type').value;
|
||||||
|
const authMethodSelect = document.getElementById('auth_method');
|
||||||
|
const daemonOption = Array.from(authMethodSelect.options).find(opt => opt.value === 'daemon');
|
||||||
|
|
||||||
|
// Mikrotik -> chowamy daemon
|
||||||
|
if (hostType === 'mikrotik') {
|
||||||
|
if (daemonOption) {
|
||||||
|
daemonOption.style.display = 'none';
|
||||||
|
if (authMethodSelect.value === 'daemon') {
|
||||||
|
authMethodSelect.value = 'password';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Linux -> pokazujemy
|
||||||
|
if (daemonOption) {
|
||||||
|
daemonOption.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toggleAuthFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAuthFields() {
|
||||||
|
const hostType = document.getElementById('host_type').value;
|
||||||
|
const authMethod = document.getElementById('auth_method').value;
|
||||||
|
|
||||||
|
const hostPortFields = document.getElementById('hostPortFields');
|
||||||
|
const userPassFields = document.getElementById('userPassFields');
|
||||||
|
const passwordField = document.getElementById('passwordField');
|
||||||
|
const sshKeyFields = document.getElementById('sshKeyFields');
|
||||||
|
const daemonFields = document.getElementById('daemonFields');
|
||||||
|
const useDaemon = document.getElementById('use_daemon');
|
||||||
|
|
||||||
|
// Najpierw wszystko chowamy
|
||||||
|
hostPortFields.style.display = 'none';
|
||||||
|
userPassFields.style.display = 'none';
|
||||||
|
passwordField.style.display = 'none';
|
||||||
|
sshKeyFields.style.display = 'none';
|
||||||
|
daemonFields.style.display = 'none';
|
||||||
|
if (useDaemon) {
|
||||||
|
useDaemon.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gdy authMethod != 'daemon', włącz hostPort
|
||||||
|
if (authMethod !== 'daemon') {
|
||||||
|
hostPortFields.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Szczegółowe reguły
|
||||||
|
if (authMethod === 'password') {
|
||||||
|
userPassFields.style.display = 'block';
|
||||||
|
passwordField.style.display = 'block';
|
||||||
|
} else if (authMethod === 'ssh_key') {
|
||||||
|
userPassFields.style.display = 'block';
|
||||||
|
sshKeyFields.style.display = 'block';
|
||||||
|
} else if (authMethod === 'global_key') {
|
||||||
|
userPassFields.style.display = 'block';
|
||||||
|
} else if (authMethod === 'daemon') {
|
||||||
|
if (hostType === 'linux') {
|
||||||
|
daemonFields.style.display = 'block';
|
||||||
|
if (useDaemon) {
|
||||||
|
useDaemon.checked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uruchamiamy przy ładowaniu
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
toggleSystemOptions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
@ -1,29 +1,32 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Lista serwerów - /etc/hosts Manager{% endblock %}
|
{% block title %}Lista serwerów - /etc/hosts Manager{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<style>
|
<style>
|
||||||
.tooltip-inner {
|
.tooltip-inner {
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>Lista serwerów</h2>
|
<h2>Lista serwerów</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body table-responsive">
|
<div class="card-body table-responsive">
|
||||||
<table class="table table-striped">
|
<table class="table table-striped align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Nazwa hosta</th>
|
<th>Nazwa hosta</th>
|
||||||
<th>Użytkownik SSH</th>
|
<th>Użytkownik</th>
|
||||||
<th>Port</th>
|
<th>Port</th>
|
||||||
<th>Typ</th>
|
<th>Typ</th>
|
||||||
<th>Metoda uwierzytelniania</th>
|
<th>Uwierzytelnianie</th>
|
||||||
|
<th>Wybrany plik /etc/hosts</th> <!-- Nowa kolumna -->
|
||||||
<th>Auto Deploy</th>
|
<th>Auto Deploy</th>
|
||||||
<th>Auto Backup</th>
|
<th>Auto Backup</th>
|
||||||
<th>Akcje</th>
|
<th>Akcje</th>
|
||||||
@ -38,20 +41,50 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ h.username }}</td>
|
<td>{{ h.username }}</td>
|
||||||
<td>{{ h.port }}</td>
|
<td>{{ h.port }}</td>
|
||||||
<td>{{ h.type }}</td>
|
<td>
|
||||||
<td>{{ h.auth_method }}</td>
|
{% if h.type == 'linux' %}
|
||||||
<!-- Formularz aktualizujący automatyczny deploy dla serwera -->
|
Linux{% if h.use_daemon %} (Demon){% endif %}
|
||||||
|
{% else %}
|
||||||
|
Mikrotik
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if h.use_daemon and h.type == 'linux' %}
|
||||||
|
<em>- używa Demona -</em>
|
||||||
|
{% else %}
|
||||||
|
{% if h.auth_method == 'password' %}
|
||||||
|
Hasło
|
||||||
|
{% elif h.auth_method == 'ssh_key' %}
|
||||||
|
Klucz SSH
|
||||||
|
{% elif h.auth_method == 'global_key' %}
|
||||||
|
Globalny klucz
|
||||||
|
{% else %}
|
||||||
|
{{ h.auth_method }}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<!-- Nowa kolumna: preferowany plik hosts -->
|
||||||
|
<td>
|
||||||
|
{% if h.preferred_hostfile %}
|
||||||
|
{{ h.preferred_hostfile.title }}
|
||||||
|
{% else %}
|
||||||
|
(Default)
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<!-- Formularz aktualizujący auto_deploy -->
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" action="{{ url_for('update_host_automation', id=h.id) }}" style="display:inline;">
|
<form method="POST" action="{{ url_for('update_host_automation', id=h.id) }}" style="display:inline;">
|
||||||
<input type="hidden" name="setting" value="auto_deploy">
|
<input type="hidden" name="setting" value="auto_deploy">
|
||||||
<input type="checkbox" name="enabled" value="1" onchange="this.form.submit()" {% if h.auto_deploy_enabled %}checked{% endif %}>
|
<input type="checkbox" name="enabled" value="1"
|
||||||
|
onchange="this.form.submit()" {% if h.auto_deploy_enabled %}checked{% endif %}>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<!-- Formularz aktualizujący automatyczny backup dla serwera -->
|
<!-- Formularz aktualizujący auto_backup -->
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" action="{{ url_for('update_host_automation', id=h.id) }}" style="display:inline;">
|
<form method="POST" action="{{ url_for('update_host_automation', id=h.id) }}" style="display:inline;">
|
||||||
<input type="hidden" name="setting" value="auto_backup">
|
<input type="hidden" name="setting" value="auto_backup">
|
||||||
<input type="checkbox" name="enabled" value="1" onchange="this.form.submit()" {% if h.auto_backup_enabled %}checked{% endif %}>
|
<input type="checkbox" name="enabled" value="1"
|
||||||
|
onchange="this.form.submit()" {% if h.auto_backup_enabled %}checked{% endif %}>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -71,7 +104,7 @@
|
|||||||
|
|
||||||
<div class="mt-3 text-center">
|
<div class="mt-3 text-center">
|
||||||
<a href="{{ url_for('add_server') }}" class="btn btn-secondary">Dodaj nowy serwer</a>
|
<a href="{{ url_for('add_server') }}" class="btn btn-secondary">Dodaj nowy serwer</a>
|
||||||
<a href="{{ url_for('import_servers') }}" class="btn btn-secondary">Importuj serwery z CSV</a>
|
<a href="{{ url_for('import_servers') }}" class="btn btn-secondary">Importuj z CSV</a>
|
||||||
<a href="{{ url_for('export_servers_to_csv') }}" class="btn btn-secondary">Eksportuj serwery do CSV</a>
|
<a href="{{ url_for('export_servers_to_csv') }}" class="btn btn-secondary">Eksportuj do CSV</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user