feat: add server-side session filtering with input bytes and human score

Replace client-side session table filtering with server-side filtering
via a new /fragments/recent-sessions htmx endpoint. Add InputBytes column
to session tables, Human score > 0 checkbox filter, and Sort by Input
Bytes option to help identify sessions with actual shell interaction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 09:09:59 +01:00
parent 0908b43724
commit cb7be28f42
11 changed files with 409 additions and 61 deletions

View File

@@ -125,6 +125,21 @@ func (s *Server) handleFragmentActiveSessions(w http.ResponseWriter, r *http.Req
}
}
func (s *Server) handleFragmentRecentSessions(w http.ResponseWriter, r *http.Request) {
f := parseDashboardFilter(r)
sessions, err := s.store.GetFilteredSessions(r.Context(), 50, false, f)
if err != nil {
s.logger.Error("failed to get filtered sessions", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.dashboard.ExecuteTemplate(w, "recent_sessions", sessions); err != nil {
s.logger.Error("failed to render recent sessions fragment", "err", err)
}
}
type sessionDetailData struct {
Session *storage.Session
Logs []storage.SessionLog
@@ -201,11 +216,13 @@ func parseDateParam(r *http.Request, name string) *time.Time {
func parseDashboardFilter(r *http.Request) storage.DashboardFilter {
return storage.DashboardFilter{
Since: parseDateParam(r, "since"),
Until: parseDateParam(r, "until"),
IP: r.URL.Query().Get("ip"),
Country: r.URL.Query().Get("country"),
Username: r.URL.Query().Get("username"),
Since: parseDateParam(r, "since"),
Until: parseDateParam(r, "until"),
IP: r.URL.Query().Get("ip"),
Country: r.URL.Query().Get("country"),
Username: r.URL.Query().Get("username"),
HumanScoreAboveZero: r.URL.Query().Get("human_score") == "1",
SortBy: r.URL.Query().Get("sort"),
}
}

View File

@@ -16,6 +16,10 @@
var until = form.elements['until'].value;
if (since) params.set('since', since);
if (until) params.set('until', until);
var humanScore = form.elements['human_score'];
if (humanScore && humanScore.checked) params.set('human_score', '1');
var sortBy = form.elements['sort'];
if (sortBy && sortBy.value) params.set('sort', sortBy.value);
return params.toString();
}
@@ -228,33 +232,20 @@
if (val) params.set(name, val);
});
var humanScore = form.elements['human_score'];
if (humanScore && humanScore.checked) params.set('human_score', '1');
var sortBy = form.elements['sort'];
if (sortBy && sortBy.value) params.set('sort', sortBy.value);
var target = document.getElementById('dashboard-content');
if (target) {
var url = '/fragments/dashboard-content?' + params.toString();
htmx.ajax('GET', url, {target: '#dashboard-content', swap: 'innerHTML'});
}
// Client-side filter for recent sessions table
filterSessionsTable(form);
}
function filterSessionsTable(form) {
var ip = form.elements['ip'].value.toLowerCase();
var country = form.elements['country'].value.toLowerCase();
var username = form.elements['username'].value.toLowerCase();
var rows = document.querySelectorAll('#recent-sessions-table tbody tr');
rows.forEach(function(row) {
var cells = row.querySelectorAll('td');
if (cells.length < 4) { row.style.display = ''; return; }
var show = true;
if (ip && cells[1].textContent.toLowerCase().indexOf(ip) === -1) show = false;
if (country && cells[2].textContent.toLowerCase().indexOf(country) === -1) show = false;
if (username && cells[3].textContent.toLowerCase().indexOf(username) === -1) show = false;
row.style.display = show ? '' : 'none';
});
// Server-side filter for recent sessions table
var sessionsUrl = '/fragments/recent-sessions?' + params.toString();
htmx.ajax('GET', sessionsUrl, {target: '#recent-sessions-table tbody', swap: 'innerHTML'});
}
window.clearFilters = function() {

View File

@@ -56,6 +56,20 @@ func templateFuncMap() template.FuncMap {
}
return s
},
"formatBytes": func(b int64) string {
const (
kb = 1024
mb = 1024 * kb
)
switch {
case b >= mb:
return fmt.Sprintf("%.1f MB", float64(b)/float64(mb))
case b >= kb:
return fmt.Sprintf("%.1f KB", float64(b)/float64(kb))
default:
return fmt.Sprintf("%d B", b)
}
},
}
}
@@ -67,6 +81,7 @@ func loadTemplates() (*templateSet, error) {
"templates/dashboard.html",
"templates/fragments/stats.html",
"templates/fragments/active_sessions.html",
"templates/fragments/recent_sessions.html",
)
if err != nil {
return nil, fmt.Errorf("parsing dashboard templates: %w", err)

View File

@@ -13,6 +13,10 @@
<label>Country <input type="text" name="country" placeholder="CN" maxlength="2"></label>
<label>Username <input type="text" name="username" placeholder="root"></label>
</div>
<div class="grid">
<label><input type="checkbox" name="human_score" value="1"> Human score &gt; 0</label>
<label>Sort by <select name="sort"><option value="connected_at">Recent</option><option value="input_bytes">Input Bytes</option></select></label>
</div>
<button type="submit">Apply</button>
<button type="button" class="secondary" onclick="clearFilters()">Clear</button>
</form>
@@ -61,25 +65,13 @@
<th>Username</th>
<th>Type</th>
<th>Score</th>
<th>Input</th>
<th>Connected</th>
<th>Disconnected</th>
</tr>
</thead>
<tbody>
{{range .RecentSessions}}
<tr>
<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>{{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>
</tr>
{{else}}
<tr><td colspan="8">No sessions</td></tr>
{{end}}
{{template "recent_sessions" .RecentSessions}}
</tbody>
</table>
</section>

View File

@@ -8,6 +8,7 @@
<th>Username</th>
<th>Type</th>
<th>Score</th>
<th>Input</th>
<th>Connected</th>
</tr>
</thead>
@@ -20,10 +21,11 @@
<td>{{.Username}}</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>{{formatBytes .InputBytes}}</td>
<td>{{formatTime .ConnectedAt}}</td>
</tr>
{{else}}
<tr><td colspan="7">No active sessions</td></tr>
<tr><td colspan="8">No active sessions</td></tr>
{{end}}
</tbody>
</table>

View File

@@ -0,0 +1,17 @@
{{define "recent_sessions"}}
{{range .}}
<tr>
<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>{{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>{{formatBytes .InputBytes}}</td>
<td>{{formatTime .ConnectedAt}}</td>
<td>{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="9">No sessions</td></tr>
{{end}}
{{end}}

View File

@@ -47,6 +47,7 @@ func NewServer(store storage.Store, logger *slog.Logger, metricsHandler http.Han
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
s.mux.HandleFunc("GET /fragments/dashboard-content", s.handleFragmentDashboardContent)
s.mux.HandleFunc("GET /fragments/recent-sessions", s.handleFragmentRecentSessions)
if metricsHandler != nil {
h := metricsHandler