feat: add protected admin area with bootstrap and data management
This commit is contained in:
parent
628d73afd6
commit
e44791fd30
6 changed files with 923 additions and 1 deletions
|
|
@ -10,6 +10,8 @@ Implementiert:
|
||||||
- API: `GET /spot/score`, `POST /spot/signal`
|
- API: `GET /spot/score`, `POST /spot/signal`
|
||||||
- Kartenwahl via OpenStreetMap (Leaflet), inkl. Klickauswahl und Ortssuche
|
- Kartenwahl via OpenStreetMap (Leaflet), inkl. Klickauswahl und Ortssuche
|
||||||
- API: `GET /geocode/search` (Nominatim-Proxy), `GET /map/tile/{z}/{x}/{y}.png` (OSM-Tile-Proxy)
|
- API: `GET /geocode/search` (Nominatim-Proxy), `GET /map/tile/{z}/{x}/{y}.png` (OSM-Tile-Proxy)
|
||||||
|
- Admin-Bereich (Setup/Login, geschuetzt per User/Passwort + Session-Token)
|
||||||
|
- Admin-API fuer Uebersicht und Event-Verwaltung (`/admin/*`)
|
||||||
- Anti-Spam ohne Account: lokaler Token + serverseitiger HMAC-Hash
|
- Anti-Spam ohne Account: lokaler Token + serverseitiger HMAC-Hash
|
||||||
- Quick Decision UI mit Signal-Buttons
|
- Quick Decision UI mit Signal-Buttons
|
||||||
- Offline-First im Frontend: Score-Cache + Signal-Queue
|
- Offline-First im Frontend: Score-Cache + Signal-Queue
|
||||||
|
|
@ -77,6 +79,9 @@ Ortssuche:
|
||||||
Tile-Proxy:
|
Tile-Proxy:
|
||||||
- `curl -I "http://127.0.0.1:8787/map/tile/12/2149/1387.png"`
|
- `curl -I "http://127.0.0.1:8787/map/tile/12/2149/1387.png"`
|
||||||
|
|
||||||
|
Admin-Setup (nur beim ersten Start):
|
||||||
|
- `curl -X POST "http://127.0.0.1:8787/admin/bootstrap" -H "Content-Type: application/json" -d '{"username":"admin","password":"<starkes-passwort>"}'`
|
||||||
|
|
||||||
## DSGVO-Hinweise im MVP
|
## DSGVO-Hinweise im MVP
|
||||||
|
|
||||||
- Kein Login
|
- Kein Login
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,15 @@ def init_db() -> None:
|
||||||
notes TEXT NOT NULL
|
notes TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_user (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
password_salt TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_signal_spot_timestamp
|
CREATE INDEX IF NOT EXISTS idx_signal_spot_timestamp
|
||||||
ON community_signal (spot_id, timestamp);
|
ON community_signal (spot_id, timestamp);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
@ -36,10 +37,14 @@ NOMINATIM_USER_AGENT = os.environ.get(
|
||||||
"StaySense/0.1 (staysense.vanityontour.de)",
|
"StaySense/0.1 (staysense.vanityontour.de)",
|
||||||
)
|
)
|
||||||
OSM_TILE_BASE_URL = os.environ.get("STAYSENSE_OSM_TILE_BASE_URL", "https://tile.openstreetmap.org")
|
OSM_TILE_BASE_URL = os.environ.get("STAYSENSE_OSM_TILE_BASE_URL", "https://tile.openstreetmap.org")
|
||||||
|
ADMIN_SESSION_HOURS = int(os.environ.get("STAYSENSE_ADMIN_SESSION_HOURS", "12"))
|
||||||
|
ADMIN_PBKDF2_ITERATIONS = int(os.environ.get("STAYSENSE_ADMIN_PBKDF2_ITERATIONS", "390000"))
|
||||||
|
ADMIN_MANUAL_SOURCE = "admin_manual"
|
||||||
|
|
||||||
FALLBACK_POLICE_POINTS = [(51.2507, 6.9751), (51.2965, 6.8494), (51.3398, 7.0438)]
|
FALLBACK_POLICE_POINTS = [(51.2507, 6.9751), (51.2965, 6.8494), (51.3398, 7.0438)]
|
||||||
FALLBACK_FIRE_POINTS = [(51.2518, 6.9800), (51.2937, 6.8568), (51.3314, 7.0540)]
|
FALLBACK_FIRE_POINTS = [(51.2518, 6.9800), (51.2937, 6.8568), (51.3314, 7.0540)]
|
||||||
FALLBACK_HOSPITAL_POINTS = [(51.2556, 6.9723), (51.2891, 6.8457), (51.3321, 7.0403)]
|
FALLBACK_HOSPITAL_POINTS = [(51.2556, 6.9723), (51.2891, 6.8457), (51.3321, 7.0403)]
|
||||||
|
ADMIN_SESSIONS: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> dt.datetime:
|
def utc_now() -> dt.datetime:
|
||||||
|
|
@ -88,6 +93,14 @@ def read_json(handler: BaseHTTPRequestHandler) -> dict:
|
||||||
return json.loads(body.decode("utf-8"))
|
return json.loads(body.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def require_fields(handler: BaseHTTPRequestHandler, body: dict, fields: list[str]) -> bool:
|
||||||
|
missing = [field for field in fields if not body.get(field)]
|
||||||
|
if missing:
|
||||||
|
json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "missing_fields", "fields": missing})
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def hashed_device(device_token: str) -> str:
|
def hashed_device(device_token: str) -> str:
|
||||||
return hmac.new(
|
return hmac.new(
|
||||||
key=SERVER_SALT.encode("utf-8"),
|
key=SERVER_SALT.encode("utf-8"),
|
||||||
|
|
@ -96,6 +109,121 @@ def hashed_device(device_token: str) -> str:
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def pbkdf2_hash(password: str, salt_hex: str) -> str:
|
||||||
|
dk = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256",
|
||||||
|
password.encode("utf-8"),
|
||||||
|
bytes.fromhex(salt_hex),
|
||||||
|
ADMIN_PBKDF2_ITERATIONS,
|
||||||
|
dklen=32,
|
||||||
|
)
|
||||||
|
return dk.hex()
|
||||||
|
|
||||||
|
|
||||||
|
def admin_exists() -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT 1 FROM admin_user WHERE id = 1").fetchone()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_user() -> dict | None:
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT id, username, password_hash, password_salt FROM admin_user WHERE id = 1").fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_admin_password(password: str) -> bool:
|
||||||
|
if not isinstance(password, str):
|
||||||
|
return False
|
||||||
|
return len(password) >= 10
|
||||||
|
|
||||||
|
|
||||||
|
def create_admin_user(username: str, password: str) -> tuple[bool, str]:
|
||||||
|
if not isinstance(username, str) or len(username.strip()) < 3:
|
||||||
|
return False, "invalid_username"
|
||||||
|
if not validate_admin_password(password):
|
||||||
|
return False, "invalid_password"
|
||||||
|
if admin_exists():
|
||||||
|
return False, "already_initialized"
|
||||||
|
|
||||||
|
now_iso = to_iso(utc_now())
|
||||||
|
username_clean = username.strip()
|
||||||
|
salt_hex = secrets.token_hex(16)
|
||||||
|
pw_hash = pbkdf2_hash(password, salt_hex)
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO admin_user (id, username, password_hash, password_salt, created_at, updated_at)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(username_clean, pw_hash, salt_hex, now_iso, now_iso),
|
||||||
|
)
|
||||||
|
return True, "created"
|
||||||
|
|
||||||
|
|
||||||
|
def create_admin_session(username: str) -> dict:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires_at = utc_now() + dt.timedelta(hours=ADMIN_SESSION_HOURS)
|
||||||
|
ADMIN_SESSIONS[token] = {
|
||||||
|
"username": username,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
}
|
||||||
|
return {"token": token, "expires_at": to_iso(expires_at), "session_hours": ADMIN_SESSION_HOURS}
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_admin_sessions() -> None:
|
||||||
|
now = utc_now()
|
||||||
|
expired = [token for token, item in ADMIN_SESSIONS.items() if item["expires_at"] <= now]
|
||||||
|
for token in expired:
|
||||||
|
ADMIN_SESSIONS.pop(token, None)
|
||||||
|
|
||||||
|
|
||||||
|
def admin_auth(handler: BaseHTTPRequestHandler) -> tuple[bool, str]:
|
||||||
|
cleanup_admin_sessions()
|
||||||
|
auth_header = handler.headers.get("Authorization", "")
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
return False, "missing_token"
|
||||||
|
token = auth_header.replace("Bearer ", "", 1).strip()
|
||||||
|
session = ADMIN_SESSIONS.get(token)
|
||||||
|
if not session:
|
||||||
|
return False, "invalid_token"
|
||||||
|
if session["expires_at"] <= utc_now():
|
||||||
|
ADMIN_SESSIONS.pop(token, None)
|
||||||
|
return False, "expired_token"
|
||||||
|
return True, session["username"]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_admin_event(body: dict) -> tuple[dict | None, str | None]:
|
||||||
|
try:
|
||||||
|
event_type = str(body.get("event_type", "")).strip()
|
||||||
|
lat = float(body.get("lat"))
|
||||||
|
lon = float(body.get("lon"))
|
||||||
|
risk_modifier = int(body.get("risk_modifier"))
|
||||||
|
start_datetime = to_iso(parse_iso8601(body.get("start_datetime")))
|
||||||
|
end_datetime = to_iso(parse_iso8601(body.get("end_datetime")))
|
||||||
|
source = str(body.get("source", ADMIN_MANUAL_SOURCE)).strip() or ADMIN_MANUAL_SOURCE
|
||||||
|
except Exception:
|
||||||
|
return None, "invalid_payload"
|
||||||
|
|
||||||
|
if event_type not in {"market", "waste", "event", "construction"}:
|
||||||
|
return None, "invalid_event_type"
|
||||||
|
if not (47.0 <= lat <= 55.5 and 5.0 <= lon <= 16.0):
|
||||||
|
return None, "lat_lon_out_of_bounds"
|
||||||
|
if start_datetime >= end_datetime:
|
||||||
|
return None, "invalid_time_window"
|
||||||
|
if risk_modifier < -50 or risk_modifier > 50:
|
||||||
|
return None, "invalid_risk_modifier"
|
||||||
|
return {
|
||||||
|
"event_type": event_type,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"risk_modifier": risk_modifier,
|
||||||
|
"start_datetime": start_datetime,
|
||||||
|
"end_datetime": end_datetime,
|
||||||
|
"source": source,
|
||||||
|
}, None
|
||||||
|
|
||||||
|
|
||||||
def fetch_points(sql: str, params: tuple) -> list[tuple[float, float]]:
|
def fetch_points(sql: str, params: tuple) -> list[tuple[float, float]]:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(sql, params).fetchall()
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
|
@ -599,6 +727,293 @@ def handle_tile_proxy(handler: BaseHTTPRequestHandler, path: str) -> None:
|
||||||
binary_response(handler, HTTPStatus.OK, payload, "image/png", cache_control="public, max-age=43200")
|
binary_response(handler, HTTPStatus.OK, payload, "image/png", cache_control="public, max-age=43200")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_admin_bootstrap_status(handler: BaseHTTPRequestHandler) -> None:
|
||||||
|
json_response(
|
||||||
|
handler,
|
||||||
|
HTTPStatus.OK,
|
||||||
|
{
|
||||||
|
"initialized": admin_exists(),
|
||||||
|
"session_hours": ADMIN_SESSION_HOURS,
|
||||||
|
"password_policy": {"min_length": 10},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_admin_bootstrap(handler: BaseHTTPRequestHandler) -> None:
|
||||||
|
try:
|
||||||
|
body = read_json(handler)
|
||||||
|
except Exception:
|
||||||
|
json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "invalid_json"})
|
||||||
|
return
|
||||||
|
if not require_fields(handler, body, ["username", "password"]):
|
||||||
|
return
|
||||||
|
|
||||||
|
ok, status = create_admin_user(body["username"], body["password"])
|
||||||
|
if not ok:
|
||||||
|
json_response(handler, HTTPStatus.BAD_REQUEST, {"error": status})
|
||||||
|
return
|
||||||
|
session = create_admin_session(body["username"].strip())
|
||||||
|
json_response(handler, HTTPStatus.CREATED, {"created": True, "session": session})
|
||||||
|
|
||||||
|
|
||||||
|
def handle_admin_login(handler: BaseHTTPRequestHandler) -> None:
|
||||||
|
try:
|
||||||
|
body = read_json(handler)
|
||||||
|
except Exception:
|
||||||
|
json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "invalid_json"})
|
||||||
|
return
|
||||||
|
if not require_fields(handler, body, ["username", "password"]):
|
||||||
|
return
|
||||||
|
if not admin_exists():
|
||||||
|
json_response(handler, HTTPStatus.PRECONDITION_FAILED, {"error": "admin_not_initialized"})
|
||||||
|
return
|
||||||
|
|
||||||
|
admin = get_admin_user()
|
||||||
|
if not admin:
|
||||||
|
json_response(handler, HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "admin_lookup_failed"})
|
||||||
|
return
|
||||||
|
if body["username"].strip() != admin["username"]:
|
||||||
|
json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": "invalid_credentials"})
|
||||||
|
return
|
||||||
|
|
||||||
|
expected = pbkdf2_hash(body["password"], admin["password_salt"])
|
||||||
|
if not hmac.compare_digest(expected, admin["password_hash"]):
|
||||||
|
json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": "invalid_credentials"})
|
||||||
|
return
|
||||||
|
|
||||||
|
session = create_admin_session(admin["username"])
|
||||||
|
json_response(handler, HTTPStatus.OK, {"login": "ok", "session": session})
|
||||||
|
|
||||||
|
|
||||||
|
def handle_admin_logout(handler: BaseHTTPRequestHandler) -> None:
|
||||||
|
auth_header = handler.headers.get("Authorization", "")
|
||||||
|
token = auth_header.replace("Bearer ", "", 1).strip() if auth_header.startswith("Bearer ") else ""
|
||||||
|
if token:
|
||||||
|
ADMIN_SESSIONS.pop(token, None)
|
||||||
|
json_response(handler, HTTPStatus.OK, {"logout": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
def handle_admin_overview(handler: BaseHTTPRequestHandler) -> None:
|
||||||
|
ok, username = admin_auth(handler)
|
||||||
|
if not ok:
|
||||||
|
json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": username})
|
||||||
|
return
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
counts = {
|
||||||
|
"spots": conn.execute("SELECT COUNT(*) AS c FROM spot").fetchone()["c"],
|
||||||
|
"signals": conn.execute("SELECT COUNT(*) AS c FROM community_signal").fetchone()["c"],
|
||||||
|
"events": conn.execute("SELECT COUNT(*) AS c FROM open_data_event").fetchone()["c"],
|
||||||
|
"data_sources": conn.execute("SELECT COUNT(*) AS c FROM data_source_state").fetchone()["c"],
|
||||||
|
}
|
||||||
|
latest_signals = [dict(r) for r in conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT spot_id, signal_type, timestamp
|
||||||
|
FROM community_signal
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
).fetchall()]
|
||||||
|
latest_events = [dict(r) for r in conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, event_type, lat, lon, start_datetime, end_datetime, risk_modifier, source, imported_at
|
||||||
|
FROM open_data_event
|
||||||
|
ORDER BY start_datetime DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
).fetchall()]
|
||||||
|
sources = [dict(r) for r in conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT source_name, imported_at, record_count, notes
|
||||||
|
FROM data_source_state
|
||||||
|
ORDER BY imported_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
).fetchall()]
|
||||||
|
json_response(
|
||||||
|
handler,
|
||||||
|
HTTPStatus.OK,
|
||||||
|
{
|
||||||
|
"admin_user": username,
|
||||||
|
"counts": counts,
|
||||||
|
"latest_signals": latest_signals,
|
||||||
|
"latest_events": latest_events,
|
||||||
|
"data_sources": sources,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_admin_events_list(handler: BaseHTTPRequestHandler, query: dict[str, list[str]]) -> None:
|
||||||
|
ok, username = admin_auth(handler)
|
||||||
|
if not ok:
|
||||||
|
json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": username})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
limit = int((query.get("limit") or ["100"])[0])
|
||||||
|
except Exception:
|
||||||
|
limit = 100
|
||||||
|
limit = max(1, min(limit, 500))
|
||||||
|
with get_conn() as conn:
|
||||||
|
events = [dict(r) for r in conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, event_type, lat, lon, start_datetime, end_datetime, risk_modifier, source, imported_at
|
||||||
|
FROM open_data_event
|
||||||
|
ORDER BY start_datetime DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()]
|
||||||
|
json_response(handler, HTTPStatus.OK, {"admin_user": username, "events": events})
|
||||||
|
|
||||||
|
|
||||||
|
def handle_admin_events_create(handler: BaseHTTPRequestHandler) -> None:
|
||||||
|
ok, username = admin_auth(handler)
|
||||||
|
if not ok:
|
||||||
|
json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": username})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
body = read_json(handler)
|
||||||
|
except Exception:
|
||||||
|
json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "invalid_json"})
|
||||||
|
return
|
||||||
|
event, error = parse_admin_event(body)
|
||||||
|
if error:
|
||||||
|
json_response(handler, HTTPStatus.BAD_REQUEST, {"error": error})
|
||||||
|
return
|
||||||
|
|
||||||
|
now_iso = to_iso(utc_now())
|
||||||
|
event_id = str(uuid.uuid4())
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO open_data_event (
|
||||||
|
id, event_type, lat, lon, start_datetime, end_datetime, risk_modifier, source, imported_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
event_id,
|
||||||
|
event["event_type"],
|
||||||
|
event["lat"],
|
||||||
|
event["lon"],
|
||||||
|
event["start_datetime"],
|
||||||
|
event["end_datetime"],
|
||||||
|
event["risk_modifier"],
|
||||||
|
event["source"],
|
||||||
|
now_iso,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
count = conn.execute("SELECT COUNT(*) AS c FROM open_data_event WHERE source = ?", (event["source"],)).fetchone()["c"]
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO data_source_state (source_name, imported_at, record_count, notes)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(source_name) DO UPDATE SET
|
||||||
|
imported_at = excluded.imported_at,
|
||||||
|
record_count = excluded.record_count,
|
||||||
|
notes = excluded.notes
|
||||||
|
""",
|
||||||
|
(event["source"], now_iso, count, f"manual update by {username}"),
|
||||||
|
)
|
||||||
|
json_response(handler, HTTPStatus.CREATED, {"created": True, "id": event_id})
|
||||||
|
|
||||||
|
|
||||||
|
def handle_admin_events_update(handler: BaseHTTPRequestHandler, event_id: str) -> None:
|
||||||
|
ok, username = admin_auth(handler)
|
||||||
|
if not ok:
|
||||||
|
json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": username})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
body = read_json(handler)
|
||||||
|
except Exception:
|
||||||
|
json_response(handler, HTTPStatus.BAD_REQUEST, {"error": "invalid_json"})
|
||||||
|
return
|
||||||
|
event, error = parse_admin_event(body)
|
||||||
|
if error:
|
||||||
|
json_response(handler, HTTPStatus.BAD_REQUEST, {"error": error})
|
||||||
|
return
|
||||||
|
|
||||||
|
now_iso = to_iso(utc_now())
|
||||||
|
with get_conn() as conn:
|
||||||
|
existing = conn.execute("SELECT source FROM open_data_event WHERE id = ?", (event_id,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
json_response(handler, HTTPStatus.NOT_FOUND, {"error": "event_not_found"})
|
||||||
|
return
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE open_data_event
|
||||||
|
SET event_type = ?, lat = ?, lon = ?, start_datetime = ?, end_datetime = ?, risk_modifier = ?, source = ?, imported_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
event["event_type"],
|
||||||
|
event["lat"],
|
||||||
|
event["lon"],
|
||||||
|
event["start_datetime"],
|
||||||
|
event["end_datetime"],
|
||||||
|
event["risk_modifier"],
|
||||||
|
event["source"],
|
||||||
|
now_iso,
|
||||||
|
event_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
count_new = conn.execute("SELECT COUNT(*) AS c FROM open_data_event WHERE source = ?", (event["source"],)).fetchone()["c"]
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO data_source_state (source_name, imported_at, record_count, notes)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(source_name) DO UPDATE SET
|
||||||
|
imported_at = excluded.imported_at,
|
||||||
|
record_count = excluded.record_count,
|
||||||
|
notes = excluded.notes
|
||||||
|
""",
|
||||||
|
(event["source"], now_iso, count_new, f"manual update by {username}"),
|
||||||
|
)
|
||||||
|
old_source = existing["source"]
|
||||||
|
if old_source != event["source"]:
|
||||||
|
count_old = conn.execute("SELECT COUNT(*) AS c FROM open_data_event WHERE source = ?", (old_source,)).fetchone()["c"]
|
||||||
|
if count_old == 0:
|
||||||
|
conn.execute("DELETE FROM data_source_state WHERE source_name = ?", (old_source,))
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE data_source_state
|
||||||
|
SET imported_at = ?, record_count = ?, notes = ?
|
||||||
|
WHERE source_name = ?
|
||||||
|
""",
|
||||||
|
(now_iso, count_old, f"manual update by {username}", old_source),
|
||||||
|
)
|
||||||
|
json_response(handler, HTTPStatus.OK, {"updated": True, "id": event_id})
|
||||||
|
|
||||||
|
|
||||||
|
def handle_admin_events_delete(handler: BaseHTTPRequestHandler, event_id: str) -> None:
|
||||||
|
ok, username = admin_auth(handler)
|
||||||
|
if not ok:
|
||||||
|
json_response(handler, HTTPStatus.UNAUTHORIZED, {"error": username})
|
||||||
|
return
|
||||||
|
|
||||||
|
now_iso = to_iso(utc_now())
|
||||||
|
with get_conn() as conn:
|
||||||
|
existing = conn.execute("SELECT source FROM open_data_event WHERE id = ?", (event_id,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
json_response(handler, HTTPStatus.NOT_FOUND, {"error": "event_not_found"})
|
||||||
|
return
|
||||||
|
source_name = existing["source"]
|
||||||
|
conn.execute("DELETE FROM open_data_event WHERE id = ?", (event_id,))
|
||||||
|
count = conn.execute("SELECT COUNT(*) AS c FROM open_data_event WHERE source = ?", (source_name,)).fetchone()["c"]
|
||||||
|
if count == 0:
|
||||||
|
conn.execute("DELETE FROM data_source_state WHERE source_name = ?", (source_name,))
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE data_source_state
|
||||||
|
SET imported_at = ?, record_count = ?, notes = ?
|
||||||
|
WHERE source_name = ?
|
||||||
|
""",
|
||||||
|
(now_iso, count, f"manual delete by {username}", source_name),
|
||||||
|
)
|
||||||
|
json_response(handler, HTTPStatus.OK, {"deleted": True, "id": event_id})
|
||||||
|
|
||||||
|
|
||||||
class StaySenseHandler(BaseHTTPRequestHandler):
|
class StaySenseHandler(BaseHTTPRequestHandler):
|
||||||
def log_message(self, format: str, *args) -> None:
|
def log_message(self, format: str, *args) -> None:
|
||||||
return
|
return
|
||||||
|
|
@ -620,6 +1035,16 @@ class StaySenseHandler(BaseHTTPRequestHandler):
|
||||||
if parsed.path.startswith("/map/tile/"):
|
if parsed.path.startswith("/map/tile/"):
|
||||||
handle_tile_proxy(self, parsed.path)
|
handle_tile_proxy(self, parsed.path)
|
||||||
return
|
return
|
||||||
|
if parsed.path == "/admin/bootstrap/status":
|
||||||
|
handle_admin_bootstrap_status(self)
|
||||||
|
return
|
||||||
|
if parsed.path == "/admin/overview":
|
||||||
|
handle_admin_overview(self)
|
||||||
|
return
|
||||||
|
if parsed.path == "/admin/events":
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
handle_admin_events_list(self, query)
|
||||||
|
return
|
||||||
json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"})
|
json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"})
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
|
|
@ -627,6 +1052,40 @@ class StaySenseHandler(BaseHTTPRequestHandler):
|
||||||
if parsed.path == "/spot/signal":
|
if parsed.path == "/spot/signal":
|
||||||
handle_signal(self)
|
handle_signal(self)
|
||||||
return
|
return
|
||||||
|
if parsed.path == "/admin/bootstrap":
|
||||||
|
handle_admin_bootstrap(self)
|
||||||
|
return
|
||||||
|
if parsed.path == "/admin/login":
|
||||||
|
handle_admin_login(self)
|
||||||
|
return
|
||||||
|
if parsed.path == "/admin/logout":
|
||||||
|
handle_admin_logout(self)
|
||||||
|
return
|
||||||
|
if parsed.path == "/admin/events":
|
||||||
|
handle_admin_events_create(self)
|
||||||
|
return
|
||||||
|
json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"})
|
||||||
|
|
||||||
|
def do_PUT(self) -> None:
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path.startswith("/admin/events/"):
|
||||||
|
event_id = parsed.path.replace("/admin/events/", "", 1).strip()
|
||||||
|
if not event_id:
|
||||||
|
json_response(self, HTTPStatus.BAD_REQUEST, {"error": "event_id_required"})
|
||||||
|
return
|
||||||
|
handle_admin_events_update(self, event_id)
|
||||||
|
return
|
||||||
|
json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"})
|
||||||
|
|
||||||
|
def do_DELETE(self) -> None:
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path.startswith("/admin/events/"):
|
||||||
|
event_id = parsed.path.replace("/admin/events/", "", 1).strip()
|
||||||
|
if not event_id:
|
||||||
|
json_response(self, HTTPStatus.BAD_REQUEST, {"error": "event_id_required"})
|
||||||
|
return
|
||||||
|
handle_admin_events_delete(self, event_id)
|
||||||
|
return
|
||||||
json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"})
|
json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
311
src/app.js
311
src/app.js
|
|
@ -8,6 +8,7 @@ const DEVICE_TOKEN_KEY = "staysense.device_token.v1";
|
||||||
const SETTINGS_KEY = "staysense.settings.v1";
|
const SETTINGS_KEY = "staysense.settings.v1";
|
||||||
const SCORE_CACHE_KEY = "staysense.score_cache.v1";
|
const SCORE_CACHE_KEY = "staysense.score_cache.v1";
|
||||||
const SIGNAL_QUEUE_KEY = "staysense.signal_queue.v1";
|
const SIGNAL_QUEUE_KEY = "staysense.signal_queue.v1";
|
||||||
|
const ADMIN_TOKEN_KEY = "staysense.admin_token.v1";
|
||||||
const MAX_CACHE_ITEMS = 50;
|
const MAX_CACHE_ITEMS = 50;
|
||||||
|
|
||||||
const latEl = document.getElementById("lat");
|
const latEl = document.getElementById("lat");
|
||||||
|
|
@ -31,6 +32,32 @@ const queueStatusEl = document.getElementById("queue-status");
|
||||||
|
|
||||||
const signalsEnabledEl = document.getElementById("signals-enabled");
|
const signalsEnabledEl = document.getElementById("signals-enabled");
|
||||||
const legalOutputEl = document.getElementById("legal-output");
|
const legalOutputEl = document.getElementById("legal-output");
|
||||||
|
const adminSetupEl = document.getElementById("admin-setup");
|
||||||
|
const adminLoginEl = document.getElementById("admin-login");
|
||||||
|
const adminContentEl = document.getElementById("admin-content");
|
||||||
|
const adminStatusEl = document.getElementById("admin-status");
|
||||||
|
const adminCountsEl = document.getElementById("admin-counts");
|
||||||
|
const adminEventsEl = document.getElementById("admin-events");
|
||||||
|
const adminSignalsEl = document.getElementById("admin-signals");
|
||||||
|
const adminSourcesEl = document.getElementById("admin-sources");
|
||||||
|
const adminSetupUserEl = document.getElementById("admin-setup-user");
|
||||||
|
const adminSetupPassEl = document.getElementById("admin-setup-pass");
|
||||||
|
const adminSetupSubmitEl = document.getElementById("admin-setup-submit");
|
||||||
|
const adminLoginUserEl = document.getElementById("admin-login-user");
|
||||||
|
const adminLoginPassEl = document.getElementById("admin-login-pass");
|
||||||
|
const adminLoginSubmitEl = document.getElementById("admin-login-submit");
|
||||||
|
const adminLogoutEl = document.getElementById("admin-logout");
|
||||||
|
const adminRefreshEl = document.getElementById("admin-refresh");
|
||||||
|
const adminEventCreateEl = document.getElementById("admin-event-create");
|
||||||
|
const adminEventDeleteEl = document.getElementById("admin-event-delete");
|
||||||
|
const adminEventIdEl = document.getElementById("admin-event-id");
|
||||||
|
const adminEventTypeEl = document.getElementById("admin-event-type");
|
||||||
|
const adminEventRiskEl = document.getElementById("admin-event-risk");
|
||||||
|
const adminEventLatEl = document.getElementById("admin-event-lat");
|
||||||
|
const adminEventLonEl = document.getElementById("admin-event-lon");
|
||||||
|
const adminEventStartEl = document.getElementById("admin-event-start");
|
||||||
|
const adminEventEndEl = document.getElementById("admin-event-end");
|
||||||
|
const adminEventSourceEl = document.getElementById("admin-event-source");
|
||||||
|
|
||||||
let currentSpot = null;
|
let currentSpot = null;
|
||||||
let scoreCache = loadJSON(SCORE_CACHE_KEY, []);
|
let scoreCache = loadJSON(SCORE_CACHE_KEY, []);
|
||||||
|
|
@ -43,6 +70,7 @@ let map = null;
|
||||||
let mapMarker = null;
|
let mapMarker = null;
|
||||||
let searchResults = [];
|
let searchResults = [];
|
||||||
let selectedSearchIndex = -1;
|
let selectedSearchIndex = -1;
|
||||||
|
let adminToken = localStorage.getItem(ADMIN_TOKEN_KEY) || "";
|
||||||
|
|
||||||
const deviceToken = ensureDeviceToken();
|
const deviceToken = ensureDeviceToken();
|
||||||
initialize();
|
initialize();
|
||||||
|
|
@ -90,6 +118,14 @@ function initialize() {
|
||||||
legalOutputEl.textContent = "MVP-Hinweis: Impressum im Produktionsbetrieb verpflichtend mit Anbieterkennzeichnung.";
|
legalOutputEl.textContent = "MVP-Hinweis: Impressum im Produktionsbetrieb verpflichtend mit Anbieterkennzeichnung.";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
adminSetupSubmitEl.addEventListener("click", adminBootstrap);
|
||||||
|
adminLoginSubmitEl.addEventListener("click", adminLogin);
|
||||||
|
adminLogoutEl.addEventListener("click", adminLogout);
|
||||||
|
adminRefreshEl.addEventListener("click", loadAdminOverview);
|
||||||
|
adminEventCreateEl.addEventListener("click", saveAdminEvent);
|
||||||
|
adminEventDeleteEl.addEventListener("click", deleteAdminEvent);
|
||||||
|
adminEventsEl.addEventListener("click", onAdminEventListClick);
|
||||||
|
|
||||||
// Pilotwert für Mettmann, falls noch keine Eingabe.
|
// Pilotwert für Mettmann, falls noch keine Eingabe.
|
||||||
if (!latEl.value && !lonEl.value) {
|
if (!latEl.value && !lonEl.value) {
|
||||||
latEl.value = "51.2500";
|
latEl.value = "51.2500";
|
||||||
|
|
@ -102,6 +138,7 @@ function initialize() {
|
||||||
renderQueueStatus();
|
renderQueueStatus();
|
||||||
checkApiHealth();
|
checkApiHealth();
|
||||||
setInterval(checkApiHealth, 30000);
|
setInterval(checkApiHealth, 30000);
|
||||||
|
loadAdminBootstrapStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDeviceToken() {
|
function ensureDeviceToken() {
|
||||||
|
|
@ -145,17 +182,30 @@ async function checkApiHealth() {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("health_failed");
|
throw new Error("health_failed");
|
||||||
}
|
}
|
||||||
|
const payload = await response.json();
|
||||||
apiOnline = true;
|
apiOnline = true;
|
||||||
lastHealthLatencyMs = Math.round(performance.now() - started);
|
lastHealthLatencyMs = Math.round(performance.now() - started);
|
||||||
lastHealthCheckAt = new Date().toISOString();
|
lastHealthCheckAt = new Date().toISOString();
|
||||||
|
renderDataStatusFromHealth(payload && payload.health ? payload.health : null);
|
||||||
} catch {
|
} catch {
|
||||||
apiOnline = false;
|
apiOnline = false;
|
||||||
lastHealthLatencyMs = null;
|
lastHealthLatencyMs = null;
|
||||||
lastHealthCheckAt = new Date().toISOString();
|
lastHealthCheckAt = new Date().toISOString();
|
||||||
|
dataStatusEl.textContent = "Datenstand: aktuell nicht abrufbar (API offline)";
|
||||||
}
|
}
|
||||||
renderNetworkStatus();
|
renderNetworkStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderDataStatusFromHealth(health) {
|
||||||
|
if (!health || !health.has_data) {
|
||||||
|
dataStatusEl.textContent = "Datenstand: keine Quellenmetadaten";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const freshness = `freshest ${health.freshest_age_hours}h, stalest ${health.stalest_age_hours}h`;
|
||||||
|
const stale = health.stale_sources && health.stale_sources.length ? `, stale: ${health.stale_sources.join(", ")}` : "";
|
||||||
|
dataStatusEl.textContent = `Datenstand: ${freshness}${stale}`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderQueueStatus() {
|
function renderQueueStatus() {
|
||||||
queueStatusEl.textContent = `Warteschlange: ${signalQueue.length} ausstehend`;
|
queueStatusEl.textContent = `Warteschlange: ${signalQueue.length} ausstehend`;
|
||||||
}
|
}
|
||||||
|
|
@ -282,6 +332,267 @@ async function searchLocation() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAdminBootstrapStatus() {
|
||||||
|
adminStatusEl.textContent = "Admin-Status wird geladen ...";
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/admin/bootstrap/status`, { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("bootstrap_status_failed");
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
renderAdminMode(payload.initialized);
|
||||||
|
if (!payload.initialized) {
|
||||||
|
adminStatusEl.textContent = "Erst-Setup erforderlich: Bitte initialen Admin anlegen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
adminStatusEl.textContent = adminToken
|
||||||
|
? "Admin bereit. Session wird geprüft ..."
|
||||||
|
: "Admin bereit. Bitte anmelden.";
|
||||||
|
if (adminToken) {
|
||||||
|
await loadAdminOverview();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
renderAdminMode(false);
|
||||||
|
adminStatusEl.textContent = "Admin-Status konnte nicht geladen werden.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdminMode(initialized) {
|
||||||
|
adminSetupEl.classList.toggle("hidden", initialized);
|
||||||
|
adminLoginEl.classList.toggle("hidden", !initialized);
|
||||||
|
if (!initialized) {
|
||||||
|
adminContentEl.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminHeaders() {
|
||||||
|
return adminToken ? { Authorization: `Bearer ${adminToken}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminBootstrap() {
|
||||||
|
const username = adminSetupUserEl.value.trim();
|
||||||
|
const password = adminSetupPassEl.value;
|
||||||
|
if (username.length < 3 || password.length < 10) {
|
||||||
|
adminStatusEl.textContent = "Setup fehlgeschlagen: User mind. 3 Zeichen, Passwort mind. 10 Zeichen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
adminSetupSubmitEl.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/admin/bootstrap`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || "setup_failed");
|
||||||
|
}
|
||||||
|
adminToken = payload.session.token;
|
||||||
|
localStorage.setItem(ADMIN_TOKEN_KEY, adminToken);
|
||||||
|
adminSetupPassEl.value = "";
|
||||||
|
adminLoginUserEl.value = username;
|
||||||
|
adminStatusEl.textContent = "Admin wurde angelegt und eingeloggt.";
|
||||||
|
renderAdminMode(true);
|
||||||
|
await loadAdminOverview();
|
||||||
|
} catch (error) {
|
||||||
|
adminStatusEl.textContent = `Setup fehlgeschlagen: ${String(error.message || "unbekannter Fehler")}`;
|
||||||
|
} finally {
|
||||||
|
adminSetupSubmitEl.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminLogin() {
|
||||||
|
const username = adminLoginUserEl.value.trim();
|
||||||
|
const password = adminLoginPassEl.value;
|
||||||
|
if (!username || !password) {
|
||||||
|
adminStatusEl.textContent = "Bitte User und Passwort eingeben.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
adminLoginSubmitEl.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/admin/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || "login_failed");
|
||||||
|
}
|
||||||
|
adminToken = payload.session.token;
|
||||||
|
localStorage.setItem(ADMIN_TOKEN_KEY, adminToken);
|
||||||
|
adminLoginPassEl.value = "";
|
||||||
|
adminStatusEl.textContent = "Admin Login erfolgreich.";
|
||||||
|
await loadAdminOverview();
|
||||||
|
} catch (error) {
|
||||||
|
adminStatusEl.textContent = `Login fehlgeschlagen: ${String(error.message || "unbekannter Fehler")}`;
|
||||||
|
} finally {
|
||||||
|
adminLoginSubmitEl.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminLogout() {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/admin/logout`, { method: "POST", headers: adminHeaders() });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
adminToken = "";
|
||||||
|
localStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||||
|
adminContentEl.classList.add("hidden");
|
||||||
|
adminStatusEl.textContent = "Abgemeldet.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdminList(target, rows, mapper) {
|
||||||
|
target.innerHTML = "";
|
||||||
|
if (!rows || !rows.length) {
|
||||||
|
target.textContent = "Keine Daten.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "admin-list-item";
|
||||||
|
if (row && row.id) {
|
||||||
|
div.dataset.eventId = row.id;
|
||||||
|
div.title = "Klick setzt Event-ID";
|
||||||
|
}
|
||||||
|
div.textContent = mapper(row);
|
||||||
|
target.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminOverview() {
|
||||||
|
if (!adminToken) {
|
||||||
|
adminStatusEl.textContent = "Nicht eingeloggt.";
|
||||||
|
adminContentEl.classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/admin/overview`, { headers: adminHeaders() });
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (response.status === 401) {
|
||||||
|
adminToken = "";
|
||||||
|
localStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||||
|
adminContentEl.classList.add("hidden");
|
||||||
|
adminStatusEl.textContent = "Session abgelaufen. Bitte erneut anmelden.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || "admin_overview_failed");
|
||||||
|
}
|
||||||
|
adminContentEl.classList.remove("hidden");
|
||||||
|
adminStatusEl.textContent = `Eingeloggt als ${payload.admin_user}.`;
|
||||||
|
adminCountsEl.textContent = `Spots: ${payload.counts.spots} | Signale: ${payload.counts.signals} | Events: ${payload.counts.events} | Quellen: ${payload.counts.data_sources}`;
|
||||||
|
renderAdminList(
|
||||||
|
adminEventsEl,
|
||||||
|
payload.latest_events,
|
||||||
|
(row) =>
|
||||||
|
`${row.id} | ${row.event_type} | ${Number(row.lat).toFixed(5)}, ${Number(row.lon).toFixed(5)} | ${row.start_datetime} -> ${row.end_datetime} | risk ${row.risk_modifier} | ${row.source}`
|
||||||
|
);
|
||||||
|
renderAdminList(
|
||||||
|
adminSignalsEl,
|
||||||
|
payload.latest_signals,
|
||||||
|
(row) => `${row.timestamp} | ${row.signal_type} | spot ${row.spot_id}`
|
||||||
|
);
|
||||||
|
renderAdminList(
|
||||||
|
adminSourcesEl,
|
||||||
|
payload.data_sources,
|
||||||
|
(row) => `${row.source_name} | ${row.record_count} records | ${row.imported_at} | ${row.notes}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
adminStatusEl.textContent = `Admin-Daten konnten nicht geladen werden: ${String(error.message || "unbekannter Fehler")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdminEventPayload() {
|
||||||
|
return {
|
||||||
|
event_type: adminEventTypeEl.value,
|
||||||
|
risk_modifier: Number(adminEventRiskEl.value),
|
||||||
|
lat: Number(adminEventLatEl.value),
|
||||||
|
lon: Number(adminEventLonEl.value),
|
||||||
|
start_datetime: adminEventStartEl.value.trim(),
|
||||||
|
end_datetime: adminEventEndEl.value.trim(),
|
||||||
|
source: adminEventSourceEl.value.trim() || "admin_manual",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAdminEvent() {
|
||||||
|
if (!adminToken) {
|
||||||
|
adminStatusEl.textContent = "Bitte zuerst anmelden.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const eventId = adminEventIdEl.value.trim();
|
||||||
|
const payload = buildAdminEventPayload();
|
||||||
|
if (!Number.isFinite(payload.lat) || !Number.isFinite(payload.lon)) {
|
||||||
|
adminStatusEl.textContent = "Ungültige Event-Koordinaten.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const method = eventId ? "PUT" : "POST";
|
||||||
|
const url = eventId ? `${API_BASE}/admin/events/${encodeURIComponent(eventId)}` : `${API_BASE}/admin/events`;
|
||||||
|
adminEventCreateEl.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { ...adminHeaders(), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body.error || "event_save_failed");
|
||||||
|
}
|
||||||
|
adminStatusEl.textContent = eventId ? `Event aktualisiert: ${eventId}` : `Event angelegt: ${body.id}`;
|
||||||
|
if (!eventId && body.id) {
|
||||||
|
adminEventIdEl.value = body.id;
|
||||||
|
}
|
||||||
|
await loadAdminOverview();
|
||||||
|
} catch (error) {
|
||||||
|
adminStatusEl.textContent = `Event konnte nicht gespeichert werden: ${String(error.message || "unbekannter Fehler")}`;
|
||||||
|
} finally {
|
||||||
|
adminEventCreateEl.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAdminEvent() {
|
||||||
|
if (!adminToken) {
|
||||||
|
adminStatusEl.textContent = "Bitte zuerst anmelden.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const eventId = adminEventIdEl.value.trim();
|
||||||
|
if (!eventId) {
|
||||||
|
adminStatusEl.textContent = "Bitte Event-ID zum Löschen eingeben.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
adminEventDeleteEl.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/admin/events/${encodeURIComponent(eventId)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: adminHeaders(),
|
||||||
|
});
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body.error || "event_delete_failed");
|
||||||
|
}
|
||||||
|
adminStatusEl.textContent = `Event gelöscht: ${eventId}`;
|
||||||
|
adminEventIdEl.value = "";
|
||||||
|
await loadAdminOverview();
|
||||||
|
} catch (error) {
|
||||||
|
adminStatusEl.textContent = `Event konnte nicht gelöscht werden: ${String(error.message || "unbekannter Fehler")}`;
|
||||||
|
} finally {
|
||||||
|
adminEventDeleteEl.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAdminEventListClick(event) {
|
||||||
|
const row = event.target.closest(".admin-list-item");
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.dataset.eventId) {
|
||||||
|
adminEventIdEl.value = row.dataset.eventId;
|
||||||
|
adminStatusEl.textContent = `Event-ID übernommen: ${row.dataset.eventId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderSearchResults() {
|
function renderSearchResults() {
|
||||||
searchResultsEl.innerHTML = "";
|
searchResultsEl.innerHTML = "";
|
||||||
searchResults.forEach((result, index) => {
|
searchResults.forEach((result, index) => {
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,103 @@
|
||||||
<small id="queue-status">Warteschlange: 0 ausstehend</small>
|
<small id="queue-status">Warteschlange: 0 ausstehend</small>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel admin-panel">
|
||||||
|
<h2>Admin</h2>
|
||||||
|
<p class="small">Geschützter Bereich für Datenansicht und Event-Verwaltung.</p>
|
||||||
|
|
||||||
|
<div id="admin-setup" class="admin-block hidden">
|
||||||
|
<h3>Erst-Setup</h3>
|
||||||
|
<label>
|
||||||
|
Admin User
|
||||||
|
<input id="admin-setup-user" type="text" placeholder="admin" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Admin Passwort
|
||||||
|
<input id="admin-setup-pass" type="password" placeholder="mind. 10 Zeichen" />
|
||||||
|
</label>
|
||||||
|
<button id="admin-setup-submit" class="btn">Admin anlegen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="admin-login" class="admin-block hidden">
|
||||||
|
<h3>Login</h3>
|
||||||
|
<label>
|
||||||
|
User
|
||||||
|
<input id="admin-login-user" type="text" placeholder="admin" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Passwort
|
||||||
|
<input id="admin-login-pass" type="password" />
|
||||||
|
</label>
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="admin-login-submit" class="btn">Anmelden</button>
|
||||||
|
<button id="admin-logout" class="btn secondary">Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small id="admin-status">Admin-Status wird geladen ...</small>
|
||||||
|
|
||||||
|
<div id="admin-content" class="admin-block hidden">
|
||||||
|
<h3>Übersicht</h3>
|
||||||
|
<div id="admin-counts" class="admin-counts">-</div>
|
||||||
|
|
||||||
|
<h3>Event-Verwaltung</h3>
|
||||||
|
<label>
|
||||||
|
Event-ID (leer = neu)
|
||||||
|
<input id="admin-event-id" type="text" placeholder="UUID für Update/Delete" />
|
||||||
|
</label>
|
||||||
|
<div class="field-grid">
|
||||||
|
<label>
|
||||||
|
Typ
|
||||||
|
<select id="admin-event-type">
|
||||||
|
<option value="event">event</option>
|
||||||
|
<option value="market">market</option>
|
||||||
|
<option value="waste">waste</option>
|
||||||
|
<option value="construction">construction</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Risk Modifier
|
||||||
|
<input id="admin-event-risk" type="number" value="-10" min="-50" max="50" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Latitude
|
||||||
|
<input id="admin-event-lat" type="number" step="0.000001" placeholder="51.250000" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Longitude
|
||||||
|
<input id="admin-event-lon" type="number" step="0.000001" placeholder="6.973000" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Start (ISO)
|
||||||
|
<input id="admin-event-start" type="text" placeholder="2026-02-15T20:00:00Z" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Ende (ISO)
|
||||||
|
<input id="admin-event-end" type="text" placeholder="2026-02-16T06:00:00Z" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Source
|
||||||
|
<input id="admin-event-source" type="text" value="admin_manual" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="admin-event-create" class="btn">Event speichern</button>
|
||||||
|
<button id="admin-event-delete" class="btn secondary">Event löschen</button>
|
||||||
|
<button id="admin-refresh" class="btn secondary">Daten neu laden</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Letzte Events</h3>
|
||||||
|
<div id="admin-events" class="admin-list"></div>
|
||||||
|
|
||||||
|
<h3>Letzte Signale</h3>
|
||||||
|
<div id="admin-signals" class="admin-list"></div>
|
||||||
|
|
||||||
|
<h3>Datenquellen</h3>
|
||||||
|
<div id="admin-sources" class="admin-list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel settings-panel">
|
<section class="panel settings-panel">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<label class="toggle">
|
<label class="toggle">
|
||||||
|
|
|
||||||
|
|
@ -108,11 +108,13 @@ label {
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
|
select,
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input,
|
||||||
|
select {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 9px;
|
padding: 9px;
|
||||||
|
|
@ -281,6 +283,45 @@ input {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-block {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-counts {
|
||||||
|
color: var(--ink);
|
||||||
|
background: #f7fafc;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-item {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 920px) {
|
@media (min-width: 920px) {
|
||||||
.grid {
|
.grid {
|
||||||
grid-template-columns: 1fr 1.2fr 0.9fr;
|
grid-template-columns: 1fr 1.2fr 0.9fr;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue