add history page and submission tracking functionality

This commit is contained in:
2026-10-06 15:36:34 -05:00
parent acf23d5430
commit 576199b1e9
8 changed files with 480 additions and 7 deletions
+2 -1
View File
@@ -5,7 +5,7 @@ use leptos_router::{
ParamSegment, StaticSegment, ParamSegment, StaticSegment,
}; };
use crate::codes::{HomePage, SubmitPage}; use crate::codes::{history::HistoryPage, HomePage, SubmitPage};
use crate::manage::ManagePage; use crate::manage::ManagePage;
pub fn shell(options: LeptosOptions) -> impl IntoView { pub fn shell(options: LeptosOptions) -> impl IntoView {
@@ -45,6 +45,7 @@ pub fn App() -> impl IntoView {
<Routes fallback=|| "Page not found.".into_view()> <Routes fallback=|| "Page not found.".into_view()>
<Route path=StaticSegment("") view=HomePage/> <Route path=StaticSegment("") view=HomePage/>
<Route path=StaticSegment("manage") view=ManagePage/> <Route path=StaticSegment("manage") view=ManagePage/>
<Route path=StaticSegment("history") view=HistoryPage/>
<Route <Route
path=(StaticSegment("submit"), ParamSegment("code")) path=(StaticSegment("submit"), ParamSegment("code"))
view=SubmitPage view=SubmitPage
+1
View File
@@ -1,3 +1,4 @@
pub const ID: &str = "code1";
pub const CODE: &str = "Qj4eAn"; pub const CODE: &str = "Qj4eAn";
pub const LIVE_MESSAGE: &str = "Welcome! Your code has been accepted."; pub const LIVE_MESSAGE: &str = "Welcome! Your code has been accepted.";
pub const NOT_LIVE_MESSAGE: &str = "Thanks for submitting your code. Please try again later."; pub const NOT_LIVE_MESSAGE: &str = "Thanks for submitting your code. Please try again later.";
+379
View File
@@ -0,0 +1,379 @@
use leptos::prelude::*;
use leptos_router::components::A;
#[cfg(feature = "ssr")]
use std::{
fs::{self, OpenOptions},
io::{BufRead, BufReader, Write},
net::IpAddr,
sync::Mutex,
time::{Duration, SystemTime, UNIX_EPOCH},
};
#[cfg(feature = "ssr")]
pub const SUBMISSION_HISTORY_FILE: &str = "successful_submissions.tsv";
#[cfg(feature = "ssr")]
#[derive(Clone, Debug)]
pub struct SuccessfulSubmission {
pub submitted_at: SystemTime,
pub ip_address: IpAddr,
pub server_was_live: bool,
pub code_id: String,
}
#[cfg(feature = "ssr")]
static SUBMISSION_HISTORY_LOCK: Mutex<()> = Mutex::new(());
/// Records a successful code submission in the persistent history file.
#[cfg(feature = "ssr")]
pub(crate) fn record_successful_submission(
ip_address: IpAddr,
server_was_live: bool,
code_id: &str,
) -> Result<(), ServerFnError> {
let _guard = SUBMISSION_HISTORY_LOCK
.lock()
.map_err(|_| ServerFnError::new("Submission history is unavailable"))?;
persist_submission(&SuccessfulSubmission {
submitted_at: SystemTime::now(),
ip_address,
server_was_live,
code_id: code_id.to_string(),
})
}
/// Returns a snapshot of successful submissions persisted in the history file.
#[cfg(feature = "ssr")]
pub fn successful_submission_history() -> Result<Vec<SuccessfulSubmission>, ServerFnError> {
let _guard = SUBMISSION_HISTORY_LOCK
.lock()
.map_err(|_| ServerFnError::new("Submission history is unavailable"))?;
let file = match fs::File::open(SUBMISSION_HISTORY_FILE) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => {
return Err(ServerFnError::new(format!(
"Unable to open submission history: {error}"
)));
}
};
BufReader::new(file)
.lines()
.enumerate()
.map(|(index, line)| {
let line = line.map_err(|error| {
ServerFnError::new(format!(
"Unable to read submission history line {}: {error}",
index + 1
))
})?;
parse_submission_record(&line).map_err(|error| {
ServerFnError::new(format!(
"Invalid submission history line {}: {error}",
index + 1
))
})
})
.collect()
}
type HistoryViewEntry = (String, String, bool, String, String);
#[server]
async fn get_submission_history(
secret_key: String,
) -> Result<Vec<HistoryViewEntry>, ServerFnError> {
if !crate::manage::valid_status_secret_key(&secret_key) {
return Err(ServerFnError::new("Invalid secret key"));
}
let mut submissions = successful_submission_history()?;
submissions.sort_by(|left, right| right.submitted_at.cmp(&left.submitted_at));
submissions
.into_iter()
.map(|submission| {
let timestamp =
submission
.submitted_at
.duration_since(UNIX_EPOCH)
.map_err(|error| {
ServerFnError::new(format!("Invalid submission timestamp: {error}"))
})?;
let age = SystemTime::now()
.duration_since(submission.submitted_at)
.map_err(|error| {
ServerFnError::new(format!("Submission timestamp is in the future: {error}"))
})?;
Ok((
format_timestamp(timestamp.as_secs(), timestamp.subsec_nanos()),
submission.ip_address.to_string(),
submission.server_was_live,
submission.code_id,
format_elapsed(age.as_secs()),
))
})
.collect()
}
#[cfg(feature = "ssr")]
fn format_elapsed(seconds: u64) -> String {
let units = [
(365 * 24 * 60 * 60, "year"),
(30 * 24 * 60 * 60, "month"),
(7 * 24 * 60 * 60, "week"),
(24 * 60 * 60, "day"),
(60 * 60, "hour"),
(60, "minute"),
(1, "second"),
];
for (unit_seconds, unit_name) in units {
let amount = seconds / unit_seconds;
if amount > 0 {
let suffix = if amount == 1 { "" } else { "s" };
return format!("{amount} {unit_name}{suffix} ago");
}
}
"Just now".to_string()
}
#[cfg(feature = "ssr")]
fn format_timestamp(seconds: u64, nanos: u32) -> String {
let days = (seconds / 86_400) as i64;
let seconds_in_day = seconds % 86_400;
let z = days + 719_468;
let era = z / 146_097;
let day_of_era = z - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_part = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_part + 2) / 5 + 1;
let month = month_part + if month_part < 10 { 3 } else { -9 };
year += i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}.{nanos:09} UTC",
seconds_in_day / 3_600,
(seconds_in_day % 3_600) / 60,
seconds_in_day % 60
)
}
#[cfg(feature = "ssr")]
fn parse_submission_record(line: &str) -> Result<SuccessfulSubmission, String> {
let mut fields = line.split('\t');
let seconds = fields
.next()
.ok_or("missing timestamp seconds")?
.parse::<u64>()
.map_err(|error| format!("invalid timestamp seconds: {error}"))?;
let nanos = fields
.next()
.ok_or("missing timestamp nanoseconds")?
.parse::<u32>()
.map_err(|error| format!("invalid timestamp nanoseconds: {error}"))?;
if nanos >= 1_000_000_000 {
return Err("timestamp nanoseconds are out of range".to_string());
}
let ip_address = fields
.next()
.ok_or("missing IP address")?
.parse::<IpAddr>()
.map_err(|error| format!("invalid IP address: {error}"))?;
let server_was_live = match fields.next().ok_or("missing live state")? {
"true" => true,
"false" => false,
_ => return Err("invalid live state".to_string()),
};
// Older history entries predate the code ID column and could only be code1.
let code_id = fields.next().unwrap_or("code1").to_string();
if fields.next().is_some()
|| code_id.is_empty()
|| !code_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
{
return Err("invalid code ID or unexpected extra fields".to_string());
}
Ok(SuccessfulSubmission {
submitted_at: UNIX_EPOCH + Duration::new(seconds, nanos),
ip_address,
server_was_live,
code_id,
})
}
#[cfg(feature = "ssr")]
fn persist_submission(submission: &SuccessfulSubmission) -> Result<(), ServerFnError> {
let timestamp = submission
.submitted_at
.duration_since(UNIX_EPOCH)
.map_err(|error| ServerFnError::new(format!("Invalid submission timestamp: {error}")))?;
let record = format!(
"{}\t{}\t{}\t{}\t{}\n",
timestamp.as_secs(),
timestamp.subsec_nanos(),
submission.ip_address,
submission.server_was_live,
submission.code_id
);
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(SUBMISSION_HISTORY_FILE)
.map_err(|error| {
ServerFnError::new(format!("Unable to open submission history: {error}"))
})?;
file.write_all(record.as_bytes())
.and_then(|()| file.sync_data())
.map_err(|error| {
ServerFnError::new(format!("Unable to persist submission history: {error}"))
})
}
#[server]
async fn clear_submission_history(secret_key: String) -> Result<(), ServerFnError> {
if !crate::manage::valid_status_secret_key(&secret_key) {
return Err(ServerFnError::new("Invalid secret key"));
}
let _guard = SUBMISSION_HISTORY_LOCK
.lock()
.map_err(|_| ServerFnError::new("Submission history is unavailable"))?;
OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(SUBMISSION_HISTORY_FILE)
.and_then(|file| file.sync_all())
.map_err(|error| ServerFnError::new(format!("Unable to clear submission history: {error}")))
}
#[component]
pub fn HistoryPage() -> impl IntoView {
let (secret_key, set_secret_key) = signal(String::new());
let (entries, set_entries) = signal(Vec::<HistoryViewEntry>::new());
let (loaded, set_loaded) = signal(false);
let (loading, set_loading) = signal(false);
let (error, set_error) = signal(None::<String>);
let on_load = move |event: leptos::ev::SubmitEvent| {
event.prevent_default();
let key = secret_key.get_untracked();
set_loading.set(true);
set_error.set(None);
leptos::task::spawn_local(async move {
match get_submission_history(key).await {
Ok(history) => {
set_entries.set(history);
set_loaded.set(true);
}
Err(error) => {
set_loaded.set(false);
set_error.set(Some(error.to_string()));
}
}
set_loading.set(false);
});
};
let on_clear = move |_| {
let key = secret_key.get_untracked();
set_loading.set(true);
set_error.set(None);
leptos::task::spawn_local(async move {
match clear_submission_history(key).await {
Ok(()) => {
set_entries.set(Vec::new());
set_loaded.set(true);
}
Err(error) => set_error.set(Some(error.to_string())),
}
set_loading.set(false);
});
};
view! {
<section class="status-card history-card">
<p class="eyebrow">"CODE SUBMISSION"</p>
<h1>"Submission history"</h1>
<p class="intro">"Enter the management key to view successful submissions."</p>
<form class="submit-form" on:submit=on_load>
<label class="key-label" for="history-secret-key">"Management key"</label>
<input
id="history-secret-key"
class="key-input"
type="password"
autocomplete="current-password"
prop:value=secret_key
on:input=move |event| set_secret_key.set(event_target_value(&event))
/>
<div class="history-actions">
<button
class="status-button clear-history-button"
type="button"
prop:disabled=loading
on:click=on_clear
>
{move || if loading.get() { "Please wait..." } else { "Clear history" }}
</button>
<button
class="status-button go-live"
type="submit"
prop:disabled=loading
>
{move || if loading.get() { "Loading..." } else { "View history" }}
</button>
</div>
</form>
{move || error.get().map(|message| view! {
<p class="error-message" role="alert">{message}</p>
})}
{move || loaded.get().then(|| view! {
<div class="history-table-wrap">
<table class="history-table">
<thead>
<tr>
<th>"Submitted (UTC)"</th>
<th>"Time since submission"</th>
<th>"IP address"</th>
<th>"Code"</th>
<th>"Live"</th>
</tr>
</thead>
<tbody>
{move || entries.get().into_iter().map(|entry| view! {
<tr>
<td>{entry.0}</td>
<td>{entry.4}</td>
<td>{entry.1}</td>
<td>{entry.3}</td>
<td>{if entry.2 { "Yes" } else { "No" }}</td>
</tr>
}).collect_view()}
</tbody>
</table>
{move || (entries.with(Vec::is_empty)).then(|| view! {
<p class="loading">"No successful submissions recorded."</p>
})}
</div>
})}
<nav class="page-nav">
<A href="/" attr:class="home-link">"Back to home"</A>
<A href="/manage" attr:class="home-link">"Manage server status"</A>
</nav>
</section>
}
}
+8 -1
View File
@@ -8,10 +8,17 @@ use leptos_router::{
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
mod code1; mod code1;
pub mod history;
#[server] #[server]
async fn submit_code(code: String) -> Result<String, ServerFnError> { async fn submit_code(code: String) -> Result<String, ServerFnError> {
let live = crate::manage::server_live_state();
let message = if code == crate::codes::code1::CODE { let message = if code == crate::codes::code1::CODE {
let live = crate::manage::server_live_state();
let axum::extract::ConnectInfo(address): axum::extract::ConnectInfo<std::net::SocketAddr> =
leptos_axum::extract().await?;
history::record_successful_submission(address.ip(), live, crate::codes::code1::ID)?;
crate::codes::code1::on_submit(live); crate::codes::code1::on_submit(live);
if live { if live {
+4 -1
View File
@@ -26,7 +26,10 @@ async fn main() {
// `axum::Server` is a re-export of `hyper::Server` // `axum::Server` is a re-export of `hyper::Server`
log!("listening on http://{}", &addr); log!("listening on http://{}", &addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service()) axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await .await
.unwrap(); .unwrap();
} }
+10 -2
View File
@@ -15,6 +15,11 @@ pub(crate) fn server_live_state() -> bool {
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
const STATUS_SECRET_KEY: &str = "password"; const STATUS_SECRET_KEY: &str = "password";
#[cfg(feature = "ssr")]
pub(crate) fn valid_status_secret_key(secret_key: &str) -> bool {
secret_key == STATUS_SECRET_KEY
}
#[server] #[server]
async fn get_live_state() -> Result<bool, ServerFnError> { async fn get_live_state() -> Result<bool, ServerFnError> {
Ok(server_live_state()) Ok(server_live_state())
@@ -22,7 +27,7 @@ async fn get_live_state() -> Result<bool, ServerFnError> {
#[server] #[server]
async fn set_live_state(live: bool, secret_key: String) -> Result<(), ServerFnError> { async fn set_live_state(live: bool, secret_key: String) -> Result<(), ServerFnError> {
if secret_key != STATUS_SECRET_KEY { if !valid_status_secret_key(&secret_key) {
return Err(ServerFnError::ServerError("Invalid secret key".to_string())); return Err(ServerFnError::ServerError("Invalid secret key".to_string()));
} }
@@ -52,7 +57,10 @@ pub fn ManagePage() -> impl IntoView {
} }
})} })}
</Suspense> </Suspense>
<A href="/" attr:class="home-link">"Back to home"</A> <nav class="page-nav">
<A href="/" attr:class="home-link">"Back to home"</A>
<A href="/history" attr:class="home-link">"View submission history"</A>
</nav>
</section> </section>
} }
} }
+74 -2
View File
@@ -2,6 +2,7 @@ body {
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr);
place-items: center; place-items: center;
padding: 24px; padding: 24px;
box-sizing: border-box; box-sizing: border-box;
@@ -13,10 +14,15 @@ body {
} }
main { main {
width: min(100%, 480px); width: 100%;
max-width: 1200px;
box-sizing: border-box;
} }
.status-card { .status-card {
width: min(100%, 480px);
box-sizing: border-box;
margin-inline: auto;
padding: clamp(28px, 7vw, 48px); padding: clamp(28px, 7vw, 48px);
border: 1px solid rgba(148, 163, 184, 0.2); border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 24px; border-radius: 24px;
@@ -55,6 +61,17 @@ h1 {
gap: 12px; gap: 12px;
} }
.history-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.history-actions .status-button {
flex: 1;
}
.key-label { .key-label {
color: #d3dceb; color: #d3dceb;
font-size: 0.9rem; font-size: 0.9rem;
@@ -111,6 +128,14 @@ h1 {
background: #d05242; background: #d05242;
} }
.clear-history-button {
background: #8f3030;
}
.clear-history-button:hover:not(:disabled) {
background: #ad3939;
}
.status-button:hover:not(:disabled) { .status-button:hover:not(:disabled) {
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -162,18 +187,65 @@ h1 {
.home-link { .home-link {
display: inline-block; display: inline-block;
margin-top: 24px;
color: #8bbaff; color: #8bbaff;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
} }
.page-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-top: 24px;
}
.home-link:hover { .home-link:hover {
color: #b3d2ff; color: #b3d2ff;
text-decoration: underline; text-decoration: underline;
} }
.history-card {
width: 100%;
overflow: hidden;
}
.history-table-wrap {
margin-top: 28px;
overflow-x: auto;
}
.history-table {
width: 100%;
border-collapse: collapse;
text-align: left;
white-space: nowrap;
}
.history-table th,
.history-table td {
padding: 12px 14px;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
}
.history-table th {
color: #a9b6c9;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.history-table td {
color: #e6edf7;
font-size: 0.9rem;
}
.history-table tbody tr:last-child td {
border-bottom: 0;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { * {
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
+2
View File
@@ -0,0 +1,2 @@
1791318883 152713800 127.0.0.1 false code1
1791318908 161513000 127.0.0.1 true code1
1 1791318883 152713800 127.0.0.1 false code1
2 1791318908 161513000 127.0.0.1 true code1