From e0f1cce626790ff835d5c66fac29bf0ffa5d8f65 Mon Sep 17 00:00:00 2001 From: ritte Date: Fri, 24 Jul 2026 10:07:28 +0200 Subject: [PATCH] Add per-user disk quotas, auto-eviction, and Keep flag Bounds each user's disk footprint and makes throwaway artifacts self-clean: - Per-user quota (default 1 GB) stored on a new UserProfile model, adjustable per user in Django admin. Artifact.size_bytes records each artifact's on-disk footprint (primary file + markdown sidecar) at ingest so usage is a cheap SUM. - Automatic eviction: on each post, oldest unkept artifacts are deleted (files and rows) until the new one fits under quota. If the quota is full of kept artifacts, the post is rejected with HTTP 413 rather than evicting anything protected. - Keep flag (Artifact.is_kept): a Keep button in the preview control bar toggles protection via POST /a/{id}/keep; kept items are never evicted. Kept rows show a green "K" badge in the listing. - Details panel: a Details toggle in the control bar expands a panel under it with full source, type, created time, size, and tags. - Settings -> Storage: usage bar (green/amber/red), used/quota summary, artifact and kept counts; superusers also see total allocated quota vs free disk with an oversubscription warning. - Type indicator in the listing is now a small grey pill instead of plain text. Storage accounting/eviction logic lives in artifacts/storage.py. Migrations 0009 (fields + UserProfile) and 0010 (backfill sizes and create profiles for existing users). Fresh installs no-op the backfill. Co-Authored-By: Claude Opus 4.8 --- artifacts/admin.py | 12 +- artifacts/migrations/0009_quota_and_keep.py | 34 +++++ .../0010_backfill_sizes_and_profiles.py | 43 ++++++ artifacts/models.py | 12 ++ artifacts/storage.py | 92 +++++++++++++ artifacts/urls.py | 2 + artifacts/views.py | 69 +++++++++- static/daybook.css | 126 +++++++++++++++++- templates/artifacts/_day_groups.html | 10 +- templates/artifacts/feed.html | 89 ++++++++++++- templates/artifacts/settings_base.html | 1 + templates/artifacts/settings_storage.html | 39 ++++++ 12 files changed, 517 insertions(+), 12 deletions(-) create mode 100644 artifacts/migrations/0009_quota_and_keep.py create mode 100644 artifacts/migrations/0010_backfill_sizes_and_profiles.py create mode 100644 artifacts/storage.py create mode 100644 templates/artifacts/settings_storage.html diff --git a/artifacts/admin.py b/artifacts/admin.py index c7366e1..5fd255a 100644 --- a/artifacts/admin.py +++ b/artifacts/admin.py @@ -1,10 +1,16 @@ from django.contrib import admin -from .models import Artifact +from .models import Artifact, UserProfile @admin.register(Artifact) class ArtifactAdmin(admin.ModelAdmin): - list_display = ("id", "title", "owner", "source", "source_type", "source_name", "kind", "day", "created_at") - list_filter = ("owner", "source", "source_type", "kind", "day") + list_display = ("id", "title", "owner", "source", "kind", "is_kept", "size_bytes", "day", "created_at") + list_filter = ("owner", "source", "source_type", "kind", "is_kept", "day") search_fields = ("id", "title", "source", "source_name", "source_id") + + +@admin.register(UserProfile) +class UserProfileAdmin(admin.ModelAdmin): + list_display = ("user", "quota_bytes") + search_fields = ("user__username",) diff --git a/artifacts/migrations/0009_quota_and_keep.py b/artifacts/migrations/0009_quota_and_keep.py new file mode 100644 index 0000000..704fbf1 --- /dev/null +++ b/artifacts/migrations/0009_quota_and_keep.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.15 on 2026-07-24 07:15 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0008_artifact_read_at'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='artifact', + name='is_kept', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='artifact', + name='size_bytes', + field=models.BigIntegerField(default=0), + ), + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quota_bytes', models.BigIntegerField(default=1073741824)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/artifacts/migrations/0010_backfill_sizes_and_profiles.py b/artifacts/migrations/0010_backfill_sizes_and_profiles.py new file mode 100644 index 0000000..7766cd9 --- /dev/null +++ b/artifacts/migrations/0010_backfill_sizes_and_profiles.py @@ -0,0 +1,43 @@ +from pathlib import Path + +from django.conf import settings +from django.db import migrations + + +def backfill(apps, schema_editor): + Artifact = apps.get_model("artifacts", "Artifact") + User = apps.get_model("auth", "User") + UserProfile = apps.get_model("artifacts", "UserProfile") + + root = Path(settings.ARTIFACTS_ROOT) + for artifact in Artifact.objects.all(): + total = 0 + primary = root / artifact.path + if primary.exists(): + total += primary.stat().st_size + if artifact.kind == "markdown": + sidecar = primary.with_suffix(".md") + if sidecar.exists(): + total += sidecar.stat().st_size + if artifact.size_bytes != total: + artifact.size_bytes = total + artifact.save(update_fields=["size_bytes"]) + + default_quota = 1024 * 1024 * 1024 # 1 GB + for user in User.objects.all(): + UserProfile.objects.get_or_create(user=user, defaults={"quota_bytes": default_quota}) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("artifacts", "0009_quota_and_keep"), + ] + + operations = [ + migrations.RunPython(backfill, noop_reverse), + ] diff --git a/artifacts/models.py b/artifacts/models.py index 94a981e..12e9255 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -48,6 +48,8 @@ class Artifact(models.Model): source_id = models.CharField(max_length=120, blank=True) metadata = models.JSONField(default=dict, blank=True) read_at = models.DateTimeField(null=True, blank=True) + is_kept = models.BooleanField(default=False) + size_bytes = models.BigIntegerField(default=0) @property def is_unread(self): @@ -66,3 +68,13 @@ class TagColor(models.Model): def __str__(self): return f"{self.name} ({self.color})" + + +class UserProfile(models.Model): + DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB + + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile") + quota_bytes = models.BigIntegerField(default=DEFAULT_QUOTA_BYTES) + + def __str__(self): + return f"{self.user} quota={self.quota_bytes}" diff --git a/artifacts/storage.py b/artifacts/storage.py new file mode 100644 index 0000000..8a0a675 --- /dev/null +++ b/artifacts/storage.py @@ -0,0 +1,92 @@ +"""Disk-usage accounting, per-user quota, and automatic eviction. + +An artifact's on-disk footprint is the primary stored file plus, for markdown, +the preserved `.md` sidecar. We record it on the row (`size_bytes`) at ingest so +usage is a cheap SUM rather than a stat of every file. +""" + +import shutil + +from django.conf import settings +from django.db.models import Sum + +from .models import Artifact, UserProfile + + +class QuotaExceeded(Exception): + """Raised when a post cannot fit even after evicting every unkept artifact.""" + + +def artifact_disk_size(relative_path, kind): + """Bytes an artifact occupies on disk, including the markdown sidecar.""" + root = settings.ARTIFACTS_ROOT + total = 0 + primary = root / relative_path + if primary.exists(): + total += primary.stat().st_size + if kind == Artifact.MARKDOWN: + sidecar = primary.with_suffix(".md") + if sidecar.exists(): + total += sidecar.stat().st_size + return total + + +def delete_artifact_files(artifact): + """Remove an artifact's files from disk (primary + markdown sidecar).""" + root = settings.ARTIFACTS_ROOT + primary = root / artifact.path + primary.unlink(missing_ok=True) + if artifact.kind == Artifact.MARKDOWN: + primary.with_suffix(".md").unlink(missing_ok=True) + + +def user_usage_bytes(user): + return ( + Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") + or 0 + ) + + +def user_quota_bytes(user): + profile, _ = UserProfile.objects.get_or_create(user=user) + return profile.quota_bytes + + +def make_room_for(user, incoming_bytes): + """Evict oldest unkept artifacts until `incoming_bytes` fits under quota. + + Returns the list of evicted artifacts (already deleted). Raises QuotaExceeded + if even removing every unkept artifact leaves no room. + """ + quota = user_quota_bytes(user) + if incoming_bytes > quota: + raise QuotaExceeded("Artifact is larger than the entire quota.") + + used = user_usage_bytes(user) + if used + incoming_bytes <= quota: + return [] + + evicted = [] + unkept = ( + Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at") + ) + for artifact in unkept: + if used + incoming_bytes <= quota: + break + used -= artifact.size_bytes + delete_artifact_files(artifact) + artifact.delete() + evicted.append(artifact) + + if used + incoming_bytes > quota: + raise QuotaExceeded("Quota is full of kept artifacts; free space to post.") + return evicted + + +def disk_free_bytes(): + """Free space on the volume that holds the artifact store.""" + return shutil.disk_usage(settings.ARTIFACTS_ROOT).free + + +def total_allocated_bytes(): + return UserProfile.objects.aggregate(total=Sum("quota_bytes")).get("total") or 0 diff --git a/artifacts/urls.py b/artifacts/urls.py index 4f10c79..c1d2a36 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -7,6 +7,7 @@ urlpatterns = [ path("", views.feed, name="feed"), path("feed/older", views.feed_older, name="feed-older"), path("a//read", views.mark_artifact_read, name="artifact-mark-read"), + path("a//keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a/", views.artifact_view, name="artifact"), path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), @@ -14,4 +15,5 @@ urlpatterns = [ path("settings/", views.settings_home, name="settings-home"), path("settings/account/", views.settings_account, name="settings-account"), path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"), + path("settings/storage/", views.settings_storage, name="settings-storage"), ] diff --git a/artifacts/views.py b/artifacts/views.py index ec2f567..12c89b6 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -13,7 +13,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth import views as auth_views from django.contrib.auth.decorators import login_required -from django.db.models import Count, Q +from django.db.models import Count, Q, Sum from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.template.defaultfilters import slugify @@ -31,6 +31,16 @@ from rest_framework.views import APIView from .demo_data import create_demo_artifacts from .forms import DaybookLoginForm from .models import Artifact, TagColor +from .storage import ( + QuotaExceeded, + artifact_disk_size, + delete_artifact_files, + disk_free_bytes, + make_room_for, + total_allocated_bytes, + user_quota_bytes, + user_usage_bytes, +) TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK} @@ -96,6 +106,8 @@ class ArtifactListCreateView(APIView): return source_error artifact = create_text_artifact(data, request, user) + if isinstance(artifact, Response): + return artifact apply_tag_colors(data) return Response(serialize_artifact(artifact, request), status=201) @@ -294,6 +306,15 @@ def mark_artifact_read(request, artifact_id): return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()}) +@login_required +@require_POST +def toggle_artifact_keep(request, artifact_id): + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) + artifact.is_kept = not artifact.is_kept + artifact.save(update_fields=["is_kept"]) + return JsonResponse({"id": artifact.id, "is_kept": artifact.is_kept}) + + @login_required def feed_older(request): """Return the next page of older day-groups as an HTML fragment for infinite scroll.""" @@ -377,6 +398,15 @@ def create_text_artifact(data, request, user): stored_path = directory / f"{artifact_id}.html" (settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8") + size_bytes = artifact_disk_size(stored_path.as_posix(), kind) + try: + make_room_for(user, size_bytes) + except QuotaExceeded as exc: + (settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True) + if kind == Artifact.MARKDOWN: + (target_dir / f"{artifact_id}.md").unlink(missing_ok=True) + return Response({"detail": str(exc)}, status=413) + return Artifact.objects.create( id=artifact_id, owner=user, @@ -389,6 +419,7 @@ def create_text_artifact(data, request, user): original_kind=kind, tags=normalize_tags(data.get("tags", [])), source_prompt=data.get("source_prompt", ""), + size_bytes=size_bytes, **source_fields(data, source), ) @@ -422,6 +453,13 @@ def create_binary_artifact(data, user): for chunk in uploaded.chunks(): target.write(chunk) + size_bytes = artifact_disk_size(stored_path.as_posix(), kind) + try: + make_room_for(user, size_bytes) + except QuotaExceeded as exc: + (settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True) + return Response({"detail": str(exc)}, status=413) + return Artifact.objects.create( id=artifact_id, owner=user, @@ -434,6 +472,7 @@ def create_binary_artifact(data, user): original_kind=kind, tags=normalize_tags(data.get("tags", [])), source_prompt=data.get("source_prompt", ""), + size_bytes=size_bytes, **source_fields(data, source), ) @@ -630,6 +669,8 @@ def serialize_artifact(artifact, request): "source_id": artifact.source_id, "metadata": artifact.metadata, "read_at": artifact.read_at.isoformat() if artifact.read_at else None, + "is_kept": artifact.is_kept, + "size_bytes": artifact.size_bytes, } @@ -696,6 +737,32 @@ def settings_demo_data(request): ) +@login_required +def settings_storage(request): + used = user_usage_bytes(request.user) + quota = user_quota_bytes(request.user) + kept_bytes = ( + Artifact.objects.filter(owner=request.user, is_kept=True) + .aggregate(total=Sum("size_bytes")) + .get("total") + or 0 + ) + context = { + "active_section": "storage", + "used_bytes": used, + "quota_bytes": quota, + "kept_bytes": kept_bytes, + "percent_used": min(100, round(used / quota * 100)) if quota else 0, + "artifact_count": Artifact.objects.filter(owner=request.user).count(), + "kept_count": Artifact.objects.filter(owner=request.user, is_kept=True).count(), + } + if request.user.is_superuser: + context["is_superuser"] = True + context["total_allocated"] = total_allocated_bytes() + context["disk_free"] = disk_free_bytes() + return render(request, "artifacts/settings_storage.html", context) + + def raw_unavailable_html(artifact): return f""" diff --git a/static/daybook.css b/static/daybook.css index ab1f742..42885b5 100644 --- a/static/daybook.css +++ b/static/daybook.css @@ -248,26 +248,37 @@ h1 { min-height: 0; overflow-x: hidden; overflow-y: auto; +} + +/* Source sidebar: scrollbar hidden until hover (short list, rarely scrolls). */ +.source-list { scrollbar-color: transparent transparent; scrollbar-width: none; } -.source-sidebar:hover .source-list, -.artifact-sidebar:hover .artifact-list { +.source-sidebar:hover .source-list { scrollbar-color: rgba(61, 69, 82, 0.35) transparent; scrollbar-width: thin; } -.source-list::-webkit-scrollbar, -.artifact-list::-webkit-scrollbar { +.source-list::-webkit-scrollbar { width: 0; } -.source-sidebar:hover .source-list::-webkit-scrollbar, -.artifact-sidebar:hover .artifact-list::-webkit-scrollbar { +.source-sidebar:hover .source-list::-webkit-scrollbar { width: 6px; } +/* Artifact list: scrollbar always visible so it doesn't jump on hover. */ +.artifact-list { + scrollbar-color: rgba(61, 69, 82, 0.35) transparent; + scrollbar-width: thin; +} + +.artifact-list::-webkit-scrollbar { + width: 8px; +} + .source-list::-webkit-scrollbar-thumb, .artifact-list::-webkit-scrollbar-thumb { background: rgba(61, 69, 82, 0.35); @@ -538,11 +549,32 @@ h1 { } .artifact-kind-text { + background: var(--day-tile-bg); + border-radius: 4px; color: var(--muted); font-size: 0.68rem; + padding: 0.1rem 0.34rem; white-space: nowrap; } +.kept-badge { + align-items: center; + background: #eaf7ed; + border-radius: 4px; + color: #28583a; + display: inline-flex; + font-size: 0.66rem; + font-weight: 700; + justify-content: center; + line-height: 1; + padding: 0.14rem 0.3rem; + white-space: nowrap; +} + +.kept-badge[hidden] { + display: none; +} + .artifact-source { align-items: center; color: var(--muted); @@ -849,3 +881,85 @@ kbd { padding: 0.4rem 0.6rem; word-break: break-all; } + +.keep-button.kept { + background: #eaf7ed; + border-color: #9bd4ab; + color: #28583a; +} + +.details-panel { + background: var(--surface-alt); + border-bottom: 1px solid var(--grid-line); + flex: 0 0 auto; + max-height: 40%; + overflow-y: auto; + padding: 0.7rem 1rem; +} + +.details-grid { + display: grid; + gap: 0.25rem 1rem; + grid-template-columns: max-content 1fr; + margin: 0; +} + +.details-grid dt { + color: var(--muted); + font-size: 0.72rem; + text-transform: uppercase; +} + +.details-grid dd { + font-size: 0.82rem; + margin: 0; + word-break: break-word; +} + +.usage-bar { + background: var(--day-tile-bg); + border-radius: 999px; + height: 0.75rem; + margin: 0.5rem 0; + overflow: hidden; +} + +.usage-bar-fill { + background: #5aa552; + border-radius: 999px; + height: 100%; + transition: width 0.3s ease; +} + +.usage-bar-fill--warn { + background: #d98b2b; +} + +.usage-bar-fill--full { + background: #c14f4f; +} + +.usage-summary { + font-size: 0.9rem; +} + +.storage-facts { + color: var(--text); + font-size: 0.85rem; + line-height: 1.7; + padding-left: 1.1rem; +} + +.storage-note { + color: var(--muted); + font-size: 0.82rem; + line-height: 1.5; +} + +.storage-warning { + background: #fdecea; + border-radius: 8px; + color: #a3312a; + font-size: 0.85rem; + padding: 0.6rem 0.9rem; +} diff --git a/templates/artifacts/_day_groups.html b/templates/artifacts/_day_groups.html index 39920b4..2bc6182 100644 --- a/templates/artifacts/_day_groups.html +++ b/templates/artifacts/_day_groups.html @@ -5,17 +5,25 @@ {{ group.artifacts|length }} {% for artifact in group.artifacts %} - {{ artifact.title }} {% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %} + K {% if artifact.label_chips %} {% for label in artifact.label_chips %} diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index 667765b..fc6d806 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -67,12 +67,15 @@ Raw + +
{% if selected_artifact %}
@@ -218,6 +221,35 @@ copyButton.disabled = false; copyButton.dataset.copyUrl = row.dataset.renderedUrl; } + syncKeepButton(row.dataset.kept === "1"); + const keepButton = actions.querySelector("[data-keep-button]"); + if (keepButton) keepButton.dataset.artifactId = row.dataset.artifactId; + actions.querySelector("[data-details-toggle]").disabled = false; + renderDetails(row); + }; + + const syncKeepButton = (isKept) => { + const keepButton = document.querySelector("[data-keep-button]"); + if (!keepButton) return; + keepButton.disabled = false; + keepButton.classList.toggle("kept", isKept); + keepButton.textContent = isKept ? "✓ Kept" : "Keep"; + }; + + const renderDetails = (row) => { + const panel = document.querySelector("[data-details-panel]"); + if (!panel) return; + const d = row.dataset; + const tags = d.tags ? d.tags.split(",").map((t) => t.trim()).filter(Boolean) : []; + const rows = [ + ["Source", `${escapeHtml(d.source)} (${escapeHtml(d.sourceType)})`], + ["Source name", escapeHtml(d.sourceName || "—")], + ["Type", escapeHtml(d.kind)], + ["Created", escapeHtml(d.created)], + ["Size", escapeHtml(d.size)], + ["Tags", tags.length ? tags.map(escapeHtml).join(", ") : "—"], + ]; + panel.innerHTML = `
${rows.map(([k, v]) => `
${k}
${v}
`).join("")}
`; }; const disableActions = () => { @@ -240,6 +272,15 @@ copyButton.disabled = true; copyButton.dataset.copyUrl = ""; } + const keepButton = actions.querySelector("[data-keep-button]"); + if (keepButton) { + keepButton.disabled = true; + keepButton.classList.remove("kept"); + keepButton.textContent = "Keep"; + } + actions.querySelector("[data-details-toggle]").disabled = true; + const panel = document.querySelector("[data-details-panel]"); + if (panel) panel.hidden = true; }; const markRead = async (row) => { @@ -440,6 +481,34 @@ observeSentinel(); + // Populate the details panel for a server-preselected artifact so the first + // "Details" click shows content immediately. + const preselected = selectedRow(); + if (preselected) renderDetails(preselected); + + const toggleKeep = async (button) => { + const artifactId = button.dataset.artifactId; + if (!artifactId) return; + button.disabled = true; + try { + const response = await fetch(`/a/${artifactId}/keep`, { + method: "POST", + headers: {"X-CSRFToken": csrfToken}, + }); + const data = await response.json(); + syncKeepButton(data.is_kept); + const row = document.querySelector(`.artifact-row[data-artifact-id="${artifactId}"]`); + if (row) { + row.dataset.kept = data.is_kept ? "1" : "0"; + row.classList.toggle("kept", data.is_kept); + const badge = row.querySelector("[data-kept-badge]"); + if (badge) badge.hidden = !data.is_kept; + } + } catch { + button.disabled = false; + } + }; + previewPane.addEventListener("click", (event) => { const copyButton = event.target.closest("[data-copy-url]"); if (copyButton) { @@ -448,6 +517,24 @@ return; } + const keepButton = event.target.closest("[data-keep-button]"); + if (keepButton && !keepButton.disabled) { + event.preventDefault(); + toggleKeep(keepButton); + return; + } + + const detailsToggle = event.target.closest("[data-details-toggle]"); + if (detailsToggle && !detailsToggle.disabled) { + event.preventDefault(); + const panel = document.querySelector("[data-details-panel]"); + if (panel) { + panel.hidden = !panel.hidden; + detailsToggle.classList.toggle("selected", !panel.hidden); + } + return; + } + const toggle = event.target.closest("[data-view]"); if (!toggle) return; if (toggle.classList.contains("disabled")) { diff --git a/templates/artifacts/settings_base.html b/templates/artifacts/settings_base.html index f6df38f..3b205b6 100644 --- a/templates/artifacts/settings_base.html +++ b/templates/artifacts/settings_base.html @@ -13,6 +13,7 @@
diff --git a/templates/artifacts/settings_storage.html b/templates/artifacts/settings_storage.html new file mode 100644 index 0000000..b511fa0 --- /dev/null +++ b/templates/artifacts/settings_storage.html @@ -0,0 +1,39 @@ +{% extends "artifacts/settings_base.html" %} + +{% block settings_content %} +

Storage

+ +
+
+
+

+ {{ used_bytes|filesizeformat }} of {{ quota_bytes|filesizeformat }} used ({{ percent_used }}%) +

+ +
    +
  • {{ artifact_count }} artifact{{ artifact_count|pluralize }} total
  • +
  • {{ kept_count }} kept ({{ kept_bytes|filesizeformat }}) — never auto-deleted
  • +
+ +

+ When your quota fills, the oldest unkept artifacts are deleted + automatically to make room for new ones. Click Keep on an artifact + to protect it. If the quota is full of kept artifacts, new posts are rejected until + you free space. +

+ + {% if is_superuser %} +

Admin

+
    +
  • Total quota allocated across all users: {{ total_allocated|filesizeformat }}
  • +
  • Free space on the artifact disk: {{ disk_free|filesizeformat }}
  • +
+ {% if total_allocated > disk_free %} +

+ ⚠ Allocated quota exceeds free disk space. Grow the volume before raising quotas, + or usage could fill the actual disk. +

+ {% endif %} +

Per-user quotas are set in the Django admin.

+ {% endif %} +{% endblock %}