Skip to content

Commit a443718

Browse files
feat: add a public statistics page (#826)
* feat: add a public statistics page * hmm * Update +layout.svelte
1 parent 041c25f commit a443718

13 files changed

Lines changed: 484 additions & 173 deletions

File tree

src/backend/app/models/information.py

Lines changed: 0 additions & 11 deletions
This file was deleted.

src/backend/app/routes/http/admin/files.py

Lines changed: 2 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
from datetime import datetime, timedelta, timezone
21
from http import HTTPStatus
32

43
from fastapi import APIRouter, BackgroundTasks, HTTPException
5-
from sqlmodel import col, func, select
4+
from sqlmodel import col, select
65

76
from app.deps import CurrentUser, PaginationDep, SessionDep
87
from app.models.files import (
@@ -22,60 +21,8 @@ async def show_all_files(
2221
session: SessionDep,
2322
pagination: PaginationDep,
2423
):
25-
now = datetime.now(timezone.utc)
26-
soon = now + timedelta(days=1)
27-
28-
# Total bytes
29-
sum_bytes_query = select(func.coalesce(func.sum(File.size), 0)).select_from(File)
30-
total_bytes = (await session.exec(sum_bytes_query)).one()
31-
32-
# Active URLs
33-
active_urls_query = (
34-
select(func.count())
35-
.select_from(File)
36-
.where(
37-
(File.expires_at >= now)
38-
& (File.download_count < File.expire_after_n_download)
39-
)
40-
)
41-
active_urls = (await session.exec(active_urls_query)).one()
42-
43-
# Expiring soon (within 24h and not already expired)
44-
expiring_soon_query = (
45-
select(func.count())
46-
.select_from(File)
47-
.where(
48-
(File.expires_at >= now)
49-
& (File.expires_at <= soon)
50-
& (File.download_count < File.expire_after_n_download)
51-
)
52-
)
53-
expiring_soon = (await session.exec(expiring_soon_query)).one()
54-
55-
# Links with download caps
56-
# Assuming any file has a download cap as it's not nullable in DB
57-
links_with_download_caps_query = select(func.count()).select_from(File)
58-
links_with_download_caps = (
59-
await session.exec(links_with_download_caps_query)
60-
).one()
61-
62-
# Latest expiry
63-
latest_expiry_query = select(func.max(File.expires_at)).where(
64-
(File.expires_at >= now) & (File.download_count < File.expire_after_n_download)
65-
)
66-
latest_expiry = (await session.exec(latest_expiry_query)).one()
67-
68-
meta = {
69-
"total_bytes": total_bytes,
70-
"active_urls": active_urls,
71-
"links_with_download_caps": links_with_download_caps,
72-
"expiring_soon": expiring_soon,
73-
}
74-
if latest_expiry:
75-
meta["latest_expiry"] = int(latest_expiry.timestamp())
76-
7724
return await paginate(
78-
select(File).order_by(col(File.id).desc()), session, pagination, meta=meta
25+
select(File).order_by(col(File.id).desc()), session, pagination
7926
)
8027

8128

src/backend/app/routes/http/instance.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
1+
from app.schemas.information import InstanceStatisticsOut
12
import contextlib
23
import json
34
import platform
5+
from datetime import datetime, timedelta, timezone
46

57
from fastapi import (
68
APIRouter,
79
Request,
810
__version__ as fastapi_version,
911
)
1012
from sqlalchemy import text
13+
from sqlmodel import func, select
1114

1215
from app.deps import RedisDep, SessionDep
13-
from app.models.information import InformationOut
16+
from app.models.files import (
17+
File,
18+
)
19+
from app.schemas.information import InformationOut
1420

1521
router = APIRouter()
1622

@@ -66,3 +72,78 @@ async def get_instance_information(
6672
commit=build_info["commit"],
6773
is_release=build_info["is_release"],
6874
)
75+
76+
77+
@router.get("/instance/statistics")
78+
async def get_instance_statistics(
79+
session: SessionDep,
80+
redis: RedisDep,
81+
):
82+
now = datetime.now(timezone.utc)
83+
soon = now + timedelta(days=1)
84+
85+
# Redis Execution
86+
active_rooms_keys = await redis.keys("chithi:room:*")
87+
active_rooms = len(active_rooms_keys)
88+
89+
# Total bytes
90+
sum_bytes_query = select(func.coalesce(func.sum(File.size), 0)).select_from(File)
91+
total_bytes = (await session.exec(sum_bytes_query)).one()
92+
93+
# Total files
94+
total_files_query = select(func.count()).select_from(File)
95+
total_files = (await session.exec(total_files_query)).one()
96+
97+
# Total downloads
98+
total_downloads_query = select(func.coalesce(func.sum(File.download_count), 0)).select_from(File)
99+
total_downloads = (await session.exec(total_downloads_query)).one()
100+
101+
# Active URLs
102+
active_urls_query = (
103+
select(func.count())
104+
.select_from(File)
105+
.where(
106+
(File.expires_at >= now)
107+
& (File.download_count < File.expire_after_n_download)
108+
)
109+
)
110+
active_urls = (await session.exec(active_urls_query)).one()
111+
112+
# Expiring soon (within 24h and not already expired)
113+
expiring_soon_query = (
114+
select(func.count())
115+
.select_from(File)
116+
.where(
117+
(File.expires_at >= now)
118+
& (File.expires_at <= soon)
119+
& (File.download_count < File.expire_after_n_download)
120+
)
121+
)
122+
expiring_soon = (await session.exec(expiring_soon_query)).one()
123+
124+
# Links with download caps
125+
# Assuming any file has a download cap as it's not nullable in DB
126+
links_with_download_caps_query = select(func.count()).select_from(File)
127+
links_with_download_caps = (
128+
await session.exec(links_with_download_caps_query)
129+
).one()
130+
131+
# Latest expiry
132+
latest_expiry_query = select(func.max(File.expires_at)).where(
133+
(File.expires_at >= now) & (File.download_count < File.expire_after_n_download)
134+
)
135+
latest_expiry = (await session.exec(latest_expiry_query)).one()
136+
137+
meta = {
138+
"total_bytes": total_bytes,
139+
"total_files": total_files,
140+
"total_downloads": total_downloads,
141+
"active_urls": active_urls,
142+
"active_rooms": active_rooms,
143+
"links_with_download_caps": links_with_download_caps,
144+
"expiring_soon": expiring_soon,
145+
}
146+
if latest_expiry:
147+
meta["latest_expiry"] = int(latest_expiry.timestamp())
148+
149+
return InstanceStatisticsOut(**meta)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from typing import Optional
2+
from sqlmodel import Field, SQLModel
3+
4+
5+
class InformationOut(SQLModel):
6+
python_version: str
7+
fastapi_version: str
8+
redis_version: str
9+
postgres_version: str
10+
version: str
11+
commit: str
12+
is_release: bool
13+
14+
15+
class InstanceStatisticsOut(SQLModel):
16+
total_bytes: int = Field(description="Total size of all stored files in bytes")
17+
total_files: int = Field(description="Total number of files stored")
18+
total_downloads: int = Field(description="Total number of downloads across all files")
19+
active_urls: int = Field(description="Number of currently active URLs")
20+
active_rooms: int = Field(description="Number of currently active reverse rooms")
21+
links_with_download_caps: int = Field(
22+
description="Total number of links with download limits"
23+
)
24+
expiring_soon: int = Field(
25+
description="Number of URLs expiring within the next 24 hours"
26+
)
27+
latest_expiry: Optional[int] = Field(
28+
default=None,
29+
description="Unix timestamp of the latest expiry among active URLs",
30+
)

src/frontend/src/lib/consts/backend.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export class Api {
4545
return this.#url('instance/information');
4646
}
4747

48+
static get INSTANCE_STATISTICS() {
49+
return this.#url('instance/statistics');
50+
}
51+
4852
static get UPLOAD() {
4953
return this.#url('upload');
5054
}

src/frontend/src/lib/queries/files.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,6 @@ export type PaginatedFiles = {
2020
total_pages: number;
2121
current_page: number;
2222
current_page_size: number;
23-
meta: {
24-
total_bytes: number;
25-
active_urls: number;
26-
links_with_download_caps: number;
27-
expiring_soon: number;
28-
latest_expiry?: number;
29-
};
3023
};
3124

3225
const queryKey = ['admin-files'];

src/frontend/src/lib/queries/instance.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,26 @@ const fetchInstanceInformation = async () => {
1111
return res.json();
1212
};
1313

14+
const fetchInstanceStatistics = async () => {
15+
const res = await fetch(Api.INSTANCE_STATISTICS);
16+
if (!res.ok) {
17+
throw new Error('Failed to fetch instance statistics');
18+
}
19+
return res.json();
20+
};
21+
1422
export const useInstanceInformationQuery = () => {
1523
return createQuery(() => ({
16-
queryKey,
24+
queryKey: ['instance-information'],
1725
queryFn: fetchInstanceInformation,
1826
staleTime: 1000 * 60 * 5 // 5 minutes
1927
}));
2028
};
29+
30+
export const useInstanceStatisticsQuery = () => {
31+
return createQuery(() => ({
32+
queryKey: ['instance-statistics'],
33+
queryFn: fetchInstanceStatistics,
34+
staleTime: 1000 * 60 * 5 // 5 minutes
35+
}));
36+
};

src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/urls/+page.svelte

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22
import * as Dialog from '$lib/components/ui/dialog';
33
import { Button } from '$lib/components/ui/button';
44
import * as Pagination from '$lib/components/ui/pagination';
5-
import { useFilesQuery, type FileInfo } from '#queries/files';
5+
import { useFilesQuery } from '#queries/files';
66
import { toast } from 'svelte-sonner';
77
import { page } from '$app/state';
8-
import UrlMetricsCard from './url_metrics_card.svelte';
98
import OutstandingUrlsCard from './outstanding_urls_card.svelte';
109
1110
let currentPage = $state(1);
@@ -14,33 +13,6 @@
1413
const { files, revokeFile } = useFilesQuery(() => currentPage, pageSize);
1514
1615
let totalItems = $derived(files.data?.total_items ?? 0);
17-
let totalBytes = $derived(files.data?.meta?.total_bytes ?? 0);
18-
let activeUrls = $derived(files.data?.meta?.active_urls ?? 0);
19-
let linksWithDownloadCaps = $derived(files.data?.meta?.links_with_download_caps ?? 0);
20-
let expiringSoon = $derived(files.data?.meta?.expiring_soon ?? 0);
21-
let latestExpiryMs = $derived(
22-
files.data?.meta?.latest_expiry ? files.data.meta.latest_expiry * 1000 : 0
23-
);
24-
let hasIndefiniteActiveUrls = $derived(activeUrls > 0 && !latestExpiryMs);
25-
26-
function formatDuration(ms: number) {
27-
if (ms <= 0) return 'Now';
28-
const totalMinutes = Math.ceil(ms / 60000);
29-
const days = Math.floor(totalMinutes / (60 * 24));
30-
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
31-
const minutes = totalMinutes % 60;
32-
33-
if (days > 0) return `${days}d ${hours}h`;
34-
if (hours > 0) return `${hours}h ${minutes}m`;
35-
return `${minutes}m`;
36-
}
37-
38-
let timeToClearLabel = $derived.by(() => {
39-
if (activeUrls === 0) return 'Cleared';
40-
if (hasIndefiniteActiveUrls) return 'Indefinite';
41-
if (!latestExpiryMs) return 'Unknown';
42-
return formatDuration(latestExpiryMs - Date.now());
43-
});
4416
4517
// States
4618
let isRevoking = $state(false);
@@ -87,13 +59,6 @@
8759
</div>
8860

8961
<div class="space-y-6">
90-
<UrlMetricsCard
91-
totalUrls={totalItems}
92-
{timeToClearLabel}
93-
{totalBytes}
94-
{linksWithDownloadCaps}
95-
{expiringSoon}
96-
/>
9762
<OutstandingUrlsCard {files} {isRevoking} {openRevokeDialog} {formatDate} />
9863

9964
<div class="flex items-center justify-end py-4">

src/frontend/src/routes/(needs_onboarding)/(login_required)/admin/urls/url_metrics_card.svelte

Lines changed: 0 additions & 53 deletions
This file was deleted.

0 commit comments

Comments
 (0)