adding more functions to media managament system
This commit is contained in:
parent
95e62c2d77
commit
70c0ba44f0
30 changed files with 1494 additions and 104 deletions
|
|
@ -2612,11 +2612,25 @@ type AccountStorageRow struct {
|
|||
FileCount int64 `json:"FileCount,string"`
|
||||
}
|
||||
|
||||
// ListAccountStorageUsage pages the per-account storage breakdown, largest
|
||||
// user first. Offset-based (not keyset): storage administration on a
|
||||
// self-hosted deployment doesn't need to support arbitrarily deep pages
|
||||
// efficiently the way an infinite-scroll feed does.
|
||||
func (s *readStore) ListAccountStorageUsage(ctx context.Context, offset, limit int) ([]AccountStorageRow, bool, error) {
|
||||
// storageUsageSortColumns whitelists the columns ListAccountStorageUsage's
|
||||
// sortBy may map to -- never interpolate the caller's sort key directly into
|
||||
// SQL, since unlike a plain value it can't go through a query parameter.
|
||||
var storageUsageSortColumns = map[string]string{
|
||||
"bytes": "t.bytes",
|
||||
"files": "t.file_count",
|
||||
"user_id": "t.owner_user_id",
|
||||
"username": "lower(COALESCE(u.username, ''))",
|
||||
"first_name": "lower(COALESCE(u.first_name, ''))",
|
||||
}
|
||||
|
||||
// ListAccountStorageUsage pages the per-account storage breakdown. sortBy
|
||||
// selects one of storageUsageSortColumns (falls back to "bytes" for an
|
||||
// unknown/empty key); sortDesc reverses it. q, if non-empty, filters to
|
||||
// accounts whose id/username/first name match it. Offset-based (not
|
||||
// keyset): storage administration on a self-hosted deployment doesn't need
|
||||
// to support arbitrarily deep pages efficiently the way an infinite-scroll
|
||||
// feed does.
|
||||
func (s *readStore) ListAccountStorageUsage(ctx context.Context, q string, sortBy string, sortDesc bool, offset, limit int) ([]AccountStorageRow, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = storageUsageListDefaultLimit
|
||||
}
|
||||
|
|
@ -2626,6 +2640,32 @@ func (s *readStore) ListAccountStorageUsage(ctx context.Context, offset, limit i
|
|||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
column, ok := storageUsageSortColumns[sortBy]
|
||||
if !ok {
|
||||
column = storageUsageSortColumns["bytes"]
|
||||
}
|
||||
direction := "ASC"
|
||||
if sortDesc {
|
||||
direction = "DESC"
|
||||
}
|
||||
q = strings.TrimSpace(q)
|
||||
var whereClause string
|
||||
args := []any{}
|
||||
argN := 1
|
||||
if q != "" {
|
||||
id := int64(-1)
|
||||
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
|
||||
id = n
|
||||
}
|
||||
whereClause = fmt.Sprintf(`WHERE t.owner_user_id = $%d
|
||||
OR lower(COALESCE(u.username, '')) LIKE $%d
|
||||
OR lower(COALESCE(u.first_name, '')) LIKE $%d`, argN, argN+1, argN+2)
|
||||
args = append(args, id, "%"+strings.ToLower(q)+"%", "%"+strings.ToLower(q)+"%")
|
||||
argN += 3
|
||||
}
|
||||
offsetArg := argN
|
||||
limitArg := argN + 1
|
||||
args = append(args, offset, limit+1)
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH totals AS (
|
||||
SELECT owner_user_id, SUM(size)::bigint AS bytes, COUNT(*)::bigint AS file_count
|
||||
|
|
@ -2636,9 +2676,10 @@ WITH totals AS (
|
|||
SELECT t.owner_user_id, COALESCE(u.username, ''), COALESCE(u.first_name, ''), t.bytes, t.file_count
|
||||
FROM totals t
|
||||
LEFT JOIN users u ON u.id = t.owner_user_id
|
||||
ORDER BY t.bytes DESC, t.owner_user_id
|
||||
OFFSET $1
|
||||
LIMIT $2`, offset, limit+1)
|
||||
`+whereClause+`
|
||||
ORDER BY `+column+` `+direction+`, t.owner_user_id
|
||||
OFFSET $`+strconv.Itoa(offsetArg)+`
|
||||
LIMIT $`+strconv.Itoa(limitArg), args...)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list account storage usage: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2498,7 +2498,14 @@ func (s *server) handleStorageAccountsAPI(w http.ResponseWriter, r *http.Request
|
|||
writeAPIError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
rows, hasMore, err := s.read.ListAccountStorageUsage(r.Context(), offset, limit)
|
||||
sortDesc := query.Get("order") != "asc"
|
||||
rows, hasMore, err := s.read.ListAccountStorageUsage(
|
||||
r.Context(),
|
||||
query.Get("q"),
|
||||
query.Get("sort"),
|
||||
sortDesc,
|
||||
offset,
|
||||
limit)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
|
|
|||
1
cmd/telesrv-admin/web/dist/assets/index-B7hI8ol7.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-B7hI8ol7.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-IZM8WmmY.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-IZM8WmmY.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-CP20QCwX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Wtwg__bv.css">
|
||||
<script type="module" crossorigin src="/assets/index-IZM8WmmY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B7hI8ol7.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,52 @@
|
|||
import { ChevronDown, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, EmptyRow, Metric, PageFrame } from "../components/ui";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, EmptyRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
|
||||
import { displayUsername, formatBytes, formatQuantity } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountStorageRow, StorageStatsResponse } from "../types";
|
||||
|
||||
export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
|
||||
type StorageSortKey = "user_id" | "username" | "bytes" | "files";
|
||||
|
||||
// SortableHeader is a <th> that toggles ascending/descending on click and
|
||||
// shows which column (and direction) is currently active -- there's no
|
||||
// existing sortable-table convention elsewhere in this admin panel to
|
||||
// mirror, so this is a small, self-contained one for this page.
|
||||
function SortableHeader({
|
||||
label,
|
||||
sortKey,
|
||||
activeKey,
|
||||
desc,
|
||||
onSort
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: StorageSortKey;
|
||||
activeKey: StorageSortKey;
|
||||
desc: boolean;
|
||||
onSort: (key: StorageSortKey) => void;
|
||||
}) {
|
||||
const active = sortKey === activeKey;
|
||||
return (
|
||||
<th>
|
||||
<button type="button" className="sort-header" onClick={() => onSort(sortKey)}>
|
||||
{label}
|
||||
{active ? (desc ? <ArrowDown size={13} /> : <ArrowUp size={13} />) : <ArrowUpDown size={13} className="sort-header-idle" />}
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageOverviewTab() {
|
||||
const [stats, setStats] = useState<StorageStatsResponse | null>(null);
|
||||
const [rows, setRows] = useState<AccountStorageRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [sortKey, setSortKey] = useState<StorageSortKey>("bytes");
|
||||
const [sortDesc, setSortDesc] = useState(true);
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
|
|
@ -26,7 +60,13 @@ export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
|
|||
setBusy(true);
|
||||
setError("");
|
||||
const at = next ? offset : 0;
|
||||
const params = new URLSearchParams({ limit: "50", offset: String(at) });
|
||||
const params = new URLSearchParams({
|
||||
limit: "50",
|
||||
offset: String(at),
|
||||
sort: sortKey,
|
||||
order: sortDesc ? "desc" : "asc"
|
||||
});
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
try {
|
||||
const result = await api.storageAccounts(params);
|
||||
const page = result.rows ?? [];
|
||||
|
|
@ -48,7 +88,16 @@ export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
|
|||
useEffect(() => {
|
||||
refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [sortKey, sortDesc]);
|
||||
|
||||
function toggleSort(key: StorageSortKey) {
|
||||
if (key === sortKey) {
|
||||
setSortDesc((current) => !current);
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDesc(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Physical is what actually consumes disk/S3 (deduplicated); logical is the
|
||||
// sum of what the per-account table below adds up to. They legitimately
|
||||
|
|
@ -56,15 +105,7 @@ export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
|
|||
const dedupBytes = stats ? Math.max(0, Number(stats.LogicalBytes) - Number(stats.PhysicalBytes)) : 0;
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"Storage"}
|
||||
eyebrow={"Media / Storage usage"}
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={"Physical usage (on disk / S3)"} value={stats ? formatBytes(stats.PhysicalBytes) : "-"} />
|
||||
|
|
@ -83,14 +124,29 @@ export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadAccounts(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"User ID / username / name"} />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"User ID"}</th>
|
||||
<th>{"Account"}</th>
|
||||
<th>{"Storage used"}</th>
|
||||
<th>{"Files"}</th>
|
||||
<SortableHeader label={"User ID"} sortKey="user_id" activeKey={sortKey} desc={sortDesc} onSort={toggleSort} />
|
||||
<SortableHeader label={"Account"} sortKey="username" activeKey={sortKey} desc={sortDesc} onSort={toggleSort} />
|
||||
<SortableHeader label={"Storage used"} sortKey="bytes" activeKey={sortKey} desc={sortDesc} onSort={toggleSort} />
|
||||
<SortableHeader label={"Files"} sortKey="files" activeKey={sortKey} desc={sortDesc} onSort={toggleSort} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -113,6 +169,330 @@ export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StoragePage({ navigate: _navigate }: { navigate: Navigate }) {
|
||||
const [tab, setTab] = useState<"overview" | "limits">("overview");
|
||||
return (
|
||||
<PageFrame title={"Storage"} eyebrow={"Media / Storage usage"}>
|
||||
<div className="tab-bar" role="tablist" aria-label={"Storage sections"}>
|
||||
<button className={`tab-btn ${tab === "overview" ? "active" : ""}`} type="button" role="tab" aria-selected={tab === "overview"} onClick={() => setTab("overview")}>
|
||||
{"Overview"}
|
||||
</button>
|
||||
<button className={`tab-btn ${tab === "limits" ? "active" : ""}`} type="button" role="tab" aria-selected={tab === "limits"} onClick={() => setTab("limits")}>
|
||||
{"Limits & Retention"}
|
||||
</button>
|
||||
</div>
|
||||
{tab === "overview" ? <StorageOverviewTab /> : <LimitsRetentionSection />}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Limits & Retention --------------------------------------------------
|
||||
//
|
||||
// Edits a handful of TELESRV_STORAGE_* keys through the existing generic
|
||||
// .env editor endpoints (GET /api/server/env, POST
|
||||
// /api/actions/update-server-env -- see cmd/telesrv-admin/serversettings.go
|
||||
// and ServerSettingsPage.tsx's EnvSection, which this mirrors for its
|
||||
// save/reload flow), but with friendly units instead of raw key=value text:
|
||||
// GB inputs for byte budgets, a mode dropdown + day count for retention.
|
||||
|
||||
const STORAGE_ENV_KEYS = {
|
||||
maxTotal: "TELESRV_STORAGE_MAX_TOTAL_BYTES",
|
||||
minFree: "TELESRV_STORAGE_MIN_FREE_BYTES",
|
||||
maxUploadFile: "TELESRV_STORAGE_MAX_UPLOAD_FILE_BYTES",
|
||||
retentionMode: "TELESRV_STORAGE_RETENTION_MODE",
|
||||
retentionMaxAge: "TELESRV_STORAGE_RETENTION_MAX_AGE"
|
||||
} as const;
|
||||
|
||||
// Mirrors internal/app/files.MaxUploadPartBytes * MaxUploadParts -- the
|
||||
// protocol's own upload-part-count ceiling that TELESRV_STORAGE_MAX_UPLOAD_FILE_BYTES
|
||||
// can never legally exceed (internal/config validates this server-side too;
|
||||
// this is just an early, friendlier warning in the form).
|
||||
const PROTOCOL_UPLOAD_CEILING_BYTES = 524288 * 8000;
|
||||
|
||||
const BYTE_UNITS: { label: string; bytes: number }[] = [
|
||||
{ label: "MB", bytes: 1024 ** 2 },
|
||||
{ label: "GB", bytes: 1024 ** 3 },
|
||||
{ label: "TB", bytes: 1024 ** 4 }
|
||||
];
|
||||
|
||||
function bestByteUnit(bytes: number): { label: string; bytes: number } {
|
||||
for (let i = BYTE_UNITS.length - 1; i >= 0; i--) {
|
||||
if (bytes >= BYTE_UNITS[i].bytes) return BYTE_UNITS[i];
|
||||
}
|
||||
return BYTE_UNITS[1]; // default to GB for small/zero values
|
||||
}
|
||||
|
||||
// parseDurationMinutes reads a Go-style duration string (e.g. "720h", "90m",
|
||||
// "1h30m") and returns the total as minutes. Unrecognized/empty input is 0.
|
||||
function parseDurationMinutes(value: string): number {
|
||||
let totalSeconds = 0;
|
||||
const re = /(\d+(?:\.\d+)?)\s*(h|m|s)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(value)) !== null) {
|
||||
const amount = parseFloat(match[1]);
|
||||
const unit = match[2];
|
||||
totalSeconds += unit === "h" ? amount * 3600 : unit === "m" ? amount * 60 : amount;
|
||||
}
|
||||
return totalSeconds / 60;
|
||||
}
|
||||
|
||||
const DURATION_UNITS: { label: string; minutes: number }[] = [
|
||||
{ label: "Minutes", minutes: 1 },
|
||||
{ label: "Hours", minutes: 60 },
|
||||
{ label: "Days", minutes: 1440 }
|
||||
];
|
||||
|
||||
function bestDurationUnit(minutes: number): { label: string; minutes: number } {
|
||||
for (let i = DURATION_UNITS.length - 1; i >= 0; i--) {
|
||||
if (minutes >= DURATION_UNITS[i].minutes) return DURATION_UNITS[i];
|
||||
}
|
||||
return DURATION_UNITS[0];
|
||||
}
|
||||
|
||||
// DurationField edits one duration env value (stored as total minutes) as a
|
||||
// friendly "amount + unit" pair, letting TTL be set in minutes, hours, or
|
||||
// days rather than being locked to whole days.
|
||||
function DurationField({
|
||||
label,
|
||||
help,
|
||||
minutes,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
help: string;
|
||||
minutes: string;
|
||||
disabled?: boolean;
|
||||
onChange: (minutes: string) => void;
|
||||
}) {
|
||||
const totalMinutes = Number(minutes || "0");
|
||||
const [unitLabel, setUnitLabel] = useState(() => bestDurationUnit(totalMinutes).label);
|
||||
const unit = DURATION_UNITS.find((u) => u.label === unitLabel) ?? DURATION_UNITS[2];
|
||||
const amount = totalMinutes > 0 ? totalMinutes / unit.minutes : NaN;
|
||||
|
||||
function handleAmountChange(raw: string) {
|
||||
const parsed = Number(raw);
|
||||
if (!raw.trim() || Number.isNaN(parsed) || parsed <= 0) {
|
||||
onChange("0");
|
||||
return;
|
||||
}
|
||||
onChange(String(Math.max(1, Math.round(parsed * unit.minutes))));
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="duration-field">
|
||||
<span>{label}</span>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={Number.isNaN(amount) ? "" : amount}
|
||||
disabled={disabled}
|
||||
onChange={(event) => handleAmountChange(event.target.value)}
|
||||
/>
|
||||
<select value={unitLabel} disabled={disabled} onChange={(event) => setUnitLabel(event.target.value)} style={{ maxWidth: 100 }}>
|
||||
{DURATION_UNITS.map((u) => (
|
||||
<option key={u.label} value={u.label}>{u.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<span className="env-field-desc">{help}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ByteSizeField edits one byte-count env value as a friendly
|
||||
// "amount + unit" pair. bytes is the raw byte count as a string (what the
|
||||
// backend stores); an empty/zero value displays as "Unlimited".
|
||||
function ByteSizeField({
|
||||
label,
|
||||
help,
|
||||
bytes,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
help: string;
|
||||
bytes: string;
|
||||
onChange: (bytes: string) => void;
|
||||
}) {
|
||||
const numericBytes = Number(bytes || "0");
|
||||
const [unitLabel, setUnitLabel] = useState(() => bestByteUnit(numericBytes).label);
|
||||
const unit = BYTE_UNITS.find((u) => u.label === unitLabel) ?? BYTE_UNITS[1];
|
||||
const amount = numericBytes > 0 ? numericBytes / unit.bytes : NaN;
|
||||
|
||||
function handleAmountChange(raw: string) {
|
||||
const parsed = Number(raw);
|
||||
if (!raw.trim() || Number.isNaN(parsed) || parsed <= 0) {
|
||||
onChange("0");
|
||||
return;
|
||||
}
|
||||
onChange(String(Math.round(parsed * unit.bytes)));
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="duration-field">
|
||||
<span>{label}</span>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={Number.isNaN(amount) ? "" : amount}
|
||||
placeholder={"Unlimited"}
|
||||
onChange={(event) => handleAmountChange(event.target.value)}
|
||||
/>
|
||||
<select value={unitLabel} onChange={(event) => setUnitLabel(event.target.value)} style={{ maxWidth: 90 }}>
|
||||
{BYTE_UNITS.map((u) => (
|
||||
<option key={u.label} value={u.label}>{u.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<span className="env-field-desc">{help}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function LimitsRetentionSection() {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [initial, setInitial] = useState<Record<string, string>>({});
|
||||
const [maxTotalBytes, setMaxTotalBytes] = useState("0");
|
||||
const [minFreeBytes, setMinFreeBytes] = useState("0");
|
||||
const [maxUploadFileBytes, setMaxUploadFileBytes] = useState("0");
|
||||
const [retentionMode, setRetentionMode] = useState("off");
|
||||
const [retentionAgeMinutes, setRetentionAgeMinutes] = useState("43200");
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
const groups = await api.serverEnv();
|
||||
const values: Record<string, string> = {};
|
||||
for (const group of groups) {
|
||||
for (const field of group.fields) {
|
||||
values[field.key] = field.value || field.default_value || "";
|
||||
}
|
||||
}
|
||||
setInitial(values);
|
||||
setMaxTotalBytes(values[STORAGE_ENV_KEYS.maxTotal] || "0");
|
||||
setMinFreeBytes(values[STORAGE_ENV_KEYS.minFree] || "0");
|
||||
setMaxUploadFileBytes(values[STORAGE_ENV_KEYS.maxUploadFile] || "0");
|
||||
const mode = (values[STORAGE_ENV_KEYS.retentionMode] || "off").trim().toLowerCase();
|
||||
setRetentionMode(mode === "orphan" || mode === "hard" ? mode : "off");
|
||||
const mins = parseDurationMinutes(values[STORAGE_ENV_KEYS.retentionMaxAge] || "720h");
|
||||
setRetentionAgeMinutes(mins > 0 ? String(Math.max(1, Math.round(mins))) : "43200");
|
||||
setLoaded(true);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
// Only the age is re-encoded from its friendly "days" input back to a Go
|
||||
// duration string; mode/off keeps whatever TELESRV_STORAGE_RETENTION_MAX_AGE
|
||||
// already was rather than clobbering it with a throwaway placeholder.
|
||||
const pendingValues = useMemo(() => {
|
||||
const next: Record<string, string> = {
|
||||
[STORAGE_ENV_KEYS.maxTotal]: maxTotalBytes || "0",
|
||||
[STORAGE_ENV_KEYS.minFree]: minFreeBytes || "0",
|
||||
[STORAGE_ENV_KEYS.maxUploadFile]: maxUploadFileBytes || "0",
|
||||
[STORAGE_ENV_KEYS.retentionMode]: retentionMode,
|
||||
[STORAGE_ENV_KEYS.retentionMaxAge]: retentionMode === "off"
|
||||
? (initial[STORAGE_ENV_KEYS.retentionMaxAge] || "720h")
|
||||
: `${Math.max(1, Math.round(Number(retentionAgeMinutes || "0")))}m`
|
||||
};
|
||||
const changed: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
if ((initial[key] ?? "") !== value) changed[key] = value;
|
||||
}
|
||||
return changed;
|
||||
}, [maxTotalBytes, minFreeBytes, maxUploadFileBytes, retentionMode, retentionAgeMinutes, initial]);
|
||||
|
||||
const hasChanges = Object.keys(pendingValues).length > 0;
|
||||
const uploadCeilingExceeded = Number(maxUploadFileBytes || "0") > PROTOCOL_UPLOAD_CEILING_BYTES;
|
||||
|
||||
return (
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Limits & Retention"}
|
||||
text={"Server-wide storage budget, per-file upload cap, and automatic media cleanup. Saved to .env -- takes effect on the next Restart/Update."}
|
||||
/>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{!loaded ? (
|
||||
<p style={{ color: "var(--muted)" }}>{"Loading current settings..."}</p>
|
||||
) : (
|
||||
<div className="card-body">
|
||||
<div className="attr-block">
|
||||
<ByteSizeField
|
||||
label={"Max total storage budget"}
|
||||
help={"Reject new uploads once total tracked blob bytes would exceed this. Empty/0 = unlimited."}
|
||||
bytes={maxTotalBytes}
|
||||
onChange={setMaxTotalBytes}
|
||||
/>
|
||||
<ByteSizeField
|
||||
label={"Min free space guard"}
|
||||
help={"localfs backend only: reject new uploads once real free disk space falls below this. Empty/0 disables the check."}
|
||||
bytes={minFreeBytes}
|
||||
onChange={setMinFreeBytes}
|
||||
/>
|
||||
<ByteSizeField
|
||||
label={"Max single file size"}
|
||||
help={"Reject a single upload once its total assembled size exceeds this. Empty/0 = unlimited, bounded only by the protocol's own ~4GB per-file ceiling."}
|
||||
bytes={maxUploadFileBytes}
|
||||
onChange={setMaxUploadFileBytes}
|
||||
/>
|
||||
{uploadCeilingExceeded && (
|
||||
<Alert>{"This exceeds the protocol's own ~4GB upload ceiling and will be refused when the server restarts."}</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="attr-block" style={{ marginTop: "1.5em" }}>
|
||||
<label className="duration-field">
|
||||
<span>{"Retention mode"}</span>
|
||||
<select value={retentionMode} onChange={(event) => setRetentionMode(event.target.value)}>
|
||||
<option value="off">{"Off"}</option>
|
||||
<option value="orphan">{"Delete once no longer used (safe)"}</option>
|
||||
<option value="hard">{"Delete after a fixed time, even if still in use"}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="env-field-desc">
|
||||
{retentionMode === "off" &&
|
||||
"No storage sweep runs; nothing is auto-deleted. Storage usage is still tracked and shown above either way."}
|
||||
{retentionMode === "orphan" &&
|
||||
"Safe: a document or photo's file is deleted only once it is no longer referenced by any message, profile photo, or sticker set. Media still visible in a conversation is never touched, regardless of age."}
|
||||
{retentionMode === "hard" &&
|
||||
"Irreversible and aggressive: a document or photo's file bytes are deleted once old enough, REGARDLESS of whether a message still references it. Old media in active conversations will start showing as unavailable once purged -- only the file is removed, the message itself keeps rendering its placeholder (name, size, thumbnail)."}
|
||||
</p>
|
||||
<DurationField
|
||||
label={"Retention age"}
|
||||
help={
|
||||
retentionMode === "hard"
|
||||
? "How old the media itself must be, counted from when it was uploaded, before its bytes are purged."
|
||||
: "How long a document or photo must have had zero references before its file is deleted."
|
||||
}
|
||||
minutes={retentionAgeMinutes}
|
||||
disabled={retentionMode === "off"}
|
||||
onChange={setRetentionAgeMinutes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="gift-table-actions env-save-row">
|
||||
<ActionButton
|
||||
tone="warn"
|
||||
label={"Save limits & retention settings"}
|
||||
path="/api/actions/update-server-env"
|
||||
payload={() => ({ values: pendingValues })}
|
||||
disabled={!hasChanges || uploadCeilingExceeded}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -779,6 +779,27 @@ textarea:focus {
|
|||
font-weight: 800;
|
||||
}
|
||||
|
||||
.sort-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sort-header:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sort-header-idle {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--panel-subtle);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -932,6 +932,7 @@ func run(logger *zap.Logger) error {
|
|||
filesapp.WithUploadPartBackend(localBlobFS),
|
||||
filesapp.WithAdditionalBlobBackend(additionalBlobBackend),
|
||||
filesapp.WithSpaceGuard(spaceGuard),
|
||||
filesapp.WithMaxUploadFileBytes(cfg.StorageMaxUploadFileBytes),
|
||||
filesapp.WithMapboxMapTiles(cfg.MapboxToken, cfg.MapTileCacheDir),
|
||||
externalMediaOption(cfg),
|
||||
webPagePreviewOption(cfg),
|
||||
|
|
@ -1063,11 +1064,7 @@ func run(logger *zap.Logger) error {
|
|||
readModelVersionStore,
|
||||
cfg.UserProjectionFactCacheMaxEntries,
|
||||
)
|
||||
storageRetentionMaxAge := cfg.StorageRetentionMaxAge
|
||||
if !cfg.StorageRetentionEnable {
|
||||
storageRetentionMaxAge = 0
|
||||
}
|
||||
go maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
|
||||
retentionWorker := maintenance.NewRetentionWorker(dispatchOutboxStore, tempAuthKeyStore, logger.Named("maintenance").Named("retention"),
|
||||
cfg.UpdateEventRetention,
|
||||
cfg.RetentionInterval,
|
||||
cfg.RetentionBatch,
|
||||
|
|
@ -1080,9 +1077,17 @@ func run(logger *zap.Logger) error {
|
|||
WithModerationRetention(moderationReportStore).
|
||||
WithUserUpdateRetention(updateEventStore).
|
||||
WithChannelUpdateRetention(channelStore).
|
||||
WithOrphanAuthKeyRetention(authKeyStore, activeSessions, cfg.OrphanAuthKeyRetention).
|
||||
WithOrphanedMediaRetention(filesService, storageRetentionMaxAge).
|
||||
Run(ctx)
|
||||
WithOrphanAuthKeyRetention(authKeyStore, activeSessions, cfg.OrphanAuthKeyRetention)
|
||||
// TELESRV_STORAGE_RETENTION_MODE is a single 3-way switch: at most one of
|
||||
// the orphan-only (safe) and hard (age-based, ignores live references)
|
||||
// media sweeps is ever wired in, matching "off"/"orphan"/"hard".
|
||||
switch cfg.StorageRetentionMode {
|
||||
case config.StorageRetentionModeOrphan:
|
||||
retentionWorker = retentionWorker.WithOrphanedMediaRetention(filesService, cfg.StorageRetentionMaxAge)
|
||||
case config.StorageRetentionModeHard:
|
||||
retentionWorker = retentionWorker.WithHardMediaRetention(filesService, cfg.StorageRetentionMaxAge)
|
||||
}
|
||||
go retentionWorker.Run(ctx)
|
||||
go filesapp.NewUploadPartGCWorker(filesService, logger.Named("files").Named("upload_gc"),
|
||||
cfg.UploadPartTTL,
|
||||
cfg.UploadPartGCInterval,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue