✨ 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
This commit is contained in:
parent
0151bf19f6
commit
b9cfefa3a9
24 changed files with 2598 additions and 0 deletions
21
fleetledger/Dockerfile
Normal file
21
fleetledger/Dockerfile
Normal 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"]
|
||||
82
fleetledger/README.md
Normal file
82
fleetledger/README.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# 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)
|
||||
|
||||
### 1. Clone / copy the repository
|
||||
|
||||
```bash
|
||||
git clone https://example.com/your/fleetledger.git
|
||||
cd fleetledger
|
||||
1
fleetledger/app/__init__.py
Normal file
1
fleetledger/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Empty file to mark "app" as a package.
|
||||
75
fleetledger/app/auth.py
Normal file
75
fleetledger/app/auth.py
Normal 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
21
fleetledger/app/db.py
Normal 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
783
fleetledger/app/main.py
Normal 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
99
fleetledger/app/models.py
Normal 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
|
||||
BIN
fleetledger/app/static/icon-192.png
Normal file
BIN
fleetledger/app/static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 593 B |
BIN
fleetledger/app/static/icon-512.png
Normal file
BIN
fleetledger/app/static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
20
fleetledger/app/static/manifest.webmanifest
Normal file
20
fleetledger/app/static/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
74
fleetledger/app/static/service-worker.js
Normal file
74
fleetledger/app/static/service-worker.js
Normal 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));
|
||||
}
|
||||
});
|
||||
5
fleetledger/app/static/style.css
Normal file
5
fleetledger/app/static/style.css
Normal 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;
|
||||
}
|
||||
154
fleetledger/app/templates/admin_dashboard.html
Normal file
154
fleetledger/app/templates/admin_dashboard.html
Normal 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 %}
|
||||
129
fleetledger/app/templates/base.html
Normal file
129
fleetledger/app/templates/base.html
Normal 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>
|
||||
44
fleetledger/app/templates/login.html
Normal file
44
fleetledger/app/templates/login.html
Normal 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 %}
|
||||
62
fleetledger/app/templates/register.html
Normal file
62
fleetledger/app/templates/register.html
Normal 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 %}
|
||||
196
fleetledger/app/templates/server_detail.html
Normal file
196
fleetledger/app/templates/server_detail.html
Normal 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 %}
|
||||
315
fleetledger/app/templates/server_form.html
Normal file
315
fleetledger/app/templates/server_form.html
Normal 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
|
||||
125
fleetledger/app/templates/servers_list.html
Normal file
125
fleetledger/app/templates/servers_list.html
Normal 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 %}
|
||||
230
fleetledger/app/templates/servers_map.html
Normal file
230
fleetledger/app/templates/servers_map.html
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// 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:
|
||||
'© <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 (~15–20km)
|
||||
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 %}
|
||||
68
fleetledger/app/templates/users_list.html
Normal file
68
fleetledger/app/templates/users_list.html
Normal 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
66
fleetledger/app/utils.py
Normal 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))
|
||||
21
fleetledger/docker-compose.yml
Normal file
21
fleetledger/docker-compose.yml
Normal 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:
|
||||
7
fleetledger/requirements.txt
Normal file
7
fleetledger/requirements.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlmodel
|
||||
jinja2
|
||||
python-multipart
|
||||
cryptography
|
||||
passlib[bcrypt]
|
||||
Loading…
Add table
Add a link
Reference in a new issue