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:
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 > 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
internal/web/templates/fragments/recent_sessions.html
Normal file
17
internal/web/templates/fragments/recent_sessions.html
Normal 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}}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user