diff --git a/.gitignore b/.gitignore
index 21f747b..2098c19 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,8 @@
data/
venv/
+.venv/
__pycache__/
.env
*.pyc
.DS_Store
+
diff --git a/main.py b/main.py
index 8cc96ae..962ff77 100644
--- a/main.py
+++ b/main.py
@@ -90,6 +90,16 @@ def init_db():
)
''')
+ # Create activity_logs table
+ c.execute('''
+ CREATE TABLE IF NOT EXISTS activity_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ timestamp REAL NOT NULL,
+ level TEXT NOT NULL,
+ message TEXT NOT NULL
+ )
+ ''')
+
# Now check for column migrations
c.execute("PRAGMA table_info(instances)")
columns = [col[1] for col in c.fetchall()]
@@ -135,7 +145,20 @@ def init_db():
conn.commit()
conn.close()
+def log_activity(level: str, message: str):
+ try:
+ conn = get_db()
+ c = conn.cursor()
+ c.execute('INSERT INTO activity_logs (timestamp, level, message) VALUES (?, ?, ?)',
+ (time.time(), level, message))
+ conn.commit()
+ conn.close()
+ except Exception as e:
+ print(f"Failed to log activity: {e}")
+
init_db()
+log_activity("INFO", "Database initialized / application startup.")
+
def is_authenticated(request: Request):
if not request.session.get("user_id"):
@@ -164,14 +187,20 @@ async def login(request: Request):
request.session["user_id"] = user['id']
request.session["username"] = user['username']
request.session["is_admin"] = bool(user['is_admin'])
+ log_activity("INFO", f"User '{user['username']}' logged in successfully.")
return {"status": "ok", "user": {"username": user['username'], "is_admin": bool(user['is_admin'])}}
+
+ log_activity("WARNING", f"Failed login attempt for username '{username}'.")
raise HTTPException(status_code=401, detail="Invalid username or password")
@app.get("/api/logout")
async def logout(request: Request):
+ username = request.session.get("username", "Unknown")
request.session.clear()
+ log_activity("INFO", f"User '{username}' logged out.")
return {"status": "ok"}
+
@app.get("/api/auth/status")
async def auth_status(request: Request):
if request.session.get("user_id"):
@@ -213,6 +242,8 @@ async def create_user(request: Request):
(username, hashed, is_admin))
conn.commit()
conn.close()
+ admin_username = request.session.get("username", "Admin")
+ log_activity("INFO", f"Admin '{admin_username}' created user '{username}' (Admin: {bool(is_admin)}).")
except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="Username already exists")
return {"status": "ok"}
@@ -223,11 +254,18 @@ def delete_user(id: int, request: Request):
raise HTTPException(status_code=400, detail="Cannot delete yourself")
conn = get_db()
c = conn.cursor()
+ c.execute('SELECT username FROM users WHERE id=?', (id,))
+ target_user_row = c.fetchone()
+ target_username = target_user_row[0] if target_user_row else f"ID {id}"
+
c.execute('DELETE FROM users WHERE id=?', (id,))
# Also delete their instances? Let's say yes for cleanliness.
c.execute('DELETE FROM instances WHERE user_id=?', (id,))
conn.commit()
conn.close()
+
+ admin_username = request.session.get("username", "Admin")
+ log_activity("INFO", f"Admin '{admin_username}' deleted user '{target_username}' and all associated instances.")
return {"status": "ok"}
@app.put("/api/users/{id}/password", dependencies=[Depends(is_authenticated), Depends(is_admin)])
@@ -239,11 +277,19 @@ async def admin_reset_password(id: int, request: Request):
hashed = get_password_hash(new_password)
conn = get_db()
c = conn.cursor()
+ c.execute('SELECT username FROM users WHERE id=?', (id,))
+ target_user_row = c.fetchone()
+ target_username = target_user_row[0] if target_user_row else f"ID {id}"
+
c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (hashed, id))
conn.commit()
conn.close()
+
+ admin_username = request.session.get("username", "Admin")
+ log_activity("INFO", f"Admin '{admin_username}' reset password for user '{target_username}'.")
return {"status": "ok"}
+
# Own Password Change (All Users)
@app.put("/api/users/me/password", dependencies=[Depends(is_authenticated)])
async def change_own_password(request: Request):
@@ -252,12 +298,15 @@ async def change_own_password(request: Request):
if not new_password:
raise HTTPException(status_code=400, detail="New password required")
user_id = request.session.get("user_id")
+ username = request.session.get("username")
hashed = get_password_hash(new_password)
conn = get_db()
c = conn.cursor()
c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (hashed, user_id))
conn.commit()
conn.close()
+
+ log_activity("INFO", f"User '{username}' updated their password.")
return {"status": "ok"}
@app.put("/api/users/me/username", dependencies=[Depends(is_authenticated)])
@@ -267,16 +316,20 @@ async def change_own_username(request: Request):
if not new_username:
raise HTTPException(status_code=400, detail="New username required")
user_id = request.session.get("user_id")
+ old_username = request.session.get("username")
try:
conn = get_db()
c = conn.cursor()
c.execute('UPDATE users SET username = ? WHERE id = ?', (new_username, user_id))
conn.commit()
conn.close()
+ request.session["username"] = new_username
+ log_activity("INFO", f"User '{old_username}' updated their username to '{new_username}'.")
except sqlite3.IntegrityError:
raise HTTPException(status_code=400, detail="Username already exists")
return {"status": "ok"}
+
class OTPSecretCreate(BaseModel):
name: str
secret: str
@@ -324,6 +377,7 @@ def get_otp_secrets(request: Request):
@app.post("/api/otp-secrets", dependencies=[Depends(is_authenticated)])
def create_otp_secret(secret_data: OTPSecretCreate, request: Request):
user_id = request.session.get("user_id")
+ username = request.session.get("username")
cleaned = clean_secret(secret_data.secret)
encrypted = fernet.encrypt(cleaned.encode()).decode()
conn = get_db()
@@ -334,19 +388,23 @@ def create_otp_secret(secret_data: OTPSecretCreate, request: Request):
''', (secret_data.name, encrypted, user_id))
conn.commit()
conn.close()
+ log_activity("INFO", f"User '{username}' created OTP secret '{secret_data.name}'.")
return {"status": "ok"}
@app.put("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)])
def update_otp_secret(id: int, secret_data: OTPSecretUpdate, request: Request):
user_id = request.session.get("user_id")
+ username = request.session.get("username")
conn = get_db()
c = conn.cursor()
# Verify ownership
- c.execute('SELECT id FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id))
- if not c.fetchone():
+ c.execute('SELECT name FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id))
+ row = c.fetchone()
+ if not row:
conn.close()
raise HTTPException(status_code=404, detail="OTP Secret not found")
+ old_name = row[0]
if secret_data.secret:
cleaned = clean_secret(secret_data.secret)
@@ -355,11 +413,13 @@ def update_otp_secret(id: int, secret_data: OTPSecretUpdate, request: Request):
UPDATE otp_secrets SET name=?, encrypted_secret=?
WHERE id=? AND user_id=?
''', (secret_data.name, encrypted, id, user_id))
+ log_activity("INFO", f"User '{username}' updated OTP secret '{old_name}' (name updated to '{secret_data.name}' and key value changed).")
else:
c.execute('''
UPDATE otp_secrets SET name=?
WHERE id=? AND user_id=?
''', (secret_data.name, id, user_id))
+ log_activity("INFO", f"User '{username}' updated OTP secret name '{old_name}' to '{secret_data.name}'.")
conn.commit()
conn.close()
@@ -368,9 +428,18 @@ def update_otp_secret(id: int, secret_data: OTPSecretUpdate, request: Request):
@app.delete("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)])
def delete_otp_secret(id: int, request: Request):
user_id = request.session.get("user_id")
+ username = request.session.get("username")
conn = get_db()
c = conn.cursor()
+ # Verify ownership
+ c.execute('SELECT name FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id))
+ row = c.fetchone()
+ if not row:
+ conn.close()
+ raise HTTPException(status_code=404, detail="OTP Secret not found")
+ secret_name = row[0]
+
# Check if used by any instances
c.execute('SELECT COUNT(*) FROM instances WHERE otp_secret_id=?', (id,))
if c.fetchone()[0] > 0:
@@ -380,6 +449,7 @@ def delete_otp_secret(id: int, request: Request):
c.execute('DELETE FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id))
conn.commit()
conn.close()
+ log_activity("INFO", f"User '{username}' deleted OTP secret '{secret_name}'.")
return {"status": "ok"}
async def poll_instances():
@@ -419,13 +489,17 @@ async def poll_instances():
if resp.status_code == 200:
status_msg = "Sent"
+ log_activity("INFO", f"Polled instance '{inst['name']}' ({ip}:{port}) - OTP sent successfully.")
else:
status_msg = f"Failed: {resp.status_code}"
+ log_activity("WARNING", f"Polled instance '{inst['name']}' ({ip}:{port}) - Failed: HTTP status {resp.status_code}.")
except (httpx.ConnectError, httpx.TimeoutException):
status_msg = "Offline"
+ log_activity("INFO", f"Polled instance '{inst['name']}' ({ip}:{port}) - Offline (no response on port {port}).")
except Exception as e:
status_msg = f"Error: {str(e)}"
+ log_activity("ERROR", f"Polled instance '{inst['name']}' ({ip}:{port}) - Error: {str(e)}.")
conn = get_db()
c = conn.cursor()
@@ -464,6 +538,7 @@ def get_instances(request: Request):
@app.post("/api/instances", dependencies=[Depends(is_authenticated)])
def create_instance(inst: InstanceCreate, request: Request):
user_id = request.session.get("user_id")
+ username = request.session.get("username")
conn = get_db()
c = conn.cursor()
c.execute('''
@@ -472,19 +547,23 @@ def create_instance(inst: InstanceCreate, request: Request):
''', (inst.name, inst.ip, inst.port, inst.otp_secret_id, user_id, ""))
conn.commit()
conn.close()
+ log_activity("INFO", f"User '{username}' created instance '{inst.name}' ({inst.ip}:{inst.port}).")
return {"status": "ok"}
@app.put("/api/instances/{id}", dependencies=[Depends(is_authenticated)])
def update_instance(id: int, inst: InstanceUpdate, request: Request):
user_id = request.session.get("user_id")
+ username = request.session.get("username")
conn = get_db()
c = conn.cursor()
# Verify ownership
- c.execute('SELECT id FROM instances WHERE id=? AND user_id=?', (id, user_id))
- if not c.fetchone():
+ c.execute('SELECT name FROM instances WHERE id=? AND user_id=?', (id, user_id))
+ row = c.fetchone()
+ if not row:
conn.close()
raise HTTPException(status_code=404, detail="Instance not found")
+ old_name = row[0]
c.execute('''
UPDATE instances SET name=?, ip=?, port=?, otp_secret_id=?
@@ -493,22 +572,44 @@ def update_instance(id: int, inst: InstanceUpdate, request: Request):
conn.commit()
conn.close()
+ log_activity("INFO", f"User '{username}' updated instance '{old_name}' -> '{inst.name}' ({inst.ip}:{inst.port}).")
return {"status": "ok"}
-@app.get("/api/config", dependencies=[Depends(is_authenticated)])
-def get_config():
- return {"firewall_host_ip": FIREWALL_HOST_IP}
-
@app.delete("/api/instances/{id}", dependencies=[Depends(is_authenticated)])
def delete_instance(id: int, request: Request):
user_id = request.session.get("user_id")
+ username = request.session.get("username")
conn = get_db()
c = conn.cursor()
+
+ c.execute('SELECT name FROM instances WHERE id=? AND user_id=?', (id, user_id))
+ row = c.fetchone()
+ if not row:
+ conn.close()
+ raise HTTPException(status_code=404, detail="Instance not found")
+ inst_name = row[0]
+
c.execute('DELETE FROM instances WHERE id=? AND user_id=?', (id, user_id))
conn.commit()
conn.close()
+ log_activity("INFO", f"User '{username}' deleted instance '{inst_name}'.")
return {"status": "ok"}
+@app.get("/api/config")
+def get_config():
+ return {"firewall_host_ip": FIREWALL_HOST_IP}
+
+@app.get("/api/logs", dependencies=[Depends(is_authenticated)])
+def get_logs():
+ conn = get_db()
+ conn.row_factory = sqlite3.Row
+ c = conn.cursor()
+ c.execute('SELECT id, timestamp, level, message FROM activity_logs ORDER BY id DESC LIMIT 100')
+ logs = [dict(row) for row in c.fetchall()]
+ conn.close()
+ return logs
+
+
# Mount root and static
@app.get("/")
def read_root():
diff --git a/static/app.js b/static/app.js
index 9a637df..023b979 100644
--- a/static/app.js
+++ b/static/app.js
@@ -31,8 +31,13 @@ document.addEventListener('DOMContentLoaded', () => {
const addOtpModal = document.getElementById('add-otp-secret-modal');
const otpSecretIdSelect = document.getElementById('otp-secret-id');
const otpSecretsList = document.getElementById('otp-secrets-list');
+ const logsToggle = document.getElementById('logs-toggle');
+ const logsSection = document.getElementById('logs-section');
+ const logsViewer = document.getElementById('logs-viewer');
+ const logsRefreshBtn = document.getElementById('logs-refresh-btn');
let config = { firewall_host_ip: null };
+ let logsInterval = null;
let currentUser = null;
let editId = null;
let editOtpId = null;
@@ -87,12 +92,18 @@ document.addEventListener('DOMContentLoaded', () => {
loginOverlay.style.display = 'flex';
logoutBtn.style.display = 'none';
mainNav.style.display = 'none';
+ if (logsSection) logsSection.style.display = 'none';
+ stopLogsPolling();
};
const hideLogin = () => {
loginOverlay.style.display = 'none';
logoutBtn.style.display = 'flex';
mainNav.style.display = 'flex';
+ if (logsToggle && logsToggle.checked) {
+ if (logsSection) logsSection.style.display = 'block';
+ startLogsPolling();
+ }
};
// Tab Switching
@@ -110,9 +121,15 @@ document.addEventListener('DOMContentLoaded', () => {
});
const updateFirewallCmds = () => {
+ const hostIp = config.firewall_host_ip || window.location.hostname;
+
+ const loginFirewallIp = document.getElementById('login-firewall-ip');
+ if (loginFirewallIp) {
+ loginFirewallIp.textContent = hostIp;
+ }
+
if (!ufwCmd || !iptablesCmd || !winCmd) return;
const port = portInput.value || '4646';
- const hostIp = config.firewall_host_ip || window.location.hostname;
ufwCmd.textContent = `sudo ufw allow from ${hostIp} to any port ${port} proto tcp`;
iptablesCmd.textContent = `sudo iptables -I INPUT -p tcp -s ${hostIp} --dport ${port} -j ACCEPT`;
winCmd.textContent = `New-NetFirewallRule -DisplayName "XIVLauncher OTP" -Direction Inbound -RemoteAddress ${hostIp} -LocalPort ${port} -Protocol TCP -Action Allow`;
@@ -121,7 +138,6 @@ document.addEventListener('DOMContentLoaded', () => {
const fetchConfig = async () => {
try {
const res = await fetch('/api/config');
- if (res.status === 401) return showLogin();
if (res.ok) {
config = await res.json();
updateFirewallCmds();
@@ -193,13 +209,10 @@ document.addEventListener('DOMContentLoaded', () => {
@@ -220,10 +233,10 @@ document.addEventListener('DOMContentLoaded', () => {
@@ -238,10 +251,10 @@ document.addEventListener('DOMContentLoaded', () => {
@@ -514,7 +527,7 @@ document.addEventListener('DOMContentLoaded', () => {
await navigator.clipboard.writeText(text);
const originalHtml = btn.innerHTML;
btn.classList.add('copied');
- btn.innerHTML = '';
+ btn.innerHTML = '';
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = originalHtml;
@@ -526,6 +539,79 @@ document.addEventListener('DOMContentLoaded', () => {
portInput.addEventListener('input', updateFirewallCmds);
+ // Bind help button on login overlay
+ const loginShowHelpBtn = document.getElementById('login-show-help-btn');
+ if (loginShowHelpBtn) {
+ loginShowHelpBtn.addEventListener('click', () => openModal(helpModal));
+ }
+
+ // Logging helpers
+ const fetchLogs = async () => {
+ if (!currentUser) return;
+ try {
+ const res = await fetch('/api/logs');
+ if (res.status === 401) return showLogin();
+ if (res.ok) {
+ const logs = await res.json();
+ renderLogs(logs);
+ }
+ } catch (e) {
+ console.error('Failed to fetch logs:', e);
+ }
+ };
+
+ const startLogsPolling = () => {
+ stopLogsPolling();
+ fetchLogs();
+ logsInterval = setInterval(fetchLogs, 5000);
+ };
+
+ const stopLogsPolling = () => {
+ if (logsInterval) {
+ clearInterval(logsInterval);
+ logsInterval = null;
+ }
+ };
+
+ const renderLogs = (logs) => {
+ if (!logsViewer) return;
+ if (logs.length === 0) {
+ logsViewer.innerHTML = 'No logs available.
';
+ return;
+ }
+
+ logsViewer.innerHTML = logs.slice().reverse().map(log => {
+ const date = new Date(log.timestamp * 1000);
+ const timeStr = date.toLocaleTimeString() + '.' + String(date.getMilliseconds()).padStart(3, '0');
+ const levelClass = `log-level-${log.level.toLowerCase()}`;
+ return `
+
+ [${timeStr}]
+ [${log.level}]
+ ${escapeHtml(log.message)}
+
+ `;
+ }).join('');
+
+ logsViewer.scrollTop = logsViewer.scrollHeight;
+ };
+
+ if (logsToggle) {
+ logsToggle.addEventListener('change', () => {
+ if (logsToggle.checked) {
+ logsSection.style.display = 'block';
+ startLogsPolling();
+ } else {
+ logsSection.style.display = 'none';
+ stopLogsPolling();
+ }
+ });
+ }
+
+ if (logsRefreshBtn) {
+ logsRefreshBtn.addEventListener('click', fetchLogs);
+ }
+
const checkAuth = async () => {
try {
const res = await fetch('/api/auth/status');
@@ -534,7 +620,7 @@ document.addEventListener('DOMContentLoaded', () => {
currentUser = data.user;
setupUIForUser();
hideLogin();
- fetchConfig();
+ updateFirewallCmds();
fetchInstances();
fetchOTPSecrets();
} else {
@@ -555,7 +641,10 @@ document.addEventListener('DOMContentLoaded', () => {
.replace(/'/g, "'");
}
+ // Unconditionally fetch config on startup to show firewall info on login overlay
+ fetchConfig();
checkAuth();
+
setInterval(() => {
if (loginOverlay.style.display === 'none' && document.getElementById('instances-section').classList.contains('active')) {
fetchInstances();
diff --git a/static/index.html b/static/index.html
index c65365e..3dc0027 100644
--- a/static/index.html
+++ b/static/index.html
@@ -9,28 +9,94 @@
+
+
+
+
+
+
+
+
+
@@ -216,7 +312,7 @@
sudo ufw allow from localhost to any port 4646 proto tcp
@@ -225,7 +321,7 @@
sudo iptables -I INPUT -p tcp -s localhost --dport 4646 -j ACCEPT
@@ -234,7 +330,7 @@
New-NetFirewallRule -DisplayName "XIVLauncher OTP" -Direction Inbound -RemoteAddress localhost -LocalPort 4646 -Protocol TCP -Action Allow
@@ -244,9 +340,12 @@
@@ -262,6 +361,23 @@
Invalid username or password
+
+
+
Firewall Connection Info
+
To allow this server to send OTP codes, ensure your XIVLauncher machine allows connections from:
+
+ Host IP
+ Loading...
+
+
+
diff --git a/static/style.css b/static/style.css
index c23e539..fa81d14 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1,14 +1,14 @@
:root {
- --bg-color: #0f172a;
- --card-bg: #1e293b;
- --primary: #3b82f6;
- --primary-hover: #2563eb;
- --text-main: #f8fafc;
- --text-muted: #94a3b8;
- --border-color: #334155;
- --status-online: #10b981;
- --status-offline: #ef4444;
- --status-sent: #3b82f6;
+ --bg-color: #e3e2e1;
+ --card-bg: #fcfcfc;
+ --primary: #2b7bc4;
+ --primary-hover: #3daee9;
+ --text-main: #232629;
+ --text-muted: #5f6265;
+ --border-color: #bcbcbc;
+ --status-online: #2ecc71;
+ --status-offline: #e74c3c;
+ --status-sent: #2b7bc4;
}
* {
@@ -18,26 +18,447 @@
}
body {
- font-family: 'Inter', sans-serif;
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
+ padding-bottom: 3rem;
}
.container {
max-width: 800px;
margin: 0 auto;
- padding: 2rem;
+ padding: 2rem 1.5rem;
}
+/* Header styled as a desktop panel */
header {
+ background: linear-gradient(to bottom, #f5f5f5 0%, #e1e1e0 100%);
+ border: 1px solid #acacac;
+ border-radius: 6px;
+ padding: 0.75rem 1.25rem;
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
+ box-shadow: inset 0 1px 0 #ffffff, 0 1px 3px rgba(0,0,0,0.08);
}
+.logo {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.logo h1 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #232629;
+ text-shadow: 0 1px 0 #ffffff;
+}
+
+.header-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+/* Nav styled as classic Oxygen folder tabs */
+.main-nav {
+ display: flex;
+ gap: 0.25rem;
+ margin-bottom: 1.5rem;
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 0;
+ padding-left: 0.5rem;
+}
+
+.nav-item {
+ background: linear-gradient(to bottom, #eaeaea 0%, #dcdcdc 100%);
+ border: 1px solid var(--border-color);
+ border-bottom: none;
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+ padding: 0.5rem 1.25rem;
+ border-radius: 5px 5px 0 0;
+ position: relative;
+ top: 1px;
+ transition: all 0.15s ease-in-out;
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.4);
+ text-shadow: 0 1px 0 rgba(255,255,255,0.6);
+}
+
+.nav-item:hover {
+ color: var(--text-main);
+ background: linear-gradient(to bottom, #fafafa 0%, #e2e2e2 100%);
+}
+
+.nav-item.active {
+ color: #2b7bc4;
+ background: #fcfcfc;
+ border-bottom: 1px solid #fcfcfc;
+ box-shadow: inset 0 1px 0 rgba(255,255,255,1);
+ top: 2px;
+ z-index: 2;
+}
+
+.tab-content {
+ display: none;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+/* Cards styled as native program groupboxes */
+.card {
+ background-color: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 1.5rem;
+ margin-bottom: 2rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255,255,255,0.9);
+}
+
+.card h2 {
+ font-size: 1.15rem;
+ margin-bottom: 1.25rem;
+ font-weight: 600;
+ color: #232629;
+ border-bottom: 1px solid #dcdcdc;
+ padding-bottom: 0.5rem;
+ text-shadow: 0 1px 0 #ffffff;
+}
+
+/* Oxygen Button styling */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.5rem 1.25rem;
+ font-weight: 500;
+ font-size: 0.9rem;
+ border-radius: 5px;
+ cursor: pointer;
+ border: 1px solid #a8a8a8;
+ background: linear-gradient(to bottom, #fcfcfc 0%, #e0e0e0 40%, #d5d5d5 100%);
+ color: #232629;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.7);
+ transition: all 0.15s ease-in-out;
+ font-family: inherit;
+ text-shadow: 0 1px 0 rgba(255,255,255,0.6);
+}
+
+.btn:hover {
+ border-color: #3daee9;
+ background: linear-gradient(to bottom, #ffffff 0%, #ebf5fb 40%, #d6eaf8 100%);
+ box-shadow: 0 1px 4px rgba(61, 174, 233, 0.25), inset 0 1px 0 rgba(255,255,255,0.9);
+}
+
+.btn:active {
+ background: linear-gradient(to bottom, #d5d5d5 0%, #e5e5e5 100%);
+ box-shadow: inset 0 1px 3px rgba(0,0,0,0.15);
+ transform: translateY(0.5px);
+}
+
+.btn-primary {
+ background: linear-gradient(to bottom, #73b9e5 0%, #2e8bdc 40%, #1c6fae 100%);
+ border-color: #1a5e95;
+ color: #ffffff;
+ text-shadow: 0 -1px 0 rgba(0,0,0,0.25);
+ box-shadow: 0 1px 2px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.3);
+}
+
+.btn-primary:hover {
+ background: linear-gradient(to bottom, #89c7eb 0%, #3daee9 40%, #2184be 100%);
+ border-color: #1a5e95;
+ color: #ffffff;
+ box-shadow: 0 1px 4px rgba(61, 174, 233, 0.35), inset 0 1px 0 rgba(255,255,255,0.4);
+}
+
+.btn-primary:active {
+ background: linear-gradient(to bottom, #1c6fae 0%, #2e8bdc 100%);
+ box-shadow: inset 0 1px 4px rgba(0,0,0,0.3);
+}
+
+.btn-secondary {
+ background: linear-gradient(to bottom, #fafafa 0%, #e5e7eb 40%, #d1d5db 100%);
+ border-color: #9ca3af;
+}
+
+.btn-icon {
+ padding: 0.45rem;
+ border-radius: 5px;
+ border: 1px solid transparent;
+ background: transparent;
+ box-shadow: none;
+}
+.btn-icon:hover {
+ border-color: #b0b0b0;
+ background: linear-gradient(to bottom, #ffffff 0%, #eaeaea 100%);
+}
+
+.btn-sm {
+ padding: 0.35rem 0.75rem;
+ font-size: 0.8rem;
+}
+
+/* Form inputs with 3D inset shadows */
+.form-group {
+ margin-bottom: 1.25rem;
+}
+
+.form-row {
+ display: flex;
+ gap: 1rem;
+}
+
+.form-row .form-group {
+ flex: 1;
+}
+
+label {
+ display: block;
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-bottom: 0.375rem;
+ font-weight: 600;
+}
+
+input, select {
+ width: 100%;
+ background-color: #ffffff;
+ border: 1px solid #b8b8b8;
+ color: #232629;
+ padding: 0.55rem 0.75rem;
+ border-radius: 4px;
+ font-family: inherit;
+ font-size: 0.95rem;
+ box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.12);
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+input:focus, select:focus {
+ outline: none;
+ border-color: #3daee9;
+ box-shadow: 0 0 5px rgba(61, 174, 233, 0.6), inset 1px 1px 2px rgba(0, 0, 0, 0.1);
+}
+
+.divider {
+ height: 1px;
+ background-color: #dcdcdc;
+ margin: 1.5rem 0;
+}
+
+/* Modals styled as native windows */
+.modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.4);
+ z-index: 2000;
+ justify-content: center;
+ align-items: center;
+ padding: 1rem;
+}
+
+.modal.active {
+ display: flex;
+}
+
+.modal-content {
+ background-color: var(--card-bg);
+ border: 1px solid #909090;
+ border-radius: 6px;
+ box-shadow: 0 10px 40px rgba(0,0,0,0.25), inset 0 1px 0 #ffffff;
+ overflow: hidden;
+ padding: 0;
+ width: 100%;
+ max-width: 500px;
+}
+
+.modal-header {
+ background: linear-gradient(to bottom, #eff2f5 0%, #dcdfe3 100%);
+ border-bottom: 1px solid #b0b0b0;
+ padding: 0.75rem 1.25rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ box-shadow: inset 0 1px 0 #ffffff;
+}
+
+.modal-header h2 {
+ font-size: 1.05rem;
+ font-weight: 600;
+ color: #232629;
+ margin: 0;
+ text-shadow: 0 1px 0 #ffffff;
+ border-bottom: none;
+ padding-bottom: 0;
+}
+
+/* Styled close button as red Oxygen ball window close */
+.btn-close {
+ width: 18px;
+ height: 18px;
+ background: radial-gradient(cx 30%, cy 30%, #ff8888 0%, #ef4444 60%, #cc0000 100%);
+ border: 1px solid #990000;
+ border-radius: 50%;
+ color: transparent;
+ cursor: pointer;
+ position: relative;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.4);
+}
+
+.btn-close:hover {
+ background: radial-gradient(cx 30%, cy 30%, #ffaaaa 0%, #ff4d4d 60%, #e60000 100%);
+}
+
+.btn-close::before, .btn-close::after {
+ content: '';
+ position: absolute;
+ width: 8px;
+ height: 2px;
+ background-color: white;
+ top: 7px;
+ left: 4px;
+}
+.btn-close::before { transform: rotate(45deg); }
+.btn-close::after { transform: rotate(-45deg); }
+
+.modal-content form {
+ padding: 1.5rem;
+}
+
+.modal-content .helper-text {
+ padding: 1.25rem 1.5rem 0 1.5rem;
+ margin-bottom: 0;
+}
+
+.modal-content .code-block {
+ margin: 1rem 1.5rem;
+}
+
+/* User management / lists items */
+.instances-list, .otp-secrets-list, .users-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.instance-item, .otp-secret-item, .user-item {
+ background: linear-gradient(to bottom, #fbfbfb 0%, #f1f1f1 100%);
+ border: 1px solid #cfcfcf;
+ border-radius: 5px;
+ padding: 0.875rem 1.25rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: all 0.2s ease-in-out;
+ box-shadow: inset 0 1px 0 #ffffff;
+}
+
+.instance-item:hover, .otp-secret-item:hover, .user-item:hover {
+ border-color: #3daee9;
+ background: linear-gradient(to bottom, #f2f9fe 0%, #e1f0fa 100%);
+ box-shadow: 0 1px 4px rgba(61, 174, 233, 0.15), inset 0 1px 0 #ffffff;
+}
+
+.instance-info h3, .otp-secret-info h3, .user-info h3 {
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: #232629;
+}
+
+.instance-meta {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ display: flex;
+ gap: 1rem;
+ margin-top: 0.25rem;
+ flex-wrap: wrap;
+}
+
+.instance-actions, .otp-secret-actions, .user-actions {
+ display: flex;
+ gap: 0.375rem;
+}
+
+/* Badges */
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.7rem;
+ font-weight: 700;
+ padding: 0.15rem 0.5rem;
+ border-radius: 4px;
+ background-color: #e2e8f0;
+ border: 1px solid #cbd5e1;
+ color: #475569;
+}
+
+.status-badge::before {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+}
+
+.status-badge.status-offline {
+ background-color: #fee2e2;
+ border-color: #fca5a5;
+ color: #991b1b;
+}
+.status-badge.status-offline::before { background-color: var(--status-offline); }
+
+.status-badge.status-sent {
+ background-color: #dbeafe;
+ border-color: #93c5fd;
+ color: #1e40af;
+}
+.status-badge.status-sent::before { background-color: var(--status-sent); }
+
+.status-badge.status-unknown::before { background-color: var(--text-muted); }
+
+/* Firewall Help Modal Code Blocks */
+.code-block {
+ background-color: #fafafa;
+ border: 1px solid #bcbcbc;
+ border-radius: 4px;
+ padding: 0.75rem 1rem;
+ margin-bottom: 0.75rem;
+ box-shadow: inset 1px 1px 2px rgba(0,0,0,0.05);
+}
+
+.code-label {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ font-weight: 700;
+ margin-bottom: 0.25rem;
+}
+
+.code-wrapper {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+}
+
+.code-block code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
+ font-size: 0.8rem;
+ color: #2b7bc4;
+ word-break: break-all;
+}
+
+/* Login Screen */
.login-overlay {
position: fixed;
top: 0;
@@ -53,489 +474,218 @@ header {
.login-card {
background-color: var(--card-bg);
- border: 1px solid var(--border-color);
- border-radius: 12px;
- padding: 2rem;
+ border: 1px solid #909090;
+ border-radius: 6px;
width: 100%;
max-width: 400px;
- box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
-}
-
-.main-nav {
- display: flex;
- gap: 1rem;
- margin-bottom: 1.5rem;
- border-bottom: 1px solid var(--border-color);
- padding-bottom: 1rem;
-}
-
-.nav-item {
- background: transparent;
- border: none;
- color: var(--text-muted);
- font-size: 0.875rem;
- font-weight: 600;
- cursor: pointer;
- padding: 0.5rem 1rem;
- border-radius: 6px;
- transition: all 0.2s;
-}
-
-.nav-item:hover {
- color: var(--text-main);
- background-color: rgba(255, 255, 255, 0.05);
-}
-
-.nav-item.active {
- color: var(--primary);
- background-color: rgba(59, 130, 246, 0.1);
-}
-
-.tab-content {
- display: none;
-}
-
-.tab-content.active {
- display: block;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2), inset 0 1px 0 #ffffff;
+ overflow: hidden;
+ padding: 0;
}
.login-card .logo {
- justify-content: center;
- margin-bottom: 2rem;
+ background: linear-gradient(to bottom, #eff2f5 0%, #dcdfe3 100%);
+ border-bottom: 1px solid #b0b0b0;
+ padding: 0.85rem 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ box-shadow: inset 0 1px 0 #ffffff;
+}
+
+.login-card .logo h2 {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: #232629;
+ text-shadow: 0 1px 0 #ffffff;
+}
+
+.login-card form {
+ padding: 1.5rem 1.5rem 0 1.5rem;
+}
+
+.login-card .login-firewall-section {
+ padding: 0 1.5rem 1.5rem 1.5rem;
}
.login-error {
color: var(--status-offline);
- font-size: 0.875rem;
+ font-size: 0.85rem;
margin-top: 1rem;
text-align: center;
-}
-
-.logo {
- display: flex;
- align-items: center;
- gap: 1rem;
-}
-
-.logo svg {
- color: var(--primary);
-}
-
-.logo h1 {
- font-size: 1.5rem;
font-weight: 600;
}
-.card {
+/* Konsole-styled System Logs Panel */
+.logs-toggle-container {
+ margin-top: 2rem;
+ margin-bottom: 1rem;
+ display: flex;
+ justify-content: flex-end;
+}
+
+/* Styled switch checkbox */
+.switch-container {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+}
+
+.switch-container input {
+ display: none;
+}
+
+.switch-slider {
+ position: relative;
+ width: 36px;
+ height: 20px;
+ background-color: #bdc3c7;
+ border-radius: 10px;
+ transition: background-color 0.2s;
+ border: 1px solid #a8a8a8;
+}
+
+.switch-slider::before {
+ content: "";
+ position: absolute;
+ left: 2px;
+ top: 2px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background-color: white;
+ transition: transform 0.2s;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.2);
+}
+
+.switch-container input:checked + .switch-slider {
+ background-color: #3daee9;
+ border-color: #2b7bc4;
+}
+
+.switch-container input:checked + .switch-slider::before {
+ transform: translateX(16px);
+}
+
+.switch-label {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ font-weight: 600;
+}
+
+.logs-card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
- border-radius: 12px;
- padding: 1.5rem;
- margin-bottom: 2rem;
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
-}
-
-.card h2 {
- font-size: 1.25rem;
- margin-bottom: 1.5rem;
- font-weight: 600;
-}
-
-.form-group {
- margin-bottom: 1rem;
-}
-
-.form-row {
- display: flex;
- gap: 1rem;
-}
-
-.form-row .form-group {
- flex: 1;
-}
-
-label {
- display: block;
- font-size: 0.875rem;
- color: var(--text-muted);
- margin-bottom: 0.5rem;
- font-weight: 500;
-}
-
-input, select {
- width: 100%;
- background-color: var(--bg-color);
- border: 1px solid var(--border-color);
- color: var(--text-main);
- padding: 0.75rem 1rem;
- border-radius: 8px;
- font-family: inherit;
- font-size: 1rem;
- transition: border-color 0.2s, box-shadow 0.2s;
-}
-
-input:focus, select:focus {
- outline: none;
- border-color: var(--primary);
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
-}
-
-.btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0.75rem 1.5rem;
- font-weight: 500;
- border-radius: 8px;
- cursor: pointer;
- border: none;
- transition: background-color 0.2s, transform 0.1s;
- font-family: inherit;
- font-size: 1rem;
-}
-
-.btn-primary {
- background-color: var(--primary);
- color: white;
-}
-
-.login-card .btn-primary {
- width: 100%;
+ border-radius: 6px;
+ padding: 0;
margin-top: 1rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ overflow: hidden;
}
-.btn-primary:hover {
- background-color: var(--primary-hover);
-}
-
-.btn-primary:active {
- transform: translateY(1px);
-}
-
-.btn-icon {
- padding: 0.5rem;
- background: transparent;
- color: var(--text-muted);
- border-radius: 6px;
-}
-
-.btn-icon:hover {
- background-color: rgba(255, 255, 255, 0.1);
- color: var(--text-main);
-}
-
-.instances-header {
+.logs-header {
+ background: linear-gradient(to bottom, #eff2f5 0%, #dcdfe3 100%);
+ border-bottom: 1px solid #b0b0b0;
+ padding: 0.5rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: 1.5rem;
+ box-shadow: inset 0 1px 0 #ffffff;
}
-.instances-header h2 {
- margin-bottom: 0;
-}
-
-.instances-list {
- display: flex;
- flex-direction: column;
- gap: 1rem;
-}
-
-.instance-item {
- background-color: var(--bg-color);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- padding: 1rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
- transition: transform 0.2s;
-}
-
-.instance-item:hover {
- transform: translateX(4px);
- border-color: rgba(255, 255, 255, 0.2);
-}
-
-.instance-info h3 {
- font-size: 1rem;
- margin-bottom: 0.25rem;
-}
-
-.instance-meta {
- font-size: 0.875rem;
- color: var(--text-muted);
- display: flex;
- gap: 1rem;
-}
-
-.status-badge {
- display: inline-flex;
- align-items: center;
- gap: 0.375rem;
- font-size: 0.75rem;
+.logs-header h3 {
+ font-size: 0.9rem;
font-weight: 600;
- padding: 0.25rem 0.5rem;
- border-radius: 999px;
- background-color: rgba(255, 255, 255, 0.1);
+ color: #232629;
+ text-shadow: 0 1px 0 #ffffff;
}
-.status-badge::before {
- content: '';
- width: 8px;
- height: 8px;
- border-radius: 50%;
-}
-
-.status-badge.status-offline::before { background-color: var(--status-offline); }
-.status-badge.status-sent::before { background-color: var(--status-sent); }
-.status-badge.status-unknown::before { background-color: var(--text-muted); }
-
-.delete-btn {
- color: var(--status-offline);
- background: transparent;
+.logs-viewer {
+ background-color: #151515;
border: none;
- padding: 0.5rem;
- cursor: pointer;
- border-radius: 6px;
- transition: background-color 0.2s;
+ border-radius: 0;
+ padding: 0.75rem 1rem;
+ height: 220px;
+ overflow-y: auto;
+ font-family: 'Source Code Pro', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.75rem;
+ line-height: 1.5;
+ color: #f0f0f0;
}
-.delete-btn:hover {
- background-color: rgba(239, 68, 68, 0.1);
+.logs-viewer::-webkit-scrollbar {
+ width: 10px;
+}
+.logs-viewer::-webkit-scrollbar-track {
+ background: #1e1e1e;
+}
+.logs-viewer::-webkit-scrollbar-thumb {
+ background: #4a4a4a;
+ border: 2px solid #1e1e1e;
+ border-radius: 5px;
+}
+.logs-viewer::-webkit-scrollbar-thumb:hover {
+ background: #606060;
+}
+
+.log-entry {
+ margin-bottom: 0.25rem;
+ word-break: break-all;
+ white-space: pre-wrap;
+ border-bottom: 1px solid #252525;
+ padding-bottom: 0.25rem;
+}
+
+.log-entry:last-child {
+ border-bottom: none;
+}
+
+.log-time {
+ color: #7f8c8d;
+ margin-right: 0.5rem;
+}
+
+.log-level {
+ font-weight: 700;
+ margin-right: 0.5rem;
+}
+
+.log-level-info {
+ color: #00a2e8;
+}
+
+.log-level-warning {
+ color: #ffc90e;
+}
+
+.log-level-error {
+ color: #ed1c24;
+}
+
+.log-message {
+ color: #e4e4e4;
+}
+
+/* Helpers */
+.helper-text {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ margin-bottom: 0.75rem;
}
.empty-state {
text-align: center;
- padding: 2rem;
+ padding: 2.5rem;
color: var(--text-muted);
-}
-
-.helper-text {
- font-size: 0.875rem;
- color: var(--text-muted);
- margin-bottom: 1rem;
-}
-
-.code-block {
- background-color: #0f172a;
- border: 1px solid var(--border-color);
- border-radius: 6px;
- padding: 0.75rem;
- margin-bottom: 0.75rem;
- position: relative;
- font-family: monospace;
- font-size: 0.875rem;
- display: flex;
- flex-direction: column;
-}
-
-.code-label {
- font-size: 0.7rem;
- color: var(--text-muted);
- text-transform: uppercase;
- font-weight: 700;
- margin-bottom: 0.25rem;
- font-family: 'Inter', sans-serif;
-}
-
-.code-block code {
- color: #34d399;
- word-break: break-all;
-}
-
-.code-wrapper {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 1rem;
-}
-
-.btn-copy {
- background: transparent;
- border: none;
- color: var(--text-muted);
- cursor: pointer;
- padding: 0.25rem;
- border-radius: 4px;
- transition: all 0.2s;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.btn-copy:hover {
- background-color: rgba(255, 255, 255, 0.1);
- color: var(--text-main);
-}
-
-.btn-copy:active {
- transform: scale(0.95);
-}
-
-.btn-copy.copied {
- color: var(--status-online);
-}
-
-.users-list, .otp-secrets-list {
- margin-top: 2rem;
- display: flex;
- flex-direction: column;
- gap: 1rem;
-}
-
-.user-item, .otp-secret-item {
- background-color: var(--bg-color);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- padding: 1rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.user-info h3, .otp-secret-info h3 {
- font-size: 1rem;
- margin-bottom: 0.25rem;
-}
-
-.user-actions, .otp-secret-actions {
- display: flex;
- gap: 0.5rem;
-}
-
-.btn-sm {
- padding: 0.35rem 0.75rem;
- font-size: 0.75rem;
- height: auto;
-}
-
-.user-create-form, #add-form {
- display: flex;
- flex-direction: column;
- gap: 1.25rem;
- width: 100%;
-}
-
-.user-create-form .btn-primary, #add-form .btn-primary {
- align-self: flex-end;
- margin-top: 0.5rem;
-}
-
-.checkbox-container {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- cursor: pointer;
- user-select: none;
- padding: 0.5rem 0;
-}
-
-.checkbox-container input {
- width: 18px;
- height: 18px;
- accent-color: var(--primary);
- cursor: pointer;
-}
-
-.checkbox-label {
- font-size: 0.875rem;
- color: var(--text-muted);
-}
-
-.profile-form {
- display: flex;
- flex-direction: column;
- gap: 1.25rem;
- max-width: 400px;
-}
-
-.profile-form .btn-primary {
- align-self: flex-start;
-}
-
-.divider {
- height: 1px;
- background-color: var(--border-color);
- margin: 2rem 0;
+ font-style: italic;
+ font-size: 0.9rem;
}
.success-msg {
color: var(--status-online);
+ font-weight: 600;
text-align: center;
margin-top: 1rem;
- font-size: 0.875rem;
-}
-
-/* Modals */
-.modal {
- display: none;
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-color: rgba(0, 0, 0, 0.7);
- backdrop-filter: blur(4px);
- z-index: 2000;
- justify-content: center;
- align-items: center;
- padding: 1rem;
-}
-
-.modal.active {
- display: flex;
-}
-
-.modal-content {
- width: 100%;
- max-width: 500px;
- margin: 0;
- position: relative;
-}
-
-.modal-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 1.5rem;
-}
-
-.modal-header h2 {
- margin: 0;
-}
-
-.btn-close {
- background: transparent;
- border: none;
- color: var(--text-muted);
- font-size: 1.5rem;
- cursor: pointer;
- line-height: 1;
-}
-
-.btn-close:hover {
- color: var(--text-main);
-}
-
-.header-actions {
- display: flex;
- gap: 0.5rem;
-}
-
-.instances-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 1.5rem;
-}
-
-.instances-header .actions {
- display: flex;
- gap: 0.5rem;
-}
-
-.btn-sm {
- padding: 0.4rem 0.8rem;
- font-size: 0.75rem;
+ font-size: 0.85rem;
}