feat: add session indicators and top exec commands to dashboard

Add visual indicators to session tables (replay badge when events exist,
exec badge for exec sessions) and a new "Top Exec Commands" table on the
dashboard. Includes EventCount field on Session, GetTopExecCommands on
Store interface, and truncateCommand template function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 19:38:10 +01:00
parent 0b44d1c83f
commit 4f10a8a422
9 changed files with 243 additions and 25 deletions

View File

@@ -9,13 +9,14 @@ import (
)
type dashboardData struct {
Stats *storage.DashboardStats
TopUsernames []storage.TopEntry
TopPasswords []storage.TopEntry
TopIPs []storage.TopEntry
TopCountries []storage.TopEntry
ActiveSessions []storage.Session
RecentSessions []storage.Session
Stats *storage.DashboardStats
TopUsernames []storage.TopEntry
TopPasswords []storage.TopEntry
TopIPs []storage.TopEntry
TopCountries []storage.TopEntry
TopExecCommands []storage.TopEntry
ActiveSessions []storage.Session
RecentSessions []storage.Session
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
@@ -56,6 +57,13 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
return
}
topExecCommands, err := s.store.GetTopExecCommands(ctx, 10)
if err != nil {
s.logger.Error("failed to get top exec commands", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
activeSessions, err := s.store.GetRecentSessions(ctx, 50, true)
if err != nil {
s.logger.Error("failed to get active sessions", "err", err)
@@ -71,13 +79,14 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
}
data := dashboardData{
Stats: stats,
TopUsernames: topUsernames,
TopPasswords: topPasswords,
TopIPs: topIPs,
TopCountries: topCountries,
ActiveSessions: activeSessions,
RecentSessions: recentSessions,
Stats: stats,
TopUsernames: topUsernames,
TopPasswords: topPasswords,
TopIPs: topIPs,
TopCountries: topCountries,
TopExecCommands: topExecCommands,
ActiveSessions: activeSessions,
RecentSessions: recentSessions,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")

View File

@@ -50,6 +50,12 @@ func templateFuncMap() template.FuncMap {
}
return *s
},
"truncateCommand": func(s string) string {
if len(s) > 50 {
return s[:50] + "..."
}
return s
},
}
}

View File

@@ -66,6 +66,21 @@
</tbody>
</table>
</article>
<article>
<header>Top Exec Commands</header>
<table>
<thead>
<tr><th>Command</th><th>Count</th></tr>
</thead>
<tbody>
{{range .TopExecCommands}}
<tr><td><code>{{truncateCommand .Value}}</code></td><td>{{.Count}}</td></tr>
{{else}}
<tr><td colspan="2">No data</td></tr>
{{end}}
</tbody>
</table>
</article>
</div>
</section>
@@ -85,7 +100,7 @@
<th>IP</th>
<th>Country</th>
<th>Username</th>
<th>Shell</th>
<th>Type</th>
<th>Score</th>
<th>Connected</th>
<th>Disconnected</th>
@@ -94,11 +109,11 @@
<tbody>
{{range .RecentSessions}}
<tr>
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a></td>
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a>{{if gt .EventCount 0}} <mark>replay</mark>{{end}}</td>
<td>{{.IP}}</td>
<td>{{.Country}}</td>
<td>{{.Username}}</td>
<td>{{.ShellName}}</td>
<td>{{if .ExecCommand}}<mark>exec</mark>{{else}}{{.ShellName}}{{end}}</td>
<td>{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}<mark>{{formatScore .HumanScore}}</mark>{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}</td>
<td>{{formatTime .ConnectedAt}}</td>
<td>{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>

View File

@@ -6,7 +6,7 @@
<th>IP</th>
<th>Country</th>
<th>Username</th>
<th>Shell</th>
<th>Type</th>
<th>Score</th>
<th>Connected</th>
</tr>
@@ -14,11 +14,11 @@
<tbody>
{{range .}}
<tr>
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a></td>
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a>{{if gt .EventCount 0}} <mark>replay</mark>{{end}}</td>
<td>{{.IP}}</td>
<td>{{.Country}}</td>
<td>{{.Username}}</td>
<td>{{.ShellName}}</td>
<td>{{if .ExecCommand}}<mark>exec</mark>{{else}}{{.ShellName}}{{end}}</td>
<td>{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}<mark>{{formatScore .HumanScore}}</mark>{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}</td>
<td>{{formatTime .ConnectedAt}}</td>
</tr>

View File

@@ -340,6 +340,61 @@ func TestMetricsBearerToken(t *testing.T) {
})
}
func TestTruncateCommand(t *testing.T) {
funcMap := templateFuncMap()
fn := funcMap["truncateCommand"].(func(string) string)
tests := []struct {
input string
want string
}{
{"short", "short"},
{"exactly fifty characters long! that is what it i.", "exactly fifty characters long! that is what it i."},
{"this string is definitely longer than fifty characters and should be truncated", "this string is definitely longer than fifty charac..."},
{"", ""},
}
for _, tt := range tests {
got := fn(tt.input)
if got != tt.want {
t.Errorf("truncateCommand(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestDashboardExecCommands(t *testing.T) {
store := storage.NewMemoryStore()
ctx := context.Background()
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "")
if err != nil {
t.Fatalf("creating session: %v", err)
}
if err := store.SetExecCommand(ctx, id, "uname -a"); err != nil {
t.Fatalf("setting exec command: %v", err)
}
srv, err := NewServer(store, slog.Default(), nil, "")
if err != nil {
t.Fatalf("NewServer: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "Top Exec Commands") {
t.Error("response should contain 'Top Exec Commands'")
}
if !strings.Contains(body, "uname -a") {
t.Error("response should contain exec command 'uname -a'")
}
}
func TestStaticAssets(t *testing.T) {
srv := newTestServer(t)