updates: Urteil und Paketliste sichtbar, ehrliche Lade- und Fehlerzustaende, Rueckfragen, Auftraege aller Gruppen
- Update-Details zeigen jetzt das Urteil des Hirns ("Nichts zu tun" / "Du musst
etwas tun" samt Text), die Zusammenfassung ist aufklappbar statt auf drei Zeilen
gekappt, das Betriebssystem zeigt seine Paketliste (und was Ubuntu zurueckhaelt).
- Scheitert eine Pruefung (z. B. git fetch bei Hermes), steht "Konnte nicht pruefen: ..."
statt "- -> neu"; waehrend des Ladens ein Platzhalter statt "..." und kein voreiliges "neu".
- Einzel-Update und Freigeben fragen vorher nach; der Ja-Knopf sperrt gegen Doppelklick.
- Neue Job-Karte (Fortschritt, Abbrechen mit Rueckfrage, letzte Meldung, Protokoll) fuer
alle laufenden Auftraege, egal welche Gruppe. Updates lassen sich bewusst nicht abbrechen.
- Job-Typen an jobengine.py angeglichen ("canceled", group, started_at/finished_at):
abgebrochene Auftraege erschienen bisher als "Laeuft".
- Fehler aus "Jetzt sichern" (Feld error) und "Es laeuft schon ein Update" (status busy)
werden gemeldet; ist ein Auftrag fertig, lesen Versionen, Modelle und Sicherungen neu.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c8718fcde0
commit
5ddc952ecd
@@ -3,16 +3,28 @@
|
||||
// Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur
|
||||
// Sicherheitsnetz und laufen fünfmal seltener.
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { api, post } from "./api"
|
||||
import { fehlerText, laeuftNoch } from "./anzeige"
|
||||
import { melden } from "./meldungen"
|
||||
import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
AktionsErgebnis, AufraeumKandidat, Job, Modell, Nutzung, Radar, Sicherung, Start,
|
||||
AktionsErgebnis, AufraeumKandidat, Job, Modell, Nutzung, Radar, Sicherung, Start, UpdateDetails,
|
||||
} from "./typen"
|
||||
import { gbText } from "./zeit"
|
||||
|
||||
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
|
||||
|
||||
/** Was ein beendeter Auftrag verändert haben kann: Versionen, Modelle, Sicherungen, Platte. */
|
||||
const NACH_AUFTRAG = ["start", "update-details", "updates-verlauf", "models", "sicherungen", "aufraeumen"]
|
||||
|
||||
/** Ist seit der letzten Abfrage ein Auftrag fertig geworden, lesen die betroffenen Ansichten neu. */
|
||||
function auftragsEndeMelden(qc: QueryClient, neu: Job[]) {
|
||||
const alt = qc.getQueryData<{ jobs: Job[] }>(["jobs"])?.jobs ?? []
|
||||
const ebenFertig = neu.some((j) => !laeuftNoch(j) && alt.some((a) => a.id === j.id && laeuftNoch(a)))
|
||||
if (ebenFertig) for (const k of NACH_AUFTRAG) qc.invalidateQueries({ queryKey: [k] })
|
||||
}
|
||||
|
||||
export const useStart = () =>
|
||||
useQuery({
|
||||
queryKey: ["start"],
|
||||
@@ -34,14 +46,19 @@ export const useNutzung = () =>
|
||||
export const useRadar = () =>
|
||||
useQuery({ queryKey: ["radar"], queryFn: () => api<Radar>("/api/radar"), retry: false, refetchInterval: 300_000 })
|
||||
|
||||
export const useJobs = () =>
|
||||
useQuery({
|
||||
export const useJobs = () => {
|
||||
const qc = useQueryClient()
|
||||
return useQuery({
|
||||
queryKey: ["jobs"],
|
||||
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
|
||||
queryFn: async () => {
|
||||
const daten = await api<{ jobs: Job[] }>("/api/jobs")
|
||||
auftragsEndeMelden(qc, daten.jobs)
|
||||
return daten
|
||||
},
|
||||
// Läuft etwas, wollen wir den Fortschritt sehen; sonst reicht der Strom.
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.jobs.some((j) => j.state === "running" || j.state === "queued") ? 2000 : takt(30_000)(),
|
||||
refetchInterval: (q) => (q.state.data?.jobs.some(laeuftNoch) ? 2000 : takt(30_000)()),
|
||||
})
|
||||
}
|
||||
|
||||
export const useSicherungen = () =>
|
||||
useQuery({
|
||||
@@ -49,21 +66,6 @@ export const useSicherungen = () =>
|
||||
queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"),
|
||||
})
|
||||
|
||||
export interface UpdateDetails {
|
||||
kind: string
|
||||
installed_version?: string | null
|
||||
behind?: number
|
||||
installed_build?: number | null
|
||||
latest_build?: number | null
|
||||
latest_tag?: string | null
|
||||
count?: number
|
||||
packages?: { name: string; current: string; candidate: string }[]
|
||||
summary?: string
|
||||
action_needed?: boolean | null
|
||||
action_text?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const useUpdateDetails = (art: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["update-details", art],
|
||||
@@ -108,7 +110,12 @@ export const useDienste = () =>
|
||||
|
||||
function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
|
||||
fn: (v: V) => Promise<E>,
|
||||
opt: { erfolg: (v: V, e: E) => string; neuLaden: string[] },
|
||||
opt: {
|
||||
erfolg: (v: V, e: E) => string
|
||||
/** Eigener Text, wenn die Box mit ok: false antwortet (sonst der Grund aus der Antwort). */
|
||||
fehler?: (v: V, e: AktionsErgebnis) => string
|
||||
neuLaden: string[]
|
||||
},
|
||||
) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
@@ -116,7 +123,7 @@ function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
|
||||
onSuccess: (e, v) => {
|
||||
const ergebnis = e as AktionsErgebnis
|
||||
if (ergebnis && ergebnis.ok === false) {
|
||||
melden("fehler", ergebnis.detail || ergebnis.err || "Das hat nicht geklappt.")
|
||||
melden("fehler", opt.fehler?.(v, ergebnis) ?? fehlerText(ergebnis))
|
||||
} else {
|
||||
melden("erfolg", opt.erfolg(v, e))
|
||||
}
|
||||
@@ -130,7 +137,11 @@ export const useHinweisAktion = () =>
|
||||
useAktion(
|
||||
({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) =>
|
||||
post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(hinweis)}/aktion/${aktion}`),
|
||||
{ erfolg: (v) => `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`, neuLaden: ["start"] },
|
||||
{
|
||||
erfolg: (v, e) =>
|
||||
v.aktion === "freigeben" && e.text ? e.text : `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`,
|
||||
neuLaden: ["start"],
|
||||
},
|
||||
)
|
||||
|
||||
const UPDATE_PFADE: Record<string, string> = {
|
||||
@@ -144,10 +155,34 @@ const UPDATE_PFADE: Record<string, string> = {
|
||||
|
||||
export const useUpdateStarten = () =>
|
||||
useAktion((art: string) => post<AktionsErgebnis>(UPDATE_PFADE[art]), {
|
||||
erfolg: (art) => (art === "pruefen" ? "Suche nach Neuem läuft." : "Update läuft. Den Fortschritt siehst du unten."),
|
||||
erfolg: (art) =>
|
||||
art === "pruefen"
|
||||
? "Die Suche nach Neuem läuft. Den Fortschritt siehst du unten bei den Aufträgen."
|
||||
: "Das Update läuft. Den Fortschritt siehst du unten bei den Aufträgen.",
|
||||
neuLaden: ["jobs", "start"],
|
||||
})
|
||||
|
||||
/** Einen laufenden Auftrag abbrechen (nur Downloads und die Suche, siehe `abbrechbar`). */
|
||||
export const useJobAbbrechen = () =>
|
||||
useAktion(({ id }: { id: string; titel: string }) => post<AktionsErgebnis>(`/api/jobs/${encodeURIComponent(id)}/cancel`), {
|
||||
erfolg: (v) => `${v.titel}: wird abgebrochen.`,
|
||||
fehler: (v) => `${v.titel} lief schon nicht mehr.`,
|
||||
neuLaden: ["jobs"],
|
||||
})
|
||||
|
||||
/** Ein Modell von Hugging Face holen (Hintergrund-Auftrag, mit optionaler Rolle nach dem Download). */
|
||||
export const useInstallieren = () =>
|
||||
useAktion(
|
||||
({ repo, quant, rolle }: { repo: string; quant: string; rolle: string | null }) =>
|
||||
post<AktionsErgebnis & { total_bytes?: number }>("/api/models/install", { repo, quant, role: rolle }),
|
||||
{
|
||||
erfolg: (v, e) =>
|
||||
`${v.repo} (${v.quant}) wird heruntergeladen${e.total_bytes ? `, ${gbText(e.total_bytes, 1)}` : ""}. ` +
|
||||
"Den Fortschritt siehst du unter Aufträge.",
|
||||
neuLaden: ["jobs", "models"],
|
||||
},
|
||||
)
|
||||
|
||||
export const useAufraeumen = () =>
|
||||
useQuery({
|
||||
queryKey: ["aufraeumen"],
|
||||
@@ -168,8 +203,10 @@ export const useFreigeben = () =>
|
||||
)
|
||||
|
||||
export const useJetztSichern = () =>
|
||||
useAktion(() => post<AktionsErgebnis>("/api/system/backup"), {
|
||||
erfolg: () => "Sicherung erstellt.",
|
||||
useAktion(() => post<AktionsErgebnis & { size_mb?: number }>("/api/system/backup"), {
|
||||
erfolg: (_, e) =>
|
||||
e.size_mb != null ? `Sicherung erstellt (${e.size_mb.toLocaleString("de-DE")} MB).` : "Sicherung erstellt.",
|
||||
fehler: (_, e) => `Die Sicherung ging nicht: ${fehlerText(e)}`,
|
||||
neuLaden: ["sicherungen", "start"],
|
||||
})
|
||||
|
||||
|
||||
+106
-6
@@ -1,8 +1,8 @@
|
||||
// Reine Anzeige-Logik der Cockpit-Seiten — getrennt von den Komponenten, damit sie ohne
|
||||
// DOM testbar bleibt und Vites Hot-Reload die Komponenten-Dateien sauber tauschen kann.
|
||||
|
||||
import type { UpdateDetails } from "./abfragen"
|
||||
import type { Modell, Start } from "./typen"
|
||||
import type { AktionsErgebnis, Job, Modell, Start, UpdateDetails } from "./typen"
|
||||
import { gbText } from "./zeit"
|
||||
|
||||
// --- Rundinstrument -----------------------------------------------------------------
|
||||
// 240°-Bogen wie ein Zeigerinstrument, Mittelpunkt (100|95) in einer 200×150-Zeichnung.
|
||||
@@ -57,10 +57,9 @@ export function hauptleuchte(z: Start["zustand"]) {
|
||||
|
||||
// --- Updates ------------------------------------------------------------------------
|
||||
|
||||
/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen. */
|
||||
export function versionen(id: string, d: UpdateDetails | undefined): { laeuft: string; neu: string | null } {
|
||||
if (!d) return { laeuft: "…", neu: null }
|
||||
if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} Pakete` : null }
|
||||
/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen (die Details sind schon geladen). */
|
||||
export function versionen(id: string, d: UpdateDetails): { laeuft: string; neu: string | null } {
|
||||
if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} ${d.count === 1 ? "Paket" : "Pakete"}` : null }
|
||||
// Hermes kommt aus git: „Neu“ ist die Zahl der Änderungen seit dem eingespielten Stand.
|
||||
if (id === "hermes") {
|
||||
return {
|
||||
@@ -74,6 +73,107 @@ export function versionen(id: string, d: UpdateDetails | undefined): { laeuft: s
|
||||
return { laeuft, neu: neuer ? `${pre}${d.latest_build}` : null }
|
||||
}
|
||||
|
||||
/** Sagen die Details, dass es Neues gibt? null = weiß man nicht (Prüfung gescheitert oder unvollständig). */
|
||||
export function detailsHabenNeues(id: string, d: UpdateDetails | undefined): boolean | null {
|
||||
if (!d || d.error) return null
|
||||
if (id === "os") return typeof d.count === "number" ? d.count > 0 : null
|
||||
if (id === "hermes") return typeof d.behind === "number" ? d.behind > 0 : null
|
||||
if (d.latest_build == null || d.installed_build == null) return null
|
||||
return d.latest_build > d.installed_build
|
||||
}
|
||||
|
||||
/** Eine gescheiterte Update-Prüfung in Worten („Konnte nicht prüfen: …“), ohne Befehlszeilen-Kauderwelsch. */
|
||||
export function pruefFehlerText(fehler: string): string {
|
||||
const f = fehler.trim()
|
||||
if (/timed out|timeout|zeitüberschreitung/i.test(f)) {
|
||||
return "Konnte nicht prüfen: Die Gegenstelle hat nicht rechtzeitig geantwortet. Später klappt es meist wieder."
|
||||
}
|
||||
if (/name resolution|name or service not known|network is unreachable|connection (refused|reset)|nicht erreichbar/i.test(f)) {
|
||||
return "Konnte nicht prüfen: Die Box kommt gerade nicht ins Internet."
|
||||
}
|
||||
return `Konnte nicht prüfen: ${f.length > 160 ? `${f.slice(0, 157)}…` : f}`
|
||||
}
|
||||
|
||||
/** Warum eine Aktion nicht geklappt hat — die Endpunkte legen den Grund in verschiedene Felder. */
|
||||
export function fehlerText(e: AktionsErgebnis | null | undefined): string {
|
||||
if (e?.status === "busy") {
|
||||
return e.running
|
||||
? `Es läuft schon ein Update („${e.running}“). Warte, bis es fertig ist.`
|
||||
: "Es läuft schon ein Update. Warte, bis es fertig ist."
|
||||
}
|
||||
return e?.detail || e?.err || e?.error || e?.text || "Das hat nicht geklappt."
|
||||
}
|
||||
|
||||
// --- Aufträge (services/jobengine.py) ---------------------------------------------------
|
||||
|
||||
/** Wartet oder läuft der Auftrag noch? */
|
||||
export const laeuftNoch = (j: Pick<Job, "state">) => j.state === "running" || j.state === "queued"
|
||||
|
||||
/**
|
||||
* Darf man den Auftrag abbrechen? Updates nicht: Der Abbruch beendet nur die äußere Shell, apt
|
||||
* und die Update-Skripte liefen weiter, und die Karte zeigte fälschlich „Abgebrochen“. Scheitert
|
||||
* ein Update, rollen Motor und llama-swap selbst zurück.
|
||||
*/
|
||||
export const abbrechbar = (j: Pick<Job, "state" | "group">) => laeuftNoch(j) && j.group !== "maintenance"
|
||||
|
||||
/** Die Beschriftung eines Auftrags auf Deutsch („download org/repo“ heißt im Backend noch englisch). */
|
||||
export function jobTitel(j: Pick<Job, "label">): string {
|
||||
const m = /^download\s+(.+)$/i.exec(j.label)
|
||||
return m ? `Download ${m[1]}` : j.label
|
||||
}
|
||||
|
||||
/** Ist der Auftrag ein Modell-Download? (Bis das Backend eine Gruppe setzt, verrät es nur die Beschriftung.) */
|
||||
export const jobDownload = (j: Pick<Job, "label" | "group">) => j.group === "download" || /^download\s/i.test(j.label)
|
||||
|
||||
/** „noch etwa 6 min“ aus der geschätzten Restzeit in Sekunden. */
|
||||
export function restzeit(s: number): string {
|
||||
if (s < 60) return "noch unter einer Minute"
|
||||
const min = Math.round(s / 60)
|
||||
if (min < 60) return `noch etwa ${min} min`
|
||||
const h = Math.floor(min / 60)
|
||||
const rest = min % 60
|
||||
return rest ? `noch etwa ${h} h ${rest} min` : `noch etwa ${h} h`
|
||||
}
|
||||
|
||||
/** „Läuft seit 12 min“ / „Wartet seit ein paar Sekunden“ für Aufträge ohne Fortschrittsanzeige. */
|
||||
export function laufText(j: Pick<Job, "state" | "started_at">, jetzt = Date.now() / 1000): string {
|
||||
const s = Math.max(0, jetzt - j.started_at)
|
||||
const min = Math.floor(s / 60)
|
||||
const dauer = s < 60 ? "ein paar Sekunden" : min < 60 ? `${min} min` : `${Math.floor(min / 60)} h ${min % 60} min`
|
||||
return `${j.state === "queued" ? "Wartet" : "Läuft"} seit ${dauer}`
|
||||
}
|
||||
|
||||
/** „42 % · 12,3 GB von 29,1 GB · noch etwa 6 min“ — oder null, wenn der Auftrag keinen Fortschritt kennt. */
|
||||
export function fortschrittText(j: Job): string | null {
|
||||
if (j.progress == null) return null
|
||||
const teile = [`${Math.round(j.progress)} %`]
|
||||
if (j.total_bytes) teile.push(`${gbText(j.done_bytes ?? 0, 1)} von ${gbText(j.total_bytes, 1)}`)
|
||||
if (j.eta_s != null && laeuftNoch(j)) teile.push(restzeit(j.eta_s))
|
||||
return teile.join(" · ")
|
||||
}
|
||||
|
||||
/** Letzte aussagekräftige Zeile des Protokolls (nicht die Befehlszeile „$ …“, die der Auftrag zuerst schreibt). */
|
||||
export function letzteMeldung(j: Pick<Job, "log">): string | null {
|
||||
for (let i = (j.log?.length ?? 0) - 1; i >= 0; i--) {
|
||||
const z = j.log[i].trim()
|
||||
if (z && !z.startsWith("$ ")) return z
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Aufträge die Oberfläche zeigt: alle laufenden und wartenden (egal aus welcher Gruppe),
|
||||
* dazu die drei zuletzt beendeten der letzten 24 Stunden — damit man sieht, wie es ausging.
|
||||
*/
|
||||
export function sichtbareJobs(jobs: Job[], jetzt = Date.now() / 1000): Job[] {
|
||||
const laufend = jobs.filter(laeuftNoch).sort((a, b) => a.started_at - b.started_at)
|
||||
const beendet = jobs
|
||||
.filter((j) => !laeuftNoch(j) && jetzt - (j.finished_at ?? j.started_at) < 86_400)
|
||||
.sort((a, b) => (b.finished_at ?? b.started_at) - (a.finished_at ?? a.started_at))
|
||||
.slice(0, 3)
|
||||
return [...laufend, ...beendet]
|
||||
}
|
||||
|
||||
// --- Modelle ------------------------------------------------------------------------
|
||||
|
||||
/** Rolle eines Modells aus seinen llama-swap-Aliasen. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Datenformen der Box-Wart-Schnittstellen — Gegenstück zu backend/routers/boxwart.py,
|
||||
// services/waechter.py, modell_nutzung.py, zeitplan.py und routers/radar.py.
|
||||
// services/waechter.py, modell_nutzung.py, zeitplan.py, routers/radar.py sowie
|
||||
// services/jobengine.py (Aufträge) und services/maintenance.py (Update-Details).
|
||||
|
||||
export type Stufe = "rot" | "gelb"
|
||||
export type LampenZustand = "ok" | "aus" | "warn" | "info" | "fehler"
|
||||
@@ -90,12 +91,48 @@ export interface AufraeumKandidat {
|
||||
hinweis: string
|
||||
}
|
||||
|
||||
/** Antwort einer Aktion. Je nach Endpunkt steht der Fehler in einem anderen Feld. */
|
||||
export interface AktionsErgebnis {
|
||||
ok: boolean
|
||||
text?: string
|
||||
out?: string
|
||||
err?: string
|
||||
detail?: string
|
||||
/** „Jetzt sichern“ (/api/system/backup) legt seinen Fehler hier ab. */
|
||||
error?: string
|
||||
/** "busy" (es läuft schon ein Update), "nothing", "password_required" … */
|
||||
status?: string
|
||||
/** Bei status "busy": welcher Auftrag gerade läuft. */
|
||||
running?: string
|
||||
job_id?: string
|
||||
}
|
||||
|
||||
/** Was ein Update eines Bausteins bringen würde (GET /api/maintenance/update-details?kind=…). */
|
||||
export interface UpdateDetails {
|
||||
kind: string
|
||||
/** Die Prüfung selbst ist gescheitert, z. B. „git fetch“ lief in eine Zeitüberschreitung. */
|
||||
error?: string
|
||||
// Betriebssystem
|
||||
count?: number
|
||||
packages?: { name: string; current: string; candidate: string }[]
|
||||
/** Pakete, die Ubuntu noch zurückhält: "phasing" (schrittweise Verteilung) oder "kept_back". */
|
||||
held_back?: { name: string; reason: string }[]
|
||||
// Motor und llama-swap
|
||||
installed_build?: number | null
|
||||
latest_build?: number | null
|
||||
latest_tag?: string | null
|
||||
name?: string | null
|
||||
url?: string | null
|
||||
// Hermes
|
||||
branch?: string | null
|
||||
installed_version?: string | null
|
||||
behind?: number
|
||||
commits?: { hash: string; subject: string; when: string }[]
|
||||
// Zusammenfassung durchs Hirn (Motor, llama-swap, Hermes) — nur, wenn es Neues gibt
|
||||
summary?: string
|
||||
/** Musst du etwas tun? false = nein, true = ja (siehe action_text), null = unbekannt. */
|
||||
action_needed?: boolean | null
|
||||
action_text?: string
|
||||
}
|
||||
|
||||
export interface Nutzung {
|
||||
@@ -120,14 +157,28 @@ export interface Modell {
|
||||
capabilities?: { vision?: boolean; tools?: boolean; params_b?: number } & Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Zustände eines Auftrags, wie services/jobengine.py sie schreibt („canceled“ mit einem l). */
|
||||
export type JobZustand = "queued" | "running" | "done" | "failed" | "canceled"
|
||||
|
||||
/** Ein Hintergrund-Auftrag (Update, Modell-Download, Suche) aus services/jobengine.py. */
|
||||
export interface Job {
|
||||
id: string
|
||||
label: string
|
||||
state: "running" | "done" | "failed" | "cancelled" | string
|
||||
state: JobZustand
|
||||
/** "maintenance" für Updates (davon läuft immer nur eins). Downloads und die Suche haben keine. */
|
||||
group: string | null
|
||||
log: string[]
|
||||
returncode: number | null
|
||||
/** Unix-Sekunden */
|
||||
started_at: number
|
||||
finished_at: number | null
|
||||
canceled?: boolean
|
||||
/** Nur bei Downloads: Fortschritt 0…100 und die Bytes dazu. */
|
||||
progress?: number | null
|
||||
log?: string[]
|
||||
started?: number
|
||||
finished?: number | null
|
||||
total_bytes?: number
|
||||
done_bytes?: number
|
||||
rate_bps?: number
|
||||
eta_s?: number
|
||||
}
|
||||
|
||||
export interface Sicherung {
|
||||
|
||||
Reference in New Issue
Block a user