fix(zombies): "running" fehlte - ein abgestuerzter RIP wurde NIE gefunden
Ampel / ampel (push) Successful in 30s
Ampel / ampel (push) Successful in 30s
Der Commander: "Ausserdem ist gerade mitten im Rip das Laufwerk ausgegangen...
glaube ich zumindest." Gemessen war es etwas anderes, und der Fund ist groesser
als der Vorfall.
WAS MESSBAR WAR:
Job 2182d525 status=running progress=12 (Rip gestartet 14:00:24)
makemkvcon laeuft NICHT (per /proc geprueft, PID gegengeprueft)
Rohdatei 5.167.382.528 Bytes, waechst in 10 s nicht
Laufwerk Status 4 (Disc drin), /dev/sr0 + /dev/sg1 da
Worker-Start 14:07:02 <- mein `docker compose up -d --build`
Das Laufwerk ist also NICHT ausgegangen. Der Rip wurde von MEINEM Deploy
getoetet: `up -d --build` baut den worker-Container neu, und der laufende Rip
stirbt mit ihm. Genau davor warnt der SAVEPOINT seit v3.18 - die Warnung half
nichts, weil sie niemand liest und nichts sie prueft.
DER EIGENTLICHE FUND: Die Zombie-Erkennung lief um 14:09:04 und meldete
`{'geprueft': 0, 'aufgeraeumt': []}` - obwohl der tote Job direkt vor ihr lag.
Ursache:
ARBEITS_STATI = ("ripping", "transcoding", "canceling") # zombies.py
db.update_job(job_id, status="running", ...) # tasks.py - der Rip
Der Rip setzt "running", gesucht wurde "ripping". Dieser Wert steht
ausschliesslich in Celerys Task-META und NIE in einer Job-Zeile (nachgeprueft:
kein einziger Schreiber im ganzen Baum). Die Zombie-Erkennung aus v3.14 wurde
gebaut, um genau einen abgestuerzten Rip zu finden - und hat ihn nie gesehen.
Besonders tueckisch: `geprueft: 0` sah bei jedem Worker-Start wie "nachgesehen,
alles gesund" aus, waehrend sie nach einem Status suchte, den es nicht gibt.
Deshalb blieb der Job auf "processing 12 %" stehen - mit einer Restzeit-Schaetzung
von 1 h 30 min obendrauf, die es fuer einen toten Prozess nicht geben duerfte.
GEBAUT:
* "running" in ARBEITS_STATI.
* Ein Test, der das Auseinanderlaufen MECHANISCH verhindert: Er liest tasks.py,
sammelt jeden Status, den der Worker per db.update_job in eine Job-Zeile
schreibt, und verlangt, dass jeder davon entweder ein Arbeitsstatus oder ein
Endzustand ist. Ein Kommentar haette das nicht verhindert.
* GET /health/arbeit + eine Sperre in deploy.sh: Laeuft ein Job, bricht der
Deploy ab (uebersteuerbar mit RIPPY_TROTZDEM=1 - dann ist es eine
Entscheidung und kein Versehen). Ein Satz Code gegen eine verlorene Stunde.
* Server-Status zeigt jetzt die eingehaengten FREIGABEN mit freiem Platz, nicht
nur die Container-Platte (Commander-Wunsch). Genau dort liegen die Rohdaten,
und bei externem Encoden muessen sie dort liegen - wer wissen wollte, ob noch
Platz fuer eine Disc ist, sah die falsche Zahl.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,28 @@ ENV_SRC="${2:-$HOME/rippy/.env}"
|
|||||||
CLONE="${3:-$HOME/notfall-rippy}"
|
CLONE="${3:-$HOME/notfall-rippy}"
|
||||||
DIENST="$4"
|
DIENST="$4"
|
||||||
|
|
||||||
|
# ⚠️ NICHT DEPLOYEN, WÄHREND EIN JOB LÄUFT (Vorfall 26.07.2026, selbst verursacht)
|
||||||
|
#
|
||||||
|
# `docker compose up -d --build` baut den worker-Container neu — und tötet damit
|
||||||
|
# einen laufenden Rip. Genau das ist passiert: mitten in einem Blu-ray-Rip, bei
|
||||||
|
# 12 %, nach 5,1 GB. Die Warnung stand im SAVEPOINT und half nichts, weil sie
|
||||||
|
# niemand las und nichts sie prüfte. Jetzt prüft es das Skript.
|
||||||
|
#
|
||||||
|
# Übersteuern mit RIPPY_TROTZDEM=1 — dann ist es eine Entscheidung und kein
|
||||||
|
# Versehen.
|
||||||
|
if [ -z "${RIPPY_TROTZDEM:-}" ]; then
|
||||||
|
ANTWORT="$(curl -s -m 5 http://localhost:8000/health/arbeit 2>/dev/null || true)"
|
||||||
|
case "$ANTWORT" in
|
||||||
|
*'"arbeit":true'*|*'"arbeit": true'*)
|
||||||
|
echo "ABBRUCH: Auf dieser Maschine läuft gerade ein Job." >&2
|
||||||
|
echo " Ein Rebuild des worker-Containers würde ihn töten." >&2
|
||||||
|
echo "$ANTWORT" | tr ',' '\n' | grep -E '"(status|titel|progress)"' >&2 || true
|
||||||
|
echo " Warten, oder bewusst überstimmen: RIPPY_TROTZDEM=1 ./deploy.sh" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
if [ ! -d "$CLONE/.git" ]; then
|
if [ ! -d "$CLONE/.git" ]; then
|
||||||
git clone --branch main "$REPO_URL" "$CLONE"
|
git clone --branch main "$REPO_URL" "$CLONE"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -308,6 +308,33 @@ async def health_check():
|
|||||||
return {"status": "ok", "service": "api"}
|
return {"status": "ok", "service": "api"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/arbeit")
|
||||||
|
async def health_arbeit():
|
||||||
|
"""Läuft gerade ein Job? — die Frage VOR einem Deploy.
|
||||||
|
|
||||||
|
⚠️ Warum es das gibt (26.07.2026, selbst verursacht): Ein
|
||||||
|
`docker compose up -d --build` baut den worker-Container neu und tötet damit
|
||||||
|
einen laufenden Rip. Genau das ist passiert — mitten in einem Blu-ray-Rip,
|
||||||
|
bei 12 %, nach 5,1 GB. Im SAVEPOINT stand die Warnung „nicht deployen,
|
||||||
|
während ein Rip läuft" schon; sie half nichts, weil niemand sie las und
|
||||||
|
nichts sie prüfte.
|
||||||
|
|
||||||
|
Jetzt fragt `deploy.sh` hier nach und bricht ab. Ein Satz Code gegen eine
|
||||||
|
verlorene Stunde.
|
||||||
|
"""
|
||||||
|
def sammle():
|
||||||
|
laufend = [
|
||||||
|
{"id": z["id"], "status": z["status"], "progress": z.get("progress") or 0,
|
||||||
|
"titel": z.get("title") or ""}
|
||||||
|
for z in db.list_jobs()
|
||||||
|
if z.get("status") in ("pending", "running", "ripping",
|
||||||
|
"transcoding", "canceling")
|
||||||
|
]
|
||||||
|
return {"arbeit": bool(laufend), "jobs": laufend}
|
||||||
|
|
||||||
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health/vorraete")
|
@app.get("/health/vorraete")
|
||||||
async def health_vorraete():
|
async def health_vorraete():
|
||||||
"""Laufen die Hintergrund-Schleifen wirklich? (Diagnose, kein UI-Endpunkt)
|
"""Laufen die Hintergrund-Schleifen wirklich? (Diagnose, kein UI-Endpunkt)
|
||||||
|
|||||||
@@ -62,6 +62,21 @@ interface LaufwerkLive {
|
|||||||
disc?: { title?: string, year?: number | null, disc_type?: string }
|
disc?: { title?: string, year?: number | null, disc_type?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media
|
||||||
|
// inklusive der eingehängten Netzwerk-Freigaben, mit freiem Platz.
|
||||||
|
//
|
||||||
|
// Commander-Befund 26.07.2026: Der Server-Status zeigte nur „Media
|
||||||
|
// (/app/media)" — also die Container-Platte. Die eingehängte NAS-Freigabe mit
|
||||||
|
// ihren 2,1 TB fehlte, obwohl genau dort die Rohdaten und (bei externem
|
||||||
|
// Encoden) auch die Ablage liegen müssen. Wer wissen will, ob noch Platz für
|
||||||
|
// eine Disc ist, sah die falsche Zahl.
|
||||||
|
interface AblageZiel {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
is_mount: boolean
|
||||||
|
free_gb: number | null
|
||||||
|
}
|
||||||
|
|
||||||
function posterUrl(meta?: JobMeta | null): string | null {
|
function posterUrl(meta?: JobMeta | null): string | null {
|
||||||
const p = meta?.poster_path
|
const p = meta?.poster_path
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
@@ -90,6 +105,7 @@ export default function Dashboard() {
|
|||||||
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
||||||
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
|
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
|
||||||
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
|
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
|
||||||
|
const [ablagen, setAblagen] = useState<AblageZiel[]>([])
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -145,7 +161,7 @@ export default function Dashboard() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [jobsData, sysData, capsData, devData] = await Promise.all([
|
const [jobsData, sysData, capsData, devData, ablData] = await Promise.all([
|
||||||
fetchJobs(),
|
fetchJobs(),
|
||||||
api.get('/system/info').then(r => r.data).catch(() => null),
|
api.get('/system/info').then(r => r.data).catch(() => null),
|
||||||
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
|
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
|
||||||
@@ -156,11 +172,15 @@ export default function Dashboard() {
|
|||||||
// Die Laufwerke: Ohne sie behauptete die Server-Status-Karte
|
// Die Laufwerke: Ohne sie behauptete die Server-Status-Karte
|
||||||
// „keine Disc in Arbeit", während oben die erkannte Disc stand.
|
// „keine Disc in Arbeit", während oben die erkannte Disc stand.
|
||||||
api.get('/devices').then(r => r.data || []).catch(() => []),
|
api.get('/devices').then(r => r.data || []).catch(() => []),
|
||||||
|
// Die Ablageziele inkl. eingehängter Freigaben — ohne sie zeigte der
|
||||||
|
// Server-Status nur die Container-Platte (Commander-Befund).
|
||||||
|
api.get('/storage-targets').then(r => Array.isArray(r.data) ? r.data : []).catch(() => []),
|
||||||
])
|
])
|
||||||
setJobs(jobsData)
|
setJobs(jobsData)
|
||||||
if (sysData) setSystemInfo(sysData)
|
if (sysData) setSystemInfo(sysData)
|
||||||
setWorkersLive(capsData)
|
setWorkersLive(capsData)
|
||||||
setLaufwerke(devData)
|
setLaufwerke(devData)
|
||||||
|
setAblagen(ablData)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -213,6 +233,10 @@ export default function Dashboard() {
|
|||||||
* weiß nicht, welcher er glauben soll.
|
* weiß nicht, welcher er glauben soll.
|
||||||
*/
|
*/
|
||||||
const discImLaufwerk = laufwerke.find(l => l.disc?.title)
|
const discImLaufwerk = laufwerke.find(l => l.disc?.title)
|
||||||
|
// Nur die echten Netzwerk-Freigaben, nicht die normalen Unterordner von
|
||||||
|
// /app/media (movies/series/music liegen auf der Container-Platte und sind
|
||||||
|
// dort schon mitgezählt).
|
||||||
|
const freigaben = ablagen.filter(a => a.is_mount)
|
||||||
const phaseText = aktiverJob
|
const phaseText = aktiverJob
|
||||||
? (aktiverJob.status === 'transcoding'
|
? (aktiverJob.status === 'transcoding'
|
||||||
? 'Kompression läuft (HandBrake)'
|
? 'Kompression läuft (HandBrake)'
|
||||||
@@ -609,11 +633,14 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3. Platz — war schon immer echt */}
|
{/* 3. Platz — Container-Platte UND die eingehängten Freigaben.
|
||||||
|
Vorher stand hier nur die Container-Platte; wer wissen
|
||||||
|
wollte, ob die NAS noch Platz für eine Disc hat, sah die
|
||||||
|
falsche Zahl (Commander-Befund 26.07.2026). */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
<span className="text-slate-400 flex items-center gap-1.5">
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
<HardDrive size={14} className="text-amber-400" /> {mainPlatz?.name || 'Ablage'}:
|
<HardDrive size={14} className="text-amber-400" /> Container-Platte:
|
||||||
</span>
|
</span>
|
||||||
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||||
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
|
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
|
||||||
@@ -627,13 +654,49 @@ export default function Dashboard() {
|
|||||||
style={{ width: `${Math.max(2, belegtPercent)}%` }}
|
style={{ width: `${Math.max(2, belegtPercent)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{freiGb > 0 && freiGb < 60 && (
|
{freiGb > 0 && freiGb < 60 && freigaben.length === 0 && (
|
||||||
<p className="text-[11px] font-mono text-amber-400 mt-1">
|
<p className="text-[11px] font-mono text-amber-400 mt-1">
|
||||||
Eine Blu-ray braucht roh ~40 GB, eine 4K-UHD bis 100 GB —
|
Eine Blu-ray braucht roh ~40 GB, eine 4K-UHD bis 100 GB —
|
||||||
das reicht nicht mehr für jede Disc.
|
das reicht nicht mehr für jede Disc.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Die eingehängten Freigaben. Sie sind der Ort, an dem die
|
||||||
|
Rohdaten liegen und — bei externem Encoden — liegen MÜSSEN. */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
|
<Server size={14} className="text-amber-400" /> Freigaben:
|
||||||
|
</span>
|
||||||
|
<span className={freigaben.length > 0 ? 'text-slate-200' : 'text-slate-400'}>
|
||||||
|
{freigaben.length > 0
|
||||||
|
? `${freigaben.length} eingehängt`
|
||||||
|
: 'keine eingehängt'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{freigaben.length === 0 ? (
|
||||||
|
<p className="text-[11px] font-mono text-slate-500">
|
||||||
|
Ohne Freigabe kann ein externer Encoder nichts tun — er sieht die
|
||||||
|
Container-Platte nicht. Einhängen: Einstellungen → Speicherziele.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{freigaben.map(f => (
|
||||||
|
<p key={f.path} className="text-[11px] font-mono text-slate-400 flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate">
|
||||||
|
<span className="text-emerald-400">●</span> {f.name}
|
||||||
|
</span>
|
||||||
|
<span className={f.free_gb != null && f.free_gb < 60
|
||||||
|
? 'text-amber-400 flex-shrink-0'
|
||||||
|
: 'text-slate-300 flex-shrink-0'}>
|
||||||
|
{f.free_gb != null ? `${f.free_gb} GB frei` : 'Platz unbekannt'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -188,3 +188,54 @@ def test_fehler_reisst_den_worker_start_nicht_mit():
|
|||||||
db = KaputteDb([])
|
db = KaputteDb([])
|
||||||
bericht = zombies.raeume_zombies_auf(FakeCelery(FakeInspektor()), db)
|
bericht = zombies.raeume_zombies_auf(FakeCelery(FakeInspektor()), db)
|
||||||
assert "Postgres weg" in bericht["uebersprungen"]
|
assert "Postgres weg" in bericht["uebersprungen"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Die Luecke, die die Erkennung nutzlos machte (Befund 26.07.2026) --------
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_gilt_als_arbeitsstatus():
|
||||||
|
"""DER Fehler: Die Erkennung suchte "ripping", der Rip setzt aber "running".
|
||||||
|
Ergebnis: Ein abgestuerzter RIP wurde NIE gefunden - und die Meldung
|
||||||
|
`{'geprueft': 0}` sah bei jedem Worker-Start wie Gesundheit aus."""
|
||||||
|
assert "running" in zombies.ARBEITS_STATI
|
||||||
|
assert "transcoding" in zombies.ARBEITS_STATI
|
||||||
|
assert "canceling" in zombies.ARBEITS_STATI
|
||||||
|
|
||||||
|
|
||||||
|
def test_abgestuerzter_rip_wird_gefunden():
|
||||||
|
"""Der konkrete Fall vom 26.07.2026: Job 2182d525 stand auf running/12 %,
|
||||||
|
kein makemkvcon lief, die Rohdatei wuchs nicht mehr - und die Erkennung
|
||||||
|
pruefte null Jobs."""
|
||||||
|
offene = [{"id": "2182d525", "status": "running", "title": "Akira"}]
|
||||||
|
assert zombies.finde_zombies(offene, set()) == offene
|
||||||
|
|
||||||
|
|
||||||
|
def test_arbeitsstati_deckt_ab_was_der_worker_wirklich_schreibt():
|
||||||
|
"""Mechanische Sperre gegen genau dieses Auseinanderlaufen.
|
||||||
|
|
||||||
|
Liest tasks.py und sammelt jeden Status, den der Worker per db.update_job in
|
||||||
|
eine Job-ZEILE schreibt. Jeder davon muss entweder ein Arbeitsstatus sein
|
||||||
|
oder ein Endzustand - sonst gibt es wieder einen Zustand, den niemand
|
||||||
|
aufraeumt. Ein Kommentar haette das nicht verhindert; dieser Test schon.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tasks.py")
|
||||||
|
quelle = open(pfad, encoding="utf-8").read()
|
||||||
|
|
||||||
|
# Nur die Aufrufe, die wirklich die Job-Zeile aendern.
|
||||||
|
geschrieben = set()
|
||||||
|
for aufruf in re.finditer(r"db\.update_job\((?:[^()]|\([^()]*\))*\)", quelle):
|
||||||
|
for treffer in re.finditer(r'status\s*=\s*"([a-z]+)"', aufruf.group(0)):
|
||||||
|
geschrieben.add(treffer.group(1))
|
||||||
|
|
||||||
|
endzustaende = {"completed", "failed"}
|
||||||
|
unbeaufsichtigt = geschrieben - set(zombies.ARBEITS_STATI) - endzustaende
|
||||||
|
assert not unbeaufsichtigt, (
|
||||||
|
f"Diese Job-Status schreibt der Worker, aber niemand raeumt sie auf: "
|
||||||
|
f"{sorted(unbeaufsichtigt)}. Entweder in ARBEITS_STATI aufnehmen oder "
|
||||||
|
f"als Endzustand behandeln."
|
||||||
|
)
|
||||||
|
# Gegenprobe, dass der Test wirklich etwas gesehen hat
|
||||||
|
assert "running" in geschrieben
|
||||||
|
|||||||
@@ -28,7 +28,22 @@ Ampel sie ohne Infrastruktur prüfen kann.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Zustände, die behaupten: hier arbeitet gerade jemand.
|
# Zustände, die behaupten: hier arbeitet gerade jemand.
|
||||||
ARBEITS_STATI = ("ripping", "transcoding", "canceling")
|
#
|
||||||
|
# ⚠️ „running" FEHLTE bis zum 26.07.2026 — und das war der ganze Witz: Die
|
||||||
|
# Zombie-Erkennung aus v3.14 wurde gebaut, um genau einen abgestürzten RIP zu
|
||||||
|
# finden, und hat ihn nie gesehen. Der Rip setzt `status="running"`
|
||||||
|
# (tasks.py, db.update_job), gesucht wurde aber „ripping". Dieser Wert steht
|
||||||
|
# ausschließlich in Celerys Task-Meta und NIE in einer Job-Zeile.
|
||||||
|
#
|
||||||
|
# Die Folge war besonders tückisch: Bei jedem Worker-Start meldete die Erkennung
|
||||||
|
# `{'geprueft': 0, ...}` — und das sah wie „nachgesehen, alles gesund" aus,
|
||||||
|
# während sie in Wahrheit nach einem Status suchte, den es nicht gibt. Gefunden
|
||||||
|
# am 26.07.2026, als ein Rip mitten im Lauf abbrach und der Job danach dauerhaft
|
||||||
|
# auf „processing 12 %" stand, mit einer Restzeit-Schätzung obendrauf.
|
||||||
|
#
|
||||||
|
# „ripping" bleibt bewusst drin: Es schadet nicht und deckt eine etwaige
|
||||||
|
# Bestandsinstallation ab, in der es doch gesetzt wurde.
|
||||||
|
ARBEITS_STATI = ("running", "ripping", "transcoding", "canceling")
|
||||||
|
|
||||||
# Wartezeit nach dem Worker-Start, bevor geurteilt wird. Deckt die
|
# Wartezeit nach dem Worker-Start, bevor geurteilt wird. Deckt die
|
||||||
# Wiederzustellung unbestätigter Aufgaben durch Celery ab.
|
# Wiederzustellung unbestätigter Aufgaben durch Celery ab.
|
||||||
|
|||||||
Reference in New Issue
Block a user