feat: add minimal web dashboard with stats, top credentials, and sessions
Implements Phase 1.5 — an embedded web UI using Go templates, Pico CSS (dark theme), and htmx for auto-refreshing stats and active sessions. Adds read query methods to the Store interface (GetDashboardStats, GetTopUsernames, GetTopPasswords, GetTopIPs, GetRecentSessions) with implementations for both SQLite and MemoryStore. Introduces the internal/web package with server, handlers, templates, and tests. Web server is opt-in via [web] config section and runs alongside SSH with graceful shutdown. Bumps version to 0.2.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -139,6 +139,102 @@ func (s *SQLiteStore) DeleteRecordsBefore(ctx context.Context, cutoff time.Time)
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
||||
stats := &DashboardStats{}
|
||||
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(count), 0), COUNT(DISTINCT ip)
|
||||
FROM login_attempts`).Scan(&stats.TotalAttempts, &stats.UniqueIPs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying attempt stats: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions`).Scan(&stats.TotalSessions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying total sessions: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM sessions WHERE disconnected_at IS NULL`).Scan(&stats.ActiveSessions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying active sessions: %w", err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetTopUsernames(ctx context.Context, limit int) ([]TopEntry, error) {
|
||||
return s.queryTopN(ctx, "username", limit)
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetTopPasswords(ctx context.Context, limit int) ([]TopEntry, error) {
|
||||
return s.queryTopN(ctx, "password", limit)
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetTopIPs(ctx context.Context, limit int) ([]TopEntry, error) {
|
||||
return s.queryTopN(ctx, "ip", limit)
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) queryTopN(ctx context.Context, column string, limit int) ([]TopEntry, error) {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT %s, SUM(count) AS total
|
||||
FROM login_attempts
|
||||
GROUP BY %s
|
||||
ORDER BY total DESC
|
||||
LIMIT ?`, column, column)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying top %s: %w", column, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var entries []TopEntry
|
||||
for rows.Next() {
|
||||
var e TopEntry
|
||||
if err := rows.Scan(&e.Value, &e.Count); err != nil {
|
||||
return nil, fmt.Errorf("scanning top %s: %w", column, err)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) {
|
||||
query := `SELECT id, ip, username, shell_name, connected_at, disconnected_at, human_score FROM sessions`
|
||||
if activeOnly {
|
||||
query += ` WHERE disconnected_at IS NULL`
|
||||
}
|
||||
query += ` ORDER BY connected_at DESC LIMIT ?`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying recent sessions: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var sessions []Session
|
||||
for rows.Next() {
|
||||
var s Session
|
||||
var connectedAt string
|
||||
var disconnectedAt sql.NullString
|
||||
var humanScore sql.NullFloat64
|
||||
if err := rows.Scan(&s.ID, &s.IP, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore); err != nil {
|
||||
return nil, fmt.Errorf("scanning session: %w", err)
|
||||
}
|
||||
s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
|
||||
if disconnectedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, disconnectedAt.String)
|
||||
s.DisconnectedAt = &t
|
||||
}
|
||||
if humanScore.Valid {
|
||||
s.HumanScore = &humanScore.Float64
|
||||
}
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user