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:
@@ -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")
|
||||
|
||||
@@ -50,6 +50,12 @@ func templateFuncMap() template.FuncMap {
|
||||
}
|
||||
return *s
|
||||
},
|
||||
"truncateCommand": func(s string) string {
|
||||
if len(s) > 50 {
|
||||
return s[:50] + "..."
|
||||
}
|
||||
return s
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user