feat(web): improve dashboard and audit presentation
CI / validate (pull_request) Successful in 26m0s
CI / validate (pull_request) Successful in 26m0s
This commit is contained in:
@@ -35,7 +35,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
|
||||
- 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.
|
||||
- The responsive shell exposes a keyboard-accessible mobile navigation toggle on every authenticated SSR page; all recent-administrator form mutations return an explicit CSRF denial when request verification fails.
|
||||
- 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.
|
||||
- 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. Instance audit events retain a minimal game/name/slug snapshot so history stays readable after instance deletion.
|
||||
- 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,12 +85,21 @@ 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.
|
||||
|
||||
For instance events, the allow-listed summary also retains the minimal display
|
||||
snapshot needed to keep history understandable after deletion: game name,
|
||||
instance name, derived slug and target ID. The viewer presents the game and
|
||||
instance name first; the technical identifiers remain in expandable details.
|
||||
|
||||
## 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.
|
||||
|
||||
## Local interface icons
|
||||
|
||||
The web interface uses a small local subset of [Material Design Icons](https://materialdesignicons.com/), licensed under MIT. The SVG sprite and its license notice are embedded with the application, so UI icons do not require a CDN or runtime network access.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -64,7 +64,7 @@ type Service struct {
|
||||
|
||||
func New(db *sql.DB) *Service { return &Service{db: db, now: time.Now} }
|
||||
|
||||
var allowedSummaryKeys = map[string]bool{"target_id": true, "target_name": true, "channel_type": true, "event_type": true, "reason_code": true, "deleted_count": true, "before": true, "after": true}
|
||||
var allowedSummaryKeys = map[string]bool{"target_id": true, "target_name": true, "instance_name": true, "game_name": true, "instance_slug": true, "channel_type": true, "event_type": true, "reason_code": true, "deleted_count": true, "before": true, "after": true}
|
||||
|
||||
func (s *Service) Record(ctx context.Context, event Event) error {
|
||||
if event.Action == "" || (event.Outcome != "allowed" && event.Outcome != "denied" && event.Outcome != "failed") {
|
||||
|
||||
@@ -47,6 +47,9 @@ func (s *server) recordAudit(r *http.Request, actor auth.User, action, outcome s
|
||||
if s.audit == nil {
|
||||
return
|
||||
}
|
||||
if id := summary["target_id"]; id != "" {
|
||||
summary = mergeAuditInstanceSummary(summary, s.auditInstanceSummary(r.Context(), id))
|
||||
}
|
||||
if err := s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, Action: action, Outcome: outcome, Summary: summary}); err != nil {
|
||||
s.logger.Warn("audit recording failed", "event", "audit.record.failed")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,9 +27,12 @@ type auditPageFilter struct {
|
||||
type auditActorOption struct{ ID, Label string }
|
||||
|
||||
type auditEventView struct {
|
||||
Actor, Instance, Action, ActionLabel, Outcome, OutcomeLabel, Time, UTC, Details string
|
||||
Actor, Game, Instance, Action, ActionLabel, Outcome, OutcomeLabel, Time, UTC string
|
||||
Details []auditDetailView
|
||||
}
|
||||
|
||||
type auditDetailView struct{ Key, Value string }
|
||||
|
||||
func auditDisplayLocation(value string) (*time.Location, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return time.UTC, nil
|
||||
@@ -150,11 +154,16 @@ func auditEventViewFrom(event audit.Event, language string, location *time.Locat
|
||||
if language == "fr" {
|
||||
format = "02/01/2006 15:04:05"
|
||||
}
|
||||
details := make([]string, 0, len(event.Summary))
|
||||
details := make([]auditDetailView, 0, len(event.Summary))
|
||||
for key, value := range event.Summary {
|
||||
details = append(details, key+": "+value)
|
||||
details = append(details, auditDetailView{Key: key, Value: 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, " · ")}
|
||||
sort.Slice(details, func(i, j int) bool { return details[i].Key < details[j].Key })
|
||||
instance := event.Summary["instance_name"]
|
||||
if instance == "" {
|
||||
instance = event.InstanceID
|
||||
}
|
||||
return auditEventView{Actor: event.ActorLabel, Game: event.Summary["game_name"], Instance: instance, 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: details}
|
||||
}
|
||||
|
||||
func auditActors(ctx context.Context, service *audit.Service) []auditActorOption {
|
||||
|
||||
@@ -50,3 +50,24 @@ func TestAuditFilterValidatesDatesAndBuildsPersistentQuery(t *testing.T) {
|
||||
t.Fatalf("invalid filter accepted: %#v", invalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditEventViewRetainsInstanceSnapshotAfterDeletion(t *testing.T) {
|
||||
event := audit.Event{InstanceID: "deleted-instance-id", ActorLabel: "admin", Action: "instance.start", Outcome: "allowed", OccurredAt: time.Date(2026, time.August, 15, 14, 36, 13, 0, time.UTC), Summary: map[string]string{"target_id": "deleted-instance-id", "game_name": "Palworld", "instance_name": "Serveur principal"}}
|
||||
paris, err := time.LoadLocation("Europe/Paris")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view := auditEventViewFrom(event, "fr", paris)
|
||||
if view.Game != "Palworld" || view.Instance != "Serveur principal" || view.Time != "15/08/2026 16:36:13" {
|
||||
t.Fatalf("historical view = %#v", view)
|
||||
}
|
||||
foundID := false
|
||||
for _, detail := range view.Details {
|
||||
if detail.Key == "target_id" && detail.Value == "deleted-instance-id" {
|
||||
foundID = true
|
||||
}
|
||||
}
|
||||
if !foundID {
|
||||
t.Fatalf("technical instance ID absent from details: %#v", view.Details)
|
||||
}
|
||||
}
|
||||
|
||||
+70
-11
@@ -159,15 +159,30 @@ type deploymentPage struct {
|
||||
|
||||
type dashboardActivity struct {
|
||||
Action string
|
||||
Outcome string
|
||||
Actor string
|
||||
Instance string
|
||||
Game string
|
||||
Occurred time.Time
|
||||
Time string
|
||||
Language string
|
||||
}
|
||||
|
||||
func (d dashboardActivity) Msg(key string) string { return localized(d.Language, key) }
|
||||
|
||||
func (d dashboardActivity) Icon() string {
|
||||
switch d.Action {
|
||||
case "instance.start":
|
||||
return "play"
|
||||
case "instance.stop":
|
||||
return "stop"
|
||||
case "instance.restart", "instance.update":
|
||||
return "restart"
|
||||
default:
|
||||
return "clock"
|
||||
}
|
||||
}
|
||||
|
||||
type dashboardSystemStatus struct {
|
||||
AgentOnline bool
|
||||
AgentAvailable bool
|
||||
@@ -418,6 +433,8 @@ func (s *server) auditRequests(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
actor, _ := s.currentUser(r)
|
||||
instanceID := auditTargetID(r)
|
||||
instanceSummary := s.auditInstanceSummary(r.Context(), instanceID)
|
||||
wrapped := &auditResponseWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(wrapped, r)
|
||||
status := wrapped.status
|
||||
@@ -430,18 +447,57 @@ func (s *server) auditRequests(next http.Handler) http.Handler {
|
||||
} else if status >= 400 {
|
||||
outcome = "failed"
|
||||
}
|
||||
summary := map[string]string{}
|
||||
if id := r.PathValue("id"); id != "" {
|
||||
summary["target_id"] = id
|
||||
summary := mergeAuditInstanceSummary(map[string]string{}, instanceSummary)
|
||||
if instanceID != "" {
|
||||
summary["target_id"] = instanceID
|
||||
}
|
||||
instanceID := ""
|
||||
storedInstanceID := ""
|
||||
if status < 400 && (strings.HasPrefix(r.URL.Path, "/api/v1/instances/") || strings.HasPrefix(r.URL.Path, "/instances/")) {
|
||||
instanceID = r.PathValue("id")
|
||||
storedInstanceID = instanceID
|
||||
}
|
||||
_ = s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, InstanceID: instanceID, Action: action, Outcome: outcome, Summary: summary})
|
||||
if action == "instance.delete" {
|
||||
storedInstanceID = ""
|
||||
}
|
||||
_ = s.audit.Record(r.Context(), audit.Event{ActorID: actor.ID, ActorLabel: actor.Username, InstanceID: storedInstanceID, Action: action, Outcome: outcome, Summary: summary})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) auditInstanceSummary(ctx context.Context, id string) map[string]string {
|
||||
if id == "" || s.repository == nil {
|
||||
return nil
|
||||
}
|
||||
value, err := s.repository.GetInstance(ctx, id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"game_name": value.Preview.Game.Name, "instance_name": value.Preview.DisplayName, "instance_slug": value.Preview.Slug}
|
||||
}
|
||||
|
||||
func mergeAuditInstanceSummary(summary, snapshot map[string]string) map[string]string {
|
||||
if summary == nil {
|
||||
summary = map[string]string{}
|
||||
}
|
||||
for key, value := range snapshot {
|
||||
if value != "" {
|
||||
summary[key] = value
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func auditTargetID(r *http.Request) string {
|
||||
if id := r.PathValue("id"); id != "" {
|
||||
return id
|
||||
}
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
for index, part := range parts[:len(parts)-1] {
|
||||
if part == "instances" {
|
||||
return parts[index+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func auditAction(method, path string) string {
|
||||
if method == http.MethodGet || strings.HasPrefix(path, "/api/v1/admin/audit") || strings.HasPrefix(path, "/api/v1/admin/notification") || strings.HasPrefix(path, "/admin/audit") || strings.HasPrefix(path, "/admin/notification") {
|
||||
return ""
|
||||
@@ -2113,13 +2169,16 @@ func (s *server) dashboardData(ctx context.Context, data *pageData) {
|
||||
byID[current.ID] = current
|
||||
}
|
||||
for _, event := range events {
|
||||
activity := dashboardActivity{Action: event.Action, Actor: event.ActorLabel, Occurred: event.OccurredAt, Language: data.Language}
|
||||
activity := dashboardActivity{Action: event.Action, Outcome: event.Outcome, Actor: event.ActorLabel, Occurred: event.OccurredAt, Language: data.Language, Time: auditEventViewFrom(event, data.Language, s.auditLocation).Time}
|
||||
current, exists := byID[event.InstanceID]
|
||||
if !exists {
|
||||
continue
|
||||
if exists {
|
||||
activity.Instance, activity.Game = current.Preview.DisplayName, current.Preview.Game.Name
|
||||
} else {
|
||||
activity.Instance, activity.Game = event.Summary["instance_name"], event.Summary["game_name"]
|
||||
}
|
||||
if activity.Instance != "" || activity.Game != "" {
|
||||
data.RecentActivity = append(data.RecentActivity, activity)
|
||||
}
|
||||
activity.Instance, activity.Game = current.Preview.DisplayName, current.Preview.Game.Name
|
||||
data.RecentActivity = append(data.RecentActivity, activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ func TestDashboardRendersRealSummarySearchAndServerActivity(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auditService := audit.New(db)
|
||||
if err := auditService.Record(ctx, audit.Event{InstanceID: "palworld-main", ActorLabel: "admin", Action: "instance.start", Outcome: "allowed", OccurredAt: time.Now().UTC()}); err != nil {
|
||||
if err := auditService.Record(ctx, audit.Event{InstanceID: "palworld-main", ActorLabel: "admin", Action: "instance.start", Outcome: "allowed", OccurredAt: time.Now().UTC(), Summary: map[string]string{"game_name": "Palworld", "instance_name": "Main server", "instance_slug": "main-server", "target_id": "palworld-main"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := auditService.Record(ctx, audit.Event{ActorLabel: "admin", Action: "auth.login", Outcome: "allowed", OccurredAt: time.Now().UTC().Add(time.Minute)}); err != nil {
|
||||
@@ -480,6 +480,16 @@ func TestDashboardRendersRealSummarySearchAndServerActivity(t *testing.T) {
|
||||
if strings.Contains(body, "auth.login") {
|
||||
t.Fatal("authentication event leaked into recent activity")
|
||||
}
|
||||
if _, err := db.Exec(`DELETE FROM instances WHERE id='palworld-main'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auditPage := request(t, handler, http.MethodGet, "/audit", []*http.Cookie{{Name: sessionCookie, Value: session.Token}, {Name: csrfCookie, Value: session.CSRFToken}})
|
||||
assertStatus(t, auditPage, http.StatusOK)
|
||||
for _, expected := range []string{"Palworld", "Main server", "palworld-main"} {
|
||||
if !strings.Contains(auditPage.Body.String(), expected) {
|
||||
t.Fatalf("deleted instance audit missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationNavigationHidesAdministrationFromRegularUsers(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Material Design Icons
|
||||
https://materialdesignicons.com/
|
||||
Copyright (c) 2014-2026 Material Design Icons contributors
|
||||
License: MIT
|
||||
|
||||
This repository ships only the SVG paths used by the DoGaMa interface in
|
||||
icons.svg. The assets are served locally and never require a runtime CDN.
|
||||
@@ -704,6 +704,14 @@ code{
|
||||
gap: 12px;
|
||||
margin-top: 22px
|
||||
}
|
||||
.ui-icon{width:1.1em;height:1.1em;fill:currentColor;flex:0 0 auto;vertical-align:-.18em}
|
||||
.audit-table-wrap{overflow-x:auto}
|
||||
.audit-table{min-width:760px;table-layout:fixed}
|
||||
.audit-table th,.audit-table td{vertical-align:top;white-space:normal;overflow-wrap:anywhere}
|
||||
.audit-table th:nth-child(1){width:14%}.audit-table th:nth-child(2){width:19%}.audit-table th:nth-child(3){width:20%}.audit-table th:nth-child(4){width:12%}.audit-table th:nth-child(5){width:12%}
|
||||
.audit-table small{display:block;margin-top:3px;color:var(--text-secondary)}
|
||||
.audit-table details{font-size:.8rem;color:var(--text-secondary)}
|
||||
.audit-table details dl{margin:8px 0 0}.audit-table details dl div{display:grid;gap:2px;margin-top:7px}.audit-table dt{font-weight:700}.audit-table dd{margin:0;overflow-wrap:anywhere}.audit-table code{white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
@media(max-width:760px){
|
||||
.audit-filter-grid{
|
||||
grid-template-columns: repeat(2,minmax(0,1fr))
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!-- Material Design Icons, https://materialdesignicons.com/, MIT License. See ICONS-LICENSE.txt. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||
<symbol id="home" viewBox="0 0 24 24"><path d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z"/></symbol>
|
||||
<symbol id="settings" viewBox="0 0 24 24"><path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.66 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/></symbol>
|
||||
<symbol id="logout" viewBox="0 0 24 24"><path d="M17 7L15.59 8.41L18.17 11H8V13H18.17L15.59 15.58L17 17L22 12M4 5H12V3H4C2.9 3 2 3.9 2 5V19C2 20.1 2.9 21 4 21H12V19H4V5Z"/></symbol>
|
||||
<symbol id="play" viewBox="0 0 24 24"><path d="M8,5.14V19.14L19,12.14L8,5.14Z"/></symbol>
|
||||
<symbol id="stop" viewBox="0 0 24 24"><path d="M18,18H6V6H18V18Z"/></symbol>
|
||||
<symbol id="restart" viewBox="0 0 24 24"><path d="M12,4C14.1,4 16.1,4.8 17.6,6.3C20.7,9.4 20.7,14.5 17.6,17.6C15.8,19.5 13.3,20.2 10.9,19.9L11.4,17.9C13.1,18.1 14.9,17.5 16.2,16.2C18.5,13.9 18.5,10.1 16.2,7.7C15.1,6.6 13.5,6 12,6V10.6L7,5.6L12,0.6V4M6.3,17.6C3.7,15 3.3,11 5.1,7.9L6.6,9.4C5.5,11.6 5.9,14.4 7.8,16.2C8.3,16.7 8.9,17.1 9.6,17.4L9,19.4C8,19 7.1,18.4 6.3,17.6Z"/></symbol>
|
||||
<symbol id="database" viewBox="0 0 24 24"><path d="M12,3C7.58,3 4,4.79 4,7C4,9.21 7.58,11 12,11C16.42,11 20,9.21 20,7C20,4.79 16.42,3 12,3M4,9V12C4,14.21 7.58,16 12,16C16.42,16 20,14.21 20,12V9C20,11.21 16.42,13 12,13C7.58,13 4,11.21 4,9M4,14V17C4,19.21 7.58,21 12,21C16.42,21 20,19.21 20,17V14C20,16.21 16.42,18 12,18C7.58,18 4,19.21 4,17V14Z"/></symbol>
|
||||
<symbol id="server" viewBox="0 0 24 24"><path d="M4,1H20A1,1 0 0,1 21,2V6A1,1 0 0,1 20,7H4A1,1 0 0,1 3,6V2A1,1 0 0,1 4,1M4,9H20A1,1 0 0,1 21,10V14A1,1 0 0,1 20,15H4A1,1 0 0,1 3,14V10A1,1 0 0,1 4,9M4,17H20A1,1 0 0,1 21,18V22A1,1 0 0,1 20,23H4A1,1 0 0,1 3,22V18A1,1 0 0,1 4,17M9,5H10V3H9V5M9,13H10V11H9V13M9,21H10V19H9V21M5,3V5H7V3H5M5,11V13H7V11H5M5,19V21H7V19H5Z"/></symbol>
|
||||
<symbol id="alert" viewBox="0 0 24 24"><path d="M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z"/></symbol>
|
||||
<symbol id="check" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"/></symbol>
|
||||
<symbol id="clock" viewBox="0 0 24 24"><path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M16.2,16.2L11,13V7H12.5V12.2L17,14.9L16.2,16.2Z"/></symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -151,13 +151,13 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-dashboard::before {
|
||||
content: "⌂";
|
||||
content: none;
|
||||
}
|
||||
.nav-audit::before {
|
||||
content: "▣";
|
||||
content: none;
|
||||
}
|
||||
.nav-settings::before {
|
||||
content: "⚙";
|
||||
content: none;
|
||||
}
|
||||
.page-heading {
|
||||
align-items: center;
|
||||
@@ -232,6 +232,9 @@ button,
|
||||
.app-main::after {
|
||||
left: 0;
|
||||
}
|
||||
.app-main {
|
||||
margin-left: 0;
|
||||
}
|
||||
.page-heading {
|
||||
align-items: stretch;
|
||||
display: grid;
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
{{.Title}}
|
||||
· DoGaMa
|
||||
</title>
|
||||
<link rel="stylesheet" href="/static/app.v2.css">
|
||||
<link rel="stylesheet" href="/static/theme.v2.css">
|
||||
<link rel="stylesheet" href="/static/app.v1.css">
|
||||
<link rel="stylesheet" href="/static/theme.v1.css">
|
||||
</head>
|
||||
<body class="app-body">
|
||||
{{template "sidebar" .}}
|
||||
@@ -156,53 +156,11 @@
|
||||
</p>
|
||||
</div>
|
||||
{{if .AuditEvents}}
|
||||
<div class="audit-list">
|
||||
<div class="audit-table-wrap"><table class="audit-table"><thead><tr><th>{{.Msg "audit.time"}}</th><th>{{.Msg "audit.action"}}</th><th>{{.Msg "audit.instance_column"}}</th><th>{{.Msg "audit.actor_column"}}</th><th>{{.Msg "audit.outcome"}}</th><th>{{.Msg "audit.details"}}</th></tr></thead><tbody>
|
||||
{{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>
|
||||
<tr><td><time datetime="{{.UTC}}" title="{{.UTC}}">{{.Time}}</time></td><td><strong>{{.ActionLabel}}</strong><small><code>{{.Action}}</code></small></td><td>{{if .Instance}}<strong>{{.Instance}}</strong>{{if .Game}}<small>{{.Game}}</small>{{end}}{{else}}—{{end}}</td><td>{{if .Actor}}{{.Actor}}{{else}}—{{end}}</td><td><span class="outcome outcome-{{.Outcome}}">{{.OutcomeLabel}}</span></td><td>{{if .Details}}<details><summary>{{$.Msg "audit.details"}}</summary><dl>{{range .Details}}<div><dt>{{.Key}}</dt><dd><code>{{.Value}}</code></dd></div>{{end}}</dl></details>{{else}}—{{end}}</td></tr>
|
||||
{{end}}
|
||||
</div>
|
||||
</tbody></table></div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">
|
||||
@@ -238,7 +196,7 @@
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/app.v2.js">
|
||||
<script src="/static/app.v1.js">
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
</a>
|
||||
<nav aria-label="{{.Msg "nav.primary"}}">
|
||||
<a class="nav-dashboard {{if eq .ActivePage "dashboard"}}active{{end}}" href="/">
|
||||
<svg class="ui-icon" aria-hidden="true"><use href="/static/icons.svg#home"></use></svg>
|
||||
{{.Msg "nav.dashboard"}}
|
||||
</a>
|
||||
<a class="nav-catalog {{if eq .ActivePage "catalog"}}active{{end}}" href="/catalog">
|
||||
@@ -28,7 +29,7 @@
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="nav-account {{if eq .ActivePage "account"}}active{{end}}" href="/account">
|
||||
⚙
|
||||
<svg class="ui-icon" aria-hidden="true"><use href="/static/icons.svg#settings"></use></svg>
|
||||
{{.Msg "nav.settings"}}
|
||||
</a>
|
||||
</nav>
|
||||
@@ -50,7 +51,7 @@
|
||||
<form method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="signout-button" type="submit">
|
||||
↪
|
||||
<svg class="ui-icon" aria-hidden="true"><use href="/static/icons.svg#logout"></use></svg>
|
||||
{{.Msg "logout.submit"}}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
{{.Title}}
|
||||
· DoGaMa
|
||||
</title>
|
||||
<link rel="stylesheet" href="/static/app.v2.css">
|
||||
<link rel="stylesheet" href="/static/theme.v2.css">
|
||||
<link rel="stylesheet" href="/static/app.v1.css">
|
||||
<link rel="stylesheet" href="/static/theme.v1.css">
|
||||
</head>
|
||||
<body class="app-body">
|
||||
{{template "sidebar" .}}
|
||||
@@ -138,7 +138,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
<div class="instance-detail-lower">
|
||||
<div class="dashboard-lower">
|
||||
<section class="panel" aria-labelledby="activity-heading">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
@@ -151,14 +151,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{{if .RecentActivity}}
|
||||
<ul>
|
||||
<ul class="activity-list">
|
||||
{{range .RecentActivity}}
|
||||
<li>
|
||||
{{activityLabel .Action}}
|
||||
·
|
||||
{{.Instance}}
|
||||
·
|
||||
{{.Actor}}
|
||||
<svg class="ui-icon activity-icon" aria-hidden="true"><use href="/static/icons.svg#{{.Icon}}"></use></svg>
|
||||
<div><strong>{{activityLabel .Action}}</strong><span>{{.Game}} — {{.Instance}}</span><span>{{.Msg "dashboard.by"}} {{.Actor}} · {{.Time}}</span></div>
|
||||
<span class="outcome outcome-{{.Outcome}}">{{.Outcome}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
@@ -179,10 +177,8 @@
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
{{.Msg "dashboard.agent"}}
|
||||
:
|
||||
<dl class="system-list">
|
||||
<div><dt><svg class="ui-icon" aria-hidden="true"><use href="/static/icons.svg#server"></use></svg>{{.Msg "dashboard.agent"}}</dt><dd class="{{if and .SystemStatus.AgentAvailable .SystemStatus.AgentOnline}}is-good{{else}}is-bad{{end}}">
|
||||
{{if .SystemStatus.AgentAvailable}}
|
||||
{{if .SystemStatus.AgentOnline}}
|
||||
{{.Msg "dashboard.online"}}
|
||||
@@ -192,31 +188,27 @@
|
||||
{{else}}
|
||||
{{.Msg "dashboard.unavailable"}}
|
||||
{{end}}
|
||||
</li>
|
||||
<li>
|
||||
{{.Msg "dashboard.database"}}
|
||||
:
|
||||
</dd></div>
|
||||
<div><dt><svg class="ui-icon" aria-hidden="true"><use href="/static/icons.svg#database"></use></svg>{{.Msg "dashboard.database"}}</dt><dd class="{{if and .SystemStatus.DatabaseAvailable .SystemStatus.DatabaseHealthy}}is-good{{else}}is-bad{{end}}">
|
||||
{{if .SystemStatus.DatabaseHealthy}}
|
||||
{{.Msg "dashboard.healthy"}}
|
||||
{{else}}
|
||||
{{.Msg "dashboard.unavailable"}}
|
||||
{{end}}
|
||||
</li>
|
||||
<li>
|
||||
{{.Msg "dashboard.storage"}}
|
||||
:
|
||||
</dd></div>
|
||||
<div><dt><svg class="ui-icon" aria-hidden="true"><use href="/static/icons.svg#server"></use></svg>{{.Msg "dashboard.storage"}}</dt><dd class="{{if .SystemStatus.StorageAvailable}}is-good{{else}}is-bad{{end}}">
|
||||
{{if .SystemStatus.StorageAvailable}}
|
||||
{{storagePercent .SystemStatus.StorageUsed .SystemStatus.StorageTotal}}
|
||||
%
|
||||
{{else}}
|
||||
{{.Msg "dashboard.unavailable"}}
|
||||
{{end}}
|
||||
</li>
|
||||
</ul>
|
||||
</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script src="/static/app.v2.js">
|
||||
<script src="/static/app.v1.js">
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user