Compare commits

...

2 commits

Author SHA1 Message Date
nocci
f113a760af test 2025-12-06 11:52:55 +00:00
nocci
b9cfefa3a9 feat(fleetledger): add initial implementation of FleetLedger app
- introduce Dockerfile for Python environment setup
- create FastAPI app with authentication and user management
- implement server management features with CRUD operations
- add PWA support with service worker and manifest
- set up initial templates for UI components

📝 docs(fleetledger): add README for FleetLedger application

- describe app features and functionalities
- provide security notes and quick start guide

📦 build(fleetledger): configure Docker and docker-compose setup

- define Dockerfile for application container
- create docker-compose.yml for service orchestration
- specify environment variables and volumes for persistence
2025-12-06 11:40:51 +00:00
26 changed files with 2611 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.env
fleetledger/.env

7
fleetledger/.env-example Normal file
View file

@ -0,0 +1,7 @@
# Copy this file to .env and fill in strong values for production.
SESSION_SECRET=changeme_super_secret_value
# Set to 1 for HTTPS deployments; set to 0 only for local HTTP testing.
SESSION_COOKIE_SECURE=1
DATABASE_PATH=/data/fleetledger.db
# Optional: Fernet key for encrypting management passwords (leave empty to disable)
ENCRYPTION_KEY=

21
fleetledger/Dockerfile Normal file
View file

@ -0,0 +1,21 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
ENV DATABASE_PATH=/data/fleetledger.db
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

86
fleetledger/README.md Normal file
View file

@ -0,0 +1,86 @@
# FleetLedger
FleetLedger is a small self-hosted web app to keep track of your rented servers:
- VPS, dedicated servers, storage boxes, managed services
- Provider, location, IPs, hardware
- Monthly / yearly pricing and contract dates
- Simple access info (management URLs, SSH user + key hint)
- Multi-user support with per-user data separation
- Admin user management (activate / deactivate users)
- Dark-mode-first UI with PWA support (installable as an app)
- Per-user **map view** for server locations
- Admin **global dashboard** for fleet-wide stats
> **Security note:** FleetLedger is *not* a full password manager.
> It is intentionally designed to store only **management password(s) optionally** and
> only **SSH key *names*** (no private keys).
---
## Features
- **Authentication & Users**
- User registration + login (session cookie based)
- First registered user becomes **admin**
- Admin can view all users and activate/deactivate them
- Deactivated users cannot log in and will be logged out automatically
- **Server Management**
- Each user has their own list of servers (no cross-visibility)
- Create / edit / archive (soft-delete) servers
- Fields include:
- General: name, hostname, type (VPS, dedicated, storage, managed, other), provider, location, tags
- Network: IPv4, IPv6
- Billing: price, currency, billing period (monthly/yearly/other), contract start/end
- Hardware: CPU model, core count, RAM, storage size & type
- Access: management URL, management user, management password (optional), SSH user, SSH key hint
- Free-form notes
- Contract badges:
- **"abgelaufen"** (expired): contract end in the past
- **"läuft bald aus"** (expiring soon): contract end within the next 30 days
- Detail view also shows how many days until / since contract end
- **Per-user Dashboard & Map**
- On `/`: small dashboard row showing:
- number of active servers
- estimated total monthly cost
- how many contracts are expiring soon / already expired
- On `/map`: Leaflet-based map showing all non-archived servers of the logged-in user
- Marker position is derived from the `location` string (city/datacenter name)
- Multiple servers per city are slightly offset so all markers remain clickable
- Click on a marker → opens the server details page
- **Admin Global Dashboard**
- On `/admin/dashboard` (admin only):
- Global counts: users, servers, monthly cost, expiring soon, expired
- Breakdown by provider (server count, monthly total, expiring soon, expired)
- List of contracts expiring soon and already expired
- **Security**
- Passwords hashed with **bcrypt** (`passlib[bcrypt]`)
- Optional encryption for management passwords using **Fernet** (`cryptography`)
- No private SSH keys are stored, only name/hint strings
- Jinja2 auto-escaping enabled; no untrusted HTML is rendered with `|safe`
- Management URLs are restricted to `http://` or `https://` (no `javascript:` links, etc.)
- **UI / UX**
- TailwindCSS via CDN for quick styling
- Dark mode is **enabled by default**
- Theme preference stored in `localStorage` and toggleable via a small button
- Responsive layout, works well on mobile
- PWA manifest and service worker for a simple offline-friendly experience
---
## Quick Start (Docker)
### 0. Environment
Kopiere `.env-example` nach `.env` und setze mindestens ein starkes `SESSION_SECRET`. Für lokale HTTP-Tests kannst du `SESSION_COOKIE_SECURE=0` setzen, in Produktion sollte es `1` bleiben. Optional kannst du einen `ENCRYPTION_KEY` (Fernet) hinterlegen, um Management-Passwörter zu speichern.
### 1. Clone / copy the repository
```bash
git clone https://example.com/your/fleetledger.git
cd fleetledger

View file

@ -0,0 +1 @@
# Empty file to mark "app" as a package.

75
fleetledger/app/auth.py Normal file
View file

@ -0,0 +1,75 @@
from typing import Optional
from fastapi import Depends, HTTPException, Request, status
from passlib.context import CryptContext
from sqlmodel import Session, select
from .db import get_session
from .models import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""Hash a password using bcrypt."""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a stored bcrypt hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_current_user(
request: Request,
session: Session = Depends(get_session),
) -> Optional[User]:
"""
Retrieve the currently logged-in user based on the session cookie.
Returns None if no user is logged in or the user is inactive.
"""
user_id = request.session.get("user_id")
if not user_id:
return None
user = session.get(User, user_id)
if not user or not user.is_active:
# Clear session if the user was deactivated.
request.session.clear()
return None
return user
def require_current_user(
current_user: Optional[User] = Depends(get_current_user),
) -> User:
"""
Require an authenticated user.
If not authenticated, redirect to /login with a 303 status.
"""
if not current_user:
raise HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
headers={"Location": "/login"},
)
return current_user
def require_admin(
current_user: Optional[User] = Depends(get_current_user),
) -> User:
"""
Require an authenticated admin user.
Non-authenticated users are redirected to /login.
Non-admins receive HTTP 403.
"""
if not current_user:
raise HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
headers={"Location": "/login"},
)
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return current_user

21
fleetledger/app/db.py Normal file
View file

@ -0,0 +1,21 @@
from sqlmodel import SQLModel, create_engine, Session
import os
# SQLite database path, mapped to a Docker volume for persistence
DB_PATH = os.getenv("DATABASE_PATH", "/data/fleetledger.db")
engine = create_engine(
f"sqlite:///{DB_PATH}",
connect_args={"check_same_thread": False},
)
def init_db() -> None:
"""Create all tables if they do not exist yet."""
SQLModel.metadata.create_all(engine)
def get_session():
"""FastAPI dependency that yields a SQLModel session."""
with Session(engine) as session:
yield session

783
fleetledger/app/main.py Normal file
View file

@ -0,0 +1,783 @@
import os
from datetime import datetime
from typing import Optional
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select
from starlette.middleware.sessions import SessionMiddleware
from .db import init_db, get_session
from .models import Server, User
from .utils import (
encrypt_secret,
decrypt_secret,
can_encrypt,
ensure_csrf_token,
validate_csrf,
)
from .auth import (
hash_password,
verify_password,
get_current_user,
require_current_user,
require_admin,
)
app = FastAPI(title="FleetLedger")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
# Session middleware (server-side session based on signed cookie)
SESSION_SECRET = os.getenv("SESSION_SECRET")
if not SESSION_SECRET or SESSION_SECRET.startswith("CHANGE_ME"):
raise RuntimeError(
"SESSION_SECRET environment variable must be set to a strong random value."
)
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "1") != "0"
app.add_middleware(
SessionMiddleware,
secret_key=SESSION_SECRET,
same_site="lax",
https_only=SESSION_COOKIE_SECURE,
cookie_name="fleetledger_session",
max_age=60 * 60 * 24 * 30, # 30 days
)
@app.on_event("startup")
def on_startup() -> None:
"""Initialize database on startup."""
init_db()
@app.get("/manifest.webmanifest")
def manifest() -> FileResponse:
"""Serve the PWA manifest."""
return FileResponse(
"app/static/manifest.webmanifest", media_type="application/manifest+json"
)
@app.get("/service-worker.js", include_in_schema=False)
def service_worker() -> FileResponse:
"""Serve the service worker from root scope."""
return FileResponse(
"app/static/service-worker.js",
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/"},
)
# ------------- Auth: Register / Login / Logout -------------
@app.get("/register", response_class=HTMLResponse)
def register_form(
request: Request,
session: Session = Depends(get_session),
current_user: Optional[User] = Depends(get_current_user),
):
"""
Render the registration form.
If at least one user already exists, only admins may register new users.
"""
user_count = len(session.exec(select(User)).all())
if user_count > 0 and (not current_user or not current_user.is_admin):
return RedirectResponse("/", status_code=303)
csrf_token = ensure_csrf_token(request)
return templates.TemplateResponse(
"register.html",
{
"request": request,
"error": None,
"current_user": current_user,
"csrf_token": csrf_token,
},
)
@app.post("/register")
def register(
request: Request,
username: str = Form(...),
email: str = Form(""),
password: str = Form(...),
password_confirm: str = Form(...),
csrf_token: str = Form(...),
session: Session = Depends(get_session),
current_user: Optional[User] = Depends(get_current_user),
):
"""
Handle registration submissions.
- First user becomes admin automatically.
- Later users can only be created by admins.
"""
if not validate_csrf(request, csrf_token):
token = ensure_csrf_token(request)
return templates.TemplateResponse(
"register.html",
{
"request": request,
"error": "Ungültiger Sicherheits-Token. Bitte erneut versuchen.",
"current_user": current_user,
"csrf_token": token,
},
status_code=400,
)
user_count = len(session.exec(select(User)).all())
if user_count > 0 and (not current_user or not current_user.is_admin):
return RedirectResponse("/", status_code=303)
error = None
if password != password_confirm:
error = "Passwords do not match."
else:
existing = session.exec(
select(User).where(User.username == username)
).first()
if existing:
error = "Username is already taken."
csrf_token = ensure_csrf_token(request)
if error:
return templates.TemplateResponse(
"register.html",
{
"request": request,
"error": error,
"current_user": current_user,
"csrf_token": csrf_token,
},
status_code=400,
)
user = User(
username=username,
email=email or None,
password_hash=hash_password(password),
is_active=True,
is_admin=(user_count == 0), # first user is admin
)
session.add(user)
session.commit()
session.refresh(user)
# Auto-login after registration
request.session["user_id"] = user.id
return RedirectResponse("/", status_code=303)
@app.get("/login", response_class=HTMLResponse)
def login_form(
request: Request,
current_user: Optional[User] = Depends(get_current_user),
):
"""Render the login form."""
if current_user:
return RedirectResponse("/", status_code=303)
csrf_token = ensure_csrf_token(request)
return templates.TemplateResponse(
"login.html",
{
"request": request,
"error": None,
"current_user": current_user,
"csrf_token": csrf_token,
},
)
@app.post("/login")
def login(
request: Request,
username: str = Form(...),
password: str = Form(...),
csrf_token: str = Form(...),
session: Session = Depends(get_session),
):
"""Handle login submissions."""
if not validate_csrf(request, csrf_token):
token = ensure_csrf_token(request)
return templates.TemplateResponse(
"login.html",
{
"request": request,
"error": "Ungültiger Sicherheits-Token. Bitte erneut versuchen.",
"current_user": None,
"csrf_token": token,
},
status_code=400,
)
user = session.exec(select(User).where(User.username == username)).first()
error = None
if not user or not verify_password(password, user.password_hash):
error = "Invalid username or password."
elif not user.is_active:
error = "User is deactivated."
csrf_token = ensure_csrf_token(request)
if error:
return templates.TemplateResponse(
"login.html",
{
"request": request,
"error": error,
"current_user": None,
"csrf_token": csrf_token,
},
status_code=400,
)
request.session["user_id"] = user.id
return RedirectResponse("/", status_code=303)
@app.get("/logout")
def logout(request: Request):
"""Log out the current user and clear the session."""
request.session.clear()
return RedirectResponse("/login", status_code=303)
# ------------- Admin: User management -------------
@app.get("/users", response_class=HTMLResponse)
def list_users(
request: Request,
session: Session = Depends(get_session),
current_user: User = Depends(require_admin),
):
"""List all users (admin only)."""
users = session.exec(select(User).order_by(User.username)).all()
csrf_token = ensure_csrf_token(request)
return templates.TemplateResponse(
"users_list.html",
{
"request": request,
"users": users,
"current_user": current_user,
"csrf_token": csrf_token,
},
)
@app.post("/users/{user_id}/toggle-active")
def toggle_user_active(
user_id: int,
request: Request,
csrf_token: str = Form(...),
session: Session = Depends(get_session),
current_user: User = Depends(require_admin),
):
"""
Toggle a user's active state (admin only).
An admin cannot deactivate themselves to avoid lockout.
"""
if not validate_csrf(request, csrf_token):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid CSRF token."
)
user = session.get(User, user_id)
if not user:
return RedirectResponse("/users", status_code=303)
if user.id == current_user.id:
return RedirectResponse("/users", status_code=303)
user.is_active = not user.is_active
session.add(user)
session.commit()
return RedirectResponse("/users", status_code=303)
# ------------- Admin: Global dashboard -------------
@app.get("/admin/dashboard", response_class=HTMLResponse)
def admin_dashboard(
request: Request,
session: Session = Depends(get_session),
current_user: User = Depends(require_admin),
):
"""
Global admin dashboard aggregating all non-archived servers across all users.
"""
servers = session.exec(
select(Server).where(Server.archived == False)
).all()
users = session.exec(select(User)).all()
total_servers = len(servers)
total_users = len(users)
expiring_soon = sum(1 for s in servers if s.is_expiring_soon)
expired = sum(1 for s in servers if s.is_expired)
monthly_total = 0.0
currencies = set()
for s in servers:
if not s.price:
continue
if s.currency:
currencies.add(s.currency)
if s.billing_period == "yearly":
monthly_total += s.price / 12.0
else:
monthly_total += s.price
monthly_currency = None
if len(currencies) == 1:
monthly_currency = next(iter(currencies))
# Provider-level stats
provider_stats = {}
for s in servers:
provider = s.provider or "Unknown"
if provider not in provider_stats:
provider_stats[provider] = {
"count": 0,
"monthly_total": 0.0,
"expiring_soon": 0,
"expired": 0,
"currency_set": set(),
}
ps = provider_stats[provider]
ps["count"] += 1
if s.price:
if s.currency:
ps["currency_set"].add(s.currency)
if s.billing_period == "yearly":
ps["monthly_total"] += s.price / 12.0
else:
ps["monthly_total"] += s.price
if s.is_expiring_soon:
ps["expiring_soon"] += 1
if s.is_expired:
ps["expired"] += 1
# Contracts expiring soon and expired, for small lists
expiring_soon_list = sorted(
[s for s in servers if s.is_expiring_soon],
key=lambda s: s.contract_end or datetime.max.date(),
)
expired_list = sorted(
[s for s in servers if s.is_expired],
key=lambda s: s.contract_end or datetime.min.date(),
reverse=True,
)
stats = {
"total_servers": total_servers,
"total_users": total_users,
"expiring_soon": expiring_soon,
"expired": expired,
"monthly_total": monthly_total,
"monthly_currency": monthly_currency,
"mixed_currencies": len(currencies) > 1,
}
return templates.TemplateResponse(
"admin_dashboard.html",
{
"request": request,
"current_user": current_user,
"stats": stats,
"provider_stats": provider_stats,
"expiring_soon_list": expiring_soon_list,
"expired_list": expired_list,
},
)
# ------------- Server views (CRUD, per user) -------------
@app.get("/", response_class=HTMLResponse)
def list_servers(
request: Request,
session: Session = Depends(get_session),
current_user: User = Depends(require_current_user),
):
"""
List all non-archived servers owned by the current user.
Additionally compute some summary stats for the dashboard.
"""
servers = session.exec(
select(Server)
.where(Server.archived == False)
.where(Server.owner_id == current_user.id)
.order_by(Server.provider, Server.name)
).all()
# --- Dashboard stats ---
total_servers = len(servers)
expiring_soon = sum(1 for s in servers if s.is_expiring_soon)
expired = sum(1 for s in servers if s.is_expired)
# Approximate total monthly cost
monthly_total = 0.0
currencies = set()
for s in servers:
if not s.price:
continue
if s.currency:
currencies.add(s.currency)
if s.billing_period == "yearly":
monthly_total += s.price / 12.0
else:
# treat "monthly" and "other" as monthly for the purpose of the overview
monthly_total += s.price
monthly_currency = None
if len(currencies) == 1:
monthly_currency = next(iter(currencies))
stats = {
"total_servers": total_servers,
"expiring_soon": expiring_soon,
"expired": expired,
"monthly_total": monthly_total,
"monthly_currency": monthly_currency,
"mixed_currencies": len(currencies) > 1,
}
return templates.TemplateResponse(
"servers_list.html",
{
"request": request,
"servers": servers,
"can_encrypt": can_encrypt(),
"current_user": current_user,
"stats": stats,
},
)
@app.get("/servers/new", response_class=HTMLResponse)
def new_server_form(
request: Request,
current_user: User = Depends(require_current_user),
):
"""Render a blank form for creating a new server."""
csrf_token = ensure_csrf_token(request)
return templates.TemplateResponse(
"server_form.html",
{
"request": request,
"server": None,
"can_encrypt": can_encrypt(),
"current_user": current_user,
"csrf_token": csrf_token,
},
)
@app.post("/servers/new")
def create_server(
request: Request,
name: str = Form(...),
provider: str = Form(...),
hostname: str = Form(""),
type: str = Form("vps"),
location: str = Form(""),
ipv4: str = Form(""),
ipv6: str = Form(""),
billing_period: str = Form("monthly"),
price: float = Form(0.0),
currency: str = Form("EUR"),
contract_start: Optional[str] = Form(None),
contract_end: Optional[str] = Form(None),
cpu_model: str = Form(""),
cpu_cores: int = Form(0),
ram_mb: int = Form(0),
storage_gb: int = Form(0),
storage_type: str = Form(""),
tags: str = Form(""),
mgmt_url: str = Form(""),
mgmt_user: str = Form(""),
mgmt_password: str = Form(""),
ssh_user: str = Form(""),
ssh_key_hint: str = Form(""),
notes: str = Form(""),
csrf_token: str = Form(...),
session: Session = Depends(get_session),
current_user: User = Depends(require_current_user),
):
"""Create a new server entry for the current user."""
if not validate_csrf(request, csrf_token):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid CSRF token."
)
c_start = (
datetime.fromisoformat(contract_start).date() if contract_start else None
)
c_end = datetime.fromisoformat(contract_end).date() if contract_end else None
enc_pwd = encrypt_secret(mgmt_password) if mgmt_password else None
# Only allow http:// or https:// URLs to avoid javascript: schemes etc.
mgmt_url_clean = mgmt_url.strip()
if mgmt_url_clean and not (
mgmt_url_clean.lower().startswith("http://")
or mgmt_url_clean.lower().startswith("https://")
):
mgmt_url_clean = ""
server = Server(
owner_id=current_user.id,
name=name,
provider=provider,
hostname=hostname or None,
type=type,
location=location or None,
ipv4=ipv4 or None,
ipv6=ipv6 or None,
billing_period=billing_period,
price=price,
currency=currency,
contract_start=c_start,
contract_end=c_end,
cpu_model=cpu_model or None,
cpu_cores=cpu_cores or None,
ram_mb=ram_mb or None,
storage_gb=storage_gb or None,
storage_type=storage_type or None,
tags=tags or None,
mgmt_url=mgmt_url_clean or None,
mgmt_user=mgmt_user or None,
mgmt_password_encrypted=enc_pwd,
ssh_user=ssh_user or None,
ssh_key_hint=ssh_key_hint or None,
notes=notes or None,
updated_at=datetime.utcnow(),
)
session.add(server)
session.commit()
session.refresh(server)
return RedirectResponse(url="/", status_code=303)
@app.get("/servers/{server_id}", response_class=HTMLResponse)
def server_detail(
server_id: int,
request: Request,
session: Session = Depends(get_session),
current_user: User = Depends(require_current_user),
):
"""Show details for a single server."""
server = session.get(Server, server_id)
if not server or server.owner_id != current_user.id:
return RedirectResponse("/", status_code=303)
decrypted_pwd = (
decrypt_secret(server.mgmt_password_encrypted)
if server.mgmt_password_encrypted
else None
)
csrf_token = ensure_csrf_token(request)
return templates.TemplateResponse(
"server_detail.html",
{
"request": request,
"server": server,
"mgmt_password": decrypted_pwd,
"can_encrypt": can_encrypt(),
"current_user": current_user,
"csrf_token": csrf_token,
},
)
@app.get("/servers/{server_id}/edit", response_class=HTMLResponse)
def edit_server_form(
server_id: int,
request: Request,
session: Session = Depends(get_session),
current_user: User = Depends(require_current_user),
):
"""Render a pre-filled form for editing an existing server."""
server = session.get(Server, server_id)
if not server or server.owner_id != current_user.id:
return RedirectResponse("/", status_code=303)
csrf_token = ensure_csrf_token(request)
return templates.TemplateResponse(
"server_form.html",
{
"request": request,
"server": server,
"can_encrypt": can_encrypt(),
"current_user": current_user,
"csrf_token": csrf_token,
},
)
@app.post("/servers/{server_id}/edit")
def update_server(
server_id: int,
request: Request,
name: str = Form(...),
provider: str = Form(...),
hostname: str = Form(""),
type: str = Form("vps"),
location: str = Form(""),
ipv4: str = Form(""),
ipv6: str = Form(""),
billing_period: str = Form("monthly"),
price: float = Form(0.0),
currency: str = Form("EUR"),
contract_start: Optional[str] = Form(None),
contract_end: Optional[str] = Form(None),
cpu_model: str = Form(""),
cpu_cores: int = Form(0),
ram_mb: int = Form(0),
storage_gb: int = Form(0),
storage_type: str = Form(""),
tags: str = Form(""),
mgmt_url: str = Form(""),
mgmt_user: str = Form(""),
mgmt_password: str = Form(""),
ssh_user: str = Form(""),
ssh_key_hint: str = Form(""),
notes: str = Form(""),
csrf_token: str = Form(...),
session: Session = Depends(get_session),
current_user: User = Depends(require_current_user),
):
"""Update an existing server entry (only owner)."""
server = session.get(Server, server_id)
if not server or server.owner_id != current_user.id:
return RedirectResponse("/", status_code=303)
if not validate_csrf(request, csrf_token):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid CSRF token."
)
c_start = (
datetime.fromisoformat(contract_start).date() if contract_start else None
)
c_end = datetime.fromisoformat(contract_end).date() if contract_end else None
# Only update mgmt password if a non-empty value was submitted.
if mgmt_password:
enc_pwd = encrypt_secret(mgmt_password)
server.mgmt_password_encrypted = enc_pwd
mgmt_url_clean = mgmt_url.strip()
if mgmt_url_clean and not (
mgmt_url_clean.lower().startswith("http://")
or mgmt_url_clean.lower().startswith("https://")
):
mgmt_url_clean = ""
server.name = name
server.provider = provider
server.hostname = hostname or None
server.type = type
server.location = location or None
server.ipv4 = ipv4 or None
server.ipv6 = ipv6 or None
server.billing_period = billing_period
server.price = price
server.currency = currency
server.contract_start = c_start
server.contract_end = c_end
server.cpu_model = cpu_model or None
server.cpu_cores = cpu_cores or None
server.ram_mb = ram_mb or None
server.storage_gb = storage_gb or None
server.storage_type = storage_type or None
server.tags = tags or None
server.mgmt_url = mgmt_url_clean or None
server.mgmt_user = mgmt_user or None
server.ssh_user = ssh_user or None
server.ssh_key_hint = ssh_key_hint or None
server.notes = notes or None
server.updated_at = datetime.utcnow()
session.add(server)
session.commit()
return RedirectResponse(f"/servers/{server_id}", status_code=303)
@app.post("/servers/{server_id}/archive")
def archive_server(
server_id: int,
request: Request,
csrf_token: str = Form(...),
session: Session = Depends(get_session),
current_user: User = Depends(require_current_user),
):
"""
Soft-delete (archive) a server.
The record is kept but not shown in the main list.
"""
if not validate_csrf(request, csrf_token):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid CSRF token."
)
server = session.get(Server, server_id)
if not server or server.owner_id != current_user.id:
return RedirectResponse("/", status_code=303)
server.archived = True
server.updated_at = datetime.utcnow()
session.add(server)
session.commit()
return RedirectResponse("/", status_code=303)
# ------------- Per-user map view -------------
@app.get("/map", response_class=HTMLResponse)
def server_map(
request: Request,
session: Session = Depends(get_session),
current_user: User = Depends(require_current_user),
):
"""
Show a per-user map view with all non-archived servers.
Coordinates are not stored in the database. Instead, the frontend derives
approximate positions from the location string (city/datacenter name)
using a built-in city map and a deterministic fallback.
"""
servers = session.exec(
select(Server)
.where(Server.archived == False)
.where(Server.owner_id == current_user.id)
.order_by(Server.provider, Server.name)
).all()
return templates.TemplateResponse(
"servers_map.html",
{
"request": request,
"servers": servers,
"current_user": current_user,
},
)

99
fleetledger/app/models.py Normal file
View file

@ -0,0 +1,99 @@
from datetime import date, datetime
from typing import Optional, List
from sqlmodel import SQLModel, Field, Relationship
class User(SQLModel, table=True):
"""Application user model."""
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(index=True, unique=True)
email: Optional[str] = Field(default=None, index=True)
password_hash: str
is_active: bool = Field(default=True)
is_admin: bool = Field(default=False)
servers: List["Server"] = Relationship(back_populates="owner")
class Server(SQLModel, table=True):
"""Server/VPS entry owned by a user."""
id: Optional[int] = Field(default=None, primary_key=True)
# Owner
owner_id: int = Field(foreign_key="user.id")
owner: Optional[User] = Relationship(back_populates="servers")
# General info
name: str
hostname: Optional[str] = None
type: str = "vps" # vps, dedicated, storage, managed, other
provider: str
location: Optional[str] = None
# Network
ipv4: Optional[str] = None
ipv6: Optional[str] = None
# Cost / billing
billing_period: str = "monthly" # monthly, yearly, other
price: float = 0.0
currency: str = "EUR"
contract_start: Optional[date] = None
contract_end: Optional[date] = None
tags: Optional[str] = None # e.g. "prod,critical,backup"
# Hardware
cpu_model: Optional[str] = None
cpu_cores: Optional[int] = None
ram_mb: Optional[int] = None
storage_gb: Optional[int] = None
storage_type: Optional[str] = None # nvme, ssd, hdd, ceph, ...
# Access (no private SSH keys, only hints)
mgmt_url: Optional[str] = None
mgmt_user: Optional[str] = None
mgmt_password_encrypted: Optional[str] = None
ssh_user: Optional[str] = None
ssh_key_hint: Optional[str] = None # e.g. "id_ed25519_ovh"
notes: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
archived: bool = Field(default=False)
# ----- Convenience properties for badges / UI -----
@property
def days_until_contract_end(self) -> Optional[int]:
"""
Number of days until the contract_end date.
Returns:
int: positive or zero if in the future,
negative if already past,
None if no contract_end is set.
"""
if not self.contract_end:
return None
return (self.contract_end - date.today()).days
@property
def is_expired(self) -> bool:
"""Return True if the contract_end date lies in the past."""
return self.contract_end is not None and self.contract_end < date.today()
@property
def is_expiring_soon(self) -> bool:
"""
Return True if the contract will end within the next 30 days.
This is used for "expiring soon" badges in the UI.
"""
days = self.days_until_contract_end
return days is not None and 0 <= days <= 30

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,20 @@
{
"name": "FleetLedger",
"short_name": "FleetLedger",
"start_url": "/",
"display": "standalone",
"background_color": "#020617",
"theme_color": "#020617",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View file

@ -0,0 +1,74 @@
const CACHE_VERSION = "v2";
const CACHE_NAME = `fleetledger-${CACHE_VERSION}`;
const ASSETS = [
"/",
"/static/style.css",
"/static/icon-192.png",
"/static/icon-512.png",
"/static/manifest.webmanifest",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS);
})
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith("fleetledger-") && key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
);
});
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch (err) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
return caches.match("/");
}
}
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
}
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") {
return;
}
const url = new URL(event.request.url);
// Navigation requests: network first, fallback to cache
if (event.request.mode === "navigate") {
event.respondWith(networkFirst(event.request));
return;
}
// Same-origin static assets: cache first
if (url.origin === self.location.origin) {
event.respondWith(cacheFirst(event.request));
}
});

View file

@ -0,0 +1,5 @@
/* Simple extra styles on top of Tailwind. */
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
"Segoe UI", sans-serif;
}

View file

@ -0,0 +1,154 @@
{% extends "base.html" %}
{% block content %}
<div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold tracking-tight">Admin-Dashboard</h1>
<p class="text-xs text-slate-400">
Globale Übersicht über alle nicht archivierten Server und Benutzer.
</p>
</div>
</div>
{% if stats %}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Benutzer</span>
<span class="text-lg font-semibold text-slate-100">
{{ stats.total_users }}
</span>
<span class="text-[11px] text-slate-500 mt-1">Accounts</span>
</div>
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Server</span>
<span class="text-lg font-semibold text-slate-100">
{{ stats.total_servers }}
</span>
<span class="text-[11px] text-slate-500 mt-1">nicht archiviert</span>
</div>
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Laufende Kosten</span>
<span class="text-lg font-semibold text-slate-100">
{{ "%.2f"|format(stats.monthly_total) }}
{% if stats.monthly_currency %} {{ stats.monthly_currency }}{% endif %}
</span>
<span class="text-[11px] text-slate-500 mt-1">
pro Monat{% if stats.mixed_currencies %} (gemischte Währungen){% endif %}
</span>
</div>
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Vertragstatus</span>
<span class="text-[11px] text-amber-200">
Bald auslaufend: {{ stats.expiring_soon }}
</span>
<span class="text-[11px] text-rose-200">
Abgelaufen: {{ stats.expired }}
</span>
</div>
</div>
{% endif %}
<!-- Provider breakdown -->
<section class="space-y-2 mt-4">
<h2 class="text-sm font-semibold tracking-tight text-slate-200">Nach Provider</h2>
<div class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/60">
<table class="min-w-full text-sm">
<thead class="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th class="px-3 py-2 text-left">Provider</th>
<th class="px-3 py-2 text-right">Server</th>
<th class="px-3 py-2 text-right">Monatskosten</th>
<th class="px-3 py-2 text-right">Laufen bald aus</th>
<th class="px-3 py-2 text-right">Abgelaufen</th>
</tr>
</thead>
<tbody>
{% if provider_stats %}
{% for provider, ps in provider_stats.items() %}
<tr class="border-t border-slate-800/80 hover:bg-slate-800/50">
<td class="px-3 py-2 text-slate-100">{{ provider }}</td>
<td class="px-3 py-2 text-right text-slate-200">{{ ps.count }}</td>
<td class="px-3 py-2 text-right text-slate-200">
{{ "%.2f"|format(ps.monthly_total) }}
{% if ps.currency_set|length == 1 %}
{{ (ps.currency_set|list)[0] }}
{% elif ps.currency_set|length > 1 %}
<span class="text-[11px] text-slate-400">(mixed)</span>
{% endif %}
</td>
<td class="px-3 py-2 text-right text-amber-200">{{ ps.expiring_soon }}</td>
<td class="px-3 py-2 text-right text-rose-200">{{ ps.expired }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="px-3 py-3 text-xs text-slate-400">
Keine Server erfasst.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</section>
<!-- Contracts: expiring soon & expired -->
<section class="grid md:grid-cols-2 gap-4 mt-4">
<div class="space-y-2">
<h2 class="text-sm font-semibold tracking-tight text-slate-200">Laufen bald aus (≤ 30 Tage)</h2>
<div class="rounded-xl border border-slate-800 bg-slate-900/60 p-3 text-xs">
{% if expiring_soon_list %}
<ul class="space-y-2">
{% for s in expiring_soon_list %}
<li class="flex items-center justify-between gap-2">
<div>
<a href="/servers/{{ s.id }}" class="text-slate-100 hover:text-indigo-300 font-medium">
{{ s.name }}
</a>
<div class="text-[11px] text-slate-400">
{{ s.provider }}
{% if s.location %} · {{ s.location }}{% endif %}
</div>
</div>
<div class="text-right text-[11px] text-amber-200">
endet {{ s.contract_end }}
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-slate-500">Keine Verträge laufen in den nächsten 30 Tagen aus.</div>
{% endif %}
</div>
</div>
<div class="space-y-2">
<h2 class="text-sm font-semibold tracking-tight text-slate-200">Abgelaufene Verträge</h2>
<div class="rounded-xl border border-slate-800 bg-slate-900/60 p-3 text-xs">
{% if expired_list %}
<ul class="space-y-2">
{% for s in expired_list[:10] %}
<li class="flex items-center justify-between gap-2">
<div>
<a href="/servers/{{ s.id }}" class="text-slate-100 hover:text-indigo-300 font-medium">
{{ s.name }}
</a>
<div class="text-[11px] text-slate-400">
{{ s.provider }}
{% if s.location %} · {{ s.location }}{% endif %}
</div>
</div>
<div class="text-right text-[11px] text-rose-200">
endete {{ s.contract_end }}
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-slate-500">Keine abgelaufenen Verträge gefunden.</div>
{% endif %}
</div>
</div>
</section>
</div>
{% endblock %}

View file

@ -0,0 +1,129 @@
<!doctype html>
<html lang="de" class="dark" id="html-root">
<head>
<meta charset="utf-8" />
<title>FleetLedger</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#020617" />
<link rel="manifest" href="/manifest.webmanifest" />
<!-- Tailwind via CDN for quick styling (sufficient for an MVP) -->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/static/style.css" />
<script>
// Apply persisted theme preference; default is dark.
(function () {
const stored = localStorage.getItem("fleetledger-theme");
const html = document.getElementById("html-root");
if (stored === "light") {
html.classList.remove("dark");
} else {
html.classList.add("dark");
}
})();
</script>
{% block extra_head %}{% endblock %}
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<div class="min-h-screen flex flex-col">
<header class="border-b border-slate-800 bg-slate-900/80 backdrop-blur">
<div class="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<div class="h-8 w-8 rounded-xl bg-indigo-500/80 flex items-center justify-center text-xs font-bold">
FL
</div>
<div>
<div class="text-sm font-semibold tracking-tight">FleetLedger</div>
<div class="text-xs text-slate-400">Deine gemieteten Server im Blick</div>
</div>
</div>
<div class="flex items-center gap-3 text-xs">
{% if current_user %}
<span class="text-slate-300">
Eingeloggt als <span class="font-semibold">{{ current_user.username }}</span>
{% if current_user.is_admin %}
<span class="ml-1 px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/40 text-emerald-200 text-[10px]">Admin</span>
{% endif %}
</span>
{% if current_user.is_admin %}
<a href="/admin/dashboard" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Admin-Dashboard
</a>
<a href="/users" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
User
</a>
{% endif %}
<a href="/" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Übersicht
</a>
<a href="/map" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Karte
</a>
<a
href="/servers/new"
class="inline-flex items-center gap-1 rounded-lg bg-indigo-500 px-3 py-1.5 font-medium hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40"
>
<span class="text-lg leading-none"></span>
<span>Server anlegen</span>
</a>
<a
href="/logout"
class="rounded-lg border border-slate-700 px-2.5 py-1 text-slate-300 hover:border-slate-500 hover:text-white"
>
Logout
</a>
{% else %}
<a href="/login" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Login
</a>
<a href="/register" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Registrieren
</a>
{% endif %}
<button
id="theme-toggle"
class="rounded-lg border border-slate-700 bg-slate-900/60 px-2.5 py-1 text-slate-300 hover:border-slate-500 hover:text-white"
type="button"
>
🌓
</button>
</div>
</div>
</header>
<main class="flex-1">
<div class="max-w-5xl mx-auto px-4 py-6">
{% block content %}{% endblock %}
</div>
</main>
<footer class="border-t border-slate-800 bg-slate-900/60">
<div class="max-w-5xl mx-auto px-4 py-3 text-xs text-slate-500 flex justify-between items-center">
<span>Selfhosted VPS overview</span>
<span>PWA ready · Dark Mode</span>
</div>
</footer>
</div>
<script>
// Theme toggle button handler
document.getElementById("theme-toggle").addEventListener("click", () => {
const html = document.getElementById("html-root");
const isDark = html.classList.toggle("dark");
localStorage.setItem("fleetledger-theme", isDark ? "dark" : "light");
});
// Register service worker for PWA
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/service-worker.js")
.catch((err) => console.error("SW registration failed", err));
}
</script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-sm mx-auto mt-8">
<h1 class="text-lg font-semibold tracking-tight mb-1">Login</h1>
<p class="text-xs text-slate-400 mb-4">Melde dich an, um deine Server zu verwalten.</p>
{% if error %}
<div class="mb-4 text-xs text-rose-200 bg-rose-500/10 border border-rose-500/60 rounded-lg p-3">
{{ error }}
</div>
{% endif %}
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="space-y-1">
<label class="text-xs text-slate-300">Benutzername</label>
<input
type="text"
name="username"
required
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Passwort</label>
<input
type="password"
name="password"
required
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<div class="flex justify-between items-center text-xs text-slate-400">
<span>Noch kein Account? <a href="/register" class="text-indigo-300 hover:text-indigo-200 underline">Registrieren</a></span>
</div>
<button
type="submit"
class="w-full rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40"
>
Einloggen
</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-sm mx-auto mt-8">
<h1 class="text-lg font-semibold tracking-tight mb-1">Registrieren</h1>
<p class="text-xs text-slate-400 mb-4">
Erstelle einen neuen Account. Der erste Benutzer wird automatisch Admin.
</p>
{% if error %}
<div class="mb-4 text-xs text-rose-200 bg-rose-500/10 border border-rose-500/60 rounded-lg p-3">
{{ error }}
</div>
{% endif %}
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="space-y-1">
<label class="text-xs text-slate-300">Benutzername</label>
<input
type="text"
name="username"
required
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">E-Mail (optional)</label>
<input
type="email"
name="email"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Passwort</label>
<input
type="password"
name="password"
required
minlength="8"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Passwort bestätigen</label>
<input
type="password"
name="password_confirm"
required
minlength="8"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<button
type="submit"
class="w-full rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40"
>
Account anlegen
</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<div class="text-xs text-slate-400 mb-1">Server</div>
<h1 class="text-lg font-semibold tracking-tight flex items-center gap-2">
{{ server.name }}
{% if server.is_expired %}
<span class="inline-flex items-center rounded-full bg-rose-600/15 border border-rose-600/60 px-2 py-0.5 text-[10px] text-rose-200">
abgelaufen
</span>
{% elif server.is_expiring_soon %}
<span class="inline-flex items-center rounded-full bg-amber-500/15 border border-amber-500/60 px-2 py-0.5 text-[10px] text-amber-200">
läuft bald aus
</span>
{% endif %}
</h1>
{% if server.hostname %}
<p class="text-xs text-slate-400">{{ server.hostname }}</p>
{% endif %}
{% if server.contract_end %}
<p class="text-[11px] text-slate-500 mt-1">
Vertragsende: {{ server.contract_end }}
{% if server.days_until_contract_end is not none %}
{% if server.days_until_contract_end < 0 %}
(vor {{ (server.days_until_contract_end * -1) }} Tagen)
{% elif server.days_until_contract_end == 0 %}
(heute)
{% else %}
(in {{ server.days_until_contract_end }} Tagen)
{% endif %}
{% endif %}
</p>
{% endif %}
</div>
<div class="flex flex-wrap gap-2 text-xs items-center">
<span class="inline-flex items-center rounded-full bg-slate-800 px-2 py-0.5 text-slate-300">
{{ server.provider }}
</span>
<span class="inline-flex items-center rounded-full bg-slate-800 px-2 py-0.5 text-slate-300">
{{ server.type }}
</span>
{% if server.location %}
<span class="inline-flex items-center rounded-full bg-slate-800 px-2 py-0.5 text-slate-300">
{{ server.location }}
</span>
{% endif %}
{% if server.tags %}
<span class="inline-flex items-center rounded-full bg-indigo-500/20 border border-indigo-500/60 px-2 py-0.5 text-indigo-100">
{{ server.tags }}
</span>
{% endif %}
<a
href="/servers/{{ server.id }}/edit"
class="ml-2 rounded-lg border border-slate-700 px-3 py-1 text-xs text-slate-100 hover:border-slate-500 hover:text-white"
>
Bearbeiten
</a>
<form method="post" action="/servers/{{ server.id }}/archive" onsubmit="return confirm('Diesen Server archivieren?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button
type="submit"
class="rounded-lg border border-rose-600 px-3 py-1 text-xs text-rose-200 hover:bg-rose-600/10"
>
Archivieren
</button>
</form>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<!-- Network & Cost -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2">
<h2 class="text-sm font-semibold text-slate-100">Netz & Kosten</h2>
<dl class="text-xs text-slate-300 space-y-1">
<div class="flex justify-between gap-3">
<dt class="text-slate-400">IPv4</dt>
<dd class="text-right">{% if server.ipv4 %}{{ server.ipv4 }}{% else %}{% endif %}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">IPv6</dt>
<dd class="text-right">{% if server.ipv6 %}{{ server.ipv6 }}{% else %}{% endif %}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">Kosten</dt>
<dd class="text-right">
{% if server.price %}
{{ "%.2f"|format(server.price) }} {{ server.currency }} /
{{ "Monat" if server.billing_period == "monthly" else "Jahr" }}
{% else %}{% endif %}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">Vertrag</dt>
<dd class="text-right">
{% if server.contract_start %}ab {{ server.contract_start }}{% endif %}
{% if server.contract_end %} bis {{ server.contract_end }}{% endif %}
{% if not server.contract_start and not server.contract_end %}{% endif %}
</dd>
</div>
</dl>
</section>
<!-- Hardware -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2">
<h2 class="text-sm font-semibold text-slate-100">Hardware</h2>
<dl class="text-xs text-slate-300 space-y-1">
<div class="flex justify-between gap-3">
<dt class="text-slate-400">CPU</dt>
<dd class="text-right">
{% if server.cpu_model %}{{ server.cpu_model }}{% else %}{% endif %}
{% if server.cpu_cores %} ({{ server.cpu_cores }} Cores){% endif %}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">RAM</dt>
<dd class="text-right">
{% if server.ram_mb %}{{ server.ram_mb }} MB{% else %}{% endif %}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">Storage</dt>
<dd class="text-right">
{% if server.storage_gb %}{{ server.storage_gb }} GB{% else %}{% endif %}
{% if server.storage_type %} ({{ server.storage_type }}){% endif %}
</dd>
</div>
</dl>
</section>
<!-- Access -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2">
<h2 class="text-sm font-semibold text-slate-100">Zugänge</h2>
<dl class="text-xs text-slate-300 space-y-1">
<div class="flex justify-between gap-3">
<dt class="text-slate-400">Management</dt>
<dd class="text-right">
{% if server.mgmt_url %}
<a href="{{ server.mgmt_url }}" target="_blank" rel="noopener noreferrer" class="text-indigo-300 hover:text-indigo-200 underline">
Console öffnen
</a>
{% else %}{% endif %}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">Mgmt-User</dt>
<dd class="text-right">{% if server.mgmt_user %}{{ server.mgmt_user }}{% else %}{% endif %}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">Mgmt-Passwort</dt>
<dd class="text-right">
{% if server.mgmt_password_encrypted %}
{% if mgmt_password %}
<span class="font-mono text-slate-100">{{ mgmt_password }}</span>
{% else %}
<span class="text-slate-500">verschlüsselt gespeichert (Key fehlt?)</span>
{% endif %}
{% else %}
{% endif %}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">SSH User</dt>
<dd class="text-right">{% if server.ssh_user %}{{ server.ssh_user }}{% else %}{% endif %}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-slate-400">SSH Key Hint</dt>
<dd class="text-right">
{% if server.ssh_key_hint %}
<span class="font-mono">{{ server.ssh_key_hint }}</span>
{% else %}{% endif %}
</dd>
</div>
</dl>
<p class="mt-2 text-[11px] text-slate-500">
Hinweis: Es werden nur Key-Namen gespeichert, keine privaten SSH-Schlüssel.
</p>
</section>
<!-- Notes -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2 md:col-span-2">
<h2 class="text-sm font-semibold text-slate-100">Notizen</h2>
<div class="text-xs text-slate-200 whitespace-pre-wrap">
{% if server.notes %}{{ server.notes }}{% else %}<span class="text-slate-500">Keine Notizen.</span>{% endif %}
</div>
</section>
</div>
<div class="flex justify-between items-center text-xs text-slate-500">
<a href="/" class="hover:text-slate-200 underline underline-offset-2">Zurück zur Übersicht</a>
<span>Zuletzt aktualisiert: {{ server.updated_at }}</span>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,315 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-3xl mx-auto">
<h1 class="text-lg font-semibold tracking-tight mb-1">
{% if server %}Server bearbeiten{% else %}Neuen Server anlegen{% endif %}
</h1>
<p class="text-xs text-slate-400 mb-4">
Trage alle relevanten Infos zu deinem VPS / Server ein.
</p>
{% if not can_encrypt %}
<div class="mb-4 text-xs text-amber-200 bg-amber-500/10 border border-amber-500/60 rounded-lg p-3">
ENCRYPTION_KEY ist nicht gesetzt eingegebene Management-Passwörter werden nicht gespeichert.
</div>
{% endif %}
<form method="post" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<!-- General -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Allgemein</h2>
<div class="grid md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="text-xs text-slate-300">Name *</label>
<input
type="text"
name="name"
required
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="Prod-DB-01"
value="{{ server.name if server else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Provider *</label>
<input
type="text"
name="provider"
required
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="Hetzner / OVH / ..."
value="{{ server.provider if server else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Hostname</label>
<input
type="text"
name="hostname"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="server1.example.com"
value="{{ server.hostname if server and server.hostname else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Typ</label>
<select
name="type"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
>
{% set t = server.type if server else 'vps' %}
<option value="vps" {% if t == 'vps' %}selected{% endif %}>VPS</option>
<option value="dedicated" {% if t == 'dedicated' %}selected{% endif %}>Dedicated</option>
<option value="storage" {% if t == 'storage' %}selected{% endif %}>Storage</option>
<option value="managed" {% if t == 'managed' %}selected{% endif %}>Managed</option>
<option value="other" {% if t == 'other' %}selected{% endif %}>Sonstiges</option>
</select>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Location</label>
<input
type="text"
name="location"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="Falkenstein / Frankfurt / Helsinki / Ashburn"
value="{{ server.location if server and server.location else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Tags (kommagetrennt)</label>
<input
type="text"
name="tags"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="prod,critical,backup"
value="{{ server.tags if server and server.tags else '' }}"
/>
</div>
</div>
</section>
<!-- Network -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Netzwerk</h2>
<div class="grid md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="text-xs text-slate-300">IPv4</label>
<input
type="text"
name="ipv4"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="192.0.2.10"
value="{{ server.ipv4 if server and server.ipv4 else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">IPv6</label>
<input
type="text"
name="ipv6"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="2001:db8::10"
value="{{ server.ipv6 if server and server.ipv6 else '' }}"
/>
</div>
</div>
</section>
<!-- Costs -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Kosten</h2>
<div class="grid md:grid-cols-3 gap-4">
<div class="space-y-1">
<label class="text-xs text-slate-300">Betrag</label>
<input
type="number"
step="0.01"
name="price"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="5.00"
value="{{ server.price if server else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Währung</label>
<input
type="text"
name="currency"
value="{{ server.currency if server else 'EUR' }}"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Abrechnung</label>
<select
name="billing_period"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
>
{% set bp = server.billing_period if server else 'monthly' %}
<option value="monthly" {% if bp == 'monthly' %}selected{% endif %}>Monatlich</option>
<option value="yearly" {% if bp == 'yearly' %}selected{% endif %}>Jährlich</option>
<option value="other" {% if bp == 'other' %}selected{% endif %}>Sonstiges</option>
</select>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Vertragsbeginn</label>
<input
type="date"
name="contract_start"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
value="{{ server.contract_start if server and server.contract_start else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Vertragsende</label>
<input
type="date"
name="contract_end"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
value="{{ server.contract_end if server and server.contract_end else '' }}"
/>
</div>
</div>
</section>
<!-- Hardware -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Hardware</h2>
<div class="grid md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="text-xs text-slate-300">CPU-Modell</label>
<input
type="text"
name="cpu_model"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="Ryzen 5 3600"
value="{{ server.cpu_model if server and server.cpu_model else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">CPU Cores</label>
<input
type="number"
name="cpu_cores"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
value="{{ server.cpu_cores if server and server.cpu_cores else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">RAM (MB)</label>
<input
type="number"
name="ram_mb"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
value="{{ server.ram_mb if server and server.ram_mb else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Storage (GB)</label>
<input
type="number"
name="storage_gb"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
value="{{ server.storage_gb if server and server.storage_gb else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Storage-Typ</label>
<input
type="text"
name="storage_type"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="nvme / ssd / hdd / ceph"
value="{{ server.storage_type if server and server.storage_type else '' }}"
/>
</div>
</div>
</section>
<!-- Access -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Zugänge</h2>
<p class="text-xs text-slate-400">
SSH: hier nur <strong>Key-Namen</strong> oder Hints eintragen, keine privaten Keys.
</p>
<div class="grid md:grid-cols-2 gap-4">
<div class="space-y-1">
<label class="text-xs text-slate-300">Management URL</label>
<input
type="url"
name="mgmt_url"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="https://hetzner.cloud/project/..."
value="{{ server.mgmt_url if server and server.mgmt_url else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Management User</label>
<input
type="text"
name="mgmt_user"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
value="{{ server.mgmt_user if server and server.mgmt_user else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">Management Passwort</label>
<input
type="password"
name="mgmt_password"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="{% if can_encrypt %}Neues Passwort setzen (leer = unverändert){% else %}Wird NICHT gespeichert{% endif %}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">SSH User</label>
<input
type="text"
name="ssh_user"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="root / debian / nocci"
value="{{ server.ssh_user if server and server.ssh_user else '' }}"
/>
</div>
<div class="space-y-1">
<label class="text-xs text-slate-300">SSH Key Hint</label>
<input
type="text"
name="ssh_key_hint"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="id_ed25519_hetzner"
value="{{ server.ssh_key_hint if server and server.ssh_key_hint else '' }}"
/>
</div>
</div>
</section>
<!-- Notes -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Notizen</h2>
<textarea
name="notes"
rows="4"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
placeholder="Besondere Einstellungen, Projekte, die hier laufen, etc."
>{{ server.notes if server and server.notes else '' }}</textarea>
</section>
<div class="flex justify-between items-center">
<a
href="/"
class="rounded-lg border border-slate-700 px-4 py-2 text-sm text-slate-200 hover:border-slate-500 hover:text-white"
>Abbrechen</a
>
<button
type="submit"
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40"
>
{% if server %}Änderungen speichern{% else %}Speichern{% endif %}
</button>
</div>
</form>
</div>
{% endblock %}
s

View file

@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block content %}
<div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold tracking-tight">Deine Server</h1>
<p class="text-xs text-slate-400">
Übersicht aller gemieteten VPS, dedizierten Server und Storage-Systeme.
</p>
</div>
<div class="flex items-center gap-3 text-xs text-slate-400">
{% if not can_encrypt %}
<span class="px-2 py-1 rounded-md border border-amber-500/60 bg-amber-500/10 text-amber-200">
Hinweis: ENCRYPTION_KEY nicht gesetzt Passwörter werden nicht gespeichert.
</span>
{% endif %}
</div>
</div>
{% if stats %}
<!-- Small dashboard summary row -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Gesamt</span>
<span class="text-lg font-semibold text-slate-100">
{{ stats.total_servers }}
</span>
<span class="text-[11px] text-slate-500 mt-1">aktive Server</span>
</div>
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Laufende Kosten</span>
<span class="text-lg font-semibold text-slate-100">
{{ "%.2f"|format(stats.monthly_total) }}
{% if stats.monthly_currency %} {{ stats.monthly_currency }}{% endif %}
</span>
<span class="text-[11px] text-slate-500 mt-1">
pro Monat{% if stats.mixed_currencies %} (gemischte Währungen){% endif %}
</span>
</div>
<div class="rounded-xl border border-amber-500/40 bg-amber-500/10 px-3 py-3 flex flex-col">
<span class="text-[11px] text-amber-200 mb-1">Laufen bald aus</span>
<span class="text-lg font-semibold text-amber-100">
{{ stats.expiring_soon }}
</span>
<span class="text-[11px] text-amber-200/80 mt-1">≤ 30 Tage</span>
</div>
<div class="rounded-xl border border-rose-600/60 bg-rose-600/10 px-3 py-3 flex flex-col">
<span class="text-[11px] text-rose-100 mb-1">Abgelaufen</span>
<span class="text-lg font-semibold text-rose-50">
{{ stats.expired }}
</span>
<span class="text-[11px] text-rose-100/80 mt-1">Vertrag beendet</span>
</div>
</div>
{% endif %}
{% if servers %}
<div class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/60">
<table class="min-w-full text-sm">
<thead class="bg-slate-900/80">
<tr class="text-xs uppercase tracking-wide text-slate-400">
<th class="px-3 py-2 text-left">Name</th>
<th class="px-3 py-2 text-left">Provider</th>
<th class="px-3 py-2 text-left">Type</th>
<th class="px-3 py-2 text-left">Location</th>
<th class="px-3 py-2 text-left">IPv4</th>
<th class="px-3 py-2 text-right">Kosten</th>
</tr>
</thead>
<tbody>
{% for s in servers %}
<tr class="border-t border-slate-800/80 hover:bg-slate-800/50">
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<div>
<a href="/servers/{{ s.id }}" class="text-slate-100 hover:text-indigo-300 font-medium">
{{ s.name }}
</a>
{% if s.hostname %}
<div class="text-[11px] text-slate-400">{{ s.hostname }}</div>
{% endif %}
</div>
{% if s.is_expired %}
<span class="inline-flex items-center rounded-full bg-rose-600/15 border border-rose-600/60 px-2 py-0.5 text-[10px] text-rose-200">
abgelaufen
</span>
{% elif s.is_expiring_soon %}
<span class="inline-flex items-center rounded-full bg-amber-500/15 border border-amber-500/60 px-2 py-0.5 text-[10px] text-amber-200">
läuft bald aus
</span>
{% endif %}
</div>
</td>
<td class="px-3 py-2 text-slate-200">{{ s.provider }}</td>
<td class="px-3 py-2">
<span class="inline-flex rounded-full bg-slate-800 px-2 py-0.5 text-[11px] text-slate-300">
{{ s.type }}
</span>
</td>
<td class="px-3 py-2 text-slate-200">
{% if s.location %}{{ s.location }}{% else %}<span class="text-slate-500"></span>{% endif %}
</td>
<td class="px-3 py-2 text-slate-200">
{% if s.ipv4 %}{{ s.ipv4 }}{% else %}<span class="text-slate-500"></span>{% endif %}
</td>
<td class="px-3 py-2 text-right text-slate-200">
{% if s.price %}
{{ "%.2f"|format(s.price) }} {{ s.currency }}
<span class="text-[11px] text-slate-400">/ {{ "Monat" if s.billing_period == "monthly" else "Jahr" }}</span>
{% else %}
<span class="text-slate-500"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="rounded-xl border border-dashed border-slate-700 bg-slate-900/40 p-6 text-sm text-slate-300">
Noch keine Server erfasst. Leg den ersten mit „Server anlegen“ oben rechts an.
</div>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,230 @@
{% extends "base.html" %}
{% block extra_head %}
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
/>
<style>
/* Make Leaflet popups look a bit nicer with Tailwind-ish typography */
.leaflet-popup-content-wrapper {
background-color: #020617;
color: #e5e7eb;
border-radius: 0.75rem;
border: 1px solid #1f2937;
}
.leaflet-popup-tip {
background-color: #020617;
}
</style>
{% endblock %}
{% block content %}
<div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-lg font-semibold tracking-tight">Server-Karte</h1>
<p class="text-xs text-slate-400">
Zeigt alle nicht archivierten Server mit gesetzter Location auf einer Karte.
Für grobe Übersicht reicht die Stadt keine exakten GPS-Daten notwendig.
</p>
</div>
</div>
<!-- Hidden list of servers passed via data attributes to JS -->
<ul id="server-data" class="hidden">
{% for s in servers %}
<li
data-id="{{ s.id }}"
data-name="{{ s.name }}"
data-provider="{{ s.provider }}"
data-ipv4="{{ s.ipv4 or '' }}"
data-location="{{ s.location or '' }}"
></li>
{% endfor %}
</ul>
<div
id="map"
class="w-full h-[520px] rounded-xl border border-slate-800 bg-slate-900/70"
></div>
<p class="text-[11px] text-slate-500">
Hinweis: Marker werden anhand der Location-Namen grob auf der Weltkarte platziert. Mehrere Server
in derselben Stadt werden leicht versetzt dargestellt, damit sie klickbar bleiben.
</p>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// Basic HTML escaping to avoid XSS when injecting text into popups
function escapeHtml(str) {
return String(str || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Very small built-in city coordinate map (approximate, not exhaustive).
// Keys are lowercase.
const CITY_COORDS = {
"berlin": [52.52, 13.405],
"frankfurt": [50.1109, 8.6821],
"nuremberg": [49.4521, 11.0767],
"nürnberg": [49.4521, 11.0767],
"falkenstein": [50.474, 12.371],
"helsinki": [60.1699, 24.9384],
"amsterdam": [52.3676, 4.9041],
"paris": [48.8566, 2.3522],
"london": [51.5074, -0.1278],
"vienna": [48.2082, 16.3738],
"wien": [48.2082, 16.3738],
"zurich": [47.3769, 8.5417],
"zürich": [47.3769, 8.5417],
"stockholm": [59.3293, 18.0686],
"prague": [50.0755, 14.4378],
"praha": [50.0755, 14.4378],
"warsaw": [52.2297, 21.0122],
"madrid": [40.4168, -3.7038],
"rome": [41.9028, 12.4964],
"milano": [45.4642, 9.19],
"new york": [40.7128, -74.006],
"nyc": [40.7128, -74.006],
"chicago": [41.8781, -87.6298],
"ireland": [53.3498, -6.2603], // Dublin
"irland": [53.3498, -6.2603],
"dublin": [53.3498, -6.2603],
"romania": [44.4268, 26.1025], // Bucharest
"rumänien": [44.4268, 26.1025],
"bucharest": [44.4268, 26.1025],
"bucuresti": [44.4268, 26.1025],
"bucurești": [44.4268, 26.1025],
"moldova": [47.0105, 28.8638], // Chișinău
"moldau": [47.0105, 28.8638],
"chisinau": [47.0105, 28.8638],
"chișinău": [47.0105, 28.8638],
"ashburn": [39.0438, -77.4874]
};
// Deterministic string hash → used for pseudo coordinates for unknown locations
function hashString(str) {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = (h << 5) - h + str.charCodeAt(i);
h |= 0; // force 32-bit
}
return h;
}
function getBaseCoords(locationKey) {
const key = locationKey.toLowerCase();
if (Object.prototype.hasOwnProperty.call(CITY_COORDS, key)) {
return CITY_COORDS[key];
}
// Fuzzy match: if the city keyword appears in the location string
for (const cityKey in CITY_COORDS) {
if (key.includes(cityKey)) {
return CITY_COORDS[cityKey];
}
}
return null;
}
document.addEventListener("DOMContentLoaded", () => {
const list = document.querySelectorAll("#server-data li");
const servers = Array.from(list).map((el) => ({
id: parseInt(el.dataset.id, 10),
name: el.dataset.name || "",
provider: el.dataset.provider || "",
ipv4: el.dataset.ipv4 || "",
location: el.dataset.location || ""
}));
const map = L.map("map", {
worldCopyJump: true,
scrollWheelZoom: true,
zoomSnap: 0.25
}).setView([20, 0], 2);
// Simple OpenStreetMap tiles
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
}).addTo(map);
const markers = [];
const locationCounts = {};
// Create markers; approximate coordinates based on location string
for (const s of servers) {
const rawLoc = (s.location || "").trim();
if (!rawLoc) {
// no location → skip from map
continue;
}
const key = rawLoc.toLowerCase();
// Base coordinates: either from CITY_COORDS or a deterministic fallback
let base = getBaseCoords(key);
if (!base) {
const h = hashString(key);
// Roughly distribute unknown locations across -60..+60 lat, -180..+180 lon
const lat = ((h % 12000) / 100) - 60; // -60 .. +60
const lng = ((((h / 12000) | 0) % 36000) / 100) - 180; // -180 .. +180
base = [lat, lng];
}
const count = locationCounts[key] || 0;
locationCounts[key] = count + 1;
// Slight radial offset for multiple servers in the same city
const offset = 0.15; // degrees (~1520km)
const angle = (count * 2 * Math.PI) / 6; // hex-like distribution
const lat = base[0] + offset * Math.sin(angle);
const lng = base[1] + offset * Math.cos(angle);
const popupContent = `
<div class="text-sm font-medium">${escapeHtml(s.name)}</div>
<div class="text-xs text-slate-400">${escapeHtml(s.provider)}</div>
${
s.ipv4
? `<div class="text-xs mt-1 font-mono text-slate-300">${escapeHtml(s.ipv4)}</div>`
: ""
}
${
rawLoc
? `<div class="text-[11px] text-slate-500 mt-1">${escapeHtml(rawLoc)}</div>`
: ""
}
<a href="/servers/${s.id}" class="text-indigo-400 underline text-xs mt-2 block">Details</a>
`;
const marker = L.circleMarker([lat, lng], {
radius: 6,
fillColor: "#6366f1",
color: "#1e3a8a",
weight: 1,
opacity: 0.8,
fillOpacity: 0.9
}).bindPopup(popupContent);
marker.addTo(map);
markers.push(marker);
}
// Auto-fit map to markers if we have any
if (markers.length > 0) {
const group = L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.25));
} else {
// No markers → keep default world view
map.setView([20, 0], 2);
}
});
</script>
{% endblock %}

View file

@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-lg font-semibold tracking-tight">Benutzerverwaltung</h1>
<p class="text-xs text-slate-400">
Admins können Benutzer aktivieren/deaktivieren. Der eigene Account kann nicht deaktiviert werden.
</p>
</div>
</div>
<div class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/60">
<table class="min-w-full text-sm">
<thead class="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th class="px-3 py-2 text-left">Benutzername</th>
<th class="px-3 py-2 text-left">E-Mail</th>
<th class="px-3 py-2 text-left">Rolle</th>
<th class="px-3 py-2 text-left">Status</th>
<th class="px-3 py-2 text-right">Aktion</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr class="border-t border-slate-800/80 hover:bg-slate-800/50">
<td class="px-3 py-2">
<span class="font-medium text-slate-100">{{ u.username }}</span>
</td>
<td class="px-3 py-2 text-slate-200">
{% if u.email %}{{ u.email }}{% else %}<span class="text-slate-500"></span>{% endif %}
</td>
<td class="px-3 py-2 text-slate-200">
{% if u.is_admin %}Admin{% else %}User{% endif %}
</td>
<td class="px-3 py-2 text-slate-200">
{% if u.is_active %}
<span class="inline-flex rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] text-emerald-200 border border-emerald-500/50">
aktiv
</span>
{% else %}
<span class="inline-flex rounded-full bg-slate-800 px-2 py-0.5 text-[11px] text-slate-300 border border-slate-600">
deaktiviert
</span>
{% endif %}
</td>
<td class="px-3 py-2 text-right">
{% if u.id != current_user.id %}
<form method="post" action="/users/{{ u.id }}/toggle-active">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button
type="submit"
class="rounded-lg border border-slate-700 px-3 py-1 text-xs text-slate-200 hover:border-slate-500 hover:text-white"
>
{% if u.is_active %}Deaktivieren{% else %}Aktivieren{% endif %}
</button>
</form>
{% else %}
<span class="text-[11px] text-slate-500">Eigener Account</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

66
fleetledger/app/utils.py Normal file
View file

@ -0,0 +1,66 @@
import os
import secrets
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from starlette.requests import Request
# Optional symmetric encryption for management passwords
_ENC_KEY = os.getenv("ENCRYPTION_KEY")
_f = None
if _ENC_KEY:
# If the key is already a valid Fernet key string, use it directly.
# Otherwise you could do more validation/derivation, but for now we assume a proper key.
_f = Fernet(
_ENC_KEY.encode() if not _ENC_KEY.strip().endswith("=") else _ENC_KEY
)
def can_encrypt() -> bool:
"""Return True if an encryption key has been configured."""
return _f is not None
def encrypt_secret(plaintext: str) -> Optional[str]:
"""Encrypt a secret string using Fernet, if configured."""
if not plaintext or not _f:
return None
token = _f.encrypt(plaintext.encode("utf-8"))
return token.decode("utf-8")
def decrypt_secret(token: str) -> Optional[str]:
"""Decrypt a Fernet-encrypted token, returning a string or None."""
if not token or not _f:
return None
try:
plaintext = _f.decrypt(token.encode("utf-8"))
return plaintext.decode("utf-8")
except (InvalidToken, ValueError):
return None
# ----- CSRF helpers -----
_CSRF_SESSION_KEY = "csrf_token"
def ensure_csrf_token(request: Request) -> str:
"""
Ensure the current session has a CSRF token and return it.
"""
token = request.session.get(_CSRF_SESSION_KEY)
if not token:
token = secrets.token_urlsafe(32)
request.session[_CSRF_SESSION_KEY] = token
return token
def validate_csrf(request: Request, token: str) -> bool:
"""
Compare provided token with the one stored in the session.
"""
stored = request.session.get(_CSRF_SESSION_KEY)
if not stored or not token:
return False
return secrets.compare_digest(str(stored), str(token))

View file

@ -0,0 +1,21 @@
version: "3.9"
services:
fleetledger:
build: .
container_name: fleetledger
ports:
- "8000:8000"
environment:
- DATABASE_PATH=/data/fleetledger.db
# SESSION_SECRET must be provided (e.g. via .env) and should be long and random
- SESSION_SECRET=${SESSION_SECRET:?Set SESSION_SECRET in your environment}
# Set to 0 only for local HTTP testing; keep secure (default) in production
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-1}
# Optional: encryption key for management passwords (Fernet key)
# - ENCRYPTION_KEY=your_fernet_key_here
volumes:
- fleetledger_data:/data
restart: unless-stopped
volumes:
fleetledger_data:

View file

@ -0,0 +1,7 @@
fastapi
uvicorn[standard]
sqlmodel
jinja2
python-multipart
cryptography
passlib[bcrypt]