feat: setup logs retention by max file size

This commit is contained in:
fsylvain committed 2026-10-07 22:02:13 +02:00
1 parent 70c8492f4f
commit f914eae3c5
44 files changed
+2316 -161

No files matched your search

+1 -1
View File
@@ -178,7 +178,7 @@ tasks:
mutate: mutate:
desc: Gremlins mutation testing of the use cases → tmp/gremlins.json desc: Gremlins mutation testing of the use cases → tmp/gremlins.json
cmds: cmds:
- bash scripts/mutate.sh - bash scripts/mutate.sh {{.CLI_ARGS}}
test:web: test:web:
desc: Run the SPA test suite (Vitest) in run mode desc: Run the SPA test suite (Vitest) in run mode
+2 -2
View File
@@ -320,8 +320,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now), ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now), GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now), ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, time.Now), GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, recorder, time.Now), UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, recorder, time.Now),
} }
} }
+30 -3
View File
@@ -7,6 +7,7 @@ import (
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"net/http" "net/http"
"time" "time"
) )
@@ -18,12 +19,15 @@ type Handler struct {
} }
type UpdateSettingsRequest struct { type UpdateSettingsRequest struct {
ActivityRetentionDays *int `json:"activity_retention_days" validate:"required,oneof=0 30 90 365"` ActivityRetentionDays *int `json:"activity_retention_days" validate:"omitempty,oneof=0 30 90 365"`
ActivityRetentionMaxGB *int `json:"activity_retention_max_gb" validate:"omitempty,oneof=0 1 5 10 25 50"`
} }
type settingsResponse struct { type settingsResponse struct {
Version string `json:"version"` Version string `json:"version"`
ActivityRetentionDays int `json:"activity_retention_days"` ActivityRetentionDays int `json:"activity_retention_days"`
ActivityRetentionMaxGB int `json:"activity_retention_max_gb"`
ActivityLogSizeBytes int64 `json:"activity_log_size_bytes"`
ActivityLastCleanupAt *time.Time `json:"activity_last_cleanup_at"` ActivityLastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
ActivityLastCleanupRemoved int `json:"activity_last_cleanup_removed"` ActivityLastCleanupRemoved int `json:"activity_last_cleanup_removed"`
ActivityNextCleanupAt *time.Time `json:"activity_next_cleanup_at"` ActivityNextCleanupAt *time.Time `json:"activity_next_cleanup_at"`
@@ -43,8 +47,13 @@ func (h *Handler) GetSettings(w http.ResponseWriter, _ *http.Request) {
} }
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil || hasNullValue(body) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
var request UpdateSettingsRequest var request UpdateSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil { if err := json.Unmarshal(body, &request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return return
} }
@@ -53,7 +62,10 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
return return
} }
retention, err := h.UpdateActivityRetentionUseCase.UpdateActivityRetention(r.Context(), *request.ActivityRetentionDays) retention, err := h.UpdateActivityRetentionUseCase.UpdateActivityRetention(r.Context(), useCases.UpdateActivityRetentionCommand{
Days: request.ActivityRetentionDays,
MaxGB: request.ActivityRetentionMaxGB,
})
if err != nil { if err != nil {
if errors.Is(err, audit.ErrInvalidRetention) { if errors.Is(err, audit.ErrInvalidRetention) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
@@ -65,10 +77,25 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
h.write(w, retention) h.write(w, retention)
} }
func hasNullValue(body []byte) bool {
var fields map[string]json.RawMessage
if err := json.Unmarshal(body, &fields); err != nil {
return false
}
for _, value := range fields {
if string(value) == "null" {
return true
}
}
return false
}
func (h *Handler) write(w http.ResponseWriter, retention audit.Settings) { func (h *Handler) write(w http.ResponseWriter, retention audit.Settings) {
body, err := json.Marshal(settingsResponse{ body, err := json.Marshal(settingsResponse{
Version: h.GetSettingsUseCase.GetSettings().Version, Version: h.GetSettingsUseCase.GetSettings().Version,
ActivityRetentionDays: retention.RetentionDays, ActivityRetentionDays: retention.RetentionDays,
ActivityRetentionMaxGB: retention.RetentionMaxGB,
ActivityLogSizeBytes: retention.LogSizeBytes,
ActivityLastCleanupAt: retention.LastCleanupAt, ActivityLastCleanupAt: retention.LastCleanupAt,
ActivityLastCleanupRemoved: retention.LastCleanupRemoved, ActivityLastCleanupRemoved: retention.LastCleanupRemoved,
ActivityNextCleanupAt: retention.NextCleanupAt, ActivityNextCleanupAt: retention.NextCleanupAt,
+6 -3
View File
@@ -45,9 +45,12 @@ Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `me
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`). are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
`GET /v1/settings` also returns `activity_retention_days` (30, 90, 365 or 0 for forever, default `GET /v1/settings` also returns `activity_retention_days` (30, 90, 365 or 0 for forever, default
90), `activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with 90), `activity_retention_max_gb` (0 for no size limit), `activity_log_size_bytes`,
`{"activity_retention_days": 30}` is session only and owner only (403 "Only the owner can do this."); `activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with
any other value answers 400. `{"activity_retention_days": 30}`, `{"activity_retention_max_gb": 5}` or both is session only and
owner only (403 "Only the owner can do this."); any other value answers 400. The daily cleanup
removes events past the age limit, then the oldest events until the log fits the size limit.
Whichever limit is reached first wins.
### What is recorded ### What is recorded
Every real action is recorded by its use case, once, after the change was made (outcome `success`) Every real action is recorded by its use case, once, after the change was made (outcome `success`)
+1 -1
View File
@@ -105,7 +105,7 @@ For example, `WorkingDirectory=/var/lib/renewcms` with `DB_FILE=./data/renewcms.
## Activity log retention ## Activity log retention
The activity log needs no environment variable. Its retention period (30, 90, 365 days or forever, 90 by default) is a setting the owner changes with `PATCH /v1/settings`, and the daily cleanup runs at 03:00 in the server's time zone (`TZ`, or the host's zone). The activity log needs no environment variable. Its retention (an age of 30, 90, 365 days or forever, 90 by default, and an optional maximum size in GB, none by default; whichever limit is reached first removes the oldest events) is a setting the owner changes with `PATCH /v1/settings`, and the daily cleanup runs at 03:00 in the server's time zone (`TZ`, or the host's zone).
## Adding a variable ## Adding a variable
+8 -7
View File
@@ -184,11 +184,12 @@ Duplicates tab: secondary "Purge duplicates" button (merge) next to the sort men
The last card on the page, below Media cleanup, owner only (hidden for admins and for the read-only Media cleanup view), same card styling and 720 px column as the others. The last card on the page, below Media cleanup, owner only (hidden for admins and for the read-only Media cleanup view), same card styling and 720 px column as the others.
- Header: "Retention" / "How long activity is kept." "Saved" (check, success) at the right after a save, fading after 2 s. - Header: "Retention" / "How long activity is kept, or how much of it. Whichever limit is reached first removes the oldest events." "Saved" (check, success) at the right after a save, fading after 2 s.
- "Keep activity for" `USelectMenu`: 30 days, 90 days, 365 days, Forever. Default 90 days. Helper: "Older events are removed automatically." For Forever: "Nothing is removed automatically. The log grows over time." - "Keep activity for" `USelectMenu`: 30 days, 90 days, 365 days, Forever. Default 90 days. Helper: "Events older than this are removed." when a size limit is set, otherwise "Older events are removed automatically." For Forever: "Nothing is removed automatically. The log grows over time." without a size limit, "No age limit. Only the size limit applies." with one.
- Info alert (`lock`): "Activity logs are append-only", "Events can't be edited or deleted from the app. Only the retention period removes them." - An "or" divider, then "Keep at most" `USelectMenu` (160 px wide, full width on mobile): "No size limit" (default) and sizes in GB. Helper without a limit: "Optional. Logs can grow without a size cap." With one: "When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB." The figure after "Currently" is the real size of the activity log. The size options are listed in the API note below.
- Divider, then read-only lines: "Last cleanup: 6 Oct 2026, 03:00 · 142 events removed" and "Next cleanup: 7 Oct 2026, 03:00". Forever: "Never" and "Not scheduled". - Info alert (`lock`): "Activity logs are append-only", "Events can't be edited or deleted from the app. Only the retention limits remove them."
- Saves on change. Choosing a shorter period than the current one first shows a warning alert, "Events older than 30 days will be removed", "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.", buttons "Apply 30 days" and "Cancel"; nothing is sent until Apply. Lengthening or choosing Forever saves at once. - Divider, then read-only lines: "Last cleanup: 6 Oct 2026, 03:00 · 142 events removed" and "Next cleanup: 7 Oct 2026, 03:00". Forever with no size limit: "Never" and "Not scheduled". Forever with a size limit still shows a schedule, because the size check runs at every cleanup.
- Both selects save on change. Choosing a shorter age, or a smaller size limit than the current one, first shows a warning alert, "Events older than 30 days will be removed" (or "Events over 5 GB will be removed"), "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.", buttons "Apply 30 days" (or "Apply 5 GB") and "Cancel"; nothing is sent until Apply. Lengthening, raising, choosing Forever or choosing "No size limit" saves at once.
- Save error alert: "Couldn't save the retention period", "Your previous setting is still in place.", "Try again". The control reverts. - Save error alert: "Couldn't save the retention period", "Your previous setting is still in place.", "Try again". The control reverts.
- States: Default (90 days, saved), Shortening the period, Forever, Save error. Loading and Offline follow the Media cleanup card. - States: "Default, 90 days or 5 GB", "Age only (no size limit)", "Size only (forever)", "Shortening the period" (also with a 5 GB limit), "Forever" and "Save error". Loading and Offline follow the Media cleanup card.
- `GET /v1/settings` adds `activity_retention_days` (30, 90, 365 or 0 for forever), `activity_last_cleanup_at` (null before the first cleanup) and `activity_last_cleanup_removed`. `PATCH /v1/settings` takes `{ "activity_retention_days": 30 }`; any other value answers 400 "The request cannot be processed due to a mismatch in the format of the body."; a non-owner gets 403 "Only the owner can do this.". The cleanup runs once a day at 03:00 server time and never touches the last 24 hours. - `GET /v1/settings` adds `activity_retention_days` (30, 90, 365 or 0 for forever), `activity_retention_max_gb` (0 for no size limit, otherwise one of the size options), `activity_log_size_bytes`, `activity_last_cleanup_at` (null before the first cleanup) and `activity_last_cleanup_removed`. `PATCH /v1/settings` takes `{ "activity_retention_days": 30 }`, `{ "activity_retention_max_gb": 5 }` or both; any other value answers 400 "The request cannot be processed due to a mismatch in the format of the body."; a non-owner gets 403 "Only the owner can do this.". The cleanup runs once a day at 03:00 server time, removes events older than the age limit, then removes the oldest events until the log is under the size limit, and never touches the last 24 hours.
+3 -3
View File
@@ -232,7 +232,7 @@
<sc-if value="{{ s.upd.link }}"><span style="align-self:flex-start;display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">{{ s.upd.link }}<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span></span></sc-if> <sc-if value="{{ s.upd.link }}"><span style="align-self:flex-start;display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">{{ s.upd.link }}<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span></span></sc-if>
</div> </div>
<div role="region" aria-labelledby="cleanup-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><sc-if value="{{ cl.loading }}"><div aria-busy="true" style="display:flex;flex-direction:column;gap:16px"><span style="display:block;width:140px;height:16px;background:var(--ui-bg-accented)"></span><span style="display:block;width:90%;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:55%;height:13px;background:var(--ui-bg-accented)"></span><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:120px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:180px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:36px;height:20px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:60%;height:13px;background:var(--ui-bg-accented)"></span><div style="height:1px;background:var(--ui-border)"></div><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:100px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:220px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:140px;height:32px;background:var(--ui-bg-accented)"></span></div></div></sc-if><sc-if value="{{ cl.ready }}"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="cleanup-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Media cleanup</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ cl.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ cl.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;"></span>Saved</span></sc-if></div></div><sc-if value="{{ cl.ro }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;"></span></span><span style="color:var(--ui-text-toned)">Only owners and admins can change this.</span></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Automatic purge</span><span style="font-size:12px;color:var(--ui-text-muted)">Runs in the background. Off by default.</span></div><div role="switch" aria-checked="{{ cl.on }}" style="display:flex;align-items:center;justify-content:center;width:{{ cl.swW }};height:{{ cl.swH }};flex-shrink:0;opacity:{{ cl.swOp }}"><div style="position:relative;width:36px;height:20px;border:1px solid {{ cl.swBd }};background:{{ cl.swBg }};outline:{{ cl.swRing }};outline-offset:2px"><div style="position:absolute;top:1px;left:{{ cl.knobL }};width:16px;height:16px;background:var(--ui-bg);border:1px solid {{ cl.knobBd }};box-sizing:border-box"></div></div></div></div><sc-if value="{{ cl.on }}"><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Every</span><span style="font-size:12px;color:var(--ui-text-muted)">Between 1 and 365 days.</span></div><div style="display:flex;flex-direction:column;gaLine truncated <div role="region" aria-labelledby="cleanup-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><sc-if value="{{ cl.loading }}"><div aria-busy="true" style="display:flex;flex-direction:column;gap:16px"><span style="display:block;width:140px;height:16px;background:var(--ui-bg-accented)"></span><span style="display:block;width:90%;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:55%;height:13px;background:var(--ui-bg-accented)"></span><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:120px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:180px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:36px;height:20px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:60%;height:13px;background:var(--ui-bg-accented)"></span><div style="height:1px;background:var(--ui-border)"></div><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:100px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:220px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:140px;height:32px;background:var(--ui-bg-accented)"></span></div></div></sc-if><sc-if value="{{ cl.ready }}"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="cleanup-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Media cleanup</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ cl.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ cl.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;"></span>Saved</span></sc-if></div></div><sc-if value="{{ cl.ro }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;"></span></span><span style="color:var(--ui-text-toned)">Only owners and admins can change this.</span></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Automatic purge</span><span style="font-size:12px;color:var(--ui-text-muted)">Runs in the background. Off by default.</span></div><div role="switch" aria-checked="{{ cl.on }}" style="display:flex;align-items:center;justify-content:center;width:{{ cl.swW }};height:{{ cl.swH }};flex-shrink:0;opacity:{{ cl.swOp }}"><div style="position:relative;width:36px;height:20px;border:1px solid {{ cl.swBd }};background:{{ cl.swBg }};outline:{{ cl.swRing }};outline-offset:2px"><div style="position:absolute;top:1px;left:{{ cl.knobL }};width:16px;height:16px;background:var(--ui-bg);border:1px solid {{ cl.knobBd }};box-sizing:border-box"></div></div></div></div><sc-if value="{{ cl.on }}"><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Every</span><span style="font-size:12px;color:var(--ui-text-muted)">Between 1 and 365 days.</span></div><div style="display:flex;flex-direction:column;gaLine truncated
<sc-if value="{{ rt.show }}"><div role="region" aria-labelledby="retention-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="retention-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Retention</span><span style="color:var(--ui-text-muted);text-wrap:pretty">How long activity is kept. Only owners can change it.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ rt.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ rt.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;"></span>Saved</span></sc-if></div></div><sc-if value="{{ rt.err }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't save the retention period</span><span style="color:var(--ui-text-toned)">Your previous setting is still in place.</span></div><span role="button" style="height:28px;padding:0 10px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:12px;white-space:nowrap;opacity:1">Try again</span></div></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Keep activity for</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ rt.help }}</span></div><div style="display:flex;align-items:center;width:{{ rt.selW }};min-height:{{ inH }};padding:0 12px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:{{ inF }};color:var(--ui-text-highlighted)"><span style="flex:1">{{ rt.val }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;color:var(--ui-text-muted)"></span></div></div><sc-if value="{{ rt.shorten }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-warning-soft);color:var(--ui-warning)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat;"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Events older than 30 days will be removed</span><span style="color:var(--ui-text-toned)">This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.</span></div><div style="display:flex;gap:8px"><span role="button" style="height:28px;padding:0 10px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:12px;white-space:nowrap;opacity:1">Apply 30 days</span><span role="button" style="height:28px;padding:0 10px;display:flex;align-items:center;justify-content:center;gap:6px;border:1Line truncated <sc-if value="{{ rt.show }}"><div role="region" aria-labelledby="retention-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="retention-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Retention</span><span style="color:var(--ui-text-muted);text-wrap:pretty">How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ rt.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ rt.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;"></span>Saved</span></sc-if></div></div><sc-if value="{{ rt.err }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't save the retention period</span><span style="color:var(--ui-text-toned)">Your previous setting is still in place.</span></div><span role="button" style="height:28px;padding:0 10px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:12px;white-space:nowrap;opacity:1">Try again</span></div></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Keep activity for</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ rt.help }}</span></div><div style="display:flex;align-items:center;width:{{ rt.selW }};min-height:{{ inH }};padding:0 12px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:{{ inF }};color:var(--ui-text-highlighted)"><span style="flex:1">{{ rt.val }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;color:var(--ui-text-muted)"></span></div></div><div style="display:flex;align-items:center;gap:12px;color:var(--ui-text-muted);font-size:12px;font-weight:500"><span style="flex:1;height:1px;background:var(--ui-border)"></span><span>or</span><span style="flex:1;height:1px;background:var(--ui-border)"></span></div><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Keep at most</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ rt.sizeHelp }}</span></div><div style="display:flex;align-items:center;width:{{ rt.selW }};min-height:{{ inH }};padding:0 12px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:{{ inF }};color:var(--ui-text-highlighted)"><span style="flex:1">{{ rt.sizeVal }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;cLine truncated
</div> </div>
</sc-if> </sc-if>
@@ -424,8 +424,8 @@ class Component extends DCLogic {
const act = { show: !!ho.act && k === 'home' && !s.boot, title: own ? 'Your recent activity' : 'Recent activity', rows: actRows, error: ho.act === 'error', empty: ho.act === 'empty', emptyText: own ? 'Your actions will appear here.' : 'Actions on your site will appear here.' } const act = { show: !!ho.act && k === 'home' && !s.boot, title: own ? 'Your recent activity' : 'Recent activity', rows: actRows, error: ho.act === 'error', empty: ho.act === 'empty', emptyText: own ? 'Your actions will appear here.' : 'Actions on your site will appear here.' }
const C = s.cleanup || {}, cOn = !!C.on, cDis = !!(C.ro || C.offline || C.loading), cErr = !!(C.invalid || C.saveErr) const C = s.cleanup || {}, cOn = !!C.on, cDis = !!(C.ro || C.offline || C.loading), cErr = !!(C.invalid || C.saveErr)
const cl = { loading: !!C.loading, ready: !C.loading, ro: !!C.ro, notRo: !C.ro, on: cOn, saving: !!C.saving, saved: !!C.saved && !C.saving, swW: m ? '44px' : '36px', swH: m ? '44px' : '24px', swOp: cDis ? '0.4' : '1', swRing: C.focusSw ? '2px solid var(--ui-primary)' : '0', swBd: cOn ? 'var(--ui-primary)' : 'var(--ui-border-accented)', swBg: cOn ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', knobL: cOn ? '17px' : '1px', knobBd: cOn ? 'transparent' : 'var(--ui-border-accented)', daysW: m ? '100%' : '140px', daysAlign: m ? 'stretch' : 'flex-end', daysBd: cErr ? 'var(--ui-error)' : C.focusDays ? 'var(--ui-primary)' : 'var(--ui-border-accented)', daysSh: C.focusDays && !cErr ? '0 0 0 1px var(--ui-primary)' : 'none', daysOp: cDis ? '0.4' : '1', daysVal: C.days ?? '30', hasErr: cErr, errTxt: C.invalid ? 'Enter a whole number of days between 1 and 365.' : 'Couldn\'t save. Try again in a moment.', errMax: m ? 'none' : '300px', errTA: m ? 'left' : 'right', lastLine: C.last ? 'Last purge: ' + C.last.d + ' · ' + C.last.c + ' copies merged · ' + C.last.a + ' articles updated' : 'Never run', nextLine: 'Next purge: ' + (C.next || '14 Jul 2026'), purgeOp: C.offline || C.loading ? '0.4' : '1', btnShrink: m ? '1' : '0' } const cl = { loading: !!C.loading, ready: !C.loading, ro: !!C.ro, notRo: !C.ro, on: cOn, saving: !!C.saving, saved: !!C.saved && !C.saving, swW: m ? '44px' : '36px', swH: m ? '44px' : '24px', swOp: cDis ? '0.4' : '1', swRing: C.focusSw ? '2px solid var(--ui-primary)' : '0', swBd: cOn ? 'var(--ui-primary)' : 'var(--ui-border-accented)', swBg: cOn ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', knobL: cOn ? '17px' : '1px', knobBd: cOn ? 'transparent' : 'var(--ui-border-accented)', daysW: m ? '100%' : '140px', daysAlign: m ? 'stretch' : 'flex-end', daysBd: cErr ? 'var(--ui-error)' : C.focusDays ? 'var(--ui-primary)' : 'var(--ui-border-accented)', daysSh: C.focusDays && !cErr ? '0 0 0 1px var(--ui-primary)' : 'none', daysOp: cDis ? '0.4' : '1', daysVal: C.days ?? '30', hasErr: cErr, errTxt: C.invalid ? 'Enter a whole number of days between 1 and 365.' : 'Couldn\'t save. Try again in a moment.', errMax: m ? 'none' : '300px', errTA: m ? 'left' : 'right', lastLine: C.last ? 'Last purge: ' + C.last.d + ' · ' + C.last.c + ' copies merged · ' + C.last.a + ' articles updated' : 'Never run', nextLine: 'Next purge: ' + (C.next || '14 Jul 2026'), purgeOp: C.offline || C.loading ? '0.4' : '1', btnShrink: m ? '1' : '0' }
const R = s.ret || {}, rd = R.days || '90', fv = rd === '0' const R = s.ret || {}, rd = R.days || '90', fv = rd === '0', sz = R.size || '0', hs = sz !== '0'
const rt = { show: !s.noRet, saving: !!R.saving, saved: !!R.saved, err: !!R.err, shorten: !!R.shorten, val: { '30': '30 days', '90': '90 days', '365': '365 days', '0': 'Forever' }[R.shorten ? '30' : rd], help: fv ? 'Nothing is removed automatically. The log grows over time.' : 'Older events are removed automatically.', last: fv ? 'Last cleanup: Never' : 'Last cleanup: 6 Oct 2026, 03:00 · 142 events removed', next: fv ? 'Next cleanup: Not scheduled' : 'Next cleanup: 7 Oct 2026, 03:00', selW: m ? '100%' : '160px' } const rt = { show: !s.noRet, saving: !!R.saving, saved: !!R.saved, err: !!R.err, shorten: !!R.shorten, val: { '30': '30 days', '90': '90 days', '365': '365 days', '0': 'Forever' }[R.shorten ? '30' : rd], help: fv ? (hs ? 'No age limit. Only the size limit applies.' : 'Nothing is removed automatically. The log grows over time.') : (hs ? 'Events older than this are removed.' : 'Older events are removed automatically.'), last: fv && !hs ? 'Last cleanup: Never' : 'Last cleanup: 6 Oct 2026, 03:00 · 142 events removed', next: fv && !hs ? 'Next cleanup: Not scheduled' : 'Next cleanup: 7 Oct 2026, 03:00', sizeVal: hs ? sz + ' GB' : 'No size limit', sizeHelp: hs ? 'When logs pass ' + sz + ' GB, the oldest events are removed. Currently 3.2 GB.' : 'Optional. Logs can grow without a size cap.', selW: m ? '100%' : '160px' }
const purgeDlg = (() => { const purgeDlg = (() => {
if (!s.purge) return null if (!s.purge) return null
const h = React.createElement, bh = m ? 44 : 32 const h = React.createElement, bh = m ? 44 : 32
@@ -427,8 +427,10 @@ class Component extends DCLogic {
Mp({ tab: 'dups', offline: true, name: 'Duplicates tab: offline', trigger: 'The button is disabled with the existing banner.' }) Mp({ tab: 'dups', offline: true, name: 'Duplicates tab: offline', trigger: 'The button is disabled with the existing banner.' })
] }, ] },
{ label: 'Settings: Retention', title: 'Settings: Retention (owner only)', route: '/settings', api: 'GET /v1/settings · PATCH /v1/settings', note: 'The last card on the page, same card styling as Version, Updates and Media cleanup. Saves on change.', states: [ { label: 'Settings: Retention', title: 'Settings: Retention (owner only)', route: '/settings', api: 'GET /v1/settings · PATCH /v1/settings', note: 'The last card on the page, same card styling as Version, Updates and Media cleanup. Saves on change.', states: [
RT('Default, 90 days', 'Saves on change.', { days: '90', saved: true }), RT('Default, 90 days or 5 GB', 'Saves on change. Whichever limit is reached first removes the oldest events.', { days: '90', size: '5', saved: true }),
RT('Shortening the period', 'Confirms before older events are removed.', { days: '90', shorten: true }), RT('Age only (no size limit)', 'The size select reads "No size limit". Same as before this option.', { days: '90' }),
RT('Size only (forever)', 'Age is set to Forever, so only the size limit removes events.', { days: '0', size: '5' }),
RT('Shortening the period', 'Confirms before older events are removed.', { days: '90', size: '5', shorten: true }),
RT('Forever', 'No cleanup scheduled.', { days: '0' }), RT('Forever', 'No cleanup scheduled.', { days: '0' }),
RT('Save error', 'The previous setting is still in place.', { days: '90', err: true }) RT('Save error', 'The previous setting is still in place.', { days: '90', err: true })
] }, ] },
+1 -1
View File
@@ -322,6 +322,6 @@ Added by the Media screens, none of which exist yet:
| My activity | `/activity` | No strip, Person filter or Export; own events only | | My activity | `/activity` | No strip, Person filter or Export; own events only |
| Export | Header button, toasts | "Preparing file…", then "Export started. Your file will download shortly." or "Couldn't export activity. Try again." | | Export | Header button, toasts | "Preparing file…", then "Export started. Your file will download shortly." or "Couldn't export activity. Try again." |
| Home card | `/home` | Owner or admin, regular user, loading, error, empty | | Home card | `/home` | Owner or admin, regular user, loading, error, empty |
| Retention | `/settings` | Default, shortening (confirm), Forever, save error | | Retention | `/settings` | Default (90 days or 5 GB), age only, size only (Forever), shortening (confirm), save error |
401 is global. The 403 on `/activity` has local copy because the page itself is refused; the 403 on the summary or export of a non-admin is never shown, since the controls are hidden. 401 is global. The 403 on `/activity` has local copy because the page itself is refused; the 403 on the summary or export of a non-admin is never shown, since the controls are hidden.
+9 -2
View File
@@ -30,7 +30,7 @@ Feature: The back-office keeps an append-only activity log that owners can read
| change.kind | text, body | | change.kind | text, body |
Scenario: The repository exposes no way to edit or delete one event Scenario: The repository exposes no way to edit or delete one event
Then the audit Repository interface has exactly the methods Append, List, Summary and DeleteOlderThan Then the audit Repository interface has exactly the methods Append, List, Summary, DeleteOlderThan, DeleteOldest and SizeBytes
And the persistence implementation exposes no other exported method than those and its constructor And the persistence implementation exposes no other exported method than those and its constructor
And the HTTP surface has no POST, PUT, PATCH or DELETE under /v1/activity And the HTTP surface has no POST, PUT, PATCH or DELETE under /v1/activity
@@ -464,7 +464,7 @@ Feature: The back-office keeps an append-only activity log that owners can read
| 2026-10-06T03:00:00 | 2026-10-06T04:00:00 | false | | 2026-10-06T03:00:00 | 2026-10-06T04:00:00 | false |
| 2026-10-06T03:00:00 | 2026-10-07T03:00:00 | true | | 2026-10-06T03:00:00 | 2026-10-07T03:00:00 | true |
Scenario: Forever never runs Scenario: Forever with no size limit never runs
Given the retention is 0 Given the retention is 0
When Tick runs at 2026-10-06T03:00:00 When Tick runs at 2026-10-06T03:00:00
Then ran is false and DeleteOlderThan is not called Then ran is false and DeleteOlderThan is not called
@@ -559,3 +559,10 @@ Feature: The back-office keeps an append-only activity log that owners can read
When the owner sends GET /v1/activity When the owner sends GET /v1/activity
Then the three events are listed newest first with their documented items Then the three events are listed newest first with their documented items
And GET /v1/activity?security=true lists only the failed login And GET /v1/activity?security=true lists only the failed login
Scenario: The size limit extension amends the retention rules
Given features/activity_home_card_and_retention.feature, rule "Retention size limit"
Then the settings default also has activity_retention_max_gb 0 and activity_log_size_bytes 0
And PATCH /v1/settings also accepts activity_retention_max_gb validated against audit.SizeOptions()
And the last 24 hours are never removed by DeleteOldest either
And a retention change is recorded with the max_gb_from and max_gb_to params when the size changes
@@ -347,3 +347,272 @@ Feature: Recent activity card on Home and Retention card on Settings
Scenario: Existing behaviour is unchanged Scenario: Existing behaviour is unchanged
Then every scenario of home.feature, settings_page.feature (except its exact-body statement, superseded above) and the Media cleanup scenarios still holds with GET /v1/activity?size=5 mocked and the settings mock widened with the four activity_* fields Then every scenario of home.feature, settings_page.feature (except its exact-body statement, superseded above) and the Media cleanup scenarios still holds with GET /v1/activity?size=5 mocked and the settings mock widened with the four activity_* fields
Rule: Retention size limit, an optional maximum size as an alternative to the age limit
Background:
Given this rule supersedes the statements above where they conflict: the settings body, the next-cleanup rule, the PATCH validation, the card copy and the shortening rule
And this extension changes Go code in the audit settings, the audit repositories, the settings and cleanup use cases and the settings handler
And GET /v1/settings and the PATCH response carry exactly {"version", "activity_retention_days", "activity_retention_max_gb", "activity_log_size_bytes", "activity_last_cleanup_at", "activity_last_cleanup_removed", "activity_next_cleanup_at"}, snake_case
And activity_next_cleanup_at is null only when activity_retention_days is 0 and activity_retention_max_gb is 0, otherwise it is the next 03:00 (audit.CleanupHour) in the server zone strictly after now, so Forever with a size limit has a schedule
And the PATCH body is a partial object with activity_retention_days in {0, 30, 90, 365} and/or activity_retention_max_gb in {0, 1, 5, 10, 25, 50}, absent keys are left unchanged, and an empty object, a null, a wrong type, any other value or malformed JSON answers 400 with apperr.BodyErrorMessage and saves nothing
And a non-owner PATCH answers 403 "Only the owner can do this."
And validation goes through validate tags and validator.Validate.Struct
And the size options are one list in the audit domain, audit.SizeOptions(), mirroring RetentionOptions()
And activity_log_size_bytes is the payload size of the activity events table, measured by audit.Repository.SizeBytes: the sum of the OCTET_LENGTH of the text columns action, actor_name, target_type, target_id, target_name, failure_reason, ip, user_agent, params and changes plus a fixed per-row overhead constant defined in the domain
And it is not the database file size and excludes indexes, free pages and other tables, and repositories never touch files
And the audit Repository gains SizeBytes() (int64, error) and DeleteOldest(limit int, before time.Time) (int, error); DeleteOldest removes the oldest events by created_at then id and only those older than before
And the cleanup runs the age step when days is greater than 0, then removes the oldest events in batches through DeleteOldest, calling SizeBytes between batches, until the log is at or under the limit or a batch removes 0
And the cutoff of every removal is the 24 hour guard (audit.KeepRecentHours); when the limit cannot be met because of the guard, the cleanup stops without error, records the run and tries again at the next 03:00
And Tick returns early only when days is 0 and max_gb is 0
And the removed count is the total of the age and size removals, in the last-cleanup fields and in the system.activity_cleanup params {"removed": n}
And system.activity_retention_changed is recorded once per PATCH that changes something, with params {"from","to"} when the age changes and "max_gb_from" and "max_gb_to" when the size changes, and no event when nothing changes
And lib/activitySentence.ts has an English and a French sentence for a size change
And GET /v1/settings computes the size on every call and a SizeBytes failure answers 500, like any use case error; every verified user receives the number, only the owner sees the card
And the SPA shows the size in GB (1 GB is 1024^3 bytes) with one decimal, or "Less than 0.1 GB" below 0.1 GB
And the header description is "How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it."
And the append-only alert reads "Events can't be edited or deleted from the app. Only the retention limits remove them."
And below the age select an "or" divider is followed by the label "Keep at most" over a USelectMenu 160 px wide (full width on mobile) with the items "No size limit", "1 GB", "5 GB", "10 GB", "25 GB", "50 GB" (values 0, 1, 5, 10, 25, 50)
And the age helper is "Older events are removed automatically." without a size limit, "Events older than this are removed." with one, "Nothing is removed automatically. The log grows over time." for Forever without a size limit and "No age limit. Only the size limit applies." for Forever with one
And the size helper is "Optional. Logs can grow without a size cap." without a limit and "When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB." with one, the figure being activity_log_size_bytes and never a hard-coded value
And the size rank is 1 < 5 < 10 < 25 < 50 < No size limit and a lower rank than the saved one is "smaller"
And choosing a smaller size, including a size when there was none, sends nothing and shows the warning "Events over {n} GB will be removed" with "This happens at the next cleanup on {date}. It can't be undone.", "Apply {n} GB" and "Cancel"; choosing a larger size or "No size limit" saves at once
And each select sends only its own key, both share one status area, one error alert and one pending warning, and a warning concerns one control at a time: a longer or larger choice on either control drops it and another shortening replaces it
And the visual states are "Default, 90 days or 5 GB", "Age only (no size limit)", "Size only (forever)", the shortening warning with a 5 GB limit, Forever and Save error
Scenario Outline: Settings API, GET reports the size limit and the measured size
Given the saved retention is <days> days with a size limit of <gb> GB and the activity table holds <bytes> bytes of payload
When a verified user sends GET /v1/settings at the server time 2026-10-06T10:00:00+02:00
Then the status is 200 and the body has exactly the seven keys
And "activity_retention_max_gb" is <gb> and "activity_log_size_bytes" is <bytes>
And "activity_next_cleanup_at" is <next>
Examples:
| days | gb | bytes | next |
| 90 | 0 | 0 | "2026-10-07T03:00:00+02:00" |
| 90 | 5 | 3435973837 | "2026-10-07T03:00:00+02:00" |
| 0 | 5 | 3435973837 | "2026-10-07T03:00:00+02:00" |
| 0 | 0 | 1024 | null |
Scenario: Settings API, a fresh install defaults to no size limit
Given no settings row exists
When a verified user sends GET /v1/settings
Then activity_retention_days is 90, activity_retention_max_gb is 0 and activity_log_size_bytes is 0
Scenario Outline: Settings API, PATCH saves a size limit, an age, or both
Given the saved retention is 90 days and no size limit and the user is the owner
When the owner sends PATCH /v1/settings with <body>
Then the status is 200 and the body has activity_retention_days <days>, activity_retention_max_gb <gb> and activity_next_cleanup_at <next>
And the repository holds <days> and <gb>
Examples:
| body | days | gb | next |
| {"activity_retention_max_gb": 5} | 90 | 5 | the next 03:00 server time |
| {"activity_retention_days": 0, "activity_retention_max_gb": 5} | 0 | 5 | the next 03:00 server time |
| {"activity_retention_days": 30} | 30 | 0 | the next 03:00 server time |
| {"activity_retention_days": 0} | 0 | 0 | null |
Scenario: Settings API, leaving the size limit while Forever clears the schedule
Given the saved retention is 0 days with a size limit of 5 GB
When the owner sends PATCH /v1/settings with {"activity_retention_max_gb": 0}
Then activity_next_cleanup_at is null
Scenario Outline: Settings API, PATCH rejects invalid size input
Given the saved retention is 90 days with a size limit of 5 GB
When the owner sends PATCH /v1/settings with <body>
Then the status is 400 with the text of apperr.BodyErrorMessage and nothing is saved
Examples:
| body |
| {"activity_retention_max_gb": 2} |
| {"activity_retention_max_gb": -1} |
| {"activity_retention_max_gb": 100} |
| {"activity_retention_max_gb": 5.5} |
| {"activity_retention_max_gb": "5"} |
| {"activity_retention_max_gb": null} |
| {"activity_retention_days": 30, "activity_retention_max_gb": 2} |
| {} |
Scenario: Settings API, a PATCH changing nothing is a no-op
Given the saved retention is 90 days and 5 GB
When the owner sends PATCH /v1/settings with {"activity_retention_days": 90, "activity_retention_max_gb": 5}
Then the status is 200, nothing is saved and no audit event is recorded
Scenario: Settings API, a non-owner cannot set the size limit
When an admin sends PATCH /v1/settings with {"activity_retention_max_gb": 5}
Then the status is 403 with "Only the owner can do this." and nothing is saved
Scenario: Settings API, a failing size measurement answers 500
Given SizeBytes fails
When a verified user sends GET /v1/settings
Then the status is 500 with the generic internal error text
Scenario Outline: Settings API, the change is audited
Given the saved retention is 90 days and no size limit
When the owner sends PATCH /v1/settings with <body>
Then one event "system.activity_retention_changed" exists with params <params>
Examples:
| body | params |
| {"activity_retention_days": 30} | {"from": 90, "to": 30} |
| {"activity_retention_max_gb": 5} | {"max_gb_from": 0, "max_gb_to": 5} |
| {"activity_retention_days": 30, "activity_retention_max_gb": 5} | {"from": 90, "to": 30, "max_gb_from": 0, "max_gb_to": 5} |
Scenario Outline: Cleanup, the size limit removes the oldest events
Given the retention is <days> days, the size limit is 5 GB and the log is 7 GB
And the oldest events are older than the guard
When the cleanup runs at 2026-10-06T03:00:00
Then events are removed oldest first, by created_at then id, until SizeBytes is at or under 5 GB
And the removed count is the total of the age removals and the size removals
And the last cleanup time is the clock's time
Examples:
| days |
| 90 |
| 0 |
Scenario: Cleanup, age first then size
Given the retention is 30 days, the size limit is 5 GB, 100 events are older than 30 days and the log still exceeds 5 GB after they go
When the cleanup runs
Then the 100 events go first, then the oldest remaining events until the log is at or under 5 GB
Scenario: Cleanup, the age limit alone is enough
Given the log is under the size limit after the age removal
When the cleanup runs
Then no size removal happens
Scenario: Cleanup, Forever with no size limit never runs
Given the retention is 0 days and no size limit
When Tick runs at 2026-10-06T03:00:00
Then ran is false and neither DeleteOlderThan nor DeleteOldest is called
Scenario: Cleanup, the last 24 hours are protected even when the size cannot be met
Given the size limit is 1 GB and the log is 4 GB, all of it from the last 24 hours
When the cleanup runs
Then nothing is removed, no error is returned, the last cleanup records 0 removed and the next tick tries again
Scenario: Cleanup, a failing size removal is retried
Given DeleteOldest fails
When Tick runs
Then the error is returned and logged, the last cleanup fields are unchanged and the next tick runs again
Scenario: Cleanup, a size removal records one system event
Given the age step removed 10 events and the size step removed 40
When the cleanup runs
Then one "system.activity_cleanup" event exists with params {"removed": 50}
Scenario: Persistence measures the table payload portably
Given a real in-memory SQLite database and events with known text lengths, one with a multi-byte name
Then SizeBytes equals the sum of the byte lengths of the text columns plus the fixed per-row overhead
And it is 0 for an empty table
And deleting events lowers it
Scenario: Persistence deletes the oldest events up to a limit and a guard
Given 10 events, 3 of them newer than the guard
When DeleteOldest runs with limit 5
Then the 5 oldest events are removed
When DeleteOldest runs with limit 20
Then only the 2 remaining events older than the guard are removed and the 3 newest stay
Scenario: Settings retention, Default with 90 days or 5 GB
Given the role is "owner" and GET /v1/settings resolves 200 with activity_retention_days 90, activity_retention_max_gb 5 and activity_log_size_bytes 3435973837
When the owner navigates to /settings
Then the age select shows "90 days" with the helper "Events older than this are removed."
And an "or" divider and "Keep at most" show "5 GB" with the helper "When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB."
And the lock alert reads "Events can't be edited or deleted from the app. Only the retention limits remove them."
And "Next cleanup" shows the date from activity_next_cleanup_at
Scenario: Settings retention, Age only (no size limit)
Given activity_retention_max_gb is 0
When the owner navigates to /settings
Then the size select reads "No size limit" with the helper "Optional. Logs can grow without a size cap." and the age helper is "Older events are removed automatically."
Scenario: Settings retention, Size only (forever)
Given activity_retention_days is 0, activity_retention_max_gb is 5 and activity_next_cleanup_at is set
When the owner navigates to /settings
Then the age helper reads "No age limit. Only the size limit applies." and "Next cleanup" shows the date, not "Not scheduled"
Scenario: Settings retention, Forever with no size limit
Given activity_retention_days is 0, activity_retention_max_gb is 0 and activity_next_cleanup_at is null
When the owner navigates to /settings
Then the age helper reads "Nothing is removed automatically. The log grows over time." and "Next cleanup" reads "Not scheduled"
Scenario Outline: Settings retention, the measured size is formatted
Given the size limit is 5 GB and activity_log_size_bytes is <bytes>
When the owner navigates to /settings
Then the size helper ends with "<shown>"
Examples:
| bytes | shown |
| 3435973837 | Currently 3.2 GB. |
| 0 | Currently Less than 0.1 GB. |
| 53687091 | Currently Less than 0.1 GB. |
| 5368709120 | Currently 5.0 GB. |
Scenario Outline: Settings retention, a larger size or no limit saves at once
Given the saved size is <from>
When the owner chooses "<to>"
Then PATCH is sent once with {"activity_retention_max_gb": <value>}, never the age key, and no warning shows
And the shared status reads "Saving…" then "Saved"
Examples:
| from | to | value |
| 5 GB | 10 GB | 10 |
| 5 GB | No size limit | 0 |
| 1 GB | 50 GB | 50 |
Scenario Outline: Settings retention, a smaller size asks first
Given the saved size is <from>
When the owner chooses "<to>"
Then no PATCH is sent and the size select shows "<to>"
And a warning alert shows "Events over <n> GB will be removed" and "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone." with "Apply <n> GB" and "Cancel"
Examples:
| from | to | n |
| 10 GB | 5 GB | 5 |
| No size limit | 5 GB | 5 |
| 50 GB | 1 GB | 1 |
Scenario: Settings retention, Apply and Cancel for a smaller size
Given the warning for 5 GB is shown on a saved 10 GB
When the owner activates "Apply 5 GB" and PATCH resolves 200 with activity_retention_max_gb 5
Then PATCH was sent once with {"activity_retention_max_gb": 5} and "Saved" shows
Given the warning is shown again
When the owner activates "Cancel"
Then the size select reads the saved value and no PATCH was sent
Scenario: Settings retention, only one warning at a time
Given the age warning for 30 days is open
When the owner chooses "5 GB" as a smaller size
Then the age warning is replaced by the size warning and the age select reverts to the saved value
When the owner chooses a longer age or a larger size
Then the warning is dropped and PATCH is sent once for that control
Scenario: Settings retention, switching age to Forever with a size limit
Given the saved period is 90 days and 5 GB
When the owner chooses "Forever"
Then PATCH is sent with {"activity_retention_days": 0} and the age helper becomes "No age limit. Only the size limit applies."
And "Next cleanup" still shows the returned date
Scenario Outline: Settings retention, a size save error reverts only the size select
Given the saved size is 5 GB and the owner chooses "10 GB"
When PATCH /v1/settings <failure>
Then the error alert "Couldn't save the retention period" with "Your previous setting is still in place." and "Try again" shows
And the size select reads "5 GB" again and the age select is unchanged
When the owner activates "Try again" and PATCH resolves 200
Then PATCH was sent a second time with {"activity_retention_max_gb": 10} and "Saved" replaces the alert
Examples:
| failure |
| resolves with a 500 response |
| resolves with a 400 response |
| resolves with a 403 response |
| never reaches the server |
Scenario: Settings retention, offline disables the size select too
Given the owner is on /settings and the browser goes offline
Then both selects, "Apply" and "Cancel" are disabled
Scenario: The French interface covers the size strings
Given the language is French
Then every new string has a French entry in messages.ts, and the Activity sentence for a size change is French
@@ -72,3 +72,51 @@ Settings (mount SettingsView, GET /v1/settings mocked with the five-key body):
- [ ] Home at 1440 and 390 px, light and dark: 420 px column in the far-right of the full content width, 16 px gap, row clamping, "View all" 44 px touch target; the article column fills the remaining width and still reads well. - [ ] Home at 1440 and 390 px, light and dark: 420 px column in the far-right of the full content width, 16 px gap, row clamping, "View all" 44 px touch target; the article column fills the remaining width and still reads well.
- [ ] Keyboard and focus: Home tab order "New article", article rows, "View all"; Settings select, Apply, Cancel in order and focus back on the select after Cancel. - [ ] Keyboard and focus: Home tab order "New article", article rows, "View all"; Settings select, Apply, Cancel in order and focus back on the select after Cancel.
- [ ] Hover, focus rings, the 2 s "Saved" fade and reduced motion. - [ ] Hover, focus rings, the 2 s "Saved" fade and reduced motion.
## Retention size limit extension
Spec: rule "Retention size limit" in activity_home_card_and_retention.feature. Items 14 to 25 above are updated for the widened mock body (activity_retention_max_gb, activity_log_size_bytes) and the new helper and alert copy.
Implementation note for coder (informational, not a QA assertion):
- audit.Settings gains RetentionMaxGB; the settings use case result carries LogSizeBytes from audit.Repository.SizeBytes.
- audit.SizeOptions() is [0, 1, 5, 10, 25, 50]; invalid values return ErrInvalidRetention.
- audit.Repository gains SizeBytes() (int64, error) and DeleteOldest(limit int, before time.Time) (int, error).
- audit.SettingsRepository.SaveRetention takes days and max_gb; the model and mapper gain retention_max_gb.
- The cleanup use case runs the age step when days > 0, then loops DeleteOldest batches with SizeBytes until the log fits or a batch removes 0; the cutoff is the 24 h guard.
- The handler request struct is a partial with pointer fields and validate tags; an empty object is rejected.
- settingsResponse gains activity_retention_max_gb and activity_log_size_bytes. Update the settings_page tests that count keys.
### Go (httptest, fakes)
- G11. GET key set is exactly the seven keys; max_gb and size_bytes carry the fake's values; a fresh fake gives 90, 0, 0.
- G12. GET next-cleanup table: (90, 0), (90, 5), (0, 5) are non-null; (0, 0) is JSON null with the key present.
- G13. PATCH table: size alone, age alone, both, Forever plus size, size 0 with Forever (next becomes null); the fake repository holds the pair.
- G14. PATCH invalid: sizes 2, -1, 100, 5.5, "5", null, mixed with a valid age, {}, not JSON; each 400 with the BodyErrorMessage text and nothing saved. Non-owner 403, no cookie 401.
- G15. No-op PATCH of both saved values: 200, no save, no event.
- G16. Audit params table for the three bodies (age, size, both).
- G17. Cleanup use case with a fake events repository that has a size model: age then size, Forever plus size, Forever with no size (ran=false, no calls), size already met (no DeleteOldest), the guard stops at 24 h with 0 removed and no error, DeleteOldest error propagates and leaves the last cleanup unchanged, total removed in the event params and in SaveLastCleanup, the batch loop terminates when a batch removes 0.
- G18. CleanupDue table unchanged; Tick runs for Forever when a size limit is set.
- G19. Domain: SizeOptions validation; next-cleanup is nil only when both are 0.
- G20. Persistence on in-memory SQLite (extend test/activity_persistence_test.go): SizeBytes with multi-byte text, the empty table, after delete; DeleteOldest with the limit and the guard; the migrated model has retention_max_gb defaulting to 0.
- G21. The interface-shape test is updated to the six methods.
- G22. A SizeBytes error propagates and the handler answers 500 "Internal server error".
### SPA (Vitest), extending web/src/views/__tests__/SettingsRetention.spec.ts
- 26. Default 90 days or 5 GB; Age only; Size only (forever); Forever without a size: selects, helpers, "Currently 3.2 GB.", the "or" divider, the lines.
- 27. Size formatting table (3.2, "Less than 0.1 GB", 5.0); 1024^3 base.
- 28. Larger size or No size limit: one PATCH with only the size key, "Saving…" then "Saved" (fake timers).
- 29. Smaller size, and a size when none was set: no PATCH, warning copy, Apply sends one PATCH, Cancel reverts.
- 30. One warning at a time; a longer or larger choice drops it.
- 31. Save errors (500, 400, 403, network): only the size select reverts; Try again re-sends the same body without re-confirming; 403 refreshes the session and the card disappears.
- 32. Offline disables both selects and Apply and Cancel; 401 is global.
- 33. French parity for the new strings; lib/activitySentence.spec.ts covers the size-change sentence in English and French.
### Visual (task visual:check)
The board draws the card in group "Settings: Retention" with the states 'Age only (no size limit)' and 'Size only (forever)'. In settings.md the states are bullets, not headings, so confirm on the first run whether the tool iterates them. Setups use the board's own sample data (5 GB, 3.2 GB) and the owner /v1/settings mock gets the widened body. Clock-sensitive states stay skip: 'clock-sensitive'. No exception beforehand.
### Manual verification
- [ ] Real SQLite file: activity_log_size_bytes grows as events are added and falls after a forced cleanup.
- [ ] Forced cleanup with a small limit removes the oldest events and leaves the last 24 h.
- [ ] Optional: SizeBytes on real MySQL and PostgreSQL.
- [ ] Real PATCH as owner for each option, as admin 403, as owner with 2 GB 400.
- [ ] Keyboard order: age select, size select, Apply, Cancel; focus returns to the triggering select after Cancel.
- [ ] 1440 and 390 px, light and dark: the "or" divider and the 160 px or full-width size select as drawn in the board.
+2
View File
@@ -105,6 +105,8 @@ const (
ParamRemoved = "removed" ParamRemoved = "removed"
ParamFrom = "from" ParamFrom = "from"
ParamTo = "to" ParamTo = "to"
ParamMaxGBFrom = "max_gb_from"
ParamMaxGBTo = "max_gb_to"
ParamMerged = "merged" ParamMerged = "merged"
ParamArticlesUpdated = "articles_updated" ParamArticlesUpdated = "articles_updated"
ParamFailedGroups = "failed_groups" ParamFailedGroups = "failed_groups"
@@ -7,10 +7,12 @@ type Repository interface {
List(filter Filter) (Page, error) List(filter Filter) (Page, error)
Summary(query SummaryQuery) (Summary, error) Summary(query SummaryQuery) (Summary, error)
DeleteOlderThan(cutoff time.Time) (int, error) DeleteOlderThan(cutoff time.Time) (int, error)
DeleteOldest(limit int, before time.Time) (int, error)
SizeBytes() (int64, error)
} }
type SettingsRepository interface { type SettingsRepository interface {
Get() (Settings, error) Get() (Settings, error)
SaveRetention(days int) error SaveRetention(days int, maxGB int) error
SaveLastCleanup(at time.Time, removed int) error SaveLastCleanup(at time.Time, removed int) error
} }
+31 -1
View File
@@ -7,6 +7,9 @@ const (
ForeverRetentionDays = 0 ForeverRetentionDays = 0
CleanupHour = 3 CleanupHour = 3
KeepRecentHours = 24 KeepRecentHours = 24
NoSizeLimitGB = 0
BytesPerGB = int64(1) << 30
RowOverheadBytes = int64(64)
) )
func RetentionOptions() []int { func RetentionOptions() []int {
@@ -22,8 +25,23 @@ func ValidateRetentionDays(days int) error {
return ErrInvalidRetention return ErrInvalidRetention
} }
func SizeOptions() []int {
return []int{NoSizeLimitGB, 1, 5, 10, 25, 50}
}
func ValidateRetentionMaxGB(maxGB int) error {
for _, option := range SizeOptions() {
if option == maxGB {
return nil
}
}
return ErrInvalidRetention
}
type Settings struct { type Settings struct {
RetentionDays int `json:"activity_retention_days"` RetentionDays int `json:"activity_retention_days"`
RetentionMaxGB int `json:"activity_retention_max_gb"`
LogSizeBytes int64 `json:"activity_log_size_bytes"`
LastCleanupAt *time.Time `json:"activity_last_cleanup_at"` LastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
LastCleanupRemoved int `json:"activity_last_cleanup_removed"` LastCleanupRemoved int `json:"activity_last_cleanup_removed"`
NextCleanupAt *time.Time `json:"activity_next_cleanup_at"` NextCleanupAt *time.Time `json:"activity_next_cleanup_at"`
@@ -33,6 +51,14 @@ func DefaultSettings() Settings {
return Settings{RetentionDays: DefaultRetentionDays} return Settings{RetentionDays: DefaultRetentionDays}
} }
func (s Settings) HasRetentionLimit() bool {
return s.RetentionDays != ForeverRetentionDays || s.RetentionMaxGB != NoSizeLimitGB
}
func (s Settings) MaxLogSizeBytes() int64 {
return int64(s.RetentionMaxGB) * BytesPerGB
}
func (s Settings) CleanupDue(now time.Time) bool { func (s Settings) CleanupDue(now time.Time) bool {
todayRun := time.Date(now.Year(), now.Month(), now.Day(), CleanupHour, 0, 0, 0, now.Location()) todayRun := time.Date(now.Year(), now.Month(), now.Day(), CleanupHour, 0, 0, 0, now.Location())
if now.Before(todayRun) { if now.Before(todayRun) {
@@ -43,7 +69,7 @@ func (s Settings) CleanupDue(now time.Time) bool {
func (s Settings) WithNextCleanup(now time.Time) Settings { func (s Settings) WithNextCleanup(now time.Time) Settings {
s.NextCleanupAt = nil s.NextCleanupAt = nil
if s.RetentionDays == ForeverRetentionDays { if !s.HasRetentionLimit() {
return s return s
} }
next := time.Date(now.Year(), now.Month(), now.Day(), CleanupHour, 0, 0, 0, now.Location()) next := time.Date(now.Year(), now.Month(), now.Day(), CleanupHour, 0, 0, 0, now.Location())
@@ -62,6 +88,10 @@ func ClampCutoff(now time.Time, cutoff time.Time) time.Time {
return cutoff return cutoff
} }
func GuardCutoff(now time.Time) time.Time {
return ClampCutoff(now, now)
}
func RetentionCutoff(now time.Time, retentionDays int) time.Time { func RetentionCutoff(now time.Time, retentionDays int) time.Time {
return ClampCutoff(now, now.Add(-time.Duration(retentionDays)*24*time.Hour)) return ClampCutoff(now, now.Add(-time.Duration(retentionDays)*24*time.Hour))
} }
@@ -83,6 +83,7 @@ func encodeJSON(value any, empty string) (string, error) {
func AuditSettingsToDomain(m models.AuditSettings) audit.Settings { func AuditSettingsToDomain(m models.AuditSettings) audit.Settings {
return audit.Settings{ return audit.Settings{
RetentionDays: m.RetentionDays, RetentionDays: m.RetentionDays,
RetentionMaxGB: m.RetentionMaxGB,
LastCleanupAt: m.LastCleanupAt, LastCleanupAt: m.LastCleanupAt,
LastCleanupRemoved: m.LastCleanupRemoved, LastCleanupRemoved: m.LastCleanupRemoved,
} }
@@ -91,6 +92,7 @@ func AuditSettingsToDomain(m models.AuditSettings) audit.Settings {
func AuditSettingsToModel(s audit.Settings) models.AuditSettings { func AuditSettingsToModel(s audit.Settings) models.AuditSettings {
return models.AuditSettings{ return models.AuditSettings{
RetentionDays: s.RetentionDays, RetentionDays: s.RetentionDays,
RetentionMaxGB: s.RetentionMaxGB,
LastCleanupAt: s.LastCleanupAt, LastCleanupAt: s.LastCleanupAt,
LastCleanupRemoved: s.LastCleanupRemoved, LastCleanupRemoved: s.LastCleanupRemoved,
} }
@@ -5,6 +5,7 @@ import "time"
type AuditSettings struct { type AuditSettings struct {
ID uint32 `gorm:"primaryKey"` ID uint32 `gorm:"primaryKey"`
RetentionDays int `gorm:"not null"` RetentionDays int `gorm:"not null"`
RetentionMaxGB int `gorm:"not null;default:0"`
LastCleanupAt *time.Time LastCleanupAt *time.Time
LastCleanupRemoved int `gorm:"not null"` LastCleanupRemoved int `gorm:"not null"`
} }
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/persistence/mappers" "RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"slices"
"strings" "strings"
"time" "time"
@@ -186,3 +187,49 @@ func (a *AuditEventRepository) DeleteOlderThan(cutoff time.Time) (int, error) {
result := a.db.Where("created_at < ?", cutoff.UTC()).Delete(&entity.AuditEvent{}) result := a.db.Where("created_at < ?", cutoff.UTC()).Delete(&entity.AuditEvent{})
return int(result.RowsAffected), result.Error return int(result.RowsAffected), result.Error
} }
const (
deleteChunkSize = 500
payloadSizeSQL = `COALESCE(SUM(
OCTET_LENGTH(action) + OCTET_LENGTH(actor_name) + OCTET_LENGTH(target_type) +
OCTET_LENGTH(target_id) + OCTET_LENGTH(target_name) + COALESCE(OCTET_LENGTH(failure_reason), 0) +
COALESCE(OCTET_LENGTH(ip), 0) + COALESCE(OCTET_LENGTH(user_agent), 0) +
OCTET_LENGTH(params) + OCTET_LENGTH(changes)), 0) AS payload, COUNT(*) AS total`
)
type payloadRow struct {
Payload int64
Total int64
}
func (a *AuditEventRepository) SizeBytes() (int64, error) {
var row payloadRow
if err := a.db.Model(&entity.AuditEvent{}).Select(payloadSizeSQL).Scan(&row).Error; err != nil {
return 0, err
}
return row.Payload + row.Total*audit.RowOverheadBytes, nil
}
func (a *AuditEventRepository) DeleteOldest(limit int, before time.Time) (int, error) {
if limit < 1 {
return 0, nil
}
var ids []uint64
err := a.db.Model(&entity.AuditEvent{}).
Where("created_at < ?", before.UTC()).
Order("created_at ASC, id ASC").
Limit(limit).
Pluck("id", &ids).Error
if err != nil {
return 0, err
}
removed := 0
for chunk := range slices.Chunk(ids, deleteChunkSize) {
result := a.db.Where("id IN ?", chunk).Delete(&entity.AuditEvent{})
if result.Error != nil {
return removed, result.Error
}
removed += int(result.RowsAffected)
}
return removed, nil
}
@@ -34,9 +34,10 @@ func (a *AuditSettingsRepository) Get() (audit.Settings, error) {
return mappers.AuditSettingsToDomain(model), nil return mappers.AuditSettingsToDomain(model), nil
} }
func (a *AuditSettingsRepository) SaveRetention(days int) error { func (a *AuditSettingsRepository) SaveRetention(days int, maxGB int) error {
return a.update(map[string]any{"retention_days": days}, func(settings *audit.Settings) { return a.update(map[string]any{"retention_days": days, "retention_max_gb": maxGB}, func(settings *audit.Settings) {
settings.RetentionDays = days settings.RetentionDays = days
settings.RetentionMaxGB = maxGB
}) })
} }
@@ -8,11 +8,12 @@ import (
type GetActivityRetentionUseCase struct { type GetActivityRetentionUseCase struct {
settings audit.SettingsRepository settings audit.SettingsRepository
events audit.Repository
now func() time.Time now func() time.Time
} }
func NewGetActivityRetentionUseCase(settings audit.SettingsRepository, now func() time.Time) *GetActivityRetentionUseCase { func NewGetActivityRetentionUseCase(settings audit.SettingsRepository, events audit.Repository, now func() time.Time) *GetActivityRetentionUseCase {
return &GetActivityRetentionUseCase{settings, now} return &GetActivityRetentionUseCase{settings, events, now}
} }
func (g *GetActivityRetentionUseCase) GetActivityRetention() (audit.Settings, error) { func (g *GetActivityRetentionUseCase) GetActivityRetention() (audit.Settings, error) {
@@ -20,38 +21,80 @@ func (g *GetActivityRetentionUseCase) GetActivityRetention() (audit.Settings, er
if err != nil { if err != nil {
return audit.Settings{}, err return audit.Settings{}, err
} }
current.LogSizeBytes, err = g.events.SizeBytes()
if err != nil {
return audit.Settings{}, err
}
return current.WithNextCleanup(g.now()), nil return current.WithNextCleanup(g.now()), nil
} }
type UpdateActivityRetentionCommand struct {
Days *int
MaxGB *int
}
type UpdateActivityRetentionUseCase struct { type UpdateActivityRetentionUseCase struct {
settings audit.SettingsRepository settings audit.SettingsRepository
events audit.Repository
recorder audit.Recorder recorder audit.Recorder
now func() time.Time now func() time.Time
} }
func NewUpdateActivityRetentionUseCase(settings audit.SettingsRepository, recorder audit.Recorder, now func() time.Time) *UpdateActivityRetentionUseCase { func NewUpdateActivityRetentionUseCase(settings audit.SettingsRepository, events audit.Repository, recorder audit.Recorder, now func() time.Time) *UpdateActivityRetentionUseCase {
return &UpdateActivityRetentionUseCase{settings, recorder, now} return &UpdateActivityRetentionUseCase{settings, events, recorder, now}
} }
func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Context, days int) (audit.Settings, error) { func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Context, command UpdateActivityRetentionCommand) (audit.Settings, error) {
if err := audit.ValidateRetentionDays(days); err != nil { if command.Days == nil && command.MaxGB == nil {
return audit.Settings{}, err return audit.Settings{}, audit.ErrInvalidRetention
}
if command.Days != nil {
if err := audit.ValidateRetentionDays(*command.Days); err != nil {
return audit.Settings{}, err
}
}
if command.MaxGB != nil {
if err := audit.ValidateRetentionMaxGB(*command.MaxGB); err != nil {
return audit.Settings{}, err
}
} }
current, err := u.settings.Get() current, err := u.settings.Get()
if err != nil { if err != nil {
return audit.Settings{}, err return audit.Settings{}, err
} }
if current.RetentionDays == days { size, err := u.events.SizeBytes()
if err != nil {
return audit.Settings{}, err
}
current.LogSizeBytes = size
updated := current
if command.Days != nil {
updated.RetentionDays = *command.Days
}
if command.MaxGB != nil {
updated.RetentionMaxGB = *command.MaxGB
}
daysChanged := updated.RetentionDays != current.RetentionDays
sizeChanged := updated.RetentionMaxGB != current.RetentionMaxGB
if !daysChanged && !sizeChanged {
return current.WithNextCleanup(u.now()), nil return current.WithNextCleanup(u.now()), nil
} }
if err := u.settings.SaveRetention(days); err != nil { if err := u.settings.SaveRetention(updated.RetentionDays, updated.RetentionMaxGB); err != nil {
return audit.Settings{}, err return audit.Settings{}, err
} }
event := audit.NewEvent(audit.ActionRetentionChanged, audit.CategorySystem) event := audit.NewEvent(audit.ActionRetentionChanged, audit.CategorySystem)
event.Params = map[string]any{audit.ParamFrom: current.RetentionDays, audit.ParamTo: days} event.Params = map[string]any{}
if daysChanged {
event.Params[audit.ParamFrom] = current.RetentionDays
event.Params[audit.ParamTo] = updated.RetentionDays
}
if sizeChanged {
event.Params[audit.ParamMaxGBFrom] = current.RetentionMaxGB
event.Params[audit.ParamMaxGBTo] = updated.RetentionMaxGB
}
u.recorder.Record(ctx, event) u.recorder.Record(ctx, event)
current.RetentionDays = days return updated.WithNextCleanup(u.now()), nil
return current.WithNextCleanup(u.now()), nil
} }
@@ -6,14 +6,22 @@ import (
"time" "time"
) )
const defaultCleanupBatchSize = 1000
type CleanupActivityUseCase struct { type CleanupActivityUseCase struct {
events audit.Repository events audit.Repository
settings audit.SettingsRepository settings audit.SettingsRepository
recorder audit.Recorder recorder audit.Recorder
batchSize int
} }
func NewCleanupActivityUseCase(events audit.Repository, settings audit.SettingsRepository, recorder audit.Recorder) *CleanupActivityUseCase { func NewCleanupActivityUseCase(events audit.Repository, settings audit.SettingsRepository, recorder audit.Recorder) *CleanupActivityUseCase {
return &CleanupActivityUseCase{events, settings, recorder} return &CleanupActivityUseCase{events, settings, recorder, defaultCleanupBatchSize}
}
func (c *CleanupActivityUseCase) WithBatchSize(batchSize int) *CleanupActivityUseCase {
c.batchSize = batchSize
return c
} }
func (c *CleanupActivityUseCase) Tick(now time.Time) (audit.CleanupResult, bool, error) { func (c *CleanupActivityUseCase) Tick(now time.Time) (audit.CleanupResult, bool, error) {
@@ -21,13 +29,23 @@ func (c *CleanupActivityUseCase) Tick(now time.Time) (audit.CleanupResult, bool,
if err != nil { if err != nil {
return audit.CleanupResult{}, false, err return audit.CleanupResult{}, false, err
} }
if settings.RetentionDays == audit.ForeverRetentionDays || !settings.CleanupDue(now) { if !settings.HasRetentionLimit() || !settings.CleanupDue(now) {
return audit.CleanupResult{}, false, nil return audit.CleanupResult{}, false, nil
} }
removed, err := c.events.DeleteOlderThan(audit.RetentionCutoff(now, settings.RetentionDays)) removed := 0
if err != nil { if settings.RetentionDays != audit.ForeverRetentionDays {
return audit.CleanupResult{}, false, err removed, err = c.events.DeleteOlderThan(audit.RetentionCutoff(now, settings.RetentionDays))
if err != nil {
return audit.CleanupResult{}, false, err
}
}
if settings.RetentionMaxGB != audit.NoSizeLimitGB {
trimmed, err := c.trimToSize(now, settings.MaxLogSizeBytes())
if err != nil {
return audit.CleanupResult{}, false, err
}
removed += trimmed
} }
if err := c.settings.SaveLastCleanup(now, removed); err != nil { if err := c.settings.SaveLastCleanup(now, removed); err != nil {
return audit.CleanupResult{}, false, err return audit.CleanupResult{}, false, err
@@ -38,6 +56,28 @@ func (c *CleanupActivityUseCase) Tick(now time.Time) (audit.CleanupResult, bool,
return audit.CleanupResult{Removed: removed}, true, nil return audit.CleanupResult{Removed: removed}, true, nil
} }
func (c *CleanupActivityUseCase) trimToSize(now time.Time, limitBytes int64) (int, error) {
guard := audit.GuardCutoff(now)
removed := 0
for {
size, err := c.events.SizeBytes()
if err != nil {
return removed, err
}
if size <= limitBytes {
return removed, nil
}
batch, err := c.events.DeleteOldest(c.batchSize, guard)
if err != nil {
return removed, err
}
if batch == 0 {
return removed, nil
}
removed += batch
}
}
func (c *CleanupActivityUseCase) recordCleanup(removed int) { func (c *CleanupActivityUseCase) recordCleanup(removed int) {
event := audit.NewEvent(audit.ActionCleanup, audit.CategorySystem) event := audit.NewEvent(audit.ActionCleanup, audit.CategorySystem)
event.Actor = audit.SystemActor() event.Actor = audit.SystemActor()
+62 -5
View File
@@ -4,6 +4,7 @@ set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
work="$(mktemp -d)" work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT trap 'rm -rf "$work"' EXIT
trap 'echo "mutate: interrupted" >&2; exit 130' INT TERM
native() { native() {
if command -v cygpath >/dev/null 2>&1; then if command -v cygpath >/dev/null 2>&1; then
@@ -13,8 +14,14 @@ native() {
fi fi
} }
exclude_pattern() {
local sep='[/\\]'
local name="${1//./[.]}"
printf '%s' "(^|${sep})${name}\$"
}
repo="$work/repo" repo="$work/repo"
mkdir -p "$repo" "$work/tmp" "$root/tmp" mkdir -p "$repo" "$repo/tmp" "$repo/.git" "$work/tmp" "$root/tmp"
tar -C "$root" -c \ tar -C "$root" -c \
--exclude=./bin --exclude=./web --exclude=./node_modules --exclude=./.git \ --exclude=./bin --exclude=./web --exclude=./node_modules --exclude=./.git \
@@ -26,12 +33,62 @@ export GOCACHE="$(native "$work/gocache")"
export TEMP="$(native "$work/tmp")" export TEMP="$(native "$work/tmp")"
export TMP="$TEMP" export TMP="$TEMP"
export TMPDIR="$TEMP" export TMPDIR="$TEMP"
export GOFLAGS="-buildvcs=false${MUTATE_RUN:+ -run=$MUTATE_RUN}"
cd "$repo" cd "$repo"
code=0 code=0
gremlins unleash --integration --tags nofrontend --workers 4 \ scopes=(
--coverpkg=./internal/infrastructure/useCases/...,./api/middleware/...,./api/pkg/claims/... \ ./internal/infrastructure/useCases
--timeout-coefficient 10 --output "$(native "$root/tmp/gremlins.json")" \ ./api/middleware
./internal/infrastructure/useCases ./api/middleware ./api/pkg/claims || code=$? ./api/pkg/claims
./internal/domain/audit
./internal/infrastructure/persistence/repositories
./api/handlers/settings
)
if [ "$#" -gt 0 ]; then
scopes=("$@")
fi
coverpkg=""
for scope in "${scopes[@]}"; do
coverpkg="${coverpkg:+$coverpkg,}$scope/..."
done
parts=()
i=0
for scope in "${scopes[@]}"; do
i=$((i + 1))
part="$work/part$i.json"
rc=0
excludes=()
if [ -n "${MUTATE_FILES:-}" ]; then
keep=0
for f in "$scope"/*.go; do
base="$(basename "$f")"
case "$base" in *_test.go) continue ;; esac
case ",${MUTATE_FILES}," in
*",$base,"*) keep=$((keep + 1)) ;;
*) excludes+=(-E "$(exclude_pattern "$base")") ;;
esac
done
if [ "$keep" -eq 0 ]; then continue; fi
fi
gremlins unleash --integration --tags nofrontend --workers "${MUTATE_WORKERS:-4}" --coverpkg="$coverpkg" --timeout-coefficient 10 ${excludes[@]+"${excludes[@]}"} --output "$(native "$part")" "$scope" || rc=$?
if [ "$rc" -ge 128 ]; then exit "$rc"; fi
if [ "$rc" -ne 0 ]; then code=$rc; fi
if [ -f "$part" ]; then parts+=("$(native "$part")"); fi
done
python - "$(native "$root/tmp/gremlins.json")" "${parts[@]}" <<'PY'
import json, sys
out, parts = sys.argv[1], sys.argv[2:]
merged = None
for p in parts:
d = json.load(open(p))
if merged is None:
merged = d
else:
merged["files"] = merged.get("files", []) + d.get("files", [])
if merged is None:
merged = {"files": []}
json.dump(merged, open(out, "w"), indent=2)
PY
go clean -cache >/dev/null 2>&1 || true go clean -cache >/dev/null 2>&1 || true
exit "$code" exit "$code"
+1 -1
View File
@@ -76,7 +76,7 @@ func TestActivityDomain(t *testing.T) {
names = append(names, repoType.Method(i).Name) names = append(names, repoType.Method(i).Name)
} }
sort.Strings(names) sort.Strings(names)
wanted := []string{"Append", "DeleteOlderThan", "List", "Summary"} wanted := []string{"Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
if !reflect.DeepEqual(names, wanted) { if !reflect.DeepEqual(names, wanted) {
t.Errorf("expected methods %v, got %v", wanted, names) t.Errorf("expected methods %v, got %v", wanted, names)
} }
+3 -3
View File
@@ -81,8 +81,8 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, e.users, h.clock, h.recorder), RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, e.users, h.clock, h.recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, h.clock), AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, h.clock),
GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"), GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.clock), GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock), UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock),
ListActivityUseCase: useCases.NewListActivityUseCase(h.probe, e.users, e.articles, newMockImageRepo(), e.keys, h.clock), ListActivityUseCase: useCases.NewListActivityUseCase(h.probe, e.users, e.articles, newMockImageRepo(), e.keys, h.clock),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(h.probe, h.clock), GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(h.probe, h.clock),
ExportActivityUseCase: useCases.NewExportActivityUseCase(h.probe, h.clock), ExportActivityUseCase: useCases.NewExportActivityUseCase(h.probe, h.clock),
@@ -670,7 +670,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) {
rec := e.get("/v1/settings", asUser(e, activityEditorID)) rec := e.get("/v1/settings", asUser(e, activityEditorID))
assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00Z"}`, rec.Body.String()) assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_retention_max_gb":0,"activity_log_size_bytes":0,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00Z"}`, rec.Body.String())
}) })
t.Run("The last cleanup is reported", func(t *testing.T) { t.Run("The last cleanup is reported", func(t *testing.T) {
+8 -8
View File
@@ -50,7 +50,7 @@ func TestSettingsNextCleanupDomain(t *testing.T) {
func TestSettingsNextCleanupUseCases(t *testing.T) { func TestSettingsNextCleanupUseCases(t *testing.T) {
t.Run("Get returns the next cleanup from the clock or nil for Forever", func(t *testing.T) { t.Run("Get returns the next cleanup from the clock or nil for Forever", func(t *testing.T) {
fake := newActivitySettingsFake() fake := newActivitySettingsFake()
get := useCases.NewGetActivityRetentionUseCase(fake, settingsTestClock) get := useCases.NewGetActivityRetentionUseCase(fake, newSizeEvents(t), settingsTestClock)
current, err := get.GetActivityRetention() current, err := get.GetActivityRetention()
if err != nil || current.NextCleanupAt == nil || current.NextCleanupAt.Format(time.RFC3339) != "2026-10-07T03:00:00+02:00" { if err != nil || current.NextCleanupAt == nil || current.NextCleanupAt.Format(time.RFC3339) != "2026-10-07T03:00:00+02:00" {
@@ -66,17 +66,17 @@ func TestSettingsNextCleanupUseCases(t *testing.T) {
t.Run("Update returns the next cleanup of the new period", func(t *testing.T) { t.Run("Update returns the next cleanup of the new period", func(t *testing.T) {
fake := newActivitySettingsFake() fake := newActivitySettingsFake()
update := useCases.NewUpdateActivityRetentionUseCase(fake, discardRecorder, settingsTestClock) update := useCases.NewUpdateActivityRetentionUseCase(fake, newSizeEvents(t), discardRecorder, settingsTestClock)
forever, err := update.UpdateActivityRetention(testCtx, 0) forever, err := update.UpdateActivityRetention(testCtx, retentionDays(0))
if err != nil || forever.RetentionDays != 0 || forever.NextCleanupAt != nil { if err != nil || forever.RetentionDays != 0 || forever.NextCleanupAt != nil {
t.Fatalf("unexpected %+v, %v", forever, err) t.Fatalf("unexpected %+v, %v", forever, err)
} }
back, err := update.UpdateActivityRetention(testCtx, 365) back, err := update.UpdateActivityRetention(testCtx, retentionDays(365))
if err != nil || back.NextCleanupAt == nil || back.NextCleanupAt.Format(time.RFC3339) != "2026-10-07T03:00:00+02:00" { if err != nil || back.NextCleanupAt == nil || back.NextCleanupAt.Format(time.RFC3339) != "2026-10-07T03:00:00+02:00" {
t.Fatalf("unexpected %+v, %v", back, err) t.Fatalf("unexpected %+v, %v", back, err)
} }
same, err := update.UpdateActivityRetention(testCtx, 365) same, err := update.UpdateActivityRetention(testCtx, retentionDays(365))
if err != nil || same.NextCleanupAt == nil { if err != nil || same.NextCleanupAt == nil {
t.Fatalf("unexpected %+v, %v", same, err) t.Fatalf("unexpected %+v, %v", same, err)
} }
@@ -84,7 +84,7 @@ func TestSettingsNextCleanupUseCases(t *testing.T) {
t.Run("Update refuses an invalid period and saves nothing", func(t *testing.T) { t.Run("Update refuses an invalid period and saves nothing", func(t *testing.T) {
fake := newActivitySettingsFake() fake := newActivitySettingsFake()
_, err := useCases.NewUpdateActivityRetentionUseCase(fake, discardRecorder, settingsTestClock).UpdateActivityRetention(testCtx, 7) _, err := useCases.NewUpdateActivityRetentionUseCase(fake, newSizeEvents(t), discardRecorder, settingsTestClock).UpdateActivityRetention(testCtx, retentionDays(7))
if !errors.Is(err, audit.ErrInvalidRetention) || fake.retentionOK != 0 { if !errors.Is(err, audit.ErrInvalidRetention) || fake.retentionOK != 0 {
t.Fatalf("expected a refusal, got %v", err) t.Fatalf("expected a refusal, got %v", err)
} }
@@ -101,7 +101,7 @@ func TestSettingsNextCleanupHttp(t *testing.T) {
return decoded return decoded
} }
t.Run("GET keeps the existing fields and adds exactly one", func(t *testing.T) { t.Run("GET keeps the existing fields and adds exactly one next cleanup and the size fields", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000) e := newActivityHTTPEnv(t, 1000)
e.settings.settings.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 1, 0, 0, 0, time.UTC)) e.settings.settings.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 1, 0, 0, 0, time.UTC))
e.settings.settings.LastCleanupRemoved = 142 e.settings.settings.LastCleanupRemoved = 142
@@ -109,7 +109,7 @@ func TestSettingsNextCleanupHttp(t *testing.T) {
rec := e.get("/v1/settings", asUser(e, activityEditorID)) rec := e.get("/v1/settings", asUser(e, activityEditorID))
body := keys(t, rec.Body.String()) body := keys(t, rec.Body.String())
if rec.Code != http.StatusOK || len(body) != 5 || body["activity_next_cleanup_at"] != "2026-10-07T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) { if rec.Code != http.StatusOK || len(body) != 7 || body["activity_next_cleanup_at"] != "2026-10-07T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) {
t.Errorf("unexpected %d %v", rec.Code, body) t.Errorf("unexpected %d %v", rec.Code, body)
} }
}) })
+2 -2
View File
@@ -108,7 +108,7 @@ func TestActivityPersistence(t *testing.T) {
names = append(names, repoType.Method(i).Name) names = append(names, repoType.Method(i).Name)
} }
sort.Strings(names) sort.Strings(names)
wanted := []string{"Append", "DeleteOlderThan", "List", "Summary"} wanted := []string{"Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
if !reflect.DeepEqual(names, wanted) { if !reflect.DeepEqual(names, wanted) {
t.Errorf("expected %v, got %v", wanted, names) t.Errorf("expected %v, got %v", wanted, names)
} }
@@ -375,7 +375,7 @@ func TestActivitySettingsPersistence(t *testing.T) {
if settings.RetentionDays != 90 || settings.LastCleanupAt == nil || !settings.LastCleanupAt.Equal(at) || settings.LastCleanupRemoved != 142 { if settings.RetentionDays != 90 || settings.LastCleanupAt == nil || !settings.LastCleanupAt.Equal(at) || settings.LastCleanupRemoved != 142 {
t.Fatalf("unexpected settings %+v", settings) t.Fatalf("unexpected settings %+v", settings)
} }
if err := repo.SaveRetention(0); err != nil { if err := repo.SaveRetention(0, 0); err != nil {
t.Fatal(err) t.Fatal(err)
} }
settings, _ = repo.Get() settings, _ = repo.Get()
+190
View File
@@ -0,0 +1,190 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"encoding/json"
"net/http"
"reflect"
"sort"
"testing"
)
func sizeSettingsBody(t *testing.T, body []byte) map[string]any {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatalf("invalid json %q: %v", body, err)
}
return decoded
}
func TestSettingsRetentionSizeHttp(t *testing.T) {
owner := func(e *activityHTTPEnv) activityRequest { return asUser(e, activityOwnerID) }
t.Run("Settings API, GET reports the size limit and the measured size", func(t *testing.T) {
cases := []struct {
days, gb int
bytes int64
next any
}{
{90, 0, 0, "2026-10-07T03:00:00+02:00"},
{90, 5, 3435973837, "2026-10-07T03:00:00+02:00"},
{0, 5, 3435973837, "2026-10-07T03:00:00+02:00"},
{0, 0, 1024, nil},
}
keys := []string{"version", "activity_retention_days", "activity_retention_max_gb", "activity_log_size_bytes", "activity_last_cleanup_at", "activity_last_cleanup_removed", "activity_next_cleanup_at"}
sort.Strings(keys)
for _, c := range cases {
e := newActivityHTTPEnv(t, 1000)
e.now = settingsTestClock()
e.settings.settings.RetentionDays = c.days
e.settings.settings.RetentionMaxGB = c.gb
e.probe.sizeBytes = &c.bytes
rec := e.get("/v1/settings", asUser(e, activityEditorID))
body := sizeSettingsBody(t, rec.Body.Bytes())
var got []string
for key := range body {
got = append(got, key)
}
sort.Strings(got)
if rec.Code != http.StatusOK || !reflect.DeepEqual(got, keys) {
t.Fatalf("unexpected %d %v", rec.Code, got)
}
if body["activity_retention_max_gb"] != float64(c.gb) || body["activity_log_size_bytes"] != float64(c.bytes) || body["activity_next_cleanup_at"] != c.next {
t.Errorf("%+v: unexpected body %v", c, body)
}
}
})
t.Run("Settings API, a fresh install defaults to no size limit", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
body := sizeSettingsBody(t, e.get("/v1/settings", asUser(e, activityEditorID)).Body.Bytes())
if body["activity_retention_days"] != float64(90) || body["activity_retention_max_gb"] != float64(0) || body["activity_log_size_bytes"] != float64(0) {
t.Errorf("unexpected body %v", body)
}
})
t.Run("Settings API, PATCH saves a size limit, an age, or both", func(t *testing.T) {
cases := []struct {
body string
days, gb int
null bool
}{
{`{"activity_retention_max_gb": 5}`, 90, 5, false},
{`{"activity_retention_days": 0, "activity_retention_max_gb": 5}`, 0, 5, false},
{`{"activity_retention_days": 30}`, 30, 0, false},
{`{"activity_retention_days": 0}`, 0, 0, true},
}
for _, c := range cases {
e := newActivityHTTPEnv(t, 1000)
rec := e.send(http.MethodPatch, "/v1/settings", c.body, owner(e))
body := sizeSettingsBody(t, rec.Body.Bytes())
next, present := body["activity_next_cleanup_at"]
if rec.Code != http.StatusOK || body["activity_retention_days"] != float64(c.days) || body["activity_retention_max_gb"] != float64(c.gb) || !present || (next == nil) != c.null {
t.Errorf("%s: unexpected %d %v", c.body, rec.Code, body)
}
if e.settings.settings.RetentionDays != c.days || e.settings.settings.RetentionMaxGB != c.gb {
t.Errorf("%s: the repository holds %+v", c.body, e.settings.settings)
}
}
})
t.Run("Settings API, leaving the size limit while Forever clears the schedule", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.settings.settings.RetentionDays = 0
e.settings.settings.RetentionMaxGB = 5
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_max_gb": 0}`, owner(e))
body := sizeSettingsBody(t, rec.Body.Bytes())
next, present := body["activity_next_cleanup_at"]
if rec.Code != http.StatusOK || !present || next != nil {
t.Errorf("unexpected %d %v", rec.Code, body)
}
})
t.Run("Settings API, PATCH rejects invalid size input", func(t *testing.T) {
bodies := []string{
`{"activity_retention_max_gb": 2}`,
`{"activity_retention_max_gb": -1}`,
`{"activity_retention_max_gb": 100}`,
`{"activity_retention_max_gb": 5.5}`,
`{"activity_retention_max_gb": "5"}`,
`{"activity_retention_max_gb": null}`,
`{"activity_retention_days": 30, "activity_retention_max_gb": 2}`,
`{"activity_retention_days": null}`,
`{"activity_retention_days": 30, "activity_retention_max_gb": null}`,
`{"activity_retention_days": null, "activity_retention_max_gb": 5}`,
`[1]`,
`{}`,
`not json`,
}
for _, body := range bodies {
e := newActivityHTTPEnv(t, 1000)
e.settings.settings.RetentionMaxGB = 5
assertPlain(t, e.send(http.MethodPatch, "/v1/settings", body, owner(e)), http.StatusBadRequest, activityMismatch)
if e.settings.retentionOK != 0 || e.settings.settings.RetentionDays != 90 || e.settings.settings.RetentionMaxGB != 5 || len(e.all(t)) != 0 {
t.Errorf("%s: something was saved", body)
}
}
})
t.Run("Settings API, a PATCH changing nothing is a no-op", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.settings.settings.RetentionMaxGB = 5
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 90, "activity_retention_max_gb": 5}`, owner(e))
if rec.Code != http.StatusOK || e.settings.retentionOK != 0 || len(e.all(t)) != 0 {
t.Errorf("unexpected %d, saves %d, events %d", rec.Code, e.settings.retentionOK, len(e.all(t)))
}
})
t.Run("Settings API, a non-owner cannot set the size limit", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_max_gb": 5}`, asUser(e, activityAdminID))
assertPlain(t, rec, http.StatusForbidden, "Only the owner can do this.")
if e.settings.retentionOK != 0 {
t.Error("the admin changed something")
}
assertPlain(t, e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_max_gb": 5}`), http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
})
t.Run("Settings API, a failing size measurement answers 500", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.probe.sizeErr = errRepositoryFailure
assertPlain(t, e.get("/v1/settings", asUser(e, activityEditorID)), http.StatusInternalServerError, "Internal server error")
})
t.Run("Settings API, the change is audited", func(t *testing.T) {
cases := []struct {
body string
params map[string]any
}{
{`{"activity_retention_days": 30}`, map[string]any{"from": float64(90), "to": float64(30)}},
{`{"activity_retention_max_gb": 5}`, map[string]any{"max_gb_from": float64(0), "max_gb_to": float64(5)}},
{`{"activity_retention_days": 30, "activity_retention_max_gb": 5}`, map[string]any{"from": float64(90), "to": float64(30), "max_gb_from": float64(0), "max_gb_to": float64(5)}},
}
for _, c := range cases {
e := newActivityHTTPEnv(t, 1000)
e.send(http.MethodPatch, "/v1/settings", c.body, owner(e))
events := e.all(t)
if len(events) != 1 || events[0].Action != "system.activity_retention_changed" || !reflect.DeepEqual(events[0].Params, c.params) {
t.Errorf("%s: unexpected events %+v", c.body, events)
}
}
})
}
+623
View File
@@ -0,0 +1,623 @@
package test
import (
"RenewCMS/internal/domain/audit"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"context"
"encoding/json"
"errors"
"reflect"
"sort"
"testing"
"time"
"gorm.io/gorm"
)
const sizeTestGB = audit.BytesPerGB
type sizedEvent struct {
id uint64
at time.Time
bytes int64
}
type sizedEventsFake struct {
events []sizedEvent
ageCalls int
oldestCalls int
sizeCalls int
sizeErr error
oldestErr error
}
var _ audit.Repository = &sizedEventsFake{}
func (f *sizedEventsFake) add(at time.Time, bytes int64) {
f.events = append(f.events, sizedEvent{id: uint64(len(f.events) + 1), at: at, bytes: bytes})
}
func (f *sizedEventsFake) Append(event audit.Event) (audit.Event, error) { return event, nil }
func (f *sizedEventsFake) List(audit.Filter) (audit.Page, error) { return audit.Page{}, nil }
func (f *sizedEventsFake) Summary(audit.SummaryQuery) (audit.Summary, error) {
return audit.Summary{}, nil
}
func (f *sizedEventsFake) DeleteOlderThan(cutoff time.Time) (int, error) {
f.ageCalls++
kept := f.events[:0:0]
removed := 0
for _, event := range f.events {
if event.at.Before(cutoff) {
removed++
continue
}
kept = append(kept, event)
}
f.events = kept
return removed, nil
}
func (f *sizedEventsFake) DeleteOldest(limit int, before time.Time) (int, error) {
f.oldestCalls++
if f.oldestErr != nil {
return 0, f.oldestErr
}
sort.SliceStable(f.events, func(i, j int) bool {
if f.events[i].at.Equal(f.events[j].at) {
return f.events[i].id < f.events[j].id
}
return f.events[i].at.Before(f.events[j].at)
})
removed := 0
for removed < limit && removed < len(f.events) && f.events[removed].at.Before(before) {
removed++
}
f.events = f.events[removed:]
return removed, nil
}
func (f *sizedEventsFake) SizeBytes() (int64, error) {
f.sizeCalls++
if f.sizeErr != nil {
return 0, f.sizeErr
}
var total int64
for _, event := range f.events {
total += event.bytes
}
return total, nil
}
type recordingRecorder struct {
events []audit.Event
}
func (r *recordingRecorder) Record(_ context.Context, event audit.Event) {
r.events = append(r.events, event)
}
type sizeCleanupEnv struct {
events *sizedEventsFake
settings *activitySettingsFake
recorder *recordingRecorder
cleanup *useCases.CleanupActivityUseCase
now time.Time
}
func newSizeCleanupEnv(days int, maxGB int) *sizeCleanupEnv {
e := &sizeCleanupEnv{
events: &sizedEventsFake{},
settings: newActivitySettingsFake(),
recorder: &recordingRecorder{},
now: cleanupAt(6, 3, 0),
}
e.settings.settings.RetentionDays = days
e.settings.settings.RetentionMaxGB = maxGB
e.cleanup = useCases.NewCleanupActivityUseCase(e.events, e.settings, e.recorder).WithBatchSize(1)
return e
}
func (e *sizeCleanupEnv) daysAgo(days int) time.Time {
return e.now.Add(-time.Duration(days) * 24 * time.Hour)
}
func TestActivityRetentionSizeDomain(t *testing.T) {
t.Run("The size options are 0, 1, 5, 10, 25 and 50", func(t *testing.T) {
if !reflect.DeepEqual(audit.SizeOptions(), []int{0, 1, 5, 10, 25, 50}) {
t.Errorf("unexpected options %v", audit.SizeOptions())
}
for _, gb := range []int{0, 1, 5, 10, 25, 50} {
if err := audit.ValidateRetentionMaxGB(gb); err != nil {
t.Errorf("%d should be valid: %v", gb, err)
}
}
for _, gb := range []int{-1, 2, 3, 100, 51} {
if err := audit.ValidateRetentionMaxGB(gb); !errors.Is(err, audit.ErrInvalidRetention) {
t.Errorf("%d should be invalid, got %v", gb, err)
}
}
})
t.Run("A fresh install has no size limit", func(t *testing.T) {
if audit.DefaultSettings().RetentionMaxGB != 0 || audit.BytesPerGB != 1024*1024*1024 {
t.Errorf("unexpected defaults %+v", audit.DefaultSettings())
}
})
t.Run("The next cleanup is empty only when both limits are 0", func(t *testing.T) {
now := time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone)
cases := []struct {
days, gb int
scheduled bool
}{{90, 0, true}, {90, 5, true}, {0, 5, true}, {0, 0, false}}
for _, c := range cases {
next := audit.Settings{RetentionDays: c.days, RetentionMaxGB: c.gb}.WithNextCleanup(now).NextCleanupAt
if (next != nil) != c.scheduled {
t.Errorf("days %d gb %d: unexpected %v", c.days, c.gb, next)
}
}
})
t.Run("The limit in bytes is gigabytes times 1024 cubed", func(t *testing.T) {
if got := (audit.Settings{RetentionMaxGB: 5}).MaxLogSizeBytes(); got != 5368709120 {
t.Errorf("unexpected %d", got)
}
})
}
func TestActivityRetentionSizeCleanup(t *testing.T) {
t.Run("The size limit removes the oldest events", func(t *testing.T) {
for _, days := range []int{90, 0} {
e := newSizeCleanupEnv(days, 5)
for age := 20; age > 13; age-- {
e.events.add(e.daysAgo(age), sizeTestGB)
}
result, ran, err := e.cleanup.Tick(e.now)
size, _ := e.events.SizeBytes()
if err != nil || !ran || result.Removed != 2 || size != 5*sizeTestGB {
t.Fatalf("days %d: unexpected %+v ran=%v size=%d err=%v", days, result, ran, size, err)
}
if e.events.events[0].id != 3 {
t.Errorf("days %d: the oldest events must go first, first left is %d", days, e.events.events[0].id)
}
last := e.settings.settings
if last.LastCleanupAt == nil || !last.LastCleanupAt.Equal(e.now) || last.LastCleanupRemoved != 2 {
t.Errorf("days %d: unexpected last cleanup %+v", days, last)
}
}
})
t.Run("Age first then size, and one event with the total", func(t *testing.T) {
e := newSizeCleanupEnv(30, 5)
for i := 0; i < 10; i++ {
e.events.add(e.daysAgo(40), 10)
}
for age := 20; age > 13; age-- {
e.events.add(e.daysAgo(age), sizeTestGB)
}
result, _, err := e.cleanup.Tick(e.now)
if err != nil || result.Removed != 12 || e.settings.settings.LastCleanupRemoved != 12 {
t.Fatalf("expected 12 removed, got %+v, %v", result, err)
}
if len(e.recorder.events) != 1 || e.recorder.events[0].Action != "system.activity_cleanup" || e.recorder.events[0].Params[audit.ParamRemoved] != 12 {
t.Errorf("unexpected events %+v", e.recorder.events)
}
})
t.Run("The age limit alone is enough", func(t *testing.T) {
e := newSizeCleanupEnv(30, 5)
e.events.add(e.daysAgo(40), 10*sizeTestGB)
e.events.add(e.daysAgo(2), sizeTestGB)
result, _, err := e.cleanup.Tick(e.now)
if err != nil || result.Removed != 1 || e.events.oldestCalls != 0 {
t.Errorf("unexpected %+v, %d size removals, %v", result, e.events.oldestCalls, err)
}
})
t.Run("The size is already met", func(t *testing.T) {
e := newSizeCleanupEnv(0, 5)
e.events.add(e.daysAgo(10), sizeTestGB)
result, ran, err := e.cleanup.Tick(e.now)
if err != nil || !ran || result.Removed != 0 || e.events.oldestCalls != 0 || len(e.recorder.events) != 0 {
t.Errorf("unexpected %+v ran=%v %v", result, ran, err)
}
})
t.Run("Forever with no size limit never runs", func(t *testing.T) {
e := newSizeCleanupEnv(0, 0)
e.events.add(e.daysAgo(10), 100*sizeTestGB)
_, ran, err := e.cleanup.Tick(e.now)
if err != nil || ran || e.events.ageCalls != 0 || e.events.oldestCalls != 0 || e.events.sizeCalls != 0 {
t.Errorf("expected nothing, got ran=%v %v", ran, err)
}
})
t.Run("Forever with a size limit runs and does no age removal", func(t *testing.T) {
e := newSizeCleanupEnv(0, 1)
e.events.add(e.daysAgo(400), 2*sizeTestGB)
_, ran, err := e.cleanup.Tick(e.now)
if err != nil || !ran || e.events.ageCalls != 0 || len(e.events.events) != 0 {
t.Errorf("unexpected ran=%v %v", ran, err)
}
})
t.Run("The last 24 hours are protected even when the size cannot be met", func(t *testing.T) {
e := newSizeCleanupEnv(0, 1)
for i := 0; i < 4; i++ {
e.events.add(e.now.Add(-time.Duration(i+1)*time.Hour), sizeTestGB)
}
result, ran, err := e.cleanup.Tick(e.now)
if err != nil || !ran || result.Removed != 0 || len(e.events.events) != 4 {
t.Fatalf("unexpected %+v ran=%v %v", result, ran, err)
}
if e.settings.settings.LastCleanupAt == nil || e.settings.settings.LastCleanupRemoved != 0 || len(e.recorder.events) != 0 {
t.Errorf("the run must be recorded with 0 removed, got %+v", e.settings.settings)
}
})
t.Run("A failing size removal is retried", func(t *testing.T) {
e := newSizeCleanupEnv(0, 1)
e.events.add(e.daysAgo(10), 4*sizeTestGB)
e.events.oldestErr = errRepositoryFailure
_, ran, err := e.cleanup.Tick(e.now)
if !errors.Is(err, errRepositoryFailure) || ran || e.settings.settings.LastCleanupAt != nil {
t.Fatalf("unexpected ran=%v %v", ran, err)
}
e.events.oldestErr = nil
if _, ran, err = e.cleanup.Tick(e.now.Add(time.Hour)); err != nil || !ran {
t.Errorf("expected the next tick to run, got ran=%v %v", ran, err)
}
})
t.Run("A failing size measurement is returned", func(t *testing.T) {
e := newSizeCleanupEnv(0, 1)
e.events.sizeErr = errRepositoryFailure
_, ran, err := e.cleanup.Tick(e.now)
if !errors.Is(err, errRepositoryFailure) || ran {
t.Errorf("unexpected ran=%v %v", ran, err)
}
})
t.Run("The batch loop ends when a batch removes nothing", func(t *testing.T) {
e := newSizeCleanupEnv(0, 1)
e.events.add(e.daysAgo(10), 3*sizeTestGB)
e.events.add(e.now.Add(-time.Hour), 3*sizeTestGB)
_, _, err := e.cleanup.Tick(e.now)
if err != nil || e.events.oldestCalls != 2 || len(e.events.events) != 1 {
t.Errorf("unexpected %d calls, %d left, %v", e.events.oldestCalls, len(e.events.events), err)
}
})
}
func TestActivityRetentionSizeUseCases(t *testing.T) {
newEnv := func() (*activitySettingsFake, *sizedEventsFake, *recordingRecorder, *useCases.UpdateActivityRetentionUseCase) {
settings := newActivitySettingsFake()
events := &sizedEventsFake{}
recorder := &recordingRecorder{}
return settings, events, recorder, useCases.NewUpdateActivityRetentionUseCase(settings, events, recorder, settingsTestClock)
}
t.Run("Get carries the measured size", func(t *testing.T) {
settings := newActivitySettingsFake()
settings.settings.RetentionMaxGB = 5
events := &sizedEventsFake{}
events.add(settingsTestClock(), 3435973837)
current, err := useCases.NewGetActivityRetentionUseCase(settings, events, settingsTestClock).GetActivityRetention()
if err != nil || current.LogSizeBytes != 3435973837 || current.RetentionMaxGB != 5 {
t.Errorf("unexpected %+v, %v", current, err)
}
})
t.Run("A size failure propagates from Get and Update", func(t *testing.T) {
settings, events, _, update := newEnv()
events.sizeErr = errRepositoryFailure
_, getErr := useCases.NewGetActivityRetentionUseCase(settings, events, settingsTestClock).GetActivityRetention()
_, updateErr := update.UpdateActivityRetention(testCtx, retentionMaxGB(5))
if !errors.Is(getErr, errRepositoryFailure) || !errors.Is(updateErr, errRepositoryFailure) || settings.retentionOK != 0 {
t.Errorf("unexpected %v, %v", getErr, updateErr)
}
})
t.Run("An empty command or an invalid size is refused and saves nothing", func(t *testing.T) {
settings, _, recorder, update := newEnv()
two := 2
for _, command := range []useCases.UpdateActivityRetentionCommand{{}, {MaxGB: &two}, {Days: activityPtr(30), MaxGB: &two}} {
if _, err := update.UpdateActivityRetention(testCtx, command); !errors.Is(err, audit.ErrInvalidRetention) {
t.Errorf("expected a refusal, got %v", err)
}
}
if settings.retentionOK != 0 || len(recorder.events) != 0 {
t.Error("something was saved")
}
})
t.Run("Each key leaves the other unchanged", func(t *testing.T) {
settings, _, _, update := newEnv()
sized, err := update.UpdateActivityRetention(testCtx, retentionMaxGB(5))
if err != nil || sized.RetentionDays != 90 || sized.RetentionMaxGB != 5 {
t.Fatalf("unexpected %+v, %v", sized, err)
}
aged, err := update.UpdateActivityRetention(testCtx, retentionDays(30))
if err != nil || aged.RetentionDays != 30 || aged.RetentionMaxGB != 5 || settings.settings.RetentionMaxGB != 5 {
t.Fatalf("unexpected %+v, %v", aged, err)
}
})
t.Run("The audit params follow what changed", func(t *testing.T) {
cases := []struct {
name string
command useCases.UpdateActivityRetentionCommand
params map[string]any
}{
{"age", retentionDays(30), map[string]any{"from": 90, "to": 30}},
{"size", retentionMaxGB(5), map[string]any{"max_gb_from": 0, "max_gb_to": 5}},
{"both", useCases.UpdateActivityRetentionCommand{Days: activityPtr(30), MaxGB: activityPtr(5)}, map[string]any{"from": 90, "to": 30, "max_gb_from": 0, "max_gb_to": 5}},
}
for _, c := range cases {
_, _, recorder, update := newEnv()
if _, err := update.UpdateActivityRetention(testCtx, c.command); err != nil {
t.Fatal(err)
}
if len(recorder.events) != 1 || recorder.events[0].Action != "system.activity_retention_changed" || !reflect.DeepEqual(recorder.events[0].Params, c.params) {
t.Errorf("%s: unexpected events %+v", c.name, recorder.events)
}
}
})
t.Run("Saving the same pair is a no-op", func(t *testing.T) {
settings, _, recorder, update := newEnv()
settings.settings.RetentionMaxGB = 5
current, err := update.UpdateActivityRetention(testCtx, useCases.UpdateActivityRetentionCommand{Days: activityPtr(90), MaxGB: activityPtr(5)})
if err != nil || current.RetentionMaxGB != 5 || settings.retentionOK != 0 || len(recorder.events) != 0 {
t.Errorf("unexpected %+v, %v", current, err)
}
})
}
func TestActivityRetentionSizePersistence(t *testing.T) {
text := func(event audit.Event) int64 {
var total int64
for _, field := range []string{event.Action, event.Actor.Name, event.Target.Type, event.Target.ID, event.Target.Name} {
total += int64(len([]byte(field)))
}
if event.FailureReason != nil {
total += int64(len(*event.FailureReason))
}
if event.IP != nil {
total += int64(len(*event.IP))
}
if event.UserAgent != nil {
total += int64(len(*event.UserAgent))
}
return total
}
t.Run("SizeBytes is 0 for an empty table", func(t *testing.T) {
repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
size, err := repo.SizeBytes()
if err != nil || size != 0 {
t.Errorf("unexpected %d, %v", size, err)
}
})
t.Run("SizeBytes sums the byte lengths of the text columns plus the row overhead", func(t *testing.T) {
repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
plain := activityUserEvent(1, "marta", activityDay(1, 10))
plain.Params = map[string]any{"a": 1}
accented := activityUserEvent(2, "Zoë Çelik 日本", activityDay(2, 10))
accented.IP = activityPtr("203.0.113.12")
accented.UserAgent = activityPtr("Mozilla/5.0 é")
accented.FailureReason = activityPtr("Wrong username or password.")
accented.Changes = []audit.Change{{Field: "title", Kind: audit.ChangeText, Before: activityPtr("é"), After: activityPtr("e")}}
var wanted int64
for _, event := range []audit.Event{plain, accented} {
stored := activityMustAppend(t, repo, event)
wanted += text(stored) + int64(len(mustMarshalParams(t, stored))) + int64(len(mustMarshalChanges(t, stored))) + audit.RowOverheadBytes
}
size, err := repo.SizeBytes()
if err != nil || size != wanted {
t.Errorf("expected %d, got %d, %v", wanted, size, err)
}
})
t.Run("Deleting events lowers the size", func(t *testing.T) {
repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
activityMustAppend(t, repo, activityUserEvent(1, "a", activityDay(1, 10)))
activityMustAppend(t, repo, activityUserEvent(1, "a", activityDay(20, 10)))
before, _ := repo.SizeBytes()
if _, err := repo.DeleteOlderThan(activityDay(10, 10)); err != nil {
t.Fatal(err)
}
after, err := repo.SizeBytes()
if err != nil || after >= before || after <= 0 {
t.Errorf("expected a lower size than %d, got %d, %v", before, after, err)
}
})
t.Run("DeleteOldest removes the oldest events up to a limit and a guard", func(t *testing.T) {
repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
now := activityDay(20, 12)
var ids []uint64
for i := 0; i < 7; i++ {
ids = append(ids, activityMustAppend(t, repo, activityUserEvent(1, "a", now.Add(-time.Duration(10-i)*24*time.Hour))).ID)
}
for i := 0; i < 3; i++ {
ids = append(ids, activityMustAppend(t, repo, activityUserEvent(1, "a", now.Add(-time.Duration(i+1)*time.Hour))).ID)
}
guard := audit.GuardCutoff(now)
removed, err := repo.DeleteOldest(5, guard)
if err != nil || removed != 5 {
t.Fatalf("expected 5 removed, got %d, %v", removed, err)
}
page, _ := repo.List(audit.Filter{Page: 1, Size: 100})
left := activityIDs(page.Items)
sort.Slice(left, func(i, j int) bool { return left[i] < left[j] })
if !reflect.DeepEqual(left, ids[5:]) {
t.Errorf("expected %v left, got %v", ids[5:], left)
}
removed, err = repo.DeleteOldest(20, guard)
page, _ = repo.List(audit.Filter{Page: 1, Size: 100})
if err != nil || removed != 2 || page.Total != 3 {
t.Errorf("expected only the 2 older events removed, got %d with %d left, %v", removed, page.Total, err)
}
if removed, err = repo.DeleteOldest(0, guard); err != nil || removed != 0 {
t.Errorf("a zero limit removes nothing, got %d, %v", removed, err)
}
if removed, err = repo.DeleteOldest(-1, guard); err != nil || removed != 0 {
t.Errorf("a negative limit removes nothing, got %d, %v", removed, err)
}
})
t.Run("DeleteOldest honours a limit of 1", func(t *testing.T) {
repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
now := activityDay(20, 12)
for i := 0; i < 3; i++ {
activityMustAppend(t, repo, activityUserEvent(1, "a", now.Add(-time.Duration(10-i)*24*time.Hour)))
}
removed, err := repo.DeleteOldest(1, audit.GuardCutoff(now))
page, _ := repo.List(audit.Filter{Page: 1, Size: 100})
if err != nil || removed != 1 || page.Total != 2 {
t.Errorf("expected 1 removed and 2 left, got %d and %d, %v", removed, page.Total, err)
}
})
t.Run("DeleteOldest removes more events than one delete chunk", func(t *testing.T) {
repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
now := activityDay(20, 12)
for i := 0; i < 1001; i++ {
activityMustAppend(t, repo, activityUserEvent(1, "a", now.Add(-time.Duration(2000-i)*time.Hour)))
}
removed, err := repo.DeleteOldest(1001, audit.GuardCutoff(now))
page, _ := repo.List(audit.Filter{Page: 1, Size: 10})
if err != nil || removed != 1001 || page.Total != 0 {
t.Errorf("expected 1001 removed, got %d with %d left, %v", removed, page.Total, err)
}
})
t.Run("The size limit column defaults to 0 and is stored", func(t *testing.T) {
repo := persistence.NewAuditSettingsRepository(openActivityDatabase(t))
settings, err := repo.Get()
if err != nil || settings.RetentionMaxGB != 0 {
t.Fatalf("unexpected %+v, %v", settings, err)
}
if err := repo.SaveRetention(0, 5); err != nil {
t.Fatal(err)
}
settings, _ = repo.Get()
if settings.RetentionDays != 0 || settings.RetentionMaxGB != 5 {
t.Errorf("unexpected %+v", settings)
}
if err := repo.SaveLastCleanup(activityDay(6, 3), 1); err != nil {
t.Fatal(err)
}
settings, _ = repo.Get()
if settings.RetentionMaxGB != 5 {
t.Errorf("the size limit must survive a cleanup record, got %+v", settings)
}
})
}
func TestActivityRetentionSizePersistenceErrors(t *testing.T) {
closedDatabase := func(t *testing.T) *gorm.DB {
t.Helper()
db := openActivityDatabase(t)
sqlDb, err := db.DB()
if err != nil {
t.Fatal(err)
}
if err := sqlDb.Close(); err != nil {
t.Fatal(err)
}
return db
}
t.Run("SizeBytes returns the database error", func(t *testing.T) {
size, err := persistence.NewAuditEventRepository(closedDatabase(t)).SizeBytes()
if err == nil || size != 0 {
t.Errorf("expected an error and 0, got %d, %v", size, err)
}
})
t.Run("DeleteOldest returns the database error", func(t *testing.T) {
removed, err := persistence.NewAuditEventRepository(closedDatabase(t)).DeleteOldest(5, activityDay(20, 12))
if err == nil || removed != 0 {
t.Errorf("expected an error and 0, got %d, %v", removed, err)
}
})
t.Run("SaveRetention returns the database error", func(t *testing.T) {
if err := persistence.NewAuditSettingsRepository(closedDatabase(t)).SaveRetention(30, 5); err == nil {
t.Error("expected an error")
}
})
t.Run("Get returns the database error", func(t *testing.T) {
if _, err := persistence.NewAuditSettingsRepository(closedDatabase(t)).Get(); err == nil {
t.Error("expected an error")
}
})
}
func mustMarshalParams(t *testing.T, event audit.Event) []byte {
t.Helper()
encoded, err := json.Marshal(event.Params)
if err != nil {
t.Fatal(err)
}
return encoded
}
func mustMarshalChanges(t *testing.T, event audit.Event) []byte {
t.Helper()
encoded, err := json.Marshal(event.Changes)
if err != nil {
t.Fatal(err)
}
return encoded
}
+7 -7
View File
@@ -18,7 +18,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
t.Run("The default is 90 days with no cleanup yet", func(t *testing.T) { t.Run("The default is 90 days with no cleanup yet", func(t *testing.T) {
h := newActivityHarness(t) h := newActivityHarness(t)
settings, err := useCases.NewGetActivityRetentionUseCase(h.settings, h.clock).GetActivityRetention() settings, err := useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock).GetActivityRetention()
if err != nil || settings.RetentionDays != 90 || settings.LastCleanupAt != nil || settings.LastCleanupRemoved != 0 { if err != nil || settings.RetentionDays != 90 || settings.LastCleanupAt != nil || settings.LastCleanupRemoved != 0 {
t.Errorf("unexpected default %+v, %v", settings, err) t.Errorf("unexpected default %+v, %v", settings, err)
@@ -29,7 +29,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
for _, days := range []int{30, 90, 365, 0} { for _, days := range []int{30, 90, 365, 0} {
h := newActivityHarness(t) h := newActivityHarness(t)
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), days) settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(days))
if err != nil || settings.RetentionDays != days || h.settings.settings.RetentionDays != days { if err != nil || settings.RetentionDays != days || h.settings.settings.RetentionDays != days {
t.Errorf("%d: unexpected result %+v, %v", days, settings, err) t.Errorf("%d: unexpected result %+v, %v", days, settings, err)
@@ -41,7 +41,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
for _, days := range []int{7, -1, 366} { for _, days := range []int{7, -1, 366} {
h := newActivityHarness(t) h := newActivityHarness(t)
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), days) _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(days))
if !errors.Is(err, audit.ErrInvalidRetention) || h.settings.settings.RetentionDays != 90 || len(h.all(t)) != 0 { if !errors.Is(err, audit.ErrInvalidRetention) || h.settings.settings.RetentionDays != 90 || len(h.all(t)) != 0 {
t.Errorf("%d: expected a refusal, got %v", days, err) t.Errorf("%d: expected a refusal, got %v", days, err)
@@ -52,7 +52,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
t.Run("A change is recorded as a system event by the owner", func(t *testing.T) { t.Run("A change is recorded as a system event by the owner", func(t *testing.T) {
h := newActivityHarness(t) h := newActivityHarness(t)
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 30) _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(30))
events := h.all(t) events := h.all(t)
if err != nil || len(events) != 1 { if err != nil || len(events) != 1 {
@@ -70,7 +70,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
t.Run("Setting the same value records nothing", func(t *testing.T) { t.Run("Setting the same value records nothing", func(t *testing.T) {
h := newActivityHarness(t) h := newActivityHarness(t)
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 90) settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(90))
if err != nil || settings.RetentionDays != 90 || len(h.all(t)) != 0 || h.settings.retentionOK != 0 { if err != nil || settings.RetentionDays != 90 || len(h.all(t)) != 0 || h.settings.retentionOK != 0 {
t.Errorf("expected no event and no write, got %v", err) t.Errorf("expected no event and no write, got %v", err)
@@ -81,7 +81,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
h := newActivityHarness(t) h := newActivityHarness(t)
h.settings.saveErr = errRepositoryFailure h.settings.saveErr = errRepositoryFailure
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 30) _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(30))
if !errors.Is(err, errRepositoryFailure) || len(h.all(t)) != 0 { if !errors.Is(err, errRepositoryFailure) || len(h.all(t)) != 0 {
t.Errorf("expected the failure and no event, got %v", err) t.Errorf("expected the failure and no event, got %v", err)
@@ -254,7 +254,7 @@ func TestActivityCleanupUseCase(t *testing.T) {
h.now = now h.now = now
h.seed(t, activityUserEvent(1, "a", now.Add(-40*24*time.Hour))) h.seed(t, activityUserEvent(1, "a", now.Add(-40*24*time.Hour)))
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(activityOwnerCtx(), 30) _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(activityOwnerCtx(), retentionDays(30))
if err != nil || len(h.all(t)) != 2 { if err != nil || len(h.all(t)) != 2 {
t.Fatalf("the change itself must remove nothing, got %d events, %v", len(h.all(t)), err) t.Fatalf("the change itself must remove nothing, got %d events, %v", len(h.all(t)), err)
} }
+38 -1
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/auditing" "RenewCMS/internal/infrastructure/auditing"
persistence "RenewCMS/internal/infrastructure/persistence/repositories" persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"bytes" "bytes"
"context" "context"
"errors" "errors"
@@ -25,6 +26,10 @@ type activityProbeRepo struct {
deleteErr error deleteErr error
deleteCalls int deleteCalls int
readErr error readErr error
sizeErr error
sizeBytes *int64
oldestErr error
oldestCalls int
} }
var _ audit.Repository = &activityProbeRepo{} var _ audit.Repository = &activityProbeRepo{}
@@ -64,6 +69,24 @@ func (p *activityProbeRepo) DeleteOlderThan(cutoff time.Time) (int, error) {
return p.inner.DeleteOlderThan(cutoff) return p.inner.DeleteOlderThan(cutoff)
} }
func (p *activityProbeRepo) DeleteOldest(limit int, before time.Time) (int, error) {
p.oldestCalls++
if p.oldestErr != nil {
return 0, p.oldestErr
}
return p.inner.DeleteOldest(limit, before)
}
func (p *activityProbeRepo) SizeBytes() (int64, error) {
if p.sizeErr != nil {
return 0, p.sizeErr
}
if p.sizeBytes != nil {
return *p.sizeBytes, nil
}
return p.inner.SizeBytes()
}
type activitySettingsFake struct { type activitySettingsFake struct {
settings audit.Settings settings audit.Settings
saveErr error saveErr error
@@ -85,12 +108,13 @@ func (f *activitySettingsFake) Get() (audit.Settings, error) {
return f.settings, nil return f.settings, nil
} }
func (f *activitySettingsFake) SaveRetention(days int) error { func (f *activitySettingsFake) SaveRetention(days int, maxGB int) error {
if f.saveErr != nil { if f.saveErr != nil {
return f.saveErr return f.saveErr
} }
f.retentionOK++ f.retentionOK++
f.settings.RetentionDays = days f.settings.RetentionDays = days
f.settings.RetentionMaxGB = maxGB
return nil return nil
} }
@@ -144,3 +168,16 @@ func (h *activityHarness) seed(t *testing.T, event audit.Event) audit.Event {
} }
var errDiskFull = errors.New("disk full") var errDiskFull = errors.New("disk full")
func newSizeEvents(t *testing.T) audit.Repository {
t.Helper()
return persistence.NewAuditEventRepository(openActivityDatabase(t))
}
func retentionDays(days int) useCases.UpdateActivityRetentionCommand {
return useCases.UpdateActivityRetentionCommand{Days: &days}
}
func retentionMaxGB(maxGB int) useCases.UpdateActivityRetentionCommand {
return useCases.UpdateActivityRetentionCommand{MaxGB: &maxGB}
}
+1 -1
View File
@@ -133,7 +133,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder), RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion), GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), time.Now), GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), time.Now),
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion), GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder), CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder),
+9 -9
View File
@@ -15,12 +15,12 @@ import (
"github.com/go-chi/jwtauth/v5" "github.com/go-chi/jwtauth/v5"
) )
func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version string) *chi.Mux { func newSettingsRouter(t *testing.T, userRepo *mockUserRepo, token *jwtauth.JWTAuth, version string) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
settingsHandler := settings.NewHandler( settingsHandler := settings.NewHandler(
useCases.NewGetSettingsUseCase(version), useCases.NewGetSettingsUseCase(version),
useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), settingsTestClock), useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), settingsTestClock),
useCases.NewUpdateActivityRetentionUseCase(newActivitySettingsFake(), discardRecorder, settingsTestClock), useCases.NewUpdateActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), discardRecorder, settingsTestClock),
) )
r := chi.NewRouter() r := chi.NewRouter()
@@ -35,7 +35,7 @@ func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version s
} }
func settingsBody(version string) string { func settingsBody(version string) string {
return `{"version":"` + version + `","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00+02:00"}` return `{"version":"` + version + `","activity_retention_days":90,"activity_retention_max_gb":0,"activity_log_size_bytes":0,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00+02:00"}`
} }
func TestSettingsHttp(t *testing.T) { func TestSettingsHttp(t *testing.T) {
@@ -46,7 +46,7 @@ func TestSettingsHttp(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0} userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
router := newSettingsRouter(userRepo, token, version) router := newSettingsRouter(t, userRepo, token, version)
cookie := mintGetCurrentUserCookie(t, token, 7, 0) cookie := mintGetCurrentUserCookie(t, token, 7, 0)
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie) rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
@@ -64,7 +64,7 @@ func TestSettingsHttp(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0} userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
router := newSettingsRouter(userRepo, token, "v2.2.0-beta") router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
cookie := mintGetCurrentUserCookie(t, token, 7, 0) cookie := mintGetCurrentUserCookie(t, token, 7, 0)
t.Setenv("ENVIRONMENT", "development") t.Setenv("ENVIRONMENT", "development")
@@ -80,7 +80,7 @@ func TestSettingsHttp(t *testing.T) {
t.Run("A request without a jwt cookie is refused", func(t *testing.T) { t.Run("A request without a jwt cookie is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := jwtauth.New("HS256", []byte("test-secret"), nil)
router := newSettingsRouter(newMockUserRepo(), token, "v2.2.0-beta") router := newSettingsRouter(t, newMockUserRepo(), token, "v2.2.0-beta")
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", nil) rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", nil)
@@ -96,7 +96,7 @@ func TestSettingsHttp(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1} userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
router := newSettingsRouter(userRepo, token, "v2.2.0-beta") router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
cookie := mintGetCurrentUserCookie(t, token, 7, 0) cookie := mintGetCurrentUserCookie(t, token, 7, 0)
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie) rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
@@ -113,7 +113,7 @@ func TestSettingsHttp(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, TokenVersion: 0} userRepo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, TokenVersion: 0}
router := newSettingsRouter(userRepo, token, "v2.2.0-beta") router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
cookie := mintGetCurrentUserCookie(t, token, 8, 0) cookie := mintGetCurrentUserCookie(t, token, 8, 0)
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie) rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
+160 -59
View File
@@ -4,15 +4,18 @@ import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useOnline } from '@vueuse/core' import { useOnline } from '@vueuse/core'
import { ApiError } from '@/composables/useApi' import { ApiError } from '@/composables/useApi'
import { useSettings, type WorkspaceSettings } from '@/composables/useSettings' import { useSettings, type RetentionPatch, type WorkspaceSettings } from '@/composables/useSettings'
import { import {
SAVED_VISIBLE_MS, SAVED_VISIBLE_MS,
helperCopy, helperCopy,
isShorter, isShorter,
isSmaller,
lastCleanupLine, lastCleanupLine,
nextCleanupLine, nextCleanupLine,
retentionOptions, retentionOptions,
shorteningDescription, shorteningDescription,
sizeHelperCopy,
sizeOptions,
} from '@/lib/activityRetention' } from '@/lib/activityRetention'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
@@ -24,6 +27,12 @@ const HTTP_UNAUTHORIZED = 401
const HTTP_FORBIDDEN = 403 const HTTP_FORBIDDEN = 403
type SaveStatus = 'idle' | 'saving' | 'saved' type SaveStatus = 'idle' | 'saving' | 'saved'
type Control = 'days' | 'gb'
interface Pending {
control: Control
value: number
}
const props = defineProps<{ const props = defineProps<{
settings: WorkspaceSettings | null settings: WorkspaceSettings | null
@@ -40,9 +49,10 @@ const session = useSessionStore()
const { updateRetention } = useSettings() const { updateRetention } = useSettings()
const online = useOnline() const online = useOnline()
const selected = ref(props.settings?.activity_retention_days ?? 0) const selectedDays = ref(props.settings?.activity_retention_days ?? 0)
const shortening = ref<number | null>(null) const selectedGb = ref(props.settings?.activity_retention_max_gb ?? 0)
const failedValue = ref<number | null>(null) const shortening = ref<Pending | null>(null)
const failed = ref<Pending | null>(null)
const saveStatus = ref<SaveStatus>('idle') const saveStatus = ref<SaveStatus>('idle')
let savedTimer: ReturnType<typeof setTimeout> | null = null let savedTimer: ReturnType<typeof setTimeout> | null = null
let pendingSaves = 0 let pendingSaves = 0
@@ -50,11 +60,68 @@ let pendingSaves = 0
const offline = computed(() => !online.value) const offline = computed(() => !online.value)
const skeletonVisible = computed(() => props.loadState === 'loading' && props.showSkeleton) const skeletonVisible = computed(() => props.loadState === 'loading' && props.showSkeleton)
const savedDays = computed(() => props.settings?.activity_retention_days ?? 0) const savedDays = computed(() => props.settings?.activity_retention_days ?? 0)
const options = computed(() => retentionOptions()) const savedGb = computed(() => props.settings?.activity_retention_max_gb ?? 0)
const logSize = computed(() => props.settings?.activity_log_size_bytes ?? 0)
watch(savedDays, (days) => { const controls = computed(() => [
{
key: 'days' as const,
labelId: 'retention-label',
label: t('Keep activity for'),
helper: helperCopy(selectedDays.value, selectedGb.value),
items: retentionOptions(),
value: selectedDays.value,
},
{
key: 'gb' as const,
labelId: 'retention-size-label',
label: t('Keep at most'),
helper: sizeHelperCopy(selectedGb.value, logSize.value),
items: sizeOptions(),
value: selectedGb.value,
},
])
const warningTitle = computed(() => {
const pending = shortening.value
if (pending === null) {
return ''
}
return pending.control === 'days'
? t('Events older than {0} days will be removed', [pending.value])
: t('Events over {0} GB will be removed', [pending.value])
})
const applyLabel = computed(() => {
const pending = shortening.value
if (pending === null) {
return ''
}
return pending.control === 'days'
? t('Apply {0} days', [pending.value])
: t('Apply {0} GB', [pending.value])
})
function select(control: Control, value: number) {
if (control === 'days') {
selectedDays.value = value
} else {
selectedGb.value = value
}
}
function savedValue(control: Control) {
return control === 'days' ? savedDays.value : savedGb.value
}
function settle() {
selectedDays.value = savedDays.value
selectedGb.value = savedGb.value
}
watch([savedDays, savedGb], () => {
if (shortening.value === null && pendingSaves === 0) { if (shortening.value === null && pendingSaves === 0) {
selected.value = days settle()
} }
}) })
@@ -76,23 +143,34 @@ function finishSaving(succeeded: boolean) {
}, SAVED_VISIBLE_MS) }, SAVED_VISIBLE_MS)
} }
async function save(days: number) { function patchFor({ control, value }: Pending): RetentionPatch {
return control === 'days'
? { activity_retention_days: value }
: { activity_retention_max_gb: value }
}
async function save(pending: Pending) {
pendingSaves += 1 pendingSaves += 1
clearSavedTimer() clearSavedTimer()
failedValue.value = null failed.value = null
selected.value = days select(pending.control, pending.value)
saveStatus.value = 'saving' saveStatus.value = 'saving'
let succeeded = false let succeeded = false
try { try {
const updated = await updateRetention(days) const updated = await updateRetention(patchFor(pending))
emit('saved', updated) emit('saved', updated)
selected.value = updated.activity_retention_days select(
pending.control,
pending.control === 'days'
? updated.activity_retention_days
: updated.activity_retention_max_gb,
)
succeeded = true succeeded = true
} catch (error) { } catch (error) {
selected.value = savedDays.value select(pending.control, savedValue(pending.control))
const status = error instanceof ApiError ? error.status : 0 const status = error instanceof ApiError ? error.status : 0
if (status !== HTTP_UNAUTHORIZED) { if (status !== HTTP_UNAUTHORIZED) {
failedValue.value = days failed.value = pending
} }
if (status === HTTP_FORBIDDEN) { if (status === HTTP_FORBIDDEN) {
session.refresh() session.refresh()
@@ -105,40 +183,49 @@ async function save(days: number) {
} }
} }
function choose(value: number) { function isReduction({ control, value }: Pending) {
return control === 'days' ? isShorter(value, savedDays.value) : isSmaller(value, savedGb.value)
}
function choose(control: Control, value: number) {
if (offline.value) { if (offline.value) {
return return
} }
const previous = shortening.value
shortening.value = null shortening.value = null
failedValue.value = null failed.value = null
if (value === savedDays.value) { if (previous !== null && previous.control !== control) {
selected.value = value select(previous.control, savedValue(previous.control))
}
const pending: Pending = { control, value }
if (value === savedValue(control)) {
select(control, value)
return return
} }
if (isShorter(value, savedDays.value)) { if (isReduction(pending)) {
selected.value = value select(control, value)
shortening.value = value shortening.value = pending
return return
} }
save(value) save(pending)
} }
function apply() { function apply() {
const days = shortening.value const pending = shortening.value
shortening.value = null shortening.value = null
if (days !== null) { if (pending !== null) {
save(days) save(pending)
} }
} }
function cancel() { function cancel() {
shortening.value = null shortening.value = null
selected.value = savedDays.value settle()
} }
function retry() { function retry() {
if (failedValue.value !== null) { if (failed.value !== null) {
save(failedValue.value) save(failed.value)
} }
} }
@@ -158,7 +245,11 @@ onBeforeUnmount(clearSavedTimer)
{{ t('Retention') }} {{ t('Retention') }}
</h2> </h2>
<p class="text-pretty text-muted"> <p class="text-pretty text-muted">
{{ t('How long activity is kept. Only owners can change it.') }} {{
t(
'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.',
)
}}
</p> </p>
</div> </div>
<span <span
@@ -218,7 +309,7 @@ onBeforeUnmount(clearSavedTimer)
<template v-else-if="loadState === 'ready' && settings"> <template v-else-if="loadState === 'ready' && settings">
<UAlert <UAlert
v-if="failedValue !== null" v-if="failed !== null"
role="alert" role="alert"
color="error" color="error"
variant="soft" variant="soft"
@@ -233,7 +324,7 @@ onBeforeUnmount(clearSavedTimer)
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
:class="OUTLINE_BUTTON" :class="[OUTLINE_BUTTON, 'w-full justify-center']"
@click="retry" @click="retry"
> >
{{ t('Try again') }} {{ t('Try again') }}
@@ -241,31 +332,41 @@ onBeforeUnmount(clearSavedTimer)
</template> </template>
</UAlert> </UAlert>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between md:gap-4"> <template v-for="(control, index) in controls" :key="control.key">
<div class="flex min-w-0 flex-col gap-0.5"> <USeparator
<span id="retention-label" class="font-medium text-highlighted"> v-if="index > 0"
{{ t('Keep activity for') }} :label="t('or')"
</span> :ui="{ label: 'text-xs/5 font-medium text-muted' }"
<span class="text-xs text-muted">{{ helperCopy(selected) }}</span>
</div>
<USelectMenu
:model-value="selected"
:items="options"
value-key="value"
:search-input="false"
:disabled="offline"
aria-labelledby="retention-label"
color="neutral"
variant="outline"
class="w-full md:w-40"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-3 text-base ring-0 md:h-8 md:text-sm',
trailingIcon: 'size-4 text-muted',
content: 'min-w-48',
}"
@update:model-value="choose($event as number)"
/> />
</div> <div
class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
>
<div class="flex min-w-0 flex-col gap-0.5">
<span :id="control.labelId" class="font-medium text-highlighted">
{{ control.label }}
</span>
<span class="text-xs/5 text-muted">{{ control.helper }}</span>
</div>
<USelectMenu
:model-value="control.value"
:items="control.items"
value-key="value"
:search-input="false"
:disabled="offline"
:aria-labelledby="control.labelId"
:data-testid="`retention-select-${control.key}`"
color="neutral"
variant="outline"
class="w-full md:w-40"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-3 text-lg ring-0 md:h-10 md:text-base',
trailingIcon: 'size-4 text-muted',
content: 'min-w-48',
}"
@update:model-value="choose(control.key, $event as number)"
/>
</div>
</template>
<UAlert <UAlert
v-if="shortening !== null" v-if="shortening !== null"
@@ -275,7 +376,7 @@ onBeforeUnmount(clearSavedTimer)
orientation="vertical" orientation="vertical"
:close="false" :close="false"
icon="i-lucide-triangle-alert" icon="i-lucide-triangle-alert"
:title="t('Events older than {0} days will be removed', [shortening])" :title="warningTitle"
:description="shorteningDescription(settings)" :description="shorteningDescription(settings)"
:ui="{ actions: 'mt-2' }" :ui="{ actions: 'mt-2' }"
> >
@@ -287,7 +388,7 @@ onBeforeUnmount(clearSavedTimer)
:class="OUTLINE_BUTTON" :class="OUTLINE_BUTTON"
@click="apply" @click="apply"
> >
{{ t('Apply {0} days', [shortening]) }} {{ applyLabel }}
</UButton> </UButton>
<UButton <UButton
color="neutral" color="neutral"
@@ -310,7 +411,7 @@ onBeforeUnmount(clearSavedTimer)
:title="t('Activity logs are append-only')" :title="t('Activity logs are append-only')"
:description=" :description="
t( t(
'Events can\'t be edited or deleted from the app. Only the retention period removes them.', 'Events can\'t be edited or deleted from the app. Only the retention limits remove them.',
) )
" "
/> />
@@ -324,7 +425,7 @@ onBeforeUnmount(clearSavedTimer)
</div> </div>
<div class="flex flex-wrap gap-x-1"> <div class="flex flex-wrap gap-x-1">
<dt class="after:content-[':']">{{ t('Next cleanup') }}</dt> <dt class="after:content-[':']">{{ t('Next cleanup') }}</dt>
<dd data-testid="retention-next" class="text-highlighted"> <dd data-testid="retention-next">
{{ nextCleanupLine(settings) }} {{ nextCleanupLine(settings) }}
</dd> </dd>
</div> </div>
+9 -2
View File
@@ -3,11 +3,18 @@ import { useApi } from '@/composables/useApi'
export interface WorkspaceSettings { export interface WorkspaceSettings {
version: string version: string
activity_retention_days: number activity_retention_days: number
activity_retention_max_gb: number
activity_log_size_bytes: number
activity_last_cleanup_at: string | null activity_last_cleanup_at: string | null
activity_last_cleanup_removed: number activity_last_cleanup_removed: number
activity_next_cleanup_at: string | null activity_next_cleanup_at: string | null
} }
export interface RetentionPatch {
activity_retention_days?: number
activity_retention_max_gb?: number
}
const SETTINGS_PATH = '/v1/settings/' const SETTINGS_PATH = '/v1/settings/'
const JSON_HEADERS = { 'Content-Type': 'application/json' } const JSON_HEADERS = { 'Content-Type': 'application/json' }
@@ -18,11 +25,11 @@ export function useSettings() {
return request<WorkspaceSettings>(SETTINGS_PATH) return request<WorkspaceSettings>(SETTINGS_PATH)
} }
function updateRetention(days: number): Promise<WorkspaceSettings> { function updateRetention(patch: RetentionPatch): Promise<WorkspaceSettings> {
return request<WorkspaceSettings>(SETTINGS_PATH, { return request<WorkspaceSettings>(SETTINGS_PATH, {
method: 'PATCH', method: 'PATCH',
headers: JSON_HEADERS, headers: JSON_HEADERS,
body: JSON.stringify({ activity_retention_days: days }), body: JSON.stringify(patch),
}) })
} }
@@ -322,8 +322,10 @@ describe('Response samples use the real domain JSON keys', () => {
expect(keysOf(parsed(find('GET /v1/settings')))).toEqual([ expect(keysOf(parsed(find('GET /v1/settings')))).toEqual([
'activity_last_cleanup_at', 'activity_last_cleanup_at',
'activity_last_cleanup_removed', 'activity_last_cleanup_removed',
'activity_log_size_bytes',
'activity_next_cleanup_at', 'activity_next_cleanup_at',
'activity_retention_days', 'activity_retention_days',
'activity_retention_max_gb',
'version', 'version',
]) ])
}) })
+2
View File
@@ -722,6 +722,8 @@ export const API_GROUPS: ApiGroup[] = [
json({ json({
version: 'v3.0.0-beta', version: 'v3.0.0-beta',
activity_retention_days: 90, activity_retention_days: 90,
activity_retention_max_gb: 5,
activity_log_size_bytes: 3435973837,
activity_last_cleanup_at: '2026-09-10T03:00:00Z', activity_last_cleanup_at: '2026-09-10T03:00:00Z',
activity_last_cleanup_removed: 12, activity_last_cleanup_removed: 12,
activity_next_cleanup_at: '2026-09-11T03:00:00Z', activity_next_cleanup_at: '2026-09-11T03:00:00Z',
+22 -4
View File
@@ -1155,6 +1155,10 @@ export const messages = {
'Restarted to apply the update {0}': 'Redémarrage pour appliquer la mise à jour {0}', 'Restarted to apply the update {0}': 'Redémarrage pour appliquer la mise à jour {0}',
'Changed the activity retention from {0} to {1}': 'Changed the activity retention from {0} to {1}':
"Durée de conservation de l'activité modifiée : {0} → {1}", "Durée de conservation de l'activité modifiée : {0} → {1}",
'Changed the activity size limit from {0} to {1}':
"Limite de taille de l'activité modifiée : {0} → {1}",
'Changed the activity retention from {0} to {1} and the size limit from {2} to {3}':
"Durée de conservation de l'activité modifiée : {0} → {1}, et limite de taille : {2} → {3}",
'Removed {0} old events from the activity log': 'Removed {0} old events from the activity log':
"{0} anciens événements supprimés du journal d'activité", "{0} anciens événements supprimés du journal d'activité",
'Removed 1 old event from the activity log': 'Removed 1 old event from the activity log':
@@ -1193,8 +1197,8 @@ export const messages = {
'Actions on your site will appear here.': 'Les actions sur votre site apparaîtront ici.', 'Actions on your site will appear here.': 'Les actions sur votre site apparaîtront ici.',
'Your actions will appear here.': 'Vos actions apparaîtront ici.', 'Your actions will appear here.': 'Vos actions apparaîtront ici.',
Retention: 'Conservation', Retention: 'Conservation',
'How long activity is kept. Only owners can change it.': 'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.':
'Durée de conservation de l’activité. Seul le propriétaire peut la modifier.', 'Durée de conservation de l’activité et volume de journal à conserver. La première limite atteinte supprime les événements les plus anciens. Seul le propriétaire peut la modifier.',
'Keep activity for': 'Conserver l’activité pendant', 'Keep activity for': 'Conserver l’activité pendant',
'90 days': '90 jours', '90 days': '90 jours',
'365 days': '365 jours', '365 days': '365 jours',
@@ -1203,8 +1207,8 @@ export const messages = {
'Nothing is removed automatically. The log grows over time.': 'Nothing is removed automatically. The log grows over time.':
'Rien n’est supprimé automatiquement. Le journal grossit avec le temps.', 'Rien n’est supprimé automatiquement. Le journal grossit avec le temps.',
'Activity logs are append-only': 'Le journal d’activité est en ajout seul', 'Activity logs are append-only': 'Le journal d’activité est en ajout seul',
"Events can't be edited or deleted from the app. Only the retention period removes them.": "Events can't be edited or deleted from the app. Only the retention limits remove them.":
'Les événements ne peuvent pas être modifiés ni supprimés depuis l’application. Seule la durée de conservation les supprime.', 'Les événements ne peuvent pas être modifiés ni supprimés depuis l’application. Seules les limites de conservation les suppriment.',
'Last cleanup': 'Dernier nettoyage', 'Last cleanup': 'Dernier nettoyage',
'Next cleanup': 'Prochain nettoyage', 'Next cleanup': 'Prochain nettoyage',
'Not scheduled': 'Non planifié', 'Not scheduled': 'Non planifié',
@@ -1217,6 +1221,20 @@ export const messages = {
"Older events will be removed at the next cleanup. It can't be undone.": "Older events will be removed at the next cleanup. It can't be undone.":
'Les anciens événements seront supprimés au prochain nettoyage. Cette action est irréversible.', 'Les anciens événements seront supprimés au prochain nettoyage. Cette action est irréversible.',
'Apply {0} days': 'Appliquer {0} jours', 'Apply {0} days': 'Appliquer {0} jours',
'Keep at most': 'Conserver au maximum',
or: 'ou',
'No size limit': 'Aucune limite de taille',
'{0} GB': '{0} Go',
'Less than 0.1 GB': 'Moins de 0,1 Go',
'Events older than this are removed.': 'Les événements plus anciens sont supprimés.',
'No age limit. Only the size limit applies.':
'Aucune limite d’âge. Seule la limite de taille s’applique.',
'Optional. Logs can grow without a size cap.':
'Facultatif. Le journal peut grossir sans limite de taille.',
'When logs pass {0} GB, the oldest events are removed. Currently {1}.':
'Au-delà de {0} Go, les événements les plus anciens sont supprimés. Actuellement {1}.',
'Events over {0} GB will be removed': 'Les événements au-delà de {0} Go seront supprimés',
'Apply {0} GB': 'Appliquer {0} Go',
"Couldn't save the retention period": 'Impossible d’enregistrer la durée de conservation', "Couldn't save the retention period": 'Impossible d’enregistrer la durée de conservation',
'Your previous setting is still in place.': 'Votre réglage précédent est conservé.', 'Your previous setting is still in place.': 'Votre réglage précédent est conservé.',
} as const } as const
@@ -3,8 +3,11 @@ import { initializeLocale, setLocale } from '@/i18n'
import type { WorkspaceSettings } from '@/composables/useSettings' import type { WorkspaceSettings } from '@/composables/useSettings'
import { recentWhenLabel } from '../activityTime' import { recentWhenLabel } from '../activityTime'
import { import {
formatLogSize,
helperCopy, helperCopy,
isShorter, isShorter,
isSmaller,
sizeHelperCopy,
lastCleanupLine, lastCleanupLine,
nextCleanupLine, nextCleanupLine,
shorteningDescription, shorteningDescription,
@@ -20,6 +23,8 @@ function settings(overrides: Partial<WorkspaceSettings> = {}): WorkspaceSettings
return { return {
version: 'v2.2.0-beta', version: 'v2.2.0-beta',
activity_retention_days: 90, activity_retention_days: 90,
activity_retention_max_gb: 0,
activity_log_size_bytes: 0,
activity_last_cleanup_at: at(6, 3, 0), activity_last_cleanup_at: at(6, 3, 0),
activity_last_cleanup_removed: 142, activity_last_cleanup_removed: 142,
activity_next_cleanup_at: at(7, 3, 0), activity_next_cleanup_at: at(7, 3, 0),
@@ -86,3 +91,43 @@ describe('retention lines', () => {
) )
}) })
}) })
describe('retention size limit', () => {
it('ranks 1 < 5 < 10 < 25 < 50 < No size limit', () => {
expect(isSmaller(1, 5)).toBe(true)
expect(isSmaller(5, 0)).toBe(true)
expect(isSmaller(0, 50)).toBe(false)
expect(isSmaller(5, 5)).toBe(false)
expect(isSmaller(10, 50)).toBe(true)
})
it.each([
[0, 'Less than 0.1 GB'],
[53687091, 'Less than 0.1 GB'],
[107374183, '0.1 GB'],
[1073741824, '1.0 GB'],
[3435973837, '3.2 GB'],
[5368709120, '5.0 GB'],
])('formats %s bytes as %s on a 1024^3 base', (bytes, shown) => {
expect(formatLogSize(bytes)).toBe(shown)
})
it('words the helpers for each combination', () => {
expect(helperCopy(90, 5)).toBe('Events older than this are removed.')
expect(helperCopy(0, 5)).toBe('No age limit. Only the size limit applies.')
expect(helperCopy(0, 0)).toBe('Nothing is removed automatically. The log grows over time.')
expect(sizeHelperCopy(0, 99)).toBe('Optional. Logs can grow without a size cap.')
expect(sizeHelperCopy(5, 3435973837)).toBe(
'When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB.',
)
})
it('words the helpers in French with Go and a decimal comma', () => {
setLocale('fr')
expect(sizeHelperCopy(5, 3435973837)).toBe(
'Au-delà de 5 Go, les événements les plus anciens sont supprimés. Actuellement 3,2 Go.',
)
expect(formatLogSize(0)).toBe('Moins de 0,1 Go')
})
})
@@ -101,6 +101,26 @@ describe('Sentences: English and French come from the same code and params', ()
'Changed the activity retention from 90 days to Forever', 'Changed the activity retention from 90 days to Forever',
"Durée de conservation de l'activité modifiée : 90 jours → Toujours", "Durée de conservation de l'activité modifiée : 90 jours → Toujours",
], ],
[
'system.activity_retention_changed size only',
{
action: 'system.activity_retention_changed',
target: null,
params: { max_gb_from: 0, max_gb_to: 5 },
},
'Changed the activity size limit from No size limit to 5 GB',
"Limite de taille de l'activité modifiée : Aucune limite de taille → 5 Go",
],
[
'system.activity_retention_changed age and size',
{
action: 'system.activity_retention_changed',
target: null,
params: { from: 90, to: 30, max_gb_from: 10, max_gb_to: 0 },
},
'Changed the activity retention from 90 days to 30 days and the size limit from 10 GB to No size limit',
"Durée de conservation de l'activité modifiée : 90 jours → 30 jours, et limite de taille : 10 Go → Aucune limite de taille",
],
[ [
'system.restart_requested update', 'system.restart_requested update',
{ {
+49 -4
View File
@@ -1,11 +1,16 @@
import { t } from '@/i18n' import { formatNumber, t } from '@/i18n'
import type { WorkspaceSettings } from '@/composables/useSettings' import type { WorkspaceSettings } from '@/composables/useSettings'
import { localDayTime } from '@/lib/activityTime' import { localDayTime } from '@/lib/activityTime'
export const FOREVER_DAYS = 0 export const FOREVER_DAYS = 0
export const NO_SIZE_LIMIT = 0
export const SAVED_VISIBLE_MS = 2000 export const SAVED_VISIBLE_MS = 2000
const BYTES_PER_GB = 1024 ** 3
const MIN_SHOWN_GB = 0.1
const RANKED_PERIODS = [30, 90, 365, FOREVER_DAYS] const RANKED_PERIODS = [30, 90, 365, FOREVER_DAYS]
const RANKED_SIZES = [1, 5, 10, 25, 50, NO_SIZE_LIMIT]
export function retentionOptions() { export function retentionOptions() {
return RANKED_PERIODS.map((value) => ({ value, label: periodLabel(value) })) return RANKED_PERIODS.map((value) => ({ value, label: periodLabel(value) }))
@@ -32,12 +37,52 @@ export function isShorter(candidate: number, saved: number) {
return periodRank(candidate) < periodRank(saved) return periodRank(candidate) < periodRank(saved)
} }
export function helperCopy(days: number) { export function sizeOptions() {
return days === FOREVER_DAYS return RANKED_SIZES.map((value) => ({ value, label: sizeLabel(value) }))
? t('Nothing is removed automatically. The log grows over time.') }
export function sizeLabel(gb: number) {
return gb === NO_SIZE_LIMIT ? t('No size limit') : t('{0} GB', [gb])
}
export function sizeRank(gb: number) {
return RANKED_SIZES.indexOf(gb)
}
export function isSmaller(candidate: number, saved: number) {
return sizeRank(candidate) < sizeRank(saved)
}
export function formatLogSize(bytes: number) {
const gb = bytes / BYTES_PER_GB
if (gb < MIN_SHOWN_GB) {
return t('Less than 0.1 GB')
}
const figure = formatNumber(gb, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
return t('{0} GB', [figure])
}
export function helperCopy(days: number, gb = NO_SIZE_LIMIT) {
const sized = gb !== NO_SIZE_LIMIT
if (days === FOREVER_DAYS) {
return sized
? t('No age limit. Only the size limit applies.')
: t('Nothing is removed automatically. The log grows over time.')
}
return sized
? t('Events older than this are removed.')
: t('Older events are removed automatically.') : t('Older events are removed automatically.')
} }
export function sizeHelperCopy(gb: number, bytes: number) {
return gb === NO_SIZE_LIMIT
? t('Optional. Logs can grow without a size cap.')
: t('When logs pass {0} GB, the oldest events are removed. Currently {1}.', [
gb,
formatLogSize(bytes),
])
}
export function lastCleanupLine(settings: WorkspaceSettings) { export function lastCleanupLine(settings: WorkspaceSettings) {
if (!settings.activity_last_cleanup_at) { if (!settings.activity_last_cleanup_at) {
return t('Never') return t('Never')
+29 -1
View File
@@ -1,6 +1,7 @@
import { formatNumber, t } from '@/i18n' import { formatNumber, t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages' import type { MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity' import type { ActivityItem } from '@/lib/activity'
import { sizeLabel } from '@/lib/activityRetention'
export interface SentenceSegment { export interface SentenceSegment {
text: string text: string
@@ -139,9 +140,36 @@ function retentionDays(value: unknown) {
return days === 0 ? t('Forever') : t('{0} days', [formatNumber(days)]) return days === 0 ? t('Forever') : t('{0} days', [formatNumber(days)])
} }
function changedAge(params: Record<string, unknown>) {
return 'from' in params || 'to' in params
}
function changedSize(params: Record<string, unknown>) {
return 'max_gb_from' in params || 'max_gb_to' in params
}
function retentionTemplate(params: Record<string, unknown>): MessageKey {
if (changedSize(params) && changedAge(params)) {
return 'Changed the activity retention from {0} to {1} and the size limit from {2} to {3}'
}
return changedSize(params)
? 'Changed the activity size limit from {0} to {1}'
: 'Changed the activity retention from {0} to {1}'
}
function retentionValues(params: Record<string, unknown>) {
const sizes = [sizeLabel(count(params.max_gb_from)), sizeLabel(count(params.max_gb_to))]
if (!changedAge(params)) return sizes
const ages = [retentionDays(params.from), retentionDays(params.to)]
return changedSize(params) ? [...ages, ...sizes] : ages
}
function resolveTemplate(item: ActivityItem): MessageKey | null { function resolveTemplate(item: ActivityItem): MessageKey | null {
const template = CATALOGUE[item.action] const template = CATALOGUE[item.action]
if (!template) return null if (!template) return null
if (item.action === 'system.activity_retention_changed') {
return retentionTemplate(item.params)
}
if (item.action === 'system.restart_requested' && item.outcome === 'success') { if (item.action === 'system.restart_requested' && item.outcome === 'success') {
return item.params.kind === 'update' return item.params.kind === 'update'
? 'Restarted to apply the update {0}' ? 'Restarted to apply the update {0}'
@@ -156,7 +184,7 @@ function slotValues(item: ActivityItem, template: MessageKey) {
return [purgeSummary(item.params)] return [purgeSummary(item.params)]
} }
if (item.action === 'system.activity_retention_changed') { if (item.action === 'system.activity_retention_changed') {
return [retentionDays(item.params.from), retentionDays(item.params.to)] return retentionValues(item.params)
} }
if (item.action === 'system.activity_cleanup') { if (item.action === 'system.activity_cleanup') {
return [formatNumber(count(item.params.removed))] return [formatNumber(count(item.params.removed))]
@@ -30,6 +30,8 @@ function settings(overrides: Partial<WorkspaceSettings> = {}): WorkspaceSettings
return { return {
version: 'v2.2.0-beta', version: 'v2.2.0-beta',
activity_retention_days: 90, activity_retention_days: 90,
activity_retention_max_gb: 0,
activity_log_size_bytes: 0,
activity_last_cleanup_at: at(6, 3), activity_last_cleanup_at: at(6, 3),
activity_last_cleanup_removed: 142, activity_last_cleanup_removed: 142,
activity_next_cleanup_at: at(7, 3), activity_next_cleanup_at: at(7, 3),
@@ -116,14 +118,26 @@ async function choose(days: number) {
await flushPromises() await flushPromises()
} }
function selectedLabel() { function labelOf(select: ReturnType<VueWrapper['findComponent']>) {
const select = wrapper!.findComponent(USelectMenu)
const value = select.props('modelValue') const value = select.props('modelValue')
return (select.props('items') as { value: number; label: string }[]).find( return (select.props('items') as { value: number; label: string }[]).find(
(item) => item.value === value, (item) => item.value === value,
)?.label )?.label
} }
function selectedLabel() {
return labelOf(wrapper!.findAllComponents(USelectMenu)[0]!)
}
function sizeSelectLabel() {
return labelOf(wrapper!.findAllComponents(USelectMenu)[1]!)
}
async function chooseSize(gb: number) {
wrapper!.findAllComponents(USelectMenu)[1]!.vm.$emit('update:modelValue', gb)
await flushPromises()
}
afterEach(async () => { afterEach(async () => {
wrapper?.unmount() wrapper?.unmount()
wrapper = null wrapper = null
@@ -153,7 +167,7 @@ describe('Settings retention, Default with 90 days', () => {
await mountPage('owner', () => Promise.resolve(settings())) await mountPage('owner', () => Promise.resolve(settings()))
expect(text()).toContain('Retention') expect(text()).toContain('Retention')
expect(text()).toContain('How long activity is kept.') expect(text()).toContain('How long activity is kept, and how much log data to keep.')
expect(selectedLabel()).toBe('90 days') expect(selectedLabel()).toBe('90 days')
expect(text()).toContain('Older events are removed automatically.') expect(text()).toContain('Older events are removed automatically.')
expect(text()).toContain('Activity logs are append-only') expect(text()).toContain('Activity logs are append-only')
@@ -476,3 +490,342 @@ describe('Settings retention, the French interface', () => {
expect(selectedLabel()).toBe('90 jours') expect(selectedLabel()).toBe('90 jours')
}) })
}) })
const FIVE_GB_SETTINGS = {
activity_retention_max_gb: 5,
activity_log_size_bytes: 3435973837,
}
describe('Settings retention size limit, states', () => {
it('Default with 90 days or 5 GB', async () => {
await mountPage('owner', () => Promise.resolve(settings(FIVE_GB_SETTINGS)))
expect(selectedLabel()).toBe('90 days')
expect(sizeSelectLabel()).toBe('5 GB')
expect(text()).toContain('Events older than this are removed.')
expect(text()).toContain('Keep at most')
expect(text()).toContain('or')
expect(text()).toContain(
'When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB.',
)
expect(text()).toContain(
"Events can't be edited or deleted from the app. Only the retention limits remove them.",
)
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
})
it('Age only (no size limit)', async () => {
await mountPage('owner', () => Promise.resolve(settings()))
expect(sizeSelectLabel()).toBe('No size limit')
expect(text()).toContain('Optional. Logs can grow without a size cap.')
expect(text()).toContain('Older events are removed automatically.')
})
it('Size only (forever)', async () => {
await mountPage('owner', () =>
Promise.resolve(settings({ ...FIVE_GB_SETTINGS, activity_retention_days: 0 })),
)
expect(selectedLabel()).toBe('Forever')
expect(text()).toContain('No age limit. Only the size limit applies.')
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
})
it('Forever with no size limit', async () => {
await mountPage('owner', () =>
Promise.resolve(
settings({ activity_retention_days: 0, activity_next_cleanup_at: null }),
),
)
expect(text()).toContain('Nothing is removed automatically. The log grows over time.')
expect(line('retention-next')).toBe('Not scheduled')
})
it.each([
[3435973837, 'Currently 3.2 GB.'],
[0, 'Currently Less than 0.1 GB.'],
[53687091, 'Currently Less than 0.1 GB.'],
[5368709120, 'Currently 5.0 GB.'],
])('formats %s bytes as "%s"', async (bytes, shown) => {
await mountPage('owner', () =>
Promise.resolve(
settings({ activity_retention_max_gb: 5, activity_log_size_bytes: bytes }),
),
)
expect(text()).toContain(shown)
})
})
describe('Settings retention size limit, a larger size or no limit saves at once', () => {
it.each([
[5, 10],
[5, 0],
[1, 50],
])('saves %s GB to %s with one PATCH of the size key only', async (from, to) => {
vi.useFakeTimers()
const pending = deferred<unknown>()
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: from })),
() => pending.promise,
)
await chooseSize(to)
expect(patches()).toHaveLength(1)
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_max_gb: to }))
expect(text()).toContain('Saving…')
expect(text()).not.toContain('Events over')
pending.resolve(settings({ activity_retention_max_gb: to }))
await flushPromises()
expect(text()).toContain('Saved')
expect(sizeSelectLabel()).toBe(to === 0 ? 'No size limit' : `${to} GB`)
await vi.advanceTimersByTimeAsync(SAVED_VISIBLE_MS)
expect(text()).not.toContain('Saved')
})
})
describe('Settings retention size limit, a smaller size asks first', () => {
it.each([
[10, 5],
[0, 5],
[50, 1],
])('warns for %s GB then %s GB without sending', async (from, to) => {
await mountPage('owner', () =>
Promise.resolve(settings({ activity_retention_max_gb: from })),
)
await chooseSize(to)
expect(patches()).toHaveLength(0)
expect(sizeSelectLabel()).toBe(`${to} GB`)
expect(text()).toContain(`Events over ${to} GB will be removed`)
expect(text()).toContain(
"This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.",
)
expect(button(`Apply ${to} GB`)).toBeDefined()
expect(button('Cancel')).toBeDefined()
})
it('applies with one PATCH and cancels without any request', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
() => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
)
await chooseSize(5)
button('Apply 5 GB')!.click()
await flushPromises()
expect(patches()).toHaveLength(1)
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_max_gb: 5 }))
expect(text()).toContain('Saved')
expect(text()).not.toContain('Events over')
await chooseSize(1)
button('Cancel')!.click()
await flushPromises()
expect(sizeSelectLabel()).toBe('5 GB')
expect(patches()).toHaveLength(1)
})
})
describe('Settings retention size limit, one warning at a time', () => {
it('replaces the age warning with the size warning and reverts the age select', async () => {
await mountPage('owner', () => Promise.resolve(settings({ activity_retention_max_gb: 10 })))
await choose(30)
await chooseSize(5)
expect(text()).toContain('Events over 5 GB will be removed')
expect(text()).not.toContain('Events older than 30 days')
expect(selectedLabel()).toBe('90 days')
})
it('drops the warning when a larger size is chosen', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
() => Promise.resolve(settings({ activity_retention_max_gb: 25 })),
)
await chooseSize(5)
await chooseSize(25)
expect(text()).not.toContain('Events over')
expect(patches()).toHaveLength(1)
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_max_gb: 25 }))
expect(sizeSelectLabel()).toBe('25 GB')
})
it('drops a size warning for a longer age and sends only the age', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
() =>
Promise.resolve(
settings({ activity_retention_days: 365, activity_retention_max_gb: 10 }),
),
)
await chooseSize(5)
await choose(365)
expect(text()).not.toContain('Events over')
expect(sizeSelectLabel()).toBe('10 GB')
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 365 }))
})
})
describe('Settings retention size limit, switching age to Forever', () => {
it('sends the age only and keeps the schedule', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings(FIVE_GB_SETTINGS)),
() =>
Promise.resolve(
settings({
...FIVE_GB_SETTINGS,
activity_retention_days: 0,
activity_next_cleanup_at: at(8, 3),
}),
),
)
await choose(0)
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 0 }))
expect(text()).toContain('No age limit. Only the size limit applies.')
expect(line('retention-next')).toBe('8 Oct 2026, 03:00')
})
})
describe('Settings retention size limit, save errors revert only the size select', () => {
it.each([
['500', () => Promise.reject(new ApiError(500))],
['400', () => Promise.reject(new ApiError(400))],
['403', () => Promise.reject(new ApiError(403))],
['network', () => Promise.reject(new NetworkError())],
])('on %s', async (_name, failure) => {
let attempt = 0
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
() => {
attempt += 1
return attempt === 1
? failure()
: Promise.resolve(settings({ activity_retention_max_gb: 10 }))
},
)
await chooseSize(10)
expect(text()).toContain("Couldn't save the retention period")
expect(sizeSelectLabel()).toBe('5 GB')
expect(selectedLabel()).toBe('90 days')
expect(text()).not.toContain('Saved')
Array.from(retention()!.querySelectorAll('button'))
.find((candidate) => candidate.textContent?.trim() === 'Try again')!
.click()
await flushPromises()
expect(patches()).toHaveLength(2)
expect(patches()[1]![1].body).toBe(JSON.stringify({ activity_retention_max_gb: 10 }))
expect(text()).toContain('Saved')
})
it('retries an applied smaller size without asking again', async () => {
let attempt = 0
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
() => {
attempt += 1
return attempt === 1
? Promise.reject(new ApiError(500))
: Promise.resolve(settings({ activity_retention_max_gb: 5 }))
},
)
await chooseSize(5)
button('Apply 5 GB')!.click()
await flushPromises()
Array.from(retention()!.querySelectorAll('button'))
.find((candidate) => candidate.textContent?.trim() === 'Try again')!
.click()
await flushPromises()
expect(patches()).toHaveLength(2)
expect(text()).not.toContain('Events over')
})
it('refreshes the session on a 403 and removes the card for a demoted owner', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
() => Promise.reject(new ApiError(403)),
)
getMeMock.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: 'admin' })
await chooseSize(10)
expect(retention()).toBeNull()
})
it('shows no card alert on a 401', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
() => Promise.reject(new ApiError(401)),
)
await chooseSize(10)
expect(text()).not.toContain("Couldn't save")
expect(sizeSelectLabel()).toBe('5 GB')
})
})
describe('Settings retention size limit, offline and French', () => {
it('disables both selects, Apply and Cancel offline', async () => {
await mountPage('owner', () => Promise.resolve(settings({ activity_retention_max_gb: 10 })))
await chooseSize(5)
const online = vi.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
const selects = wrapper!.findAllComponents(USelectMenu)
expect(selects[0]!.props('disabled')).toBe(true)
expect(selects[1]!.props('disabled')).toBe(true)
expect(button('Apply 5 GB')!.disabled).toBe(true)
expect(button('Cancel')!.disabled).toBe(true)
online.mockRestore()
})
it('words the size strings in French', async () => {
setLocale('fr')
await mountPage('owner', () => Promise.resolve(settings(FIVE_GB_SETTINGS)))
expect(text()).toContain('Conserver au maximum')
expect(text()).toContain('ou')
expect(sizeSelectLabel()).toBe('5 Go')
expect(text()).toContain(
'Au-delà de 5 Go, les événements les plus anciens sont supprimés. Actuellement 3,2 Go.',
)
await chooseSize(1)
expect(text()).toContain('Les événements au-delà de 1 Go seront supprimés')
expect(button('Appliquer 1 Go')).toBeDefined()
})
})
+13
View File
@@ -67,6 +67,19 @@ const extraSources: Record<string, Source[]> = {
], ],
alias: {}, alias: {},
}, },
{
board: officeBoard,
group: 'Settings: Retention',
states: [
'Default, 90 days or 5 GB',
'Age only (no size limit)',
'Size only (forever)',
'Shortening the period',
'Forever',
'Save error',
],
alias: {},
},
{ {
board: officeBoard, board: officeBoard,
group: 'Purge dialog', group: 'Purge dialog',
+91 -2
View File
@@ -168,6 +168,8 @@ const updateRunningVersion = 'v2.2.0-beta'
const workspaceSettingsBody = { const workspaceSettingsBody = {
version: updateRunningVersion, version: updateRunningVersion,
activity_retention_days: 90, activity_retention_days: 90,
activity_retention_max_gb: 0,
activity_log_size_bytes: 0,
activity_last_cleanup_at: '2026-10-06T03:00:00Z', activity_last_cleanup_at: '2026-10-06T03:00:00Z',
activity_last_cleanup_removed: 142, activity_last_cleanup_removed: 142,
activity_next_cleanup_at: '2026-10-07T03:00:00Z', activity_next_cleanup_at: '2026-10-07T03:00:00Z',
@@ -2401,8 +2403,8 @@ function settingsBody(body: unknown): ApiMock {
return { status: 200, body } return { status: 200, body }
} }
async function scrollSettingsToCleanup(page: Page) { async function scrollSettingsToCleanup(page: Page, desktopTop = 300, mobileTop = 480) {
const top = (page.viewportSize()?.width ?? 1440) < 768 ? 480 : 300 const top = (page.viewportSize()?.width ?? 1440) < 768 ? mobileTop : desktopTop
await page.evaluate((top) => { await page.evaluate((top) => {
const card = document.querySelector<HTMLElement>('[data-testid="settings-card"]') const card = document.querySelector<HTMLElement>('[data-testid="settings-card"]')
if (card?.parentElement && !card.parentElement.querySelector('[data-scroll-room]')) { if (card?.parentElement && !card.parentElement.querySelector('[data-scroll-room]')) {
@@ -2499,6 +2501,92 @@ function successState(settings: ApiMock) {
) )
} }
const retentionPatchPath = 'PATCH /v1/settings/'
const retentionGetPath = 'GET /v1/settings/'
const retentionFiveGb = { activity_retention_max_gb: 5, activity_log_size_bytes: 3435973837 }
function retentionApi(
settings: Record<string, unknown>,
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return cleanupApi(settingsBody(purgeSettingsBody()), {
[retentionGetPath]: settingsBody({ ...workspaceSettingsBody, ...settings }),
...overrides,
})
}
function retentionState(
api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
exception?: DiffException,
): VisualState {
const scroll = (page: Page) => scrollSettingsToCleanup(page, 560, 936)
return {
path: '/settings',
now: boardNow,
api,
exception,
steps: async (page) => {
await scroll(page)
await steps?.(page)
await page.mouse.move(0, 0)
await scroll(page)
},
}
}
function chooseRetention(control: string, option: string) {
return async (page: Page) => {
await page.locator(`[aria-labelledby="${control}"]`).click()
await page.getByRole('option', { name: option, exact: true }).click()
}
}
const retentionStates: Record<string, VisualState> = {
'Default, 90 days or 5 GB': retentionState(
retentionApi(
{ activity_retention_max_gb: 1, activity_log_size_bytes: 3435973837 },
{
[retentionPatchPath]: settingsBody({
...workspaceSettingsBody,
...retentionFiveGb,
}),
},
),
async (page) => {
await chooseRetention('retention-size-label', '5 GB')(page)
await page.getByText('Saved', { exact: true }).first().waitFor()
},
),
'Age only (no size limit)': retentionState(retentionApi({})),
'Size only (forever)': retentionState(
retentionApi({ ...retentionFiveGb, activity_retention_days: 0 }),
),
'Shortening the period': retentionState(
retentionApi(retentionFiveGb),
chooseRetention('retention-label', '30 days'),
),
Forever: retentionState(
retentionApi({
activity_retention_days: 0,
activity_last_cleanup_at: null,
activity_next_cleanup_at: null,
}),
),
'Save error': retentionState(
retentionApi({}, { [retentionPatchPath]: { status: 500 } }),
async (page) => {
await chooseRetention('retention-label', '365 days')(page)
await page.getByText("Couldn't save the retention period").waitFor()
},
{
maxPercent: 13,
reason: 'Board draws 28 px buttons on mobile; foundations.md requires 44 px touch targets',
approvedBy: 'user',
},
),
}
const cleanupStates: Record<string, VisualState> = { const cleanupStates: Record<string, VisualState> = {
'Default: off, never run': cleanupState(cleanupApi(settingsBody(purgeSettingsBody()))), 'Default: off, never run': cleanupState(cleanupApi(settingsBody(purgeSettingsBody()))),
'On: 30 days, last run': cleanupState(cleanupApi(settingsBody(purgeOnBody))), 'On: 30 days, last run': cleanupState(cleanupApi(settingsBody(purgeOnBody))),
@@ -3155,6 +3243,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
}, },
settings: { settings: {
...cleanupStates, ...cleanupStates,
...retentionStates,
'Up to date': { path: '/settings', skip: 'clock-sensitive' }, 'Up to date': { path: '/settings', skip: 'clock-sensitive' },
Checking: { Checking: {
path: '/settings', path: '/settings',