feat(PROJ-29): Tenant-Quotas & Usage-Limits
- DB: max_storage_bytes, max_users, max_emails per Tenant (NULL = unlimited)
- storage/quota.go: CheckQuota() mit 60s-Cache, ErrQuotaExceeded
- Save() prüft Quota vor dem Schreiben — Ablehnung bei Hard-Limit
- tenantstore/quota.go: SetQuota(), GetQuota(), GetUsage()
- API: GET/PUT /api/admin/tenant/{id}/quota, GET /api/admin/quotas
- QuotaTab: Usage-Balken (Speicher/Nutzer/Mails), Edit-Dialog, Warnung ab 80%
- InvalidateQuotaCache() nach Quota-Änderung für sofortige Wirkung
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
ebc9e278ea
commit
7930b85cde
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { getAllTenantUsage, setTenantQuota, type TenantUsageEntry } from "@/lib/api/tenants";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function usagePercent(used: number, max: number | null): number {
|
||||
if (!max || max === 0) return 0;
|
||||
return Math.min(100, Math.round((used / max) * 100));
|
||||
}
|
||||
|
||||
function usageColor(pct: number): string {
|
||||
if (pct >= 100) return "bg-destructive";
|
||||
if (pct >= 80) return "bg-yellow-500";
|
||||
return "bg-primary";
|
||||
}
|
||||
|
||||
function QuotaBadge({ used, max, format }: { used: number; max: number | null; format: (n: number) => string }) {
|
||||
if (!max) return <Badge variant="secondary">Unbegrenzt</Badge>;
|
||||
const pct = usagePercent(used, max);
|
||||
return (
|
||||
<div className="space-y-1 min-w-[140px]">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>{format(used)}</span>
|
||||
<span className="text-muted-foreground">/ {format(max)}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div className={`h-full rounded-full ${usageColor(pct)}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{pct >= 80 && <span className="text-xs font-medium text-yellow-600">{pct}% belegt</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface EditState {
|
||||
tenant: TenantUsageEntry;
|
||||
maxStorageGB: string; // empty = unlimited
|
||||
maxUsers: string;
|
||||
maxEmails: string;
|
||||
}
|
||||
|
||||
export function QuotaTab() {
|
||||
const [tenants, setTenants] = useState<TenantUsageEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [edit, setEdit] = useState<EditState | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
getAllTenantUsage()
|
||||
.then(setTenants)
|
||||
.catch(() => setError("Quota-Daten konnten nicht geladen werden"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleEditOpen = (t: TenantUsageEntry) => {
|
||||
setEdit({
|
||||
tenant: t,
|
||||
maxStorageGB: t.max_storage_bytes ? String(Math.round(t.max_storage_bytes / (1024 ** 3))) : "",
|
||||
maxUsers: t.max_users ? String(t.max_users) : "",
|
||||
maxEmails: t.max_emails ? String(t.max_emails) : "",
|
||||
});
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!edit) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const maxStorageBytes = edit.maxStorageGB.trim()
|
||||
? parseInt(edit.maxStorageGB, 10) * 1024 ** 3
|
||||
: null;
|
||||
const maxUsers = edit.maxUsers.trim() ? parseInt(edit.maxUsers, 10) : null;
|
||||
const maxEmails = edit.maxEmails.trim() ? parseInt(edit.maxEmails, 10) : null;
|
||||
|
||||
await setTenantQuota(edit.tenant.id, {
|
||||
max_storage_bytes: maxStorageBytes,
|
||||
max_users: maxUsers,
|
||||
max_emails: maxEmails,
|
||||
});
|
||||
setEdit(null);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Fehler beim Speichern");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tenant-Quotas & Verbrauch</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Speicher-, Nutzer- und E-Mail-Limits pro Mandant.
|
||||
<strong className="text-foreground"> NULL = unbegrenzt.</strong>{" "}
|
||||
Bei Überschreitung werden neue Mails abgewiesen (bestehende Daten bleiben erhalten).
|
||||
Soft-Warnung ab 80 %.
|
||||
</p>
|
||||
{error && <p className="text-sm text-destructive mb-3">{error}</p>}
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Lädt...</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Mandant</TableHead>
|
||||
<TableHead>Speicher</TableHead>
|
||||
<TableHead>Nutzer</TableHead>
|
||||
<TableHead>E-Mails</TableHead>
|
||||
<TableHead className="w-24"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tenants.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell>
|
||||
<QuotaBadge
|
||||
used={t.storage_bytes}
|
||||
max={t.max_storage_bytes}
|
||||
format={formatBytes}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<QuotaBadge
|
||||
used={t.user_count}
|
||||
max={t.max_users}
|
||||
format={(n) => String(n)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<QuotaBadge
|
||||
used={t.email_count}
|
||||
max={t.max_emails}
|
||||
format={(n) => n.toLocaleString("de-DE")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" variant="outline" onClick={() => handleEditOpen(t)}>
|
||||
Limits
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={!!edit} onOpenChange={(o) => { if (!o) setEdit(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Quotas für "{edit?.tenant.name}"</DialogTitle>
|
||||
<DialogDescription>
|
||||
Leer lassen = unbegrenzt. Speicher in GB angeben.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Max. Speicher (GB)</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="leer = unbegrenzt"
|
||||
value={edit?.maxStorageGB ?? ""}
|
||||
onChange={(e) => setEdit((prev) => prev ? { ...prev, maxStorageGB: e.target.value } : prev)}
|
||||
/>
|
||||
{edit?.maxStorageGB && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
= {formatBytes(parseInt(edit.maxStorageGB, 10) * 1024 ** 3)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Max. Nutzer</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="leer = unbegrenzt"
|
||||
value={edit?.maxUsers ?? ""}
|
||||
onChange={(e) => setEdit((prev) => prev ? { ...prev, maxUsers: e.target.value } : prev)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Max. E-Mails</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="leer = unbegrenzt"
|
||||
value={edit?.maxEmails ?? ""}
|
||||
onChange={(e) => setEdit((prev) => prev ? { ...prev, maxEmails: e.target.value } : prev)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEdit(null)}>Abbrechen</Button>
|
||||
<Button disabled={saving} onClick={handleEditSave}>
|
||||
{saving ? "Speichern..." : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user