Skip to content

Commit f76cdf8

Browse files
committed
feat: add pagination to user list + estimated heartbeat count
1 parent d8a0282 commit f76cdf8

File tree

9 files changed

+116
-66
lines changed

9 files changed

+116
-66
lines changed

frontend/src/lib/types/admin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ export interface AdminResponse {
88
requests_per_second: number;
99
daily_activity: Array<{ date: string; count: number }>;
1010
all_users: Array<PartialUser>;
11+
limit: number;
12+
offset: number;
1113
}

frontend/src/routes/admin/+page.server.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import type { AdminResponse } from '$lib/types/admin';
33
import { createApi, ApiError } from '$lib/api/api';
44
import { redirect, error } from '@sveltejs/kit';
55

6-
export const load: PageServerLoad = async ({ fetch, depends, request }) => {
6+
export const load: PageServerLoad = async ({ fetch, depends, request, url }) => {
77
depends('app:admin');
88

9+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
10+
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
11+
912
try {
1013
const cookieHeader = request.headers.get('cookie') || undefined;
1114
const api = createApi(fetch, cookieHeader);
12-
return await api.get<AdminResponse>('/page/admin');
15+
return await api.get<AdminResponse>(`/page/admin?limit=${limit}&offset=${offset}`);
1316
} catch (e) {
1417
console.error('Error loading admin page data:', e);
1518
const err = e as ApiError;

frontend/src/routes/admin/+page.svelte

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
22
import { tick } from 'svelte';
33
import { browser } from '$app/environment';
4-
import { invalidate } from '$app/navigation';
4+
import { invalidate, goto } from '$app/navigation';
55
import { apexcharts } from '$lib/stores/apexcharts';
66
import { theme } from '$lib/stores/theme';
77
import type { Theme } from '$lib/stores/theme';
@@ -21,7 +21,8 @@
2121
UserTag,
2222
DataTable,
2323
EmptyState,
24-
Button
24+
Button,
25+
Pagination
2526
} from '$lib';
2627
import { auth } from '$lib/stores/auth';
2728
import { impersonateUser, changeAdminLevel } from '$lib/api/admin';
@@ -94,6 +95,15 @@
9495
}
9596
});
9697
98+
const currentOffset = $derived(adminData.offset);
99+
const limit = $derived(adminData.limit);
100+
const total = $derived(adminData.total_users);
101+
102+
function goToPage(offset: number) {
103+
// eslint-disable-next-line svelte/no-navigation-without-resolve
104+
goto(`/admin?offset=${offset}&limit=${limit}`);
105+
}
106+
97107
async function initializeCharts(theme: Theme) {
98108
if (!browser) {
99109
return;
@@ -160,7 +170,7 @@
160170
<div class="md:col-span-2 lg:col-span-1">
161171
<StatCard
162172
title="Total Heartbeats"
163-
value={adminData.total_heartbeats.toLocaleString()}
173+
value={`~${adminData.total_heartbeats.toLocaleString()}`}
164174
valueClass="text-3xl font-bold text-ctp-green-600"
165175
/>
166176
</div>
@@ -191,11 +201,7 @@
191201
<Container>
192202
<SectionTitle className="mb-4">Users</SectionTitle>
193203
<DataTable {columns} tableClassName="min-w-lg">
194-
{#each [...adminData.all_users].sort((a, b) => {
195-
const adminDiff = (b.admin_level ?? 0) - (a.admin_level ?? 0);
196-
if (adminDiff !== 0) return adminDiff;
197-
return a.id - b.id;
198-
}) as user (user.id)}
204+
{#each adminData.all_users as user (user.id)}
199205
<tr class="border-b border-ctp-surface0 last:border-0 hover:bg-ctp-surface0/50">
200206
<td class="pl-6 py-4 whitespace-nowrap text-sm text-ctp-subtext1">{user.id}</td>
201207
<td class="px-6 py-4 whitespace-nowrap">
@@ -275,6 +281,14 @@
275281
</tr>
276282
{/each}
277283
</DataTable>
284+
285+
<Pagination
286+
offset={currentOffset}
287+
{limit}
288+
{total}
289+
className="mt-4"
290+
onchange={(newOffset) => goToPage(newOffset)}
291+
/>
278292
</Container>
279293
{:else}
280294
<EmptyState

rustytime/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rustytime/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "rustytime-server"
33
description = "🕒 blazingly fast time tracking for developers"
4-
version = "0.15.1"
4+
version = "0.15.2"
55
edition = "2024"
66
authors = ["ImShyMike"]
77
readme = "../README.md"

rustytime/src/handlers/page/admin.rs

Lines changed: 64 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use aide::NoApi;
22
use axum::Json;
3-
use axum::extract::Path;
3+
use axum::extract::{Path, Query};
44
use axum::{
55
Extension,
66
extract::State,
@@ -9,7 +9,7 @@ use axum::{
99
};
1010

1111
use schemars::JsonSchema;
12-
use serde::Serialize;
12+
use serde::{Deserialize, Serialize};
1313
use tower_cookies::Cookies;
1414

1515
use crate::models::heartbeat::Heartbeat;
@@ -20,6 +20,18 @@ use crate::utils::cache::CachedAdminStats;
2020
use crate::utils::session::{ImpersonationContext, SessionManager};
2121
use crate::{db_query, get_db_conn};
2222

23+
#[derive(Deserialize, JsonSchema)]
24+
pub struct AdminQuery {
25+
#[serde(default = "default_limit")]
26+
pub limit: i64,
27+
#[serde(default)]
28+
pub offset: i64,
29+
}
30+
31+
fn default_limit() -> i64 {
32+
50
33+
}
34+
2335
#[derive(Serialize, JsonSchema)]
2436
pub struct FormattedDailyActivity {
2537
pub date: String,
@@ -35,10 +47,13 @@ pub struct AdminDashboardResponse {
3547
pub requests_per_second: f64,
3648
pub daily_activity: Vec<FormattedDailyActivity>,
3749
pub all_users: Vec<PartialUser>,
50+
pub limit: i64,
51+
pub offset: i64,
3852
}
3953

4054
pub async fn admin_dashboard(
4155
State(app_state): State<AppState>,
56+
Query(query): Query<AdminQuery>,
4257
user: NoApi<Option<Extension<User>>>,
4358
) -> Result<Json<AdminDashboardResponse>, Response> {
4459
// check if user is an admin
@@ -52,60 +67,58 @@ pub async fn admin_dashboard(
5267
}
5368

5469
let include_api_key = current_user.is_owner();
70+
let limit = query.limit.clamp(1, 100);
71+
let offset = query.offset.max(0);
5572

5673
let cached = app_state.cache.admin.get(&());
57-
let (
58-
total_users,
59-
total_heartbeats,
60-
heartbeats_last_hour,
61-
heartbeats_last_24h,
62-
daily_activity,
63-
all_users,
64-
) = if let Some(cached) = cached {
65-
(
66-
cached.total_users,
67-
cached.total_heartbeats,
68-
cached.heartbeats_last_hour,
69-
cached.heartbeats_last_24h,
70-
cached.daily_activity,
71-
cached.all_users,
72-
)
73-
} else {
74-
let mut conn = get_db_conn!(app_state);
75-
76-
let raw_daily_activity = db_query!(
77-
Heartbeat::get_daily_activity_last_week(&mut conn),
78-
"Failed to fetch daily activity"
79-
);
80-
let all_users = db_query!(User::list_all_users(&mut conn), "Failed to fetch users");
81-
let total_users = db_query!(User::count_total_users(&mut conn, false));
82-
let total_heartbeats = db_query!(Heartbeat::count_total_heartbeats(&mut conn));
83-
let heartbeats_last_hour = db_query!(Heartbeat::count_heartbeats_last_hour(&mut conn));
84-
let heartbeats_last_24h = db_query!(Heartbeat::count_heartbeats_last_24h(&mut conn));
85-
86-
app_state.cache.admin.insert(
87-
(),
88-
CachedAdminStats {
74+
let (total_users, total_heartbeats, heartbeats_last_hour, heartbeats_last_24h, daily_activity) =
75+
if let Some(cached) = cached {
76+
(
77+
cached.total_users,
78+
cached.total_heartbeats,
79+
cached.heartbeats_last_hour,
80+
cached.heartbeats_last_24h,
81+
cached.daily_activity,
82+
)
83+
} else {
84+
let mut conn = get_db_conn!(app_state);
85+
86+
let raw_daily_activity = db_query!(
87+
Heartbeat::get_daily_activity_last_week(&mut conn),
88+
"Failed to fetch daily activity"
89+
);
90+
let total_users = db_query!(User::count_total_users(&mut conn, false));
91+
let total_heartbeats = db_query!(Heartbeat::count_total_heartbeats_estimate(&mut conn));
92+
let heartbeats_last_hour = db_query!(Heartbeat::count_heartbeats_last_hour(&mut conn));
93+
let heartbeats_last_24h = db_query!(Heartbeat::count_heartbeats_last_24h(&mut conn));
94+
95+
app_state.cache.admin.insert(
96+
(),
97+
CachedAdminStats {
98+
total_users,
99+
total_heartbeats,
100+
heartbeats_last_hour,
101+
heartbeats_last_24h,
102+
daily_activity: raw_daily_activity.clone(),
103+
},
104+
);
105+
106+
(
89107
total_users,
90108
total_heartbeats,
91109
heartbeats_last_hour,
92110
heartbeats_last_24h,
93-
daily_activity: raw_daily_activity.clone(),
94-
all_users: all_users.clone(),
95-
},
96-
);
97-
98-
(
99-
total_users,
100-
total_heartbeats,
101-
heartbeats_last_hour,
102-
heartbeats_last_24h,
103-
raw_daily_activity,
104-
all_users,
105-
)
106-
};
111+
raw_daily_activity,
112+
)
113+
};
114+
115+
let mut conn = get_db_conn!(app_state);
116+
let paginated_users = db_query!(
117+
User::list_users_paginated(&mut conn, limit, offset),
118+
"Failed to fetch users"
119+
);
107120

108-
let partial_users = all_users
121+
let partial_users = paginated_users
109122
.iter()
110123
.map(|user| PartialUser {
111124
id: user.id,
@@ -137,6 +150,8 @@ pub async fn admin_dashboard(
137150
/ 1000.0,
138151
daily_activity,
139152
all_users: partial_users,
153+
limit,
154+
offset,
140155
}))
141156
}
142157

rustytime/src/models/heartbeat.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ use crate::schema::heartbeats::{self};
1212
use crate::utils::http::parse_user_agent;
1313
use crate::utils::time::{TimeFormat, human_readable_duration};
1414

15+
#[derive(QueryableByName)]
16+
struct ApproximateRowCount {
17+
#[diesel(sql_type = BigInt)]
18+
count: i64,
19+
}
20+
1521
diesel::define_sql_function! {
1622
/// Calculate user duration with filters
1723
#[allow(clippy::too_many_arguments)]
@@ -756,8 +762,10 @@ impl From<Heartbeat> for BulkResponseItem {
756762
}
757763

758764
impl Heartbeat {
759-
pub fn count_total_heartbeats(conn: &mut PgConnection) -> QueryResult<i64> {
760-
heartbeats::table.count().get_result(conn)
765+
pub fn count_total_heartbeats_estimate(conn: &mut PgConnection) -> QueryResult<i64> {
766+
diesel::sql_query("SELECT approximate_row_count('heartbeats') AS count")
767+
.get_result::<ApproximateRowCount>(conn)
768+
.map(|r| r.count)
761769
}
762770

763771
pub fn count_heartbeats_last_24h(conn: &mut PgConnection) -> QueryResult<i64> {

rustytime/src/models/user.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,17 @@ impl User {
103103
self.admin_level > 1
104104
}
105105

106-
pub fn list_all_users(conn: &mut PgConnection) -> QueryResult<Vec<User>> {
107-
users::table.load::<User>(conn)
106+
pub fn list_users_paginated(
107+
conn: &mut PgConnection,
108+
limit: i64,
109+
offset: i64,
110+
) -> QueryResult<Vec<User>> {
111+
users::table
112+
.order(users::admin_level.desc())
113+
.then_order_by(users::id.asc())
114+
.limit(limit)
115+
.offset(offset)
116+
.load::<User>(conn)
108117
}
109118

110119
pub fn get_by_ids(

rustytime/src/utils/cache.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ pub struct CachedAdminStats {
3838
pub heartbeats_last_hour: i64,
3939
pub heartbeats_last_24h: i64,
4040
pub daily_activity: Vec<DailyActivity>,
41-
pub all_users: Vec<User>,
4241
}
4342

4443
#[derive(Clone)]

0 commit comments

Comments
 (0)