Merge pull request 'feat(audit): add filters and timezone-aware display' (#35) from codex/block-08-audit-timezone into main
CI / validate (push) Canceled after 0s

Reviewed-on: #35
Reviewed-by: tony <1+tony@noreply.localhost>
This commit was merged in pull request #35.
This commit is contained in:
2026-08-14 15:17:25 +02:00
13 changed files with 389 additions and 34 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ Managed game ports are selected per instance from validated templates.
|---|---|---|
| `DOGAMA_VERSION` | `latest` | Image version; pin a release in production |
| `DOGAMA_HTTP_PORT` | `8080` | Published web port |
| `TZ` | `UTC` | Container timezone |
| `TZ` | `UTC` | IANA container timezone (also used for Audit display, e.g. `Europe/Paris`) |
| `DOGAMA_DATA_PATH` | `./data` | Host path for DoGaMa data |
| `DOGAMA_SERVERS_PATH` | `./servers` | Host path for game-server data |
| `DOGAMA_BACKUPS_PATH` | `./backups` | Host path for backups |
+1
View File
@@ -33,6 +33,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Compact allow-listed audit events for authentication and significant mutations, administrator filtering, bounded manual purge, daily retention and maximum-count enforcement.
- Responsive server-rendered application shell with synthwave-derived design tokens, permission-aware navigation, the official DoGaMa wordmark in the sidebar, searchable real instance cards, lifecycle summaries, server-only recent Audit activity and resilient agent/database/storage/backup/audit status on the Dashboard.
- Dedicated administrator Audit and Settings pages; notification channels, audit retention/purge and game-container labels retain their existing backend contracts outside the Dashboard.
- Audit uses server-side filtering and 50-event pagination; timestamps remain UTC in SQLite and are rendered in the Compose `TZ` IANA timezone with an invalid-zone fallback to UTC.
- Separate personal account settings and administrator user management, including email/password preferences, active-state session revocation, protected global roles, per-instance memberships and permission overrides.
- Restrictive browser headers and bounded public HTTP headers.
- Hardened read-only two-service Compose, capability dropping, private agent networking and distinct minimal OCI image targets.
@@ -85,6 +85,12 @@ Do not audit:
Entries are compact and use allow-listed structured summaries. Player IDs may be hashed or minimized where full identifiers are unnecessary. Never include secret values, HTTP authorization headers, imported content or module raw responses.
## Audit viewer and timezones
Audit timestamps are written and retained in UTC (RFC 3339). The administrator viewer converts them only at render time, using the IANA timezone configured by the existing Compose `TZ` setting (for example `Europe/Paris`). This conversion uses the timezone database, including daylight-saving changes. An invalid or absent `TZ` falls back safely to UTC and is logged; it never changes stored values.
The viewer is server-paginated at 50 events per page, newest first. Its URL retains filters for period (today, 7 or 30 days, or a simple custom date range), actor, existing instance, action, outcome and safe text search over public action/actor/instance fields. Event summaries remain allow-listed and are the only expandable details; secrets are not searchable or rendered.
## Retention
Audit retention defaults to 30 days and is globally administrator-configurable. An optional maximum count prevents unbounded growth. Purge runs daily and records one aggregate audit event, not an event per deleted row. Unlimited retention requires an explicit warning and displays database usage. Administrators may manually purge by date with confirmation.
+65 -19
View File
@@ -28,7 +28,8 @@ type Filter struct {
ActorID, InstanceID, Action, Outcome string
ActionPrefixes []string
Since, Until time.Time
Limit int
Query string
Limit, Offset int
}
type Policy struct {
@@ -36,6 +37,26 @@ type Policy struct {
MaximumCount int `json:"maximum_count"`
}
type Actor struct{ ID, Label string }
// Actors returns the bounded set of actors represented in the audit trail.
func (s *Service) Actors(ctx context.Context) ([]Actor, error) {
rows, err := s.db.QueryContext(ctx, `SELECT COALESCE(actor_id,''), actor_label FROM audit_events WHERE actor_id IS NOT NULL AND actor_id != '' GROUP BY actor_id, actor_label ORDER BY actor_label, actor_id LIMIT 200`)
if err != nil {
return nil, fmt.Errorf("list audit actors: %w", err)
}
defer rows.Close()
var values []Actor
for rows.Next() {
var value Actor
if err := rows.Scan(&value.ID, &value.Label); err != nil {
return nil, err
}
values = append(values, value)
}
return values, rows.Err()
}
type Service struct {
db *sql.DB
now func() time.Time
@@ -58,8 +79,9 @@ func (s *Service) Record(ctx context.Context, event Event) error {
body, _ := json.Marshal(clean)
when := event.OccurredAt
if when.IsZero() {
when = s.now().UTC()
when = s.now()
}
when = when.UTC()
var actor, instance any
if event.ActorID != "" {
actor = event.ActorID
@@ -79,6 +101,42 @@ func (s *Service) List(ctx context.Context, f Filter) ([]Event, error) {
if limit <= 0 || limit > 200 {
limit = 100
}
clauses, args := auditClauses(f)
offset := f.Offset
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, `SELECT id,occurred_at,COALESCE(actor_id,''),actor_label,COALESCE(instance_id,''),action,outcome,summary_json FROM audit_events WHERE `+strings.Join(clauses, " AND ")+` ORDER BY occurred_at DESC,id DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, fmt.Errorf("list audit events: %w", err)
}
defer func() { _ = rows.Close() }()
var events []Event
for rows.Next() {
var e Event
var occurred, body string
if err := rows.Scan(&e.ID, &occurred, &e.ActorID, &e.ActorLabel, &e.InstanceID, &e.Action, &e.Outcome, &body); err != nil {
return nil, err
}
e.OccurredAt, _ = time.Parse(time.RFC3339Nano, occurred)
_ = json.Unmarshal([]byte(body), &e.Summary)
events = append(events, e)
}
return events, rows.Err()
}
// Count returns the number of events matching a server-side filter.
func (s *Service) Count(ctx context.Context, f Filter) (int, error) {
clauses, args := auditClauses(f)
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_events WHERE `+strings.Join(clauses, " AND "), args...).Scan(&total); err != nil {
return 0, fmt.Errorf("count audit events: %w", err)
}
return total, nil
}
func auditClauses(f Filter) ([]string, []any) {
clauses, args := []string{"1=1"}, []any{}
for _, item := range []struct{ column, value string }{{"actor_id", f.ActorID}, {"instance_id", f.InstanceID}, {"action", f.Action}, {"outcome", f.Outcome}} {
if item.value != "" {
@@ -107,24 +165,12 @@ func (s *Service) List(ctx context.Context, f Filter) ([]Event, error) {
clauses = append(clauses, "occurred_at<?")
args = append(args, f.Until.UTC().Format(time.RFC3339Nano))
}
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, `SELECT id,occurred_at,COALESCE(actor_id,''),actor_label,COALESCE(instance_id,''),action,outcome,summary_json FROM audit_events WHERE `+strings.Join(clauses, " AND ")+` ORDER BY occurred_at DESC,id DESC LIMIT ?`, args...)
if err != nil {
return nil, fmt.Errorf("list audit events: %w", err)
if query := strings.TrimSpace(f.Query); query != "" {
clauses = append(clauses, "(action LIKE ? ESCAPE '\\' OR actor_label LIKE ? ESCAPE '\\' OR COALESCE(instance_id,'') LIKE ? ESCAPE '\\')")
query = "%" + strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(query, "\\", "\\\\"), "%", "\\%"), "_", "\\_") + "%"
args = append(args, query, query, query)
}
defer func() { _ = rows.Close() }()
var events []Event
for rows.Next() {
var e Event
var occurred, body string
if err := rows.Scan(&e.ID, &occurred, &e.ActorID, &e.ActorLabel, &e.InstanceID, &e.Action, &e.Outcome, &body); err != nil {
return nil, err
}
e.OccurredAt, _ = time.Parse(time.RFC3339Nano, occurred)
_ = json.Unmarshal([]byte(body), &e.Summary)
events = append(events, e)
}
return events, rows.Err()
return clauses, args
}
func (s *Service) Policy(ctx context.Context) (Policy, error) {
+44
View File
@@ -76,3 +76,47 @@ func TestListActionPrefixesExcludesAuthenticationAndAppliesLimit(t *testing.T) {
t.Fatalf("unexpected ordering: %#v", events)
}
}
func TestRecordNormalizesSuppliedTimestampToUTCAndPaginatesFilters(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
service := audit.New(db)
paris, err := time.LoadLocation("Europe/Paris")
if err != nil {
t.Fatal(err)
}
when := time.Date(2026, time.August, 13, 20, 28, 37, 0, paris)
for index, value := range []audit.Event{
{OccurredAt: when, ActorLabel: "alice", Action: "instance.start", Outcome: "allowed"},
{OccurredAt: when.Add(time.Minute), ActorLabel: "bob", Action: "backup.create", Outcome: "failed"},
{OccurredAt: when.Add(2 * time.Minute), ActorLabel: "alice", Action: "instance.start", Outcome: "allowed"},
} {
if err := service.Record(ctx, value); err != nil {
t.Fatalf("record %d: %v", index, err)
}
}
var stored string
if err := db.QueryRow(`SELECT occurred_at FROM audit_events WHERE action='instance.start' ORDER BY occurred_at LIMIT 1`).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != "2026-08-13T18:28:37Z" {
t.Fatalf("stored timestamp = %q, want UTC", stored)
}
filter := audit.Filter{Action: "instance.start", Since: when.Add(-time.Second), Until: when.Add(3 * time.Minute)}
count, err := service.Count(ctx, filter)
if err != nil || count != 2 {
t.Fatalf("count=%d err=%v", count, err)
}
filter.Limit, filter.Offset = 1, 1
events, err := service.List(ctx, filter)
if err != nil || len(events) != 1 || events[0].Action != "instance.start" {
t.Fatalf("page=%#v err=%v", events, err)
}
if events, err = service.List(ctx, audit.Filter{Query: "bob"}); err != nil || len(events) != 1 || events[0].Action != "backup.create" {
t.Fatalf("text search=%#v err=%v", events, err)
}
}
+3
View File
@@ -270,6 +270,9 @@ CREATE TABLE audit_events (
);
CREATE INDEX audit_events_time_idx ON audit_events(occurred_at DESC, id DESC);
CREATE INDEX audit_events_filters_idx ON audit_events(actor_id, instance_id, action, outcome);
CREATE INDEX audit_events_actor_time_idx ON audit_events(actor_id, occurred_at DESC, id DESC);
CREATE INDEX audit_events_instance_time_idx ON audit_events(instance_id, occurred_at DESC, id DESC);
CREATE INDEX audit_events_action_time_idx ON audit_events(action, occurred_at DESC, id DESC);
INSERT INTO system_state (singleton, bootstrap_completed_at) VALUES (1, NULL);
INSERT INTO system_settings(key, value_json, revision, updated_at)
+190
View File
@@ -0,0 +1,190 @@
package web
import (
"context"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
)
const auditPageSize = 50
var auditValuePattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`)
type auditPageFilter struct {
ActorID, InstanceID, Action, Outcome string
Period, From, To, Query string
Timezone string
}
type auditActorOption struct{ ID, Label string }
type auditEventView struct {
Actor, Instance, Action, ActionLabel, Outcome, OutcomeLabel, Time, UTC, Details string
}
func auditDisplayLocation(value string) (*time.Location, error) {
if strings.TrimSpace(value) == "" {
return time.UTC, nil
}
location, err := time.LoadLocation(value)
if err != nil {
return time.UTC, err
}
return location, nil
}
func (s *server) populateAuditPage(r *http.Request, data *pageData) {
if s.audit == nil {
return
}
filter := parseAuditFilter(r.URL.Query(), s.auditLocation)
filter.Timezone = s.auditLocation.String()
data.AuditFilter = filter
data.AuditActions = auditActions()
data.AuditActors = auditActors(r.Context(), s.audit)
if s.repository != nil {
if values, err := s.repository.ListLifecycleInstances(r.Context()); err == nil {
data.Instances = values
if filter.InstanceID != "" && !containsAuditInstance(values, filter.InstanceID) {
filter.InstanceID = ""
data.AuditFilter.InstanceID = ""
}
}
}
query := r.URL.Query()
page, _ := strconv.Atoi(query.Get("page"))
if page < 1 {
page = 1
}
dbFilter := audit.Filter{ActorID: filter.ActorID, InstanceID: filter.InstanceID, Action: filter.Action, Outcome: filter.Outcome, Query: filter.Query}
dbFilter.Since, dbFilter.Until = auditDateRange(filter, s.auditLocation)
total, err := s.audit.Count(r.Context(), dbFilter)
if err != nil {
return
}
pages := max(1, (total+auditPageSize-1)/auditPageSize)
if page > pages {
page = pages
}
dbFilter.Limit, dbFilter.Offset = auditPageSize, (page-1)*auditPageSize
events, err := s.audit.List(r.Context(), dbFilter)
if err != nil {
return
}
data.AuditEventCount, data.AuditPage, data.AuditPages = total, page, pages
data.AuditQuery = auditQuery(filter)
for _, event := range events {
data.AuditEvents = append(data.AuditEvents, auditEventViewFrom(event, data.Language, s.auditLocation))
}
}
func parseAuditFilter(query url.Values, location *time.Location) auditPageFilter {
value := auditPageFilter{Period: query.Get("period"), From: query.Get("from"), To: query.Get("to"), Query: strings.TrimSpace(query.Get("q"))}
if value.Period != "today" && value.Period != "7d" && value.Period != "30d" && value.Period != "custom" {
value.Period = ""
}
if len(value.Query) > 100 {
value.Query = value.Query[:100]
}
for key, target := range map[string]*string{"actor_id": &value.ActorID, "instance_id": &value.InstanceID, "action": &value.Action, "outcome": &value.Outcome} {
candidate := query.Get(key)
if auditValuePattern.MatchString(candidate) {
*target = candidate
}
}
if value.Outcome != "allowed" && value.Outcome != "denied" && value.Outcome != "failed" {
value.Outcome = ""
}
if _, err := time.ParseInLocation("2006-01-02", value.From, location); value.From != "" && err != nil {
value.From = ""
}
if _, err := time.ParseInLocation("2006-01-02", value.To, location); value.To != "" && err != nil {
value.To = ""
}
return value
}
func auditDateRange(filter auditPageFilter, location *time.Location) (time.Time, time.Time) {
now := time.Now().In(location)
switch filter.Period {
case "today":
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location), time.Time{}
case "7d":
return now.AddDate(0, 0, -7), time.Time{}
case "30d":
return now.AddDate(0, 0, -30), time.Time{}
case "custom":
var since, until time.Time
if filter.From != "" {
since, _ = time.ParseInLocation("2006-01-02", filter.From, location)
}
if filter.To != "" {
until, _ = time.ParseInLocation("2006-01-02", filter.To, location)
until = until.AddDate(0, 0, 1)
}
return since, until
}
return time.Time{}, time.Time{}
}
func auditQuery(filter auditPageFilter) string {
values := url.Values{}
for key, value := range map[string]string{"actor_id": filter.ActorID, "instance_id": filter.InstanceID, "action": filter.Action, "outcome": filter.Outcome, "period": filter.Period, "from": filter.From, "to": filter.To, "q": filter.Query} {
if value != "" {
values.Set(key, value)
}
}
return values.Encode()
}
func auditEventViewFrom(event audit.Event, language string, location *time.Location) auditEventView {
when := event.OccurredAt.UTC()
format := "2006-01-02 15:04:05"
if language == "fr" {
format = "02/01/2006 15:04:05"
}
details := make([]string, 0, len(event.Summary))
for key, value := range event.Summary {
details = append(details, key+": "+value)
}
return auditEventView{Actor: event.ActorLabel, Instance: event.InstanceID, Action: event.Action, ActionLabel: auditActionLabel(language, event.Action), Outcome: event.Outcome, OutcomeLabel: localized(language, "audit."+event.Outcome), Time: when.In(location).Format(format), UTC: when.Format(time.RFC3339), Details: strings.Join(details, " · ")}
}
func auditActors(ctx context.Context, service *audit.Service) []auditActorOption {
actors, err := service.Actors(ctx)
if err != nil {
return nil
}
values := make([]auditActorOption, 0, len(actors))
for _, actor := range actors {
values = append(values, auditActorOption{ID: actor.ID, Label: actor.Label})
}
return values
}
func containsAuditInstance(values []instance.StoredInstance, id string) bool {
for _, value := range values {
if value.ID == id {
return true
}
}
return false
}
func auditActions() []string {
return []string{"auth.login", "auth.login.blocked", "instance.deploy", "instance.start", "instance.stop", "instance.restart", "instance.update", "backup.create", "backup.restore", "players.unban", "audit.policy.update", "audit.purge", "notification.channel.update", "notification.channel.delete", "notification.channel.test"}
}
func auditActionLabel(language, action string) string {
if value := localized(language, "audit.action."+action); value != "audit.action."+action {
return value
}
return action
}
+52
View File
@@ -0,0 +1,52 @@
package web
import (
"net/url"
"strings"
"testing"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
)
func TestAuditDisplayLocationUsesIANAZonesAndSafeFallback(t *testing.T) {
utc, err := auditDisplayLocation("UTC")
if err != nil || utc.String() != "UTC" {
t.Fatalf("UTC location = %v, %v", utc, err)
}
paris, err := auditDisplayLocation("Europe/Paris")
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
when time.Time
want string
}{
{time.Date(2026, time.August, 13, 18, 28, 37, 0, time.UTC), "13/08/2026 20:28:37"},
{time.Date(2026, time.January, 13, 18, 28, 37, 0, time.UTC), "13/01/2026 19:28:37"},
{time.Date(2026, time.March, 29, 1, 30, 0, 0, time.UTC), "29/03/2026 03:30:00"},
} {
if got := auditEventViewFrom(audit.Event{OccurredAt: test.when, Action: "instance.start"}, "fr", paris).Time; got != test.want {
t.Fatalf("%s rendered %q, want %q", test.when, got, test.want)
}
}
fallback, err := auditDisplayLocation("Not/A_Zone")
if err == nil || fallback != time.UTC {
t.Fatalf("invalid timezone = %v, %v", fallback, err)
}
}
func TestAuditFilterValidatesDatesAndBuildsPersistentQuery(t *testing.T) {
location, _ := time.LoadLocation("Europe/Paris")
filter := parseAuditFilter(url.Values{"period": {"custom"}, "from": {"2026-03-29"}, "to": {"2026-03-29"}, "actor_id": {"actor-a"}, "instance_id": {"instance-a"}, "action": {"instance.start"}, "q": {"Main"}}, location)
since, until := auditDateRange(filter, location)
if since.UTC().Format(time.RFC3339) != "2026-03-28T23:00:00Z" || until.UTC().Format(time.RFC3339) != "2026-03-29T22:00:00Z" {
t.Fatalf("DST range = %s to %s", since.UTC(), until.UTC())
}
if query := auditQuery(filter); query == "" || !strings.Contains(query, "period=custom") {
t.Fatalf("query not retained: %q", query)
}
if invalid := parseAuditFilter(url.Values{"period": {"bad"}, "actor_id": {"' OR 1=1"}, "from": {"not-date"}}, location); invalid.Period != "" || invalid.ActorID != "" || invalid.From != "" {
t.Fatalf("invalid filter accepted: %#v", invalid)
}
}
+8
View File
@@ -40,6 +40,14 @@ var messages = map[string]map[string]string{
}
func init() {
for language, values := range map[string]map[string]string{
"en": {"audit.empty": "No audit events yet.", "audit.empty_filtered": "No audit events match these filters.", "audit.events": "events", "audit.timezone": "Times shown in", "audit.filters": "Filters", "audit.reset": "Reset filters", "audit.period": "Period", "audit.today": "Today", "audit.last_7_days": "Last 7 days", "audit.last_30_days": "Last 30 days", "audit.custom": "Custom", "audit.from": "From", "audit.to": "To", "audit.search": "Search", "audit.results": "Results", "audit.details": "Details", "audit.pagination": "Audit pagination", "audit.previous": "Previous", "audit.next": "Next", "audit.action.auth.login": "Sign in", "audit.action.auth.login.blocked": "Blocked sign in", "audit.action.instance.deploy": "Instance deployment", "audit.action.instance.start": "Instance start", "audit.action.instance.stop": "Instance stop", "audit.action.instance.restart": "Instance restart", "audit.action.instance.update": "Instance update", "audit.action.backup.create": "Backup creation", "audit.action.backup.restore": "Backup restore", "audit.action.players.unban": "Player unban", "audit.action.audit.policy.update": "Audit retention update", "audit.action.audit.purge": "Audit purge", "audit.action.notification.channel.update": "Notification channel update", "audit.action.notification.channel.delete": "Notification channel deletion", "audit.action.notification.channel.test": "Notification channel test"},
"fr": {"audit.empty": "Aucun événement d’audit.", "audit.empty_filtered": "Aucun événement ne correspond aux filtres.", "audit.events": "événements", "audit.timezone": "Heures affichées en", "audit.filters": "Filtres", "audit.reset": "Réinitialiser les filtres", "audit.period": "Période", "audit.today": "Aujourd’hui", "audit.last_7_days": "7 derniers jours", "audit.last_30_days": "30 derniers jours", "audit.custom": "Personnalisée", "audit.from": "Du", "audit.to": "Au", "audit.search": "Rechercher", "audit.results": "Résultats", "audit.details": "Détails", "audit.pagination": "Pagination de l’audit", "audit.previous": "Précédent", "audit.next": "Suivant", "audit.action.auth.login": "Connexion", "audit.action.auth.login.blocked": "Connexion bloquée", "audit.action.instance.deploy": "Déploiement d’instance", "audit.action.instance.start": "Démarrage d’instance", "audit.action.instance.stop": "Arrêt d’instance", "audit.action.instance.restart": "Redémarrage d’instance", "audit.action.instance.update": "Mise à jour d’instance", "audit.action.backup.create": "Création de sauvegarde", "audit.action.backup.restore": "Restauration de sauvegarde", "audit.action.players.unban": "Débannissement de joueur", "audit.action.audit.policy.update": "Mise à jour de la rétention", "audit.action.audit.purge": "Purge de l’audit", "audit.action.notification.channel.update": "Mise à jour de canal de notification", "audit.action.notification.channel.delete": "Suppression de canal de notification", "audit.action.notification.channel.test": "Test de canal de notification"},
} {
for key, value := range values {
messages[language][key] = value
}
}
for language, values := range map[string]map[string]string{
"en": {"instance.update_unknown": "Update verification is unavailable.", "instance.update_available": "A verified update is available.", "instance.update_current": "This instance is up to date.", "instance.banned_players": "Banned players", "instance.no_banned_players": "No banned players.", "instance.player_identifier": "Player identifier", "instance.bans_not_supported": "The banned-player list is unavailable for this server. Enter the identifier expected by the game API.", "instance.action_done.instance.update": "Instance updated.", "instance.action_failed.instance.update": "Unable to update the instance."},
"fr": {"instance.update_unknown": "La vérification de mise à jour est indisponible.", "instance.update_available": "Une mise à jour vérifiée est disponible.", "instance.update_current": "Cette instance est à jour.", "instance.banned_players": "Joueurs bannis", "instance.no_banned_players": "Aucun joueur banni.", "instance.player_identifier": "Identifiant du joueur", "instance.bans_not_supported": "La liste des joueurs bannis n’est pas disponible pour ce serveur. Saisissez l’identifiant attendu par l’API du jeu.", "instance.action_done.instance.update": "Instance mise à jour.", "instance.action_failed.instance.update": "Impossible de mettre l’instance à jour."},
+16 -12
View File
@@ -15,6 +15,7 @@ import (
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
@@ -53,6 +54,7 @@ type server struct {
backups *backup.Service
imports *importexport.Service
audit *audit.Service
auditLocation *time.Location
notifications *notification.Service
catalogScan func(context.Context) (catalog.ScanResult, error)
serversRoot string
@@ -93,12 +95,14 @@ type pageData struct {
Channels []notification.Channel
NotificationPreferences map[string]bool
NotificationLanguage string
AuditEvents []audit.Event
AuditEvents []auditEventView
AuditPolicy audit.Policy
AuditActor string
AuditInstance string
AuditAction string
AuditOutcome string
AuditFilter auditPageFilter
AuditActors []auditActorOption
AuditActions []string
AuditEventCount int
AuditPage, AuditPages int
AuditQuery string
ActivePage string
Instances []instance.StoredInstance
Users []auth.User
@@ -247,11 +251,15 @@ func newHandlerServices(authService *auth.Service, repository repository, lifecy
}
func newHandlerServicesWithCatalog(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, scanner func(context.Context) (catalog.ScanResult, error), logger *slog.Logger) (http.Handler, error) {
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message, "statusClass": statusClass, "statusLabel": statusLabel, "activityLabel": activityLabel, "relativeTime": relativeTime, "storagePercent": storagePercent, "formatBytes": formatBytes}).ParseFS(assets, "templates/*.html")
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message, "statusClass": statusClass, "statusLabel": statusLabel, "activityLabel": activityLabel, "relativeTime": relativeTime, "storagePercent": storagePercent, "formatBytes": formatBytes, "plus": func(a, b int) int { return a + b }, "minus": func(a, b int) int { return a - b }}).ParseFS(assets, "templates/*.html")
if err != nil {
return nil, err
}
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService, audit: auditService, notifications: notificationService, catalogScan: scanner}
location, locationErr := auditDisplayLocation(os.Getenv("TZ"))
if locationErr != nil {
logger.Warn("invalid audit display timezone; using UTC", "timezone", os.Getenv("TZ"), "event", "audit.timezone.invalid")
}
s := &server{auth: authService, templates: templates, logger: logger, repository: repository, lifecycle: lifecycle, backups: backupService, imports: importService, audit: auditService, auditLocation: location, notifications: notificationService, catalogScan: scanner}
if repository != nil {
s.permissions = authorization.New(repository)
}
@@ -2000,11 +2008,7 @@ func (s *server) auditPage(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if s.audit != nil {
data.AuditActor, data.AuditInstance = r.URL.Query().Get("actor_id"), r.URL.Query().Get("instance_id")
data.AuditAction, data.AuditOutcome = r.URL.Query().Get("action"), r.URL.Query().Get("outcome")
data.AuditEvents, _ = s.audit.List(r.Context(), audit.Filter{ActorID: data.AuditActor, InstanceID: data.AuditInstance, Action: data.AuditAction, Outcome: data.AuditOutcome, Limit: 50})
}
s.populateAuditPage(r, &data)
s.render(w, http.StatusOK, "audit.html", data)
}
+1 -1
View File
@@ -392,7 +392,7 @@ func TestNotificationAndAuditAdministration(t *testing.T) {
assertStatus(t, lastAdmin, http.StatusConflict)
auditPage := request(t, handler, http.MethodGet, "/audit", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}})
assertStatus(t, auditPage, http.StatusOK)
if !strings.Contains(auditPage.Body.String(), "Recent") && !strings.Contains(auditPage.Body.String(), "Review significant") {
if !strings.Contains(auditPage.Body.String(), "audit-filter-grid") || !strings.Contains(auditPage.Body.String(), "audit-pagination") && !strings.Contains(auditPage.Body.String(), "Heures affichées en") && !strings.Contains(auditPage.Body.String(), "Times shown in") {
t.Fatal("audit UI missing")
}
}
+1
View File
@@ -2,6 +2,7 @@
@media(max-width:1050px){.summary-grid{grid-template-columns:repeat(2,1fr)}.filter-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:760px){.sidebar{transform:translateX(-105%);transition:transform .2s;box-shadow:20px 0 50px #000a}.nav-open .sidebar{transform:none}.app-main{margin-left:0;padding:0 18px 50px}.mobile-header{display:flex;align-items:center;gap:10px;margin:0 -18px 28px;padding:12px 18px;background:#0b0d18ee;border-bottom:1px solid var(--border);position:sticky;top:0;z-index:15}.mobile-header img{width:38px;height:38px;border-radius:9px}.menu-button{margin-left:auto;padding:8px 12px;background:#171b30;border:1px solid #383d5b}.page-heading{align-items:start}.two-panels{grid-template-columns:1fr}.two-panels>form+form{padding:20px 0 0;border-top:1px solid var(--border);border-left:0}.section-heading{display:grid}.channel-list li{align-items:start;flex-direction:column}.form-grid{grid-template-columns:1fr}}
@media(max-width:520px){.instance-grid{grid-template-columns:1fr}.summary-grid,.filter-grid{grid-template-columns:1fr}.panel{padding:17px}.auth-card{padding:25px 22px}.instance-art{height:160px}}
.audit-filter-grid{display:grid;grid-template-columns:repeat(4,minmax(150px,1fr));gap:14px}.audit-filter-grid button{align-self:end}.audit-list{display:grid;gap:8px}.audit-event{display:grid;grid-template-columns:180px minmax(220px,1fr) minmax(120px,.6fr) auto;gap:16px;align-items:start;padding:15px 0;border-bottom:1px solid var(--border)}.audit-event time,.audit-event small{color:var(--text-secondary);font-size:.8rem}.audit-event small{display:block;margin-top:3px}.audit-event details{max-width:260px;color:var(--text-secondary);font-size:.82rem}.audit-event details p{white-space:normal}.audit-pagination{display:flex;justify-content:center;align-items:center;gap:12px;margin-top:22px}@media(max-width:760px){.audit-filter-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.audit-event{grid-template-columns:1fr 1fr}.audit-event details{grid-column:1/-1}}@media(max-width:520px){.audit-filter-grid,.audit-event{grid-template-columns:1fr}}
.app-body{background-color:#050814;background-image:radial-gradient(circle at 12% 14%,#20d9f355 0 1px,transparent 1.5px),radial-gradient(circle at 78% 8%,#f4f7ff77 0 1px,transparent 1.5px),radial-gradient(circle at 55% 26%,#f02fb844 0 1px,transparent 1.5px),linear-gradient(165deg,#060917 0%,#080b18 52%,#070916 100%);background-size:137px 149px,191px 173px,223px 211px,auto}.sidebar{width:220px;border-right-color:#f02fb866;box-shadow:8px 0 40px #0005}.app-main{margin-left:220px}.brand{display:grid;justify-items:center;text-align:center;padding-bottom:20px}.brand img{width:72px;height:72px}.brand small{display:none}.page-heading{padding-bottom:16px;border-bottom:1px solid #232943}.page-heading h1{font-size:clamp(1.9rem,3vw,2.6rem)}.instance-grid{grid-template-columns:repeat(auto-fill,minmax(min(100%,280px),1fr));gap:16px}.instance-card{border-color:#3d4667;border-radius:10px}.instance-art{height:112px;border-bottom:1px solid #424b6d}.instance-art:after{height:25%}.instance-card-body{padding:13px 15px 15px}.instance-card h2{margin:0;font-size:1.05rem}.instance-name{margin:3px 0 12px;color:var(--text-secondary);font-size:.84rem}.status-badge{position:static;display:inline-flex;padding:0;border:0;background:transparent;font-size:.82rem}.summary-grid{grid-template-columns:repeat(5,1fr);gap:12px}.summary-grid article{position:relative;padding:15px 17px;border-top:2px solid #384260}.summary-grid strong{display:block;line-height:1.1}.summary-total{border-top-color:var(--accent-cyan)!important}.summary-running{border-top-color:var(--status-running)!important}.summary-stopped{border-top-color:var(--status-stopped)!important}.summary-warning{border-top-color:var(--status-warning)!important}.summary-error{border-top-color:var(--status-error)!important}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:1050px){.summary-grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:760px){.sidebar{width:250px}.app-main{margin-left:0}.brand{display:flex;justify-items:initial;text-align:left}.brand img{width:54px;height:54px}.brand small{display:block}}@media(max-width:520px){.summary-grid{grid-template-columns:1fr 1fr}.summary-grid article:first-child{grid-column:1/-1}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
.auth-card{padding:0;overflow:hidden}.auth-brand{width:100%;line-height:0}.auth-brand img{width:100%;height:auto;display:block;object-fit:contain}.auth-content{padding:28px 34px 34px}.language-select{display:flex;grid-auto-flow:column;justify-content:end;gap:7px;margin:0 0 18px}.language-select button{padding:5px 9px;font-size:.75rem}.auth-content form:not(.language-select){margin-top:26px}@media(max-width:520px){.auth-card{padding:0}.auth-content{padding:22px}.language-select{justify-content:start}}
+1 -1
View File
@@ -1 +1 @@
{{define "audit.html"}}<!doctype html><html lang="{{.Language}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v1.css"></head><body class="app-body">{{template "sidebar" .}}<main class="app-main"><div class="page-heading"><div><p class="eyebrow">{{.Msg "audit.eyebrow"}}</p><h1>{{.Msg "audit.title"}}</h1><p>{{.Msg "audit.introduction"}}</p></div></div><section class="panel"><form class="filter-grid" method="get" action="/audit"><label>{{.Msg "audit.actor"}}<input name="actor_id" value="{{.AuditActor}}"></label><label>{{.Msg "audit.instance"}}<input name="instance_id" value="{{.AuditInstance}}"></label><label>{{.Msg "audit.action"}}<input name="action" value="{{.AuditAction}}"></label><label>{{.Msg "audit.outcome"}}<select name="outcome"><option value="">{{.Msg "audit.any"}}</option><option value="allowed">{{.Msg "audit.allowed"}}</option><option value="denied">{{.Msg "audit.denied"}}</option><option value="failed">{{.Msg "audit.failed"}}</option></select></label><button type="submit">{{.Msg "audit.filter"}}</button></form><div class="table-wrap"><table><thead><tr><th>{{.Msg "audit.time"}}</th><th>{{.Msg "audit.actor_column"}}</th><th>{{.Msg "audit.action"}}</th><th>{{.Msg "audit.outcome"}}</th><th>{{.Msg "audit.instance_column"}}</th></tr></thead><tbody>{{range .AuditEvents}}<tr><td>{{.OccurredAt.Format "2006-01-02 15:04:05Z"}}</td><td>{{.ActorLabel}}</td><td><code>{{.Action}}</code></td><td>{{.Outcome}}</td><td>{{.InstanceID}}</td></tr>{{else}}<tr><td colspan="5">{{$.Msg "audit.empty"}}</td></tr>{{end}}</tbody></table></div></section></main></body></html>{{end}}
{{define "audit.html"}}<!doctype html><html lang="{{.Language}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · DoGaMa</title><link rel="stylesheet" href="/static/app.v2.css"><link rel="stylesheet" href="/static/theme.v2.css"></head><body class="app-body">{{template "sidebar" .}}<main class="app-main"><header class="page-heading audit-header"><div><p class="eyebrow">{{.Msg "audit.eyebrow"}}</p><h1>{{.Msg "audit.title"}}</h1><p>{{.AuditEventCount}} {{.Msg "audit.events"}} · {{.Msg "audit.timezone"}} {{.AuditFilter.Timezone}}</p></div></header><section class="panel audit-panel"><div class="section-heading"><div><p class="eyebrow">{{.Msg "audit.filters"}}</p><h2>{{.Msg "audit.filter"}}</h2></div><a class="button-secondary link-button" href="/audit">{{.Msg "audit.reset"}}</a></div><form class="audit-filter-grid" method="get" action="/audit"><label>{{.Msg "audit.period"}}<select name="period"><option value="">{{.Msg "audit.any"}}</option><option value="today" {{if eq .AuditFilter.Period "today"}}selected{{end}}>{{.Msg "audit.today"}}</option><option value="7d" {{if eq .AuditFilter.Period "7d"}}selected{{end}}>{{.Msg "audit.last_7_days"}}</option><option value="30d" {{if eq .AuditFilter.Period "30d"}}selected{{end}}>{{.Msg "audit.last_30_days"}}</option><option value="custom" {{if eq .AuditFilter.Period "custom"}}selected{{end}}>{{.Msg "audit.custom"}}</option></select></label><label>{{.Msg "audit.from"}}<input name="from" type="date" value="{{.AuditFilter.From}}"></label><label>{{.Msg "audit.to"}}<input name="to" type="date" value="{{.AuditFilter.To}}"></label><label>{{.Msg "audit.actor"}}<select name="actor_id"><option value="">{{.Msg "audit.any"}}</option>{{range .AuditActors}}<option value="{{.ID}}" {{if eq $.AuditFilter.ActorID .ID}}selected{{end}}>{{.Label}}</option>{{end}}</select></label><label>{{.Msg "audit.instance"}}<select name="instance_id"><option value="">{{.Msg "audit.any"}}</option>{{range .Instances}}<option value="{{.ID}}" {{if eq $.AuditFilter.InstanceID .ID}}selected{{end}}>{{.Preview.DisplayName}}</option>{{end}}</select></label><label>{{.Msg "audit.action"}}<select name="action"><option value="">{{.Msg "audit.any"}}</option>{{range .AuditActions}}<option value="{{.}}" {{if eq $.AuditFilter.Action .}}selected{{end}}>{{.}}</option>{{end}}</select></label><label>{{.Msg "audit.outcome"}}<select name="outcome"><option value="">{{.Msg "audit.any"}}</option><option value="allowed" {{if eq .AuditFilter.Outcome "allowed"}}selected{{end}}>{{.Msg "audit.allowed"}}</option><option value="denied" {{if eq .AuditFilter.Outcome "denied"}}selected{{end}}>{{.Msg "audit.failed"}}</option><option value="failed" {{if eq .AuditFilter.Outcome "failed"}}selected{{end}}>{{.Msg "audit.failed"}}</option></select></label><label>{{.Msg "audit.search"}}<input name="q" type="search" value="{{.AuditFilter.Query}}" maxlength="100"></label><button type="submit">{{.Msg "audit.filter"}}</button></form></section><section class="panel audit-results"><div class="section-heading"><div><p class="eyebrow">{{.Msg "audit.results"}}</p><h2>{{.AuditEventCount}} {{.Msg "audit.events"}}</h2></div><p class="muted">{{.Msg "audit.timezone"}} {{.AuditFilter.Timezone}}</p></div>{{if .AuditEvents}}<div class="audit-list">{{range .AuditEvents}}<article class="audit-event"><time datetime="{{.UTC}}" title="{{.UTC}}">{{.Time}}</time><div><strong>{{.ActionLabel}}</strong><small><code>{{.Action}}</code> · {{.OutcomeLabel}}</small></div><div><strong>{{if .Actor}}{{.Actor}}{{else}}—{{end}}</strong><small>{{if .Instance}}{{.Instance}}{{else}}—{{end}}</small></div>{{if .Details}}<details><summary>{{$.Msg "audit.details"}}</summary><p>{{.Details}}</p></details>{{end}}</article>{{end}}</div>{{else}}<div class="empty-state"><span class="empty-icon">◈</span><h2>{{if .AuditQuery}}{{.Msg "audit.empty_filtered"}}{{else}}{{.Msg "audit.empty"}}{{end}}</h2></div>{{end}}{{if gt .AuditPages 1}}<nav class="audit-pagination" aria-label="{{.Msg "audit.pagination"}}">{{if gt .AuditPage 1}}<a class="button-secondary link-button" href="/audit?{{.AuditQuery}}&amp;page={{minus .AuditPage 1}}">{{.Msg "audit.previous"}}</a>{{end}}<span>{{.AuditPage}} / {{.AuditPages}}</span>{{if lt .AuditPage .AuditPages}}<a class="button-secondary link-button" href="/audit?{{.AuditQuery}}&amp;page={{plus .AuditPage 1}}">{{.Msg "audit.next"}}</a>{{end}}</nav>{{end}}</section></main></body></html>{{end}}