initialize XIVLauncher Remote OTP project with web interface and Docker support
This commit is contained in:
commit
6ce356ffed
8 changed files with 717 additions and 0 deletions
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Ensure data directory exists
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
37
README.md
Normal file
37
README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# XIVLauncher Remote OTP
|
||||||
|
|
||||||
|
A lightweight web application running in a minimal Docker container to automatically inject OTP codes into remote XIVLauncher instances.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
XIVLauncher can expose an internal OTP server that listens for incoming HTTP requests containing your TOTP code.
|
||||||
|
This application acts as a central manager. You can add the IP address, Port (default 4646), and your OTP secret. The application will securely store the secret and continuously poll your remote XIVLauncher instances. When the instance starts and the port becomes available, the application automatically generates your current OTP and injects it.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- **Password Protected**: The entire web interface is protected by basic authentication.
|
||||||
|
- **Secure Storage**: OTP Secrets are stored in a local SQLite database encrypted with a Fernet key (`ENCRYPTION_KEY`).
|
||||||
|
- **Minimal Footprint**: Built using Python FastAPI, using only the necessary dependencies, and runs in a lightweight container.
|
||||||
|
- **Background Polling**: Checks instances continuously without blocking.
|
||||||
|
|
||||||
|
## Setup & Running
|
||||||
|
|
||||||
|
1. Clone or copy this directory.
|
||||||
|
2. Edit `docker-compose.yml`:
|
||||||
|
- Change `WEB_PASSWORD` to your desired password for the web interface (the username can be anything, e.g., `admin`).
|
||||||
|
- Change `ENCRYPTION_KEY` to a securely generated Fernet key.
|
||||||
|
*You can generate one by running: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`*
|
||||||
|
3. Start the container:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
4. Access the web interface at `http://localhost:8080`.
|
||||||
|
5. Login using any username and your `WEB_PASSWORD`.
|
||||||
|
|
||||||
|
## Adding Instances
|
||||||
|
- **Name**: A friendly name (e.g., "Steam Deck").
|
||||||
|
- **IP Address**: The local IP address of your XIVLauncher instance.
|
||||||
|
- **Port**: Default is 4646.
|
||||||
|
- **OTP Secret**: Your base32 OTP secret or an `otpauth://` URL.
|
||||||
|
|
||||||
|
## Important Note
|
||||||
|
If you change your `ENCRYPTION_KEY`, the application will no longer be able to decrypt your previously saved OTP secrets. Ensure you backup your key if you intend to keep your configuration across deployments.
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
remote-otp:
|
||||||
|
build: .
|
||||||
|
container_name: xivlauncher-remote-otp
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- WEB_PASSWORD=admin
|
||||||
|
# Generate a secure key using: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
- ENCRYPTION_KEY=CHANGE_ME_TO_A_VALID_FERNET_KEY
|
||||||
|
restart: unless-stopped
|
||||||
183
main.py
Normal file
183
main.py
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import sqlite3
|
||||||
|
import asyncio
|
||||||
|
import pyotp
|
||||||
|
import httpx
|
||||||
|
import base64
|
||||||
|
import secrets
|
||||||
|
import urllib.parse
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import Response, FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
WEB_PASSWORD = os.getenv("WEB_PASSWORD", "admin")
|
||||||
|
ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY")
|
||||||
|
DB_PATH = "data/instances.db"
|
||||||
|
|
||||||
|
if not ENCRYPTION_KEY or ENCRYPTION_KEY == "CHANGE_ME_TO_A_VALID_FERNET_KEY":
|
||||||
|
print("WARNING: ENCRYPTION_KEY not set or invalid.")
|
||||||
|
print("Generating a random key for this session, but secrets will be lost on restart!")
|
||||||
|
ENCRYPTION_KEY = Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
try:
|
||||||
|
fernet = Fernet(ENCRYPTION_KEY)
|
||||||
|
except ValueError:
|
||||||
|
print("ERROR: ENCRYPTION_KEY must be a valid 32-byte base64 encoded string.")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS instances (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
encrypted_secret TEXT NOT NULL,
|
||||||
|
last_status TEXT DEFAULT 'Unknown',
|
||||||
|
last_checked REAL DEFAULT 0
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def basic_auth_middleware(request: Request, call_next):
|
||||||
|
if request.url.path.startswith("/api/") or request.url.path == "/" or request.url.path.startswith("/static/"):
|
||||||
|
auth = request.headers.get("Authorization")
|
||||||
|
if not auth or not auth.startswith("Basic "):
|
||||||
|
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Login Required"'}, content="Unauthorized")
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(auth[6:]).decode()
|
||||||
|
username, password = decoded.split(":", 1)
|
||||||
|
# Accept any username, just check password
|
||||||
|
if not secrets.compare_digest(password, WEB_PASSWORD):
|
||||||
|
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Login Required"'}, content="Unauthorized")
|
||||||
|
except:
|
||||||
|
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Login Required"'}, content="Unauthorized")
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
class InstanceCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
ip: str
|
||||||
|
port: int = 4646
|
||||||
|
secret: str
|
||||||
|
|
||||||
|
def clean_secret(secret_input):
|
||||||
|
secret_clean = secret_input.strip()
|
||||||
|
if secret_clean.lower().startswith('otpauth://'):
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(secret_clean)
|
||||||
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
|
if 'secret' in params:
|
||||||
|
return params['secret'][0].replace(" ", "").upper()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return secret_clean.replace(" ", "").upper()
|
||||||
|
|
||||||
|
async def poll_instances():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT * FROM instances')
|
||||||
|
instances = c.fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=1.0) as client:
|
||||||
|
for inst in instances:
|
||||||
|
# Skip if we checked recently and it was successful (60s cooldown)
|
||||||
|
if inst['last_status'] == 'Sent' and (time.time() - inst['last_checked'] < 60):
|
||||||
|
continue
|
||||||
|
|
||||||
|
ip = inst['ip']
|
||||||
|
port = inst['port']
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"http://{ip}:{port}/ffxivlauncher/"
|
||||||
|
# Poll the server endpoint to see if it's accepting connections
|
||||||
|
await client.get(url, timeout=0.5)
|
||||||
|
|
||||||
|
secret = fernet.decrypt(inst['encrypted_secret'].encode()).decode()
|
||||||
|
totp = pyotp.TOTP(secret)
|
||||||
|
otp = totp.now()
|
||||||
|
|
||||||
|
send_url = f"http://{ip}:{port}/ffxivlauncher/{otp}"
|
||||||
|
resp = await client.get(send_url, timeout=1.0)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
status_msg = "Sent"
|
||||||
|
else:
|
||||||
|
status_msg = f"Failed: {resp.status_code}"
|
||||||
|
|
||||||
|
except (httpx.ConnectError, httpx.TimeoutException):
|
||||||
|
status_msg = "Offline"
|
||||||
|
except Exception as e:
|
||||||
|
status_msg = f"Error: {str(e)}"
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
UPDATE instances
|
||||||
|
SET last_status = ?, last_checked = ?
|
||||||
|
WHERE id = ?
|
||||||
|
''', (status_msg, time.time(), inst['id']))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Poller error: {e}")
|
||||||
|
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
asyncio.create_task(poll_instances())
|
||||||
|
|
||||||
|
@app.get("/api/instances")
|
||||||
|
def get_instances():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('SELECT id, name, ip, port, last_status, last_checked FROM instances')
|
||||||
|
instances = [dict(row) for row in c.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return instances
|
||||||
|
|
||||||
|
@app.post("/api/instances")
|
||||||
|
def create_instance(inst: InstanceCreate):
|
||||||
|
cleaned = clean_secret(inst.secret)
|
||||||
|
encrypted = fernet.encrypt(cleaned.encode()).decode()
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('''
|
||||||
|
INSERT INTO instances (name, ip, port, encrypted_secret)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
''', (inst.name, inst.ip, inst.port, encrypted))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.delete("/api/instances/{id}")
|
||||||
|
def delete_instance(id: int):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute('DELETE FROM instances WHERE id=?', (id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# Mount root and static
|
||||||
|
@app.get("/")
|
||||||
|
def read_root():
|
||||||
|
return FileResponse("static/index.html")
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn==0.24.0
|
||||||
|
cryptography==41.0.5
|
||||||
|
pyotp==2.9.0
|
||||||
|
httpx==0.25.1
|
||||||
|
pydantic==2.4.2
|
||||||
117
static/app.js
Normal file
117
static/app.js
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const addForm = document.getElementById('add-form');
|
||||||
|
const instancesList = document.getElementById('instances-list');
|
||||||
|
const refreshBtn = document.getElementById('refresh-btn');
|
||||||
|
const portInput = document.getElementById('port');
|
||||||
|
const ufwCmd = document.getElementById('ufw-cmd');
|
||||||
|
const iptablesCmd = document.getElementById('iptables-cmd');
|
||||||
|
|
||||||
|
portInput.addEventListener('input', (e) => {
|
||||||
|
const port = e.target.value || '4646';
|
||||||
|
ufwCmd.textContent = `sudo ufw allow ${port}/tcp`;
|
||||||
|
iptablesCmd.textContent = `sudo iptables -I INPUT -p tcp --dport ${port} -j ACCEPT`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchInstances = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/instances');
|
||||||
|
if (res.status === 401) {
|
||||||
|
return; // Let browser handle auth
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
renderInstances(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch instances:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderInstances = (instances) => {
|
||||||
|
if (instances.length === 0) {
|
||||||
|
instancesList.innerHTML = '<div class="empty-state">No instances configured yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
instancesList.innerHTML = instances.map(inst => {
|
||||||
|
let statusClass = 'status-unknown';
|
||||||
|
if (inst.last_status === 'Sent') statusClass = 'status-sent';
|
||||||
|
else if (inst.last_status === 'Offline') statusClass = 'status-offline';
|
||||||
|
else if (inst.last_status.startsWith('Failed')) statusClass = 'status-offline';
|
||||||
|
|
||||||
|
const timeAgo = inst.last_checked ? Math.round((Date.now()/1000 - inst.last_checked)) + 's ago' : 'Never';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="instance-item">
|
||||||
|
<div class="instance-info">
|
||||||
|
<h3>${escapeHtml(inst.name)}</h3>
|
||||||
|
<div class="instance-meta">
|
||||||
|
<span>${escapeHtml(inst.ip)}:${inst.port}</span>
|
||||||
|
<span class="status-badge ${statusClass}">${escapeHtml(inst.last_status)}</span>
|
||||||
|
<span>Last checked: ${timeAgo}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="delete-btn" onclick="deleteInstance(${inst.id})" title="Delete">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
addForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = document.getElementById('name').value;
|
||||||
|
const ip = document.getElementById('ip').value;
|
||||||
|
const port = parseInt(document.getElementById('port').value, 10);
|
||||||
|
const secret = document.getElementById('secret').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/instances', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name, ip, port, secret })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
addForm.reset();
|
||||||
|
document.getElementById('port').value = "4646";
|
||||||
|
fetchInstances();
|
||||||
|
} else {
|
||||||
|
alert('Failed to add instance');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error adding instance', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshBtn.addEventListener('click', fetchInstances);
|
||||||
|
|
||||||
|
window.deleteInstance = async (id) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this instance?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/instances/${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
fetchInstances();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting instance', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(unsafe) {
|
||||||
|
if (!unsafe) return '';
|
||||||
|
return unsafe.toString()
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchInstances();
|
||||||
|
setInterval(fetchInstances, 5000);
|
||||||
|
});
|
||||||
79
static/index.html
Normal file
79
static/index.html
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>XIVLauncher Remote OTP</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<div class="logo">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||||
|
</svg>
|
||||||
|
<h1>XIVLauncher Remote OTP</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card add-instance-card">
|
||||||
|
<h2>Add Instance</h2>
|
||||||
|
<form id="add-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" id="name" placeholder="e.g. Steam Deck" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ip">IP Address</label>
|
||||||
|
<input type="text" id="ip" placeholder="192.168.1.100" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="port">Port</label>
|
||||||
|
<input type="number" id="port" value="4646" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="secret">OTP Secret</label>
|
||||||
|
<input type="password" id="secret" placeholder="Base32 Secret or otpauth:// URL" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add Instance</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card firewall-helper-card">
|
||||||
|
<h2>Firewall Helper</h2>
|
||||||
|
<p class="helper-text">Run these commands on the machine running XIVLauncher to allow incoming connections on the specified port.</p>
|
||||||
|
<div class="code-block">
|
||||||
|
<span class="code-label">UFW</span>
|
||||||
|
<code id="ufw-cmd">sudo ufw allow 4646/tcp</code>
|
||||||
|
</div>
|
||||||
|
<div class="code-block">
|
||||||
|
<span class="code-label">iptables</span>
|
||||||
|
<code id="iptables-cmd">sudo iptables -I INPUT -p tcp --dport 4646 -j ACCEPT</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card instances-card">
|
||||||
|
<div class="instances-header">
|
||||||
|
<h2>Managed Instances</h2>
|
||||||
|
<button class="btn btn-icon" id="refresh-btn" title="Refresh">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="23 4 23 10 17 10"></polyline>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="instances-list" class="instances-list">
|
||||||
|
<!-- Instances injected via JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
265
static/style.css
Normal file
265
static/style.css
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo svg {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
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 {
|
||||||
|
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;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue