feat(web): improve dashboard and audit presentation
CI / validate (pull_request) Successful in 26m0s

This commit is contained in:
2026-08-16 00:07:31 +02:00
parent cb884a4bd9
commit 77d1161944
15 changed files with 189 additions and 95 deletions
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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") {
+3
View File
@@ -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")
}
+13 -4
View File
@@ -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 {
+21
View File
@@ -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
View File
@@ -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)
}
}
}
+11 -1
View File
@@ -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) {
+7
View File
@@ -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.
+8
View File
@@ -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))
+14
View File
@@ -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

+6 -3
View File
@@ -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;
+6 -48
View File
@@ -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>
+3 -2
View File
@@ -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>
+16 -24
View File
@@ -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>