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 %}