feat(admin): require a resolution and fix panel layout and copy (#3335)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:40:34 +02:00
1 parent 4db13b0375
commit 6534ee4300
57 files changed
+1186 -255

No files matched your search

+34
View File
@@ -17,6 +17,7 @@ pub mod user_flag_bits {
pub const FRIENDLY_BOT: u64 = 1 << 4;
pub const FRIENDLY_BOT_MANUAL_APPROVAL: u64 = 1 << 5;
pub const SPAMMER: u64 = 1 << 6;
pub const PROFILE_HIDDEN: u64 = 1 << 7;
pub const HIGH_GLOBAL_RATE_LIMIT: u64 = 1 << 33;
pub const DELETED: u64 = 1 << 34;
pub const SELF_DELETED: u64 = 1 << 36;
@@ -58,6 +59,10 @@ pub const USER_FLAGS: &[U64Flag] = &[
name: "SPAMMER",
value: user_flag_bits::SPAMMER,
},
U64Flag {
name: "PROFILE_HIDDEN",
value: user_flag_bits::PROFILE_HIDDEN,
},
U64Flag {
name: "HIGH_GLOBAL_RATE_LIMIT",
value: user_flag_bits::HIGH_GLOBAL_RATE_LIMIT,
@@ -154,3 +159,32 @@ pub const PREMIUM_FLAGS: &[I32Flag] = &[
value: 1 << 8,
},
];
#[cfg(test)]
mod tests {
use super::USER_FLAGS;
#[test]
fn user_flags_cover_every_flag_in_the_admin_spec() {
let spec: serde_json::Value =
serde_json::from_str(include_str!("../openapi-admin.json")).expect("admin spec");
let values = spec["components"]["schemas"]["UserFlags"]["x-bitflagValues"]
.as_array()
.expect("UserFlags bitflag values");
assert!(!values.is_empty());
for entry in values {
let name = entry["name"].as_str().expect("flag name");
let value: u64 = entry["value"]
.as_str()
.expect("flag value")
.parse()
.expect("numeric flag value");
assert!(
USER_FLAGS
.iter()
.any(|flag| flag.name == name && flag.value == value),
"{name} ({value}) is missing from USER_FLAGS"
);
}
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::snowflake;
use crate::api::generated::{snowflake, types::UpdateReportRequestResolution};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
@@ -41,11 +41,12 @@ impl AdminApiClient {
pub async fn resolve_report(
&self,
report_id: &str,
resolution: UpdateReportRequestResolution,
public_comment: Option<&str>,
notify_reporter: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<ResolveReportResponse> {
let mut body = serde_json::json!({"status": "resolved"});
let mut body = serde_json::json!({"status": "resolved", "resolution": resolution});
if let Some(public_comment) = public_comment {
body["public_comment"] = serde_json::Value::from(public_comment);
}
+311 -2
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_common::config::{
normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env,
env_value, normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env,
read_first_env, trim_trailing_slash,
};
const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789";
const DEFAULT_REPORTS_BUCKET_ORIGIN: &str = "https://fluxer-reports.ewr1.vultrobjects.com";
#[derive(Clone, Debug)]
pub struct AdminConfig {
@@ -17,6 +18,7 @@ pub struct AdminConfig {
pub api_endpoint: String,
pub media_endpoint: String,
pub static_cdn_endpoint: String,
pub reports_bucket_origin: String,
pub admin_endpoint: String,
pub web_app_endpoint: String,
pub oauth_client_id: String,
@@ -76,6 +78,7 @@ impl AdminConfig {
static_cdn_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(
&read_env("FLUXER_STATIC_CDN_ENDPOINT", ""),
)),
reports_bucket_origin: reports_bucket_origin_from_env(),
admin_endpoint,
web_app_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
@@ -120,6 +123,72 @@ impl AdminConfig {
}
}
fn reports_bucket_origin_from_env() -> String {
let public_endpoint = env_value("FLUXER_S3_PUBLIC_ENDPOINT")
.map(|value| normalize_public_endpoint_from_env(value.trim()));
let endpoint = env_value("FLUXER_S3_ENDPOINT");
presign_endpoint(
public_endpoint.as_deref(),
endpoint.as_deref(),
&read_env("FLUXER_S3_BUCKET_UPLOADS", "fluxer-uploads"),
)
.and_then(|endpoint| {
bucket_origin(
&endpoint,
read_bool_env("FLUXER_S3_FORCE_PATH_STYLE", false),
&read_env("FLUXER_S3_BUCKET_REPORTS", "fluxer-reports"),
)
})
.unwrap_or_else(|| DEFAULT_REPORTS_BUCKET_ORIGIN.to_owned())
}
fn presign_endpoint(
public_endpoint: Option<&str>,
endpoint: Option<&str>,
uploads_bucket: &str,
) -> Option<url::Url> {
let Some(public_endpoint) = public_endpoint else {
return url::Url::parse(endpoint?.trim()).ok();
};
let mut parsed = url::Url::parse(public_endpoint).ok()?;
let host = parsed.host_str()?.to_owned();
if let Some(shared_host) = host.strip_prefix(&format!("{uploads_bucket}.")) {
parsed.set_host(Some(shared_host)).ok()?;
}
Some(parsed)
}
fn bucket_origin(endpoint: &url::Url, force_path_style: bool, bucket: &str) -> Option<String> {
if !matches!(endpoint.scheme(), "http" | "https") {
return None;
}
let path_style = force_path_style
|| !matches!(endpoint.host(), Some(url::Host::Domain(_)))
|| !is_virtual_hostable_bucket(bucket, endpoint.scheme() == "http");
if path_style {
return Some(endpoint.origin().ascii_serialization());
}
let mut virtual_host = endpoint.clone();
virtual_host
.set_host(Some(&format!("{bucket}.{}", endpoint.host_str()?)))
.ok()?;
Some(virtual_host.origin().ascii_serialization())
}
fn is_virtual_hostable_bucket(bucket: &str, allow_dots: bool) -> bool {
if allow_dots && bucket.contains('.') {
return bucket
.split('.')
.all(|label| is_virtual_hostable_bucket(label, false));
}
(3..=63).contains(&bucket.len())
&& bucket
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
&& !bucket.starts_with('-')
&& !bucket.ends_with('-')
}
impl RuntimeEnv {
pub(crate) fn from_env_value(value: &str) -> Self {
match value {
@@ -138,7 +207,7 @@ mod tests {
static ENV_LOCK: Mutex<()> = Mutex::new(());
const MANAGED_ENV: [&str; 10] = [
const MANAGED_ENV: [&str; 15] = [
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
@@ -149,6 +218,11 @@ mod tests {
"FLUXER_MEDIA_ENDPOINT",
"FLUXER_STATIC_CDN_ENDPOINT",
"FLUXER_BASE_DOMAIN",
"FLUXER_S3_ENDPOINT",
"FLUXER_S3_PUBLIC_ENDPOINT",
"FLUXER_S3_FORCE_PATH_STYLE",
"FLUXER_S3_BUCKET_UPLOADS",
"FLUXER_S3_BUCKET_REPORTS",
];
fn config_from_env(vars: &[(&str, &str)]) -> AdminConfig {
@@ -238,6 +312,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
@@ -266,6 +341,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
@@ -368,4 +444,237 @@ mod tests {
format!("{api_admin_endpoint}/oauth2_callback")
);
}
fn origin_for(endpoint: &str, force_path_style: bool, bucket: &str) -> Option<String> {
bucket_origin(
&url::Url::parse(endpoint).expect("valid endpoint"),
force_path_style,
bucket,
)
}
#[test]
fn bucket_origin_matches_the_addressing_of_presigned_urls() {
let cases = [
(
"https://ewr1.vultrobjects.com",
false,
"fluxer-reports",
"https://fluxer-reports.ewr1.vultrobjects.com",
),
(
"https://ewr1.vultrobjects.com/",
true,
"fluxer-reports",
"https://ewr1.vultrobjects.com",
),
(
"http://seaweedfs:8333",
true,
"fluxer-reports",
"http://seaweedfs:8333",
),
(
"http://seaweedfs:8333",
false,
"fluxer-reports",
"http://fluxer-reports.seaweedfs:8333",
),
(
"http://127.0.0.1:8333",
false,
"fluxer-reports",
"http://127.0.0.1:8333",
),
(
"http://[::1]:8333",
false,
"fluxer-reports",
"http://[::1]:8333",
),
(
"https://s3.example.com:9000",
false,
"fluxer-reports",
"https://fluxer-reports.s3.example.com:9000",
),
(
"https://s3.example.com:443/base/path",
false,
"fluxer-reports",
"https://fluxer-reports.s3.example.com",
),
(
"https://S3.Example.com",
false,
"fluxer-reports",
"https://fluxer-reports.s3.example.com",
),
(
"https://s3.example.com",
false,
"reports.example",
"https://s3.example.com",
),
(
"http://s3.example.com",
false,
"reports.example",
"http://reports.example.s3.example.com",
),
(
"http://s3.example.com",
false,
"a.example",
"http://s3.example.com",
),
(
"https://s3.example.com",
false,
"Reports",
"https://s3.example.com",
),
(
"https://s3.example.com",
false,
"ab",
"https://s3.example.com",
),
(
"https://s3.example.com",
false,
"reports_bucket",
"https://s3.example.com",
),
(
"https://s3.example.com",
false,
"-reports",
"https://s3.example.com",
),
(
"https://s3.example.com",
false,
"192.168.1.1",
"https://s3.example.com",
),
];
for (endpoint, force_path_style, bucket, expected) in cases {
assert_eq!(
origin_for(endpoint, force_path_style, bucket).as_deref(),
Some(expected),
"{endpoint} {force_path_style} {bucket}"
);
}
assert_eq!(origin_for("ftp://s3.example.com", true, "reports"), None);
}
#[test]
fn presign_endpoint_prefers_the_public_endpoint_and_drops_the_uploads_bucket_host() {
let host = |public: Option<&str>, endpoint: Option<&str>| {
presign_endpoint(public, endpoint, "fluxer-uploads").map(|url| url.to_string())
};
assert_eq!(
host(
Some("https://fluxer-uploads.ewr1.vultrobjects.com"),
Some("https://internal.example")
)
.as_deref(),
Some("https://ewr1.vultrobjects.com/")
);
assert_eq!(
host(
Some("https://cdn.example.com"),
Some("http://seaweedfs:8333")
)
.as_deref(),
Some("https://cdn.example.com/")
);
assert_eq!(
host(None, Some("http://seaweedfs:8333")).as_deref(),
Some("http://seaweedfs:8333/")
);
assert_eq!(host(Some("not a url"), Some("http://seaweedfs:8333")), None);
assert_eq!(host(None, None), None);
}
#[test]
fn the_reports_bucket_origin_defaults_to_the_hosted_bucket() {
let config = config_from_env(&[]);
assert_eq!(config.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
let config = config_from_env(&[("FLUXER_S3_ENDPOINT", "not a url")]);
assert_eq!(config.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
}
#[test]
fn the_reports_bucket_origin_follows_the_object_store_settings() {
let hosted = config_from_env(&[
("FLUXER_S3_ENDPOINT", "https://ewr1.vultrobjects.com"),
(
"FLUXER_S3_PUBLIC_ENDPOINT",
"https://fluxer-uploads.ewr1.vultrobjects.com",
),
]);
assert_eq!(hosted.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
let bundled = config_from_env(&[
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
(
"FLUXER_S3_PUBLIC_ENDPOINT",
"https://objects.fluxer.example",
),
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
]);
assert_eq!(
bundled.reports_bucket_origin,
"https://objects.fluxer.example"
);
let internal_only = config_from_env(&[
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
]);
assert_eq!(internal_only.reports_bucket_origin, "http://seaweedfs:8333");
let outside = config_from_env(&[
(
"FLUXER_S3_ENDPOINT",
"https://s3.eu-central-1.amazonaws.com",
),
("FLUXER_S3_FORCE_PATH_STYLE", "false"),
("FLUXER_S3_BUCKET_UPLOADS", "example-uploads"),
("FLUXER_S3_BUCKET_REPORTS", "example-reports"),
]);
assert_eq!(
outside.reports_bucket_origin,
"https://example-reports.s3.eu-central-1.amazonaws.com"
);
let renamed_uploads = config_from_env(&[
("FLUXER_S3_ENDPOINT", "https://s3.example.com"),
(
"FLUXER_S3_PUBLIC_ENDPOINT",
"https://example-uploads.s3.example.com",
),
("FLUXER_S3_BUCKET_UPLOADS", "example-uploads"),
("FLUXER_S3_BUCKET_REPORTS", "example-reports"),
]);
assert_eq!(
renamed_uploads.reports_bucket_origin,
"https://example-reports.s3.example.com"
);
}
#[test]
fn a_non_default_public_port_reaches_the_reports_bucket_origin() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
("FLUXER_S3_PUBLIC_ENDPOINT", "http://fluxer.example"),
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
]);
assert_eq!(config.reports_bucket_origin, "http://fluxer.example:19080");
}
}
+1
View File
@@ -213,6 +213,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: admin_endpoint.to_owned(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
+8 -1
View File
@@ -4,6 +4,7 @@ use crate::api::client::{AdminApiClient, ApiResult};
use crate::api::types::{FlashLevel, FlashMessage};
use crate::middleware::auth::AuthContext;
use crate::templates;
use crate::utils::timestamps::format_admin_timestamp;
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
@@ -201,7 +202,13 @@ async fn execute_check(
};
match result {
Ok(r) if r.banned => match r.expires_at {
Some(expires_at) => ("info", format!("{value} is banned until {expires_at}")),
Some(expires_at) => (
"info",
format!(
"{value} is banned until {}",
format_admin_timestamp(&expires_at)
),
),
None => ("info", format!("{value} is banned")),
},
Ok(_) => ("info", format!("{value} is NOT banned")),
+4 -1
View File
@@ -150,7 +150,10 @@ fn build_admin_csp(config: &AdminConfig) -> String {
"default-src 'self'".to_owned(),
"script-src 'self' 'unsafe-inline'".to_owned(),
"style-src 'self' 'unsafe-inline'".to_owned(),
format!("img-src 'self' data: blob: {static_cdn} {media} https://fluxer-reports.ewr1.vultrobjects.com"),
format!(
"img-src 'self' data: blob: {static_cdn} {media} {}",
config.reports_bucket_origin
),
"font-src 'self'".to_owned(),
"connect-src 'self'".to_owned(),
"object-src 'none'".to_owned(),
+30 -30
View File
@@ -22,7 +22,6 @@ use axum::{
Form, Router,
extract::{FromRequest, Path, Query, Request, State},
http::HeaderMap,
http::StatusCode,
response::{Html, IntoResponse, Redirect, Response},
routing::get,
};
@@ -69,6 +68,8 @@ struct ResolveForm {
#[serde(default)]
resolution: Option<String>,
#[serde(default)]
public_comment: Option<String>,
#[serde(default)]
notify_reporter: Option<String>,
#[serde(default)]
notify_reporter_present: Option<String>,
@@ -287,53 +288,52 @@ async fn report_resolve(
) -> Response {
let config = state.config();
let base = &config.base_path;
let is_background = request
.uri()
.query()
.is_some_and(|query| query.split('&').any(|part| part == "background=1"));
let back = format!("{base}/reports/{report_id}");
let form: ResolveForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
tracing::warn!(%error, report_id, "failed to parse report resolve form");
return flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
&back,
FlashData::error("Invalid form data"),
config.secure_cookies(),
);
}
};
let Some((resolution, label)) =
templates::pages::report_detail::resolution_choice(form.resolution.as_deref())
else {
return flash::redirect_with_flash(
&back,
FlashData::error(RESOLUTION_REQUIRED),
config.secure_cookies(),
);
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let public_comment = clean_string(form.resolution.as_deref().unwrap_or(""));
let public_comment = clean_string(form.public_comment.as_deref().unwrap_or(""));
let notify_reporter =
form.notify_reporter_present.is_none() || form.notify_reporter.as_deref() == Some("true");
let result = client
.resolve_report(&report_id, public_comment.as_deref(), notify_reporter, None)
.await;
match result {
Ok(_) => {
if is_background {
return StatusCode::NO_CONTENT.into_response();
}
flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
FlashData::success("Report resolved"),
config.secure_cookies(),
)
}
let flash = match client
.resolve_report(
&report_id,
resolution,
public_comment.as_deref(),
notify_reporter,
None,
)
.await
{
Ok(_) => FlashData::success(format!("Report resolved: {label}")),
Err(error) => {
tracing::warn!(%error, report_id, "admin API request failed: resolve report");
if is_background {
return StatusCode::BAD_GATEWAY.into_response();
}
flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
FlashData::error("Failed to resolve report"),
config.secure_cookies(),
)
FlashData::error("Failed to resolve report")
}
}
};
flash::redirect_with_flash(&back, flash, config.secure_cookies())
}
const RESOLUTION_REQUIRED: &str = "Choose a resolution";
async fn report_legal_hold(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
+7 -5
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
admin_flags,
api::client::{AdminApiClient, ApiError},
@@ -311,7 +312,7 @@ pub async fn dispatch(
};
if !form.bool_value("confirm") {
return DispatchOutcome::error(
"Confirm whose deletion you are cancelling before submitting",
"Confirm whose deletion you are canceling before submitting",
);
}
let Some(private_reason) = get("private_reason") else {
@@ -322,7 +323,7 @@ pub async fn dispatch(
.cancel_deletion(user_id, &expected, notify_user, Some(&private_reason))
.await
{
Ok(_) => DispatchOutcome::success("User deletion cancelled successfully"),
Ok(_) => DispatchOutcome::success("User deletion canceled successfully"),
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
"The pending deletion changed since this page loaded. Reload and review.",
),
@@ -435,8 +436,9 @@ pub async fn dispatch(
match client.delete_all_user_messages(user_id, dry_run).await {
Ok(response) if dry_run => DispatchOutcome {
flash: FlashData::success(format!(
"Dry run found {} messages across {} channels. Confirm to delete them permanently.",
response.message_count, response.channel_count
"Dry run found {} across {}. Confirm to delete them permanently.",
count_noun(response.message_count, "message", "messages"),
count_noun(response.channel_count, "channel", "channels")
)),
redirect_params: vec![
("delete_all_messages_dry_run".to_owned(), "true".to_owned()),
@@ -470,7 +472,7 @@ pub async fn dispatch(
}
"cancel_bulk_message_deletion" => DispatchOutcome::from_result(
client.cancel_bulk_message_deletion(user_id).await,
"Bulk message deletion cancelled successfully",
"Bulk message deletion canceled successfully",
"Failed to cancel bulk message deletion",
),
"message_shred" => {
+6
View File
@@ -35,6 +35,12 @@ h1, h2, h3, h4, h5, h6 {
input, select, textarea {
font-size: 16px;
}
select,
input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="range"], [type="color"], [type="file"]) {
height: auto;
min-height: 2.5rem;
}
}
a:focus-visible,
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::Archive,
templates::components::form::{checkbox, csrf_input, submit_button},
@@ -72,7 +73,7 @@ fn archives_table(base: &str, archives: &[Archive]) -> Markup {
@for archive in archives {
tr {
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
(archive.requested_at)
(format_admin_timestamp(&archive.requested_at))
}
td class="px-4 py-3 text-sm text-neutral-900" {
(status_text(archive))
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::AuditLogEntry,
templates::pages::audit_logs_table::{
@@ -27,7 +28,7 @@ fn log_row(base_path: &str, log: &AuditLogEntry, index: usize) -> Markup {
(table_row(html! {
(table_cell(true, html! {
span class="text-sm whitespace-nowrap" {
(log.created_at)
(format_admin_timestamp(&log.created_at))
}
}))
(table_cell(false, badge(&format_action(action), action_badge_variant(action))))
@@ -222,7 +222,7 @@ pub fn checkbox(name: &str, value: &str, label: &str, checked: bool, enabled: bo
div class="checkbox-custom" {
(PreEscaped(CHECKMARK_SVG))
}
span class="min-w-0 break-all text-sm leading-5 text-neutral-900" { (label) }
span class="min-w-0 [overflow-wrap:anywhere] text-sm leading-5 text-neutral-900" { (label) }
}
}
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::admin_flags::user_flag_bits;
use crate::utils::timestamps::format_admin_timestamp;
use maud::{Markup, html};
pub mod premium_types {
@@ -22,18 +23,21 @@ fn premium_tooltip(
if is_self_hosted {
let name = self_hosted_premium_name?;
return Some(match premium_since {
Some(since) => format!("{name} subscriber since {since}"),
Some(since) => format!("{name} subscriber since {}", format_admin_timestamp(since)),
None => name.to_owned(),
});
}
Some(if premium_type == premium_types::LIFETIME {
match premium_since {
Some(since) => format!("Fluxer Visionary since {since}"),
Some(since) => format!("Fluxer Visionary since {}", format_admin_timestamp(since)),
None => "Fluxer Visionary".into(),
}
} else {
match premium_since {
Some(since) => format!("Fluxer Plutonium subscriber since {since}"),
Some(since) => format!(
"Fluxer Plutonium subscriber since {}",
format_admin_timestamp(since)
),
None => "Fluxer Plutonium".into(),
}
})
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::{CreateAdminApiKeyResponse, FlashMessage, ListAdminApiKeyEntry},
config::AdminConfig,
@@ -127,14 +128,14 @@ fn api_key_item(base: &str, csrf_token: &str, key: &ListAdminApiKeyEntry) -> Mar
"Key ID: " (key_id)
}
p class="text-sm text-neutral-500" {
"Created: " (created_at)
"Created: " (format_admin_timestamp(created_at))
}
p class="text-sm text-neutral-500" {
"Created by: " (key.created_by_user_id)
}
@if let Some(ref expires_at) = key.expires_at {
p class="text-sm text-neutral-500" {
"Expires: " (expires_at)
"Expires: " (format_admin_timestamp(expires_at))
}
}
@if !key.acls.is_empty() {
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::Archive,
config::AdminConfig,
@@ -69,7 +70,7 @@ fn archive_table(archives: &[Archive], base: &str) -> Markup {
span class="text-sm" { (archive.requested_by) }
}))
(table_cell(false, html! {
span class="text-sm" { (archive.requested_at) }
span class="text-sm" { (format_admin_timestamp(&archive.requested_at)) }
}))
(table_cell(false, html! {
div class="flex flex-col gap-1" {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::noun_for;
use crate::{
api::types::AuditLogsListResponse,
config::AdminConfig,
@@ -120,7 +121,12 @@ pub fn audit_logs_page(
.current_page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
let showing = format!("Showing {} of {} entries", entries.len(), total);
let showing = format!(
"Showing {} of {} {}",
entries.len(),
total,
noun_for(total, "entry", "entries")
);
html! {
(page_header_with_actions("Audit Logs", None, html! {
span class="text-sm text-neutral-500" { (showing) }
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::{
AuditLogChannelSummary, AuditLogEntry, AuditLogGuildSummary, AuditLogUserSummary,
@@ -273,7 +274,7 @@ pub fn log_row(base: &str, entry: &AuditLogEntry, index: usize) -> Markup {
(table_row(html! {
(table_cell(true, html! {
span class="text-sm whitespace-nowrap" {
(entry.created_at)
(format_admin_timestamp(&entry.created_at))
}
}))
(table_cell(false, badge(&format_action(&entry.action), action_badge_variant(&entry.action))))
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
config::AdminConfig,
middleware::auth::AuthContext,
@@ -35,7 +36,7 @@ pub fn ip_ban_duration_label(hours: u32) -> String {
.iter()
.find(|(value, _)| *value == hours)
.map_or_else(
|| format!("{hours} hours"),
|| count_noun(u64::from(hours), "hour", "hours"),
|(_, label)| (*label).to_owned(),
)
}
@@ -72,6 +72,10 @@ const PATCHABLE_USER_FLAGS: &[UserFlag] = &[
name: "SPAMMER",
value: 1 << 6,
},
UserFlag {
name: "PROFILE_HIDDEN",
value: 1 << 7,
},
UserFlag {
name: "HIGH_GLOBAL_RATE_LIMIT",
value: 1 << 33,
+2 -1
View File
@@ -3,6 +3,7 @@
use super::gateway_leaderboard;
use super::gateway_nodes;
use crate::utils::plural::count_noun;
use crate::{
api::types::{GatewayVoiceStateCountsResponse, GuildMemoryStatsResponse, NodeStatsResponse},
config::AdminConfig,
@@ -43,7 +44,7 @@ pub fn gateway_page(config: &AdminConfig, auth: &AuthContext, p: &GatewayPagePar
))
@if let Some(count) = p.reload_result {
div class="rounded-lg border p-4 bg-green-50 border-green-200 text-green-700" {
"Successfully reloaded " (count) " guilds!"
"Successfully reloaded " (count_noun(count, "guild", "guilds")) "!"
}
}
div class="space-y-6" {
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::{GuildInfo, GuildMember, ListGuildMembersResponse},
config::AdminConfig,
@@ -149,11 +151,11 @@ fn member_card(
"ID: " (member.user.id)
}
p class="text-sm text-neutral-500" {
"Joined: " (member.joined_at)
"Joined: " (format_admin_timestamp(&member.joined_at))
}
@if !member.roles.is_empty() {
p class="text-sm text-neutral-500" {
(member.roles.len()) " roles"
(count_noun(member.roles.len() as u64, "role", "roles"))
}
}
}
@@ -4,9 +4,10 @@ use crate::{
api::types::{GuildInfo, ReportEntry},
config::AdminConfig,
templates::{
components::{page_container::card_with_header, table::data_table},
components::page_container::card_with_header,
pages::user_detail_tabs::reports::{
format_status, reported_at_cell, reporter_label, type_and_category_cell,
format_status, report_tab_table, reported_at_cell, reporter_label,
type_and_category_cell,
},
},
};
@@ -43,7 +44,7 @@ pub fn reports_tab(
}
}))
} @else {
(data_table(
(report_tab_table(
&["Reported At", "Type / Category", "Reporter", "Status", "Actions"],
html! {
@for report in reports {
@@ -82,7 +83,7 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
tr class="hover:bg-neutral-50 transition-colors" {
(reported_at_cell(base, report))
(type_and_category_cell(report))
td class="px-4 py-3 text-sm [overflow-wrap:anywhere]" {
td class="px-2 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal xl:px-4 xl:[&_.whitespace-nowrap]:whitespace-nowrap" {
@if let Some(ref rid) = report.reporter_id {
a href={(base) "/users/" (rid)}
class="hover:underline" {
@@ -92,10 +93,13 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
span { (reporter_label(report)) }
}
}
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell" {
(format_status(report.status))
}
td class="whitespace-nowrap px-4 py-3 text-sm" {
td class="whitespace-nowrap px-2 py-3 text-sm xl:px-4" {
div class="mb-1 text-neutral-900 xl:hidden" data-status-compact=(report.report_id) {
(format_status(report.status))
}
a href={(base) "/reports/" (report.report_id)}
class="inline-flex items-center rounded-md border border-neutral-300 \
bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 \
@@ -138,14 +142,17 @@ mod tests {
"{markup}"
);
assert!(markup.contains("Avery Reporter "), "{markup}");
assert_eq!(
markup.matches("whitespace-nowrap px-4").count(),
2,
assert!(
markup.contains(r#"<td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell">Pending</td>"#),
"{markup}"
);
assert!(
markup.contains(r#"<div class="mb-1 text-neutral-900 xl:hidden" data-status-compact="1800000000000000006">Pending</div>"#),
"{markup}"
);
assert!(markup.contains(">Pending<"), "{markup}");
assert!(markup.contains(">View<"), "{markup}");
assert_eq!(markup.matches(">Spam<").count(), 1, "{markup}");
assert_eq!(markup.matches(">Spam<").count(), 2, "{markup}");
assert!(markup.contains(r#"data-report-reason="spam""#), "{markup}");
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
api::types::GuildInfo,
config::AdminConfig,
@@ -97,7 +98,7 @@ pub fn guilds_list_page(
} @else if let Some(count) = result_state.total {
(page_header_with_actions("Guilds", None, html! {
p class="text-sm font-normal text-neutral-500" {
"Found " (count) (if count == 1 { " result" } else { " results" }) " (showing "
"Found " (count_noun(count, "result", "results")) " (showing "
(result_state.guilds.map(|guilds| guilds.len()).unwrap_or_default())
")"
}
@@ -654,7 +654,7 @@ fn integrations_config_section(
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
}
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorisation", integrations.email.disable_new_ip_authorization, true))
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorization", integrations.email.disable_new_ip_authorization, true))
div class="flex flex-wrap gap-2" {
button type="submit"
formaction={(base) "/instance-config?action=test_smtp"}
@@ -1364,7 +1364,7 @@ fn experiment_delivery_section(
"How often every client revalidates its experiment assignments. This is instance-wide \
and covers every experiment, not just the ones above. Raising the interval sheds \
request volume and makes a change take longer to reach a client. Raising the jitter \
spreads a fleet that has synchronised on one tick back out across the interval.",
spreads a fleet that has synchronized on one tick back out across the interval.",
html! {
form method="post" action={(base) "/instance-config?action=update_experiment_delivery"} {
(csrf_input(csrf_token))
@@ -17,7 +17,7 @@ use crate::{
};
use maud::{Markup, html};
use super::jobs_list_helpers::status_badge;
use super::jobs_list_helpers::{job_time, status_badge};
fn val_str<'a>(job: &'a serde_json::Value, key: &str) -> &'a str {
job.get(key).and_then(|v| v.as_str()).unwrap_or("")
@@ -113,10 +113,10 @@ fn overview_section(job: &serde_json::Value) -> Markup {
html! {
(data_field_mono("Task", task_type))
(data_field_mono("Lane", if lane.is_empty() { "\u{2014}" } else { lane }))
(data_field_text("Created", if created_at.is_empty() { "\u{2014}" } else { created_at }))
(data_field_text("Started", if started_at.is_empty() { "\u{2014}" } else { started_at }))
(data_field_text("Completed", if completed_at.is_empty() { "\u{2014}" } else { completed_at }))
(data_field_text("Run-at", run_at))
(data_field_text("Created", &job_time(created_at)))
(data_field_text("Started", &job_time(started_at)))
(data_field_text("Completed", &job_time(completed_at)))
(data_field_text("Run-at", &job_time(run_at)))
(data_field_text("Attempts", &format!("{attempts}/{max_attempts}")))
(data_field_mono("Scheduled by", requester_display))
(data_field_text("Audit reason", if audit_reason.is_empty() { "\u{2014}" } else { audit_reason }))
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
config::AdminConfig,
middleware::auth::AuthContext,
@@ -25,7 +26,7 @@ fn filter_bar(base: &str, p: &JobsListParams) -> Markup {
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" {
(select_input("status", "Status", &[
("", "Any"), ("queued", "Queued"), ("running", "Running"),
("succeeded", "Succeeded"), ("cancelled", "Cancelled"),
("succeeded", "Succeeded"), ("cancelled", "Canceled"),
("deadletter", "Dead-letter"),
], p.status_filter))
div class="flex flex-col gap-2" {
@@ -69,8 +70,8 @@ pub struct JobsListParams<'a> {
pub fn jobs_list_page(config: &AdminConfig, auth: &AuthContext, params: &JobsListParams) -> Markup {
let base = &config.base_path;
let count_label = format!(
"{} jobs{}",
params.jobs.len(),
"{}{}",
count_noun(params.jobs.len() as u64, "job", "jobs"),
if params.next_cursor.is_some() {
" (more available)"
} else {
@@ -4,18 +4,28 @@ use crate::templates::components::{
badge::{BadgeVariant, badge},
table::{data_table, table_cell, table_row},
};
use crate::utils::timestamps::format_admin_timestamp;
use maud::{Markup, html};
pub(crate) fn status_badge(status: &str) -> Markup {
let variant = match status {
"queued" => BadgeVariant::Default,
"running" => BadgeVariant::Info,
"succeeded" => BadgeVariant::Success,
"failed" | "deadletter" => BadgeVariant::Danger,
"cancelled" => BadgeVariant::Warning,
_ => BadgeVariant::Default,
let (label, variant) = match status {
"queued" => ("Queued", BadgeVariant::Default),
"running" => ("Running", BadgeVariant::Info),
"succeeded" => ("Succeeded", BadgeVariant::Success),
"failed" => ("Failed", BadgeVariant::Danger),
"deadletter" => ("Dead-letter", BadgeVariant::Danger),
"cancelled" => ("Canceled", BadgeVariant::Warning),
other => (other, BadgeVariant::Default),
};
badge(status, variant)
badge(label, variant)
}
pub(crate) fn job_time(value: &str) -> String {
match value {
"" | "\u{2014}" => "\u{2014}".to_owned(),
"immediate" => "Immediate".to_owned(),
other => format_admin_timestamp(other),
}
}
pub(crate) fn format_progress(job: &serde_json::Value) -> String {
@@ -82,7 +92,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
table_row(html! {
(table_cell(true, html! {
span class="whitespace-nowrap text-sm" { (created_at) }
span class="whitespace-nowrap text-sm" { (job_time(created_at)) }
}))
(table_cell(false, html! {
a href={(base) "/jobs/" (job_id)}
@@ -186,3 +196,22 @@ pub(crate) fn next_page_link(
}
}
}
#[cfg(test)]
mod tests {
use super::status_badge;
#[test]
fn status_badges_use_us_spelling_and_readable_labels() {
let canceled = status_badge("cancelled").into_string();
assert!(canceled.contains(">Canceled<"));
assert!(!canceled.contains("Cancelled"));
assert!(
status_badge("deadletter")
.into_string()
.contains(">Dead-letter<")
);
assert!(status_badge("running").into_string().contains(">Running<"));
assert!(status_badge("mystery").into_string().contains(">mystery<"));
}
}
@@ -2,9 +2,12 @@
use crate::{
acl,
api::types::{
AdminUser, GuildDetailInfo, ReportEntry, ReportFlowAnswerStepEntry, ReportFlowAnswersEntry,
ReportProfileSnapshot, ReportProfileSnapshotAsset,
api::{
generated::types::UpdateReportRequestResolution,
types::{
AdminUser, GuildDetailInfo, ReportEntry, ReportFlowAnswerStepEntry,
ReportFlowAnswersEntry, ReportProfileSnapshot, ReportProfileSnapshotAsset,
},
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -15,7 +18,9 @@ use crate::{
data_field, data_field_link_mono, data_field_mono, data_field_muted,
data_field_text,
},
form::{FORM_CONTROL_CLASS, FORM_LABEL_CLASS, csrf_input, opt_out_checkbox},
form::{
FORM_CONTROL_CLASS, FORM_LABEL_CLASS, csrf_input, opt_out_checkbox, select_chevron,
},
media::{guild_icon_url, initials, user_avatar_url},
message_data::ordered_messages,
message_list::{message_deletion_script, message_list},
@@ -576,19 +581,42 @@ fn actions_card(config: &AdminConfig, report: &ReportEntry, csrf_token: &str) ->
}
}
const RESOLUTION_CHOICES: [(UpdateReportRequestResolution, &str); 3] = [
(UpdateReportRequestResolution::Actioned, "Action taken"),
(UpdateReportRequestResolution::NoViolation, "No violation"),
(UpdateReportRequestResolution::Duplicate, "Duplicate"),
];
pub fn resolution_choice(
value: Option<&str>,
) -> Option<(UpdateReportRequestResolution, &'static str)> {
let value = value?.trim();
RESOLUTION_CHOICES
.into_iter()
.find(|(resolution, _)| resolution.to_string() == value)
}
fn resolve_report_form(base: &str, report_id: &str, csrf_token: &str) -> Markup {
html! {
form method="post" action={(base) "/reports/" (report_id) "/resolve"} class="flex flex-col gap-3"
data-admin-refresh-on-success="#report-detail" {
(csrf_input(csrf_token))
label for="resolution" class="block text-sm font-medium text-neutral-700" {
label for="resolution" class=(FORM_LABEL_CLASS) { "Resolution" }
div class="relative" {
select id="resolution" name="resolution" required
class={(FORM_CONTROL_CLASS) " h-8 appearance-none px-3 py-1.5 pr-10"} {
option value="" selected disabled { "Choose one" }
@for (resolution, label) in RESOLUTION_CHOICES {
option value=(resolution) { (label) }
}
}
(select_chevron())
}
label for="public_comment" class=(FORM_LABEL_CLASS) {
"Public comment to the reporter (optional)"
}
textarea id="resolution" name="resolution" rows="3" maxlength="512"
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary" {}
textarea id="public_comment" name="public_comment" rows="3" maxlength="512"
class={(FORM_CONTROL_CLASS) " px-3 py-2"} {}
(opt_out_checkbox("notify_reporter", "Include the public comment in the reporter notice"))
p class="text-neutral-500 text-xs" data-resolve-notice-help="" {
"The reporter is told the report was resolved either way, unless their account is gone or barred from reporting. \
@@ -1142,6 +1170,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
@@ -1741,7 +1770,7 @@ mod tests {
let markup = resolve_report_form("/admin", "1500000000000000001", "csrf").into_string();
assert!(markup.contains(r#"action="/admin/reports/1500000000000000001/resolve""#));
assert!(markup.contains(r##"data-admin-refresh-on-success="#report-detail""##));
assert!(markup.contains(r#"name="resolution""#));
assert!(markup.contains(r#"<textarea id="public_comment" name="public_comment""#));
assert!(markup.contains(r#"maxlength="512""#));
assert!(markup.contains(r#"name="notify_reporter" value="true" checked"#));
assert!(markup.contains(r#"name="notify_reporter_present" value="1""#));
@@ -1760,6 +1789,57 @@ mod tests {
assert!(!markup.contains("Notify the reporter"), "{markup}");
}
#[test]
fn resolve_form_requires_one_of_the_three_resolutions() {
let markup = resolve_report_form("/admin", "1500000000000000001", "csrf").into_string();
assert!(
markup.contains(r#"<select id="resolution" name="resolution" required"#),
"{markup}"
);
assert!(
markup.contains(r#"<option value="" selected disabled>Choose one</option>"#),
"{markup}"
);
let options: Vec<&str> = markup
.split("<option value=\"")
.skip(2)
.map(|option| option.split("</option>").next().unwrap_or(""))
.collect();
assert_eq!(
options,
[
r#"actioned">Action taken"#,
r#"no_violation">No violation"#,
r#"duplicate">Duplicate"#
]
);
}
#[test]
fn resolution_choice_accepts_only_the_wire_values() {
assert_eq!(
resolution_choice(Some("actioned")),
Some((UpdateReportRequestResolution::Actioned, "Action taken"))
);
assert_eq!(
resolution_choice(Some(" no_violation ")),
Some((UpdateReportRequestResolution::NoViolation, "No violation"))
);
assert_eq!(
resolution_choice(Some("duplicate")),
Some((UpdateReportRequestResolution::Duplicate, "Duplicate"))
);
for missing in [
None,
Some(""),
Some("Action taken"),
Some("auto_resolved"),
Some("unspecified"),
] {
assert_eq!(resolution_choice(missing), None, "{missing:?}");
}
}
#[test]
fn reported_user_shows_a_bot_badge_only_for_bot_accounts() {
let config = test_config();
+147 -11
View File
@@ -20,7 +20,7 @@ use crate::{
layout::admin_layout,
pages::user_detail_tabs::reports::tagged_user,
},
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
utils::{plural::count_noun, timestamps::format_admin_timestamp, user_tag::user_tag},
};
use maud::{Markup, PreEscaped, html};
@@ -80,7 +80,7 @@ fn report_count_summary(result: Option<&SearchReportsResponse>) -> Markup {
html! {
@if let Some(result) = result {
p class="text-neutral-500 text-sm" {
"Found " (result.total) (if result.total == 1 { " result" } else { " results" })
"Found " (count_noun(result.total, "result", "results"))
" (showing " (result.reports.len()) ")"
}
}
@@ -405,7 +405,7 @@ fn reported_webhook_author(webhook: &ReportedWebhook<'_>) -> Markup {
div class="flex flex-col items-start gap-0.5" {
(webhook_identity(webhook))
a href=(webhook.reports_href) title="Reports about this webhook"
class="font-mono text-blue-600 text-xs hover:underline" {
class="font-mono text-blue-600 text-xs [overflow-wrap:normal] hover:underline" {
"Webhook ID: " (webhook.id)
}
@if let Some(ref creator) = webhook.creator {
@@ -452,11 +452,14 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
let rows = html! {
@for report in reports {
tr class="hover:bg-neutral-50 transition-colors" {
td class="whitespace-nowrap px-3 py-3 text-neutral-600 text-sm" {
td class="hidden whitespace-nowrap px-3 py-3 text-neutral-600 text-sm xl:table-cell" {
(format_admin_timestamp(&report.reported_at))
}
td class="px-3 py-3 text-sm" {
div class="flex flex-col items-start gap-1" {
span class="text-neutral-600 text-xs xl:hidden" data-report-reported-at-compact=(report.report_id) {
(format_admin_timestamp(&report.reported_at))
}
@let (type_label, type_variant) = format_report_type(report.report_type);
(badge(type_label, type_variant))
@let reason_label = report.reason.as_deref().map(|reason| report.reason_label.as_deref().unwrap_or(reason));
@@ -486,20 +489,23 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
}
}
}
td class="px-3 py-3 text-sm" {
td class="px-3 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal" {
(reporter_cell(config, report))
}
td class="px-3 py-3 text-sm" {
td class="px-3 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal" {
(reported_cell(config, report))
}
td class="whitespace-nowrap px-3 py-3 text-sm" {
@let (label, variant) = format_status(report.status);
@let (status_label, status_variant) = format_status(report.status);
td class="hidden whitespace-nowrap px-3 py-3 text-sm xl:table-cell" {
span data-status-pill=(report.report_id) {
(badge(label, variant))
(badge(status_label, status_variant))
}
}
td class="whitespace-nowrap px-3 py-3 text-sm" {
div class="flex flex-col items-start gap-1" {
span class="xl:hidden" data-status-pill-compact=(report.report_id) {
(badge(status_label, status_variant))
}
button type="button"
data-drawer-open="report-peek"
data-drawer-href={(base) "/reports/" (report.report_id) "/fragment"}
@@ -530,8 +536,15 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
(table_container(table(html! {
(table_head(html! {
tr {
@for header in headers {
th class="whitespace-nowrap px-3 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider" {
@for (index, header) in headers.iter().enumerate() {
th class={"px-3 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider" (match index {
0 | 4 => " hidden whitespace-nowrap xl:table-cell",
5 => " xl:whitespace-nowrap",
_ => " whitespace-nowrap",
})} {
@if index == 5 {
span class="xl:hidden" { "Status / " }
}
(header)
}
}
@@ -627,3 +640,126 @@ fn reports_url(config: &AdminConfig, filters: &ReportFilters<'_>, page: u32, lim
.join("&");
format!("{}/reports?{}", config.base_path, query)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ProxyConfig, RuntimeEnv};
use serde_json::json;
fn test_config() -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: String::new(),
port: 0,
secret_key_base: "test-secret".to_owned(),
base_path: "/admin".to_owned(),
api_endpoint: String::new(),
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
}
}
fn report() -> ReportEntry {
let mut value = json!({
"report_id": "1556709309709027556",
"reporter_id": "1556709306000000001",
"reporter_username": "reporter_80f33d09e88a",
"reporter_global_name": "Avery Reporter",
"reporter_discriminator": "5193",
"reported_at": "2026-10-06T04:33:00Z",
"status": 0,
"report_type": 1,
"category": "harassment",
"reported_user_id": "1556709306000000002",
"reported_user_username": "target_bc57b33ca5c4",
"reported_user_discriminator": "8316"
});
for key in [
"reporter_tag",
"reporter_email",
"reporter_full_legal_name",
"reporter_country_of_residence",
"additional_info",
"reported_user_tag",
"reported_user_global_name",
"reported_user_avatar_hash",
"reported_guild_id",
"reported_guild_name",
"reported_guild_icon_hash",
"reported_message_id",
"reported_channel_id",
"reported_channel_name",
"reported_channel_nsfw",
"reported_guild_invite_code",
"reported_guild_nsfw_level",
"reported_guild_nsfw",
"reported_guild_content_warning_level",
"reported_guild_content_warning_text",
"reported_channel_nsfw_override",
"reported_channel_content_warning_level",
"reported_channel_content_warning_text",
"reported_channel_effective_nsfw",
"reported_channel_effective_content_warning_level",
"reported_channel_effective_content_warning_text",
"resolved_at",
"resolved_by_admin_id",
"public_comment",
"mutual_dm_channel_id",
"message_context",
] {
value[key] = serde_json::Value::Null;
}
serde_json::from_value(value).expect("report fixture")
}
#[test]
fn narrow_layout_moves_date_and_status_into_visible_columns() {
let markup = render_reports_table(&test_config(), &[report()]).into_string();
assert!(markup.contains(
r#"<span class="text-neutral-600 text-xs xl:hidden" data-report-reported-at-compact="1556709309709027556">Oct 6, 2026, 4:33 AM UTC</span>"#
));
assert!(markup.contains(
r#"<span class="xl:hidden" data-status-pill-compact="1556709309709027556">"#
));
assert!(markup.contains(r#"<span data-status-pill="1556709309709027556">"#));
assert_eq!(
markup
.matches("hidden whitespace-nowrap xl:table-cell")
.count(),
2
);
assert_eq!(
markup
.matches(
"hidden whitespace-nowrap px-3 py-3 text-neutral-600 text-sm xl:table-cell"
)
.count(),
1
);
assert_eq!(
markup
.matches("hidden whitespace-nowrap px-3 py-3 text-sm xl:table-cell")
.count(),
1
);
assert_eq!(
markup
.matches("[overflow-wrap:anywhere] [&amp;_.whitespace-nowrap]:whitespace-normal")
.count(),
2
);
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::noun_for;
use crate::{
config::AdminConfig,
middleware::auth::AuthContext,
@@ -139,7 +140,7 @@ fn status_section(base: &str, rs: &RefreshStatus) -> Markup {
@if status_str == "completed" {
@if let (Some(idx), Some(tot)) = (rs.indexed, rs.total) {
p class="text-sm text-neutral-700" {
"Indexed " (idx) " / " (tot) " items"
"Indexed " (idx) " / " (tot) " " (noun_for(tot, "item", "items"))
}
}
}
@@ -16,6 +16,7 @@ use crate::{
BlocklistActionVariant, blocklist_action_card, blocklist_text_field,
},
},
utils::timestamps::format_admin_timestamp,
};
use maud::{Markup, html};
@@ -160,7 +161,7 @@ fn entry_row(base: &str, csrf_token: &str, entry: &BlocklistEntry) -> Markup {
@if entry.match_subdomains.unwrap_or(true) { "Yes" } @else { "No" }
}))
(table_cell(true, html! { (entry.category.as_deref().unwrap_or("")) }))
(table_cell(true, html! { (entry.created_at.as_deref().unwrap_or("")) }))
(table_cell(true, html! { (entry.created_at.as_deref().map(format_admin_timestamp).unwrap_or_default()) }))
(table_cell(false, html! {
form method="post" action=(action_url) {
(csrf_input(csrf_token))
@@ -216,11 +216,11 @@ fn session_entry(base: &str, s: &UserSession, is_tombstone: bool) -> Markup {
span class="ml-2 inline-flex items-center rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-medium text-neutral-600" { "Terminated" }
}
}))
(meta_cell("Created", html! { (s.created_at) }))
(meta_cell("Created", html! { (format_admin_timestamp(&s.created_at)) }))
@if let Some(ref d) = s.deleted_at {
(meta_cell("Terminated", html! { (d) }))
(meta_cell("Terminated", html! { (format_admin_timestamp(d)) }))
} @else {
(meta_cell("Last Used", html! { (s.approx_last_used_at) }))
(meta_cell("Last Used", html! { (format_admin_timestamp(&s.approx_last_used_at)) }))
}
div class="md:col-span-3" {
(meta_cell("IP Address", html! {
@@ -388,11 +388,11 @@ fn webauthn_credential_row(
p class="text-sm text-neutral-900" { (credential.name) }
}
td class="py-2 pr-4" {
p class="text-sm text-neutral-900" { (credential.created_at) }
p class="text-sm text-neutral-900" { (format_admin_timestamp(&credential.created_at)) }
}
td class="py-2 pr-4" {
p class="text-sm text-neutral-900" {
(credential.last_used_at.as_deref().unwrap_or("Never"))
(credential.last_used_at.as_deref().map_or_else(|| "Never".to_string(), format_admin_timestamp))
}
}
td class="py-2" {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
api::types::DmChannel, config::AdminConfig,
templates::components::page_container::card_with_header,
@@ -85,7 +86,7 @@ fn group_dm_card(base: &str, user_id: &str, channel: &DmChannel) -> Markup {
}
div class="space-y-1" {
p class="text-sm text-neutral-500" {
(channel.recipients.len()) " recipients \u{00b7} Status: "
(count_noun(channel.recipients.len() as u64, "recipient", "recipients")) " \u{00b7} Status: "
@if channel.is_open { "Open" } @else { "Closed" }
}
@if let Some(o) = owner {
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
api::types::GuildInfo,
config::AdminConfig,
@@ -63,7 +64,7 @@ fn guild_row(config: &AdminConfig, user_id: &str, guild: &GuildInfo) -> Markup {
}
}
span class="text-xs text-neutral-500" {
(guild.member_count) " members"
(count_noun(guild.member_count, "member", "members"))
}
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::{count_noun, noun_for};
use crate::{
acl,
api::{
@@ -359,13 +360,13 @@ fn deletion_card(
div class="space-y-3" {
(form_label("Private Reason"))
input type="text" name="private_reason" required
placeholder="Why this deletion is being cancelled (audit log)..."
placeholder="Why this deletion is being canceled (audit log)..."
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
@if !username_sign_in {
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
(checkbox("notify_user", "true", "Email the user that the deletion was canceled", false, true))
}
(checkbox("confirm", "true", &confirmation, false, true))
(form_actions(html! {
@@ -567,7 +568,7 @@ fn message_shred_status_content(status: &serde_json::Value) -> Markup {
"Status: " (message_shred_status_label(status_value))
}
p class="text-sm text-neutral-700" {
"Requested " (requested) " entries, skipped " (skipped) " entries"
"Requested " (count_noun(requested, "entry", "entries")) ", skipped " (count_noun(skipped, "entry", "entries"))
}
@if status_value == "in_progress" {
div class="space-y-2" {
@@ -582,17 +583,17 @@ fn message_shred_status_content(status: &serde_json::Value) -> Markup {
}
@if status_value == "completed" {
p class="text-sm text-neutral-700" {
"Deleted " (processed) " / " (total) " entries"
"Deleted " (processed) " / " (total) " " (noun_for(total, "entry", "entries"))
}
}
@if let Some(started_at) = value_str(status, "started_at") {
p class="text-xs text-neutral-500" { "Started " (started_at) }
p class="text-xs text-neutral-500" { "Started " (format_admin_timestamp(started_at)) }
}
@if let Some(completed_at) = value_str(status, "completed_at") {
p class="text-xs text-neutral-500" { "Completed " (completed_at) }
p class="text-xs text-neutral-500" { "Completed " (format_admin_timestamp(completed_at)) }
}
@if let Some(failed_at) = value_str(status, "failed_at") {
p class="text-xs text-red-600" { "Failed " (failed_at) }
p class="text-xs text-red-600" { "Failed " (format_admin_timestamp(failed_at)) }
}
@if let Some(error) = value_str(status, "error") {
p class="text-sm text-red-600" { (error) }
@@ -66,13 +66,13 @@ fn render_overview_tab(
div class="space-y-6" {
@if let Some(until) = &user.temp_banned_until {
div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm font-medium text-red-900" {
"Temporarily banned until " (until)
"Temporarily banned until " (format_admin_timestamp(until))
}
}
@if user.temp_banned_until.is_none() {
@if let Some(pending) = &user.pending_deletion_at {
div class="rounded-lg border border-orange-200 bg-orange-50 p-4 text-sm text-orange-900" {
div class="font-medium" { "Scheduled for deletion: " (pending) }
div class="font-medium" { "Scheduled for deletion: " (format_admin_timestamp(pending)) }
@if let Some(code) = user.deletion_reason_code {
div class="mt-1" { "Reason code: " (code) }
}
@@ -101,7 +101,7 @@ fn render_overview_tab(
@if let Some(pending) = &user.pending_bulk_message_deletion_at {
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
div class="font-medium text-neutral-700 text-sm" {
"Bulk message deletion scheduled for " (pending)
"Bulk message deletion scheduled for " (format_admin_timestamp(pending))
}
@if acl::has_permission(admin_acls, acl::USER_CANCEL_BULK_MESSAGE_DELETION) {
form method="post"
@@ -197,10 +197,10 @@ fn render_overview_tab(
}
}))
@if let Some(ref value) = user.premium_grace_ends_at {
(detail_row("Grace Period Ends", html! { (value) }))
(detail_row("Grace Period Ends", html! { (format_admin_timestamp(value)) }))
}
(detail_row("Last Active", html! {
(user.last_active_at.as_deref().unwrap_or("Never"))
(user.last_active_at.as_deref().map_or_else(|| "Never".to_string(), format_admin_timestamp))
}))
@if acl::has_permission(admin_acls, acl::USER_VIEW_IP) {
(detail_row("Last IP", html! {
@@ -239,7 +239,7 @@ fn render_overview_tab(
div class="py-3 text-sm" {
dt class="font-medium text-neutral-900" {
(entry.field)
span class="ml-2 font-normal text-xs text-neutral-500" { (entry.event_at) }
span class="ml-2 font-normal text-xs text-neutral-500" { (format_admin_timestamp(&entry.event_at)) }
}
dd class="mt-1 font-mono text-xs text-neutral-700" {
(entry.old_value.as_deref().unwrap_or("null"))
@@ -599,6 +599,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
@@ -644,4 +645,23 @@ mod tests {
fn email_mode_shows_the_email_row() {
assert!(render_email_row(false).contains("[email protected]"));
}
#[test]
fn last_active_and_grace_end_use_the_panel_date_format() {
let user: AdminUser = serde_json::from_value(serde_json::json!({
"id": "1500000000000000001",
"username": "target",
"discriminator": "0001",
"last_active_at": "2026-10-06T20:23:38.591Z",
"premium_grace_ends_at": "2026-11-01T08:00:00Z"
}))
.expect("valid admin user");
let html =
render_overview_tab(&test_config(), &user, &[], "csrf", None, None, false, false)
.into_string();
assert!(!html.contains("2026-10-06T20:23"));
assert!(!html.contains("2026-11-01T08:00"));
assert!(html.contains(&format_admin_timestamp("2026-10-06T20:23:38.591Z")));
assert!(html.contains(&format_admin_timestamp("2026-11-01T08:00:00Z")));
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::{ListUserRelationshipsResponse, RelationshipEntry},
config::AdminConfig,
@@ -176,7 +177,7 @@ fn relationship_row(
}
@if let Some(ref since) = entry.since {
p class="text-xs text-neutral-500" {
"Since: " (since)
"Since: " (format_admin_timestamp(since))
}
}
}
@@ -8,7 +8,7 @@ use crate::{
page_container::card_with_header,
report_category::{reason_repeats_category, report_category},
report_webhook::{reported_webhook, webhook_identity},
table::data_table,
table::{table, table_body, table_container, table_head},
},
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
};
@@ -108,7 +108,7 @@ fn report_section(
} else {
vec!["Reported At", "Type / Category", "Reporter", "Status"]
};
(data_table(
(report_tab_table(
&headers,
html! {
@for report in reports {
@@ -196,19 +196,52 @@ pub(crate) fn reporter_label(report: &ReportEntry) -> Markup {
}
}
pub(crate) fn report_tab_table(headers: &[&str], rows: Markup) -> Markup {
let has_actions = headers.last() == Some(&"Actions");
html! {
(table_container(table(html! {
(table_head(html! {
tr {
@for (index, header) in headers.iter().enumerate() {
@let folded = index == 1 || (has_actions && *header == "Status");
th class={"px-2 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider xl:px-4" (if folded { " hidden whitespace-nowrap xl:table-cell" } else { " whitespace-nowrap" })} {
@if has_actions && index + 1 == headers.len() {
span class="xl:hidden" { "Status / " }
}
(header)
}
}
}
}))
(table_body(rows))
})))
}
}
pub(crate) fn reported_at_cell(base: &str, report: &ReportEntry) -> Markup {
html! {
td class="px-3 py-3 text-sm text-neutral-900" {
td class="px-2 py-3 text-sm text-neutral-900 xl:px-3" {
div { (format_admin_timestamp(&report.reported_at)) }
a href={(base) "/reports/" (report.report_id)}
class="whitespace-nowrap font-mono text-blue-600 text-xs tracking-tight hover:underline" {
(report.report_id)
}
div class="mt-1 xl:hidden" data-report-type-compact=(report.report_id) {
(type_and_category_lines(report))
}
}
}
}
pub(crate) fn type_and_category_cell(report: &ReportEntry) -> Markup {
html! {
td class="hidden px-4 py-3 text-sm text-neutral-900 xl:table-cell" {
(type_and_category_lines(report))
}
}
}
fn type_and_category_lines(report: &ReportEntry) -> Markup {
let reason_label = report
.reason
.as_deref()
@@ -219,17 +252,15 @@ pub(crate) fn type_and_category_cell(report: &ReportEntry) -> Markup {
.zip(reason_label)
.is_some_and(|(category, label)| reason_repeats_category(category, label));
html! {
td class="px-4 py-3 text-sm text-neutral-900" {
div class="text-neutral-500 text-xs" { (format_report_type(report.report_type)) }
@if let Some(category) = &report.category {
div class="break-words" data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
(report_category(category))
}
div class="text-neutral-500 text-xs" { (format_report_type(report.report_type)) }
@if let Some(category) = &report.category {
div class="break-words" data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
(report_category(category))
}
@if !repeats && let (Some(reason), Some(label)) = (&report.reason, reason_label) {
div class="break-words text-neutral-500 text-xs" data-report-reason=(reason) {
(label)
}
}
@if !repeats && let (Some(reason), Some(label)) = (&report.reason, reason_label) {
div class="break-words text-neutral-500 text-xs" data-report-reason=(reason) {
(label)
}
}
}
@@ -270,7 +301,7 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
tr class="hover:bg-neutral-50 transition-colors" {
(reported_at_cell(base, report))
(type_and_category_cell(report))
td class="px-4 py-3 text-sm [overflow-wrap:anywhere]" {
td class="px-2 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal xl:px-4 xl:[&_.whitespace-nowrap]:whitespace-nowrap" {
@if let Some(webhook) = &webhook {
a href=(webhook.reports_href) class="hover:underline" {
(webhook_identity(webhook))
@@ -287,7 +318,7 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
span { (entity_display) }
}
}
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
td class="whitespace-nowrap px-2 py-3 text-sm text-neutral-900 xl:px-4" {
(format_status(report.status))
}
}
@@ -361,6 +392,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
@@ -476,12 +508,24 @@ mod tests {
let markup = report_row(&config, "/admin", "received", &report).into_string();
assert_eq!(
markup.matches("Harassment or bullying").count(),
1,
2,
"{markup}"
);
assert_eq!(
markup.matches(r#"data-report-reason="harassment""#).count(),
1,
2,
"{markup}"
);
assert!(
markup.contains(
r#"<div class="mt-1 xl:hidden" data-report-type-compact="1800000000000000007">"#
),
"{markup}"
);
assert!(
markup.contains(
r#"<td class="hidden px-4 py-3 text-sm text-neutral-900 xl:table-cell">"#
),
"{markup}"
);
assert!(
+1
View File
@@ -2,5 +2,6 @@
pub mod bigint;
pub mod forms;
pub mod plural;
pub mod timestamps;
pub mod user_tag;
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub fn count_noun(count: u64, singular: &str, plural: &str) -> String {
if count == 1 {
format!("{count} {singular}")
} else {
format!("{count} {plural}")
}
}
pub fn noun_for(count: u64, singular: &'static str, plural: &'static str) -> &'static str {
if count == 1 { singular } else { plural }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn count_noun_picks_the_singular_only_for_one() {
assert_eq!(count_noun(0, "result", "results"), "0 results");
assert_eq!(count_noun(1, "result", "results"), "1 result");
assert_eq!(count_noun(2, "entry", "entries"), "2 entries");
assert_eq!(noun_for(1, "item", "items"), "item");
assert_eq!(noun_for(3, "item", "items"), "items");
}
}
+48 -74
View File
@@ -10,22 +10,6 @@ pub fn snowflake_to_timestamp_ms(snowflake: &str) -> Option<u64> {
Some(timestamp_ms)
}
pub fn format_unix_timestamp(unix_seconds: u64) -> String {
let offset = time::OffsetDateTime::from_unix_timestamp(unix_seconds as i64).ok();
match offset {
Some(dt) => {
let format = time::format_description::well_known::Rfc3339;
dt.format(&format)
.unwrap_or_else(|_| unix_seconds.to_string())
}
None => unix_seconds.to_string(),
}
}
pub fn format_unix_timestamp_ms(unix_ms: u64) -> String {
format_unix_timestamp(unix_ms / 1000)
}
pub fn relative_time(unix_seconds: u64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -60,51 +44,50 @@ pub fn relative_time(unix_seconds: u64) -> String {
}
pub fn format_admin_timestamp(iso: &str) -> String {
let dt = time::OffsetDateTime::parse(iso, &time::format_description::well_known::Rfc3339)
time::OffsetDateTime::parse(iso, &time::format_description::well_known::Rfc3339)
.or_else(|_| {
time::OffsetDateTime::parse(
iso,
&time::format_description::well_known::Iso8601::DEFAULT,
)
})
.ok();
match dt {
Some(dt) => {
let dt = dt.to_offset(time::UtcOffset::UTC);
let month = match dt.month() {
time::Month::January => "Jan",
time::Month::February => "Feb",
time::Month::March => "Mar",
time::Month::April => "Apr",
time::Month::May => "May",
time::Month::June => "Jun",
time::Month::July => "Jul",
time::Month::August => "Aug",
time::Month::September => "Sep",
time::Month::October => "Oct",
time::Month::November => "Nov",
time::Month::December => "Dec",
};
let day = dt.day();
let year = dt.year();
let hour_12 = match dt.hour() {
0 => 12,
h if h > 12 => h - 12,
h => h,
};
let minute = dt.minute();
let ampm = if dt.hour() < 12 { "AM" } else { "PM" };
format!("{month} {day}, {year}, {hour_12}:{minute:02} {ampm} UTC")
}
None => iso.to_owned(),
}
.map_or_else(|_| iso.to_owned(), format_admin_datetime)
}
fn format_admin_datetime(dt: time::OffsetDateTime) -> String {
let dt = dt.to_offset(time::UtcOffset::UTC);
let month = match dt.month() {
time::Month::January => "Jan",
time::Month::February => "Feb",
time::Month::March => "Mar",
time::Month::April => "Apr",
time::Month::May => "May",
time::Month::June => "Jun",
time::Month::July => "Jul",
time::Month::August => "Aug",
time::Month::September => "Sep",
time::Month::October => "Oct",
time::Month::November => "Nov",
time::Month::December => "Dec",
};
let day = dt.day();
let year = dt.year();
let hour_12 = match dt.hour() {
0 => 12,
h if h > 12 => h - 12,
h => h,
};
let minute = dt.minute();
let ampm = if dt.hour() < 12 { "AM" } else { "PM" };
format!("{month} {day}, {year}, {hour_12}:{minute:02} {ampm} UTC")
}
pub fn snowflake_creation_date(snowflake: &str) -> String {
match snowflake_to_timestamp_ms(snowflake) {
Some(ms) => format_unix_timestamp_ms(ms),
None => "Unknown".to_owned(),
}
snowflake_to_timestamp_ms(snowflake)
.and_then(|ms| {
time::OffsetDateTime::from_unix_timestamp_nanos(i128::from(ms) * 1_000_000).ok()
})
.map_or_else(|| "Unknown".to_owned(), format_admin_datetime)
}
#[cfg(test)]
@@ -132,24 +115,6 @@ mod tests {
assert_eq!(snowflake_to_timestamp_ms("-1"), None);
}
#[test]
fn format_unix_timestamp_iso8601() {
let result = format_unix_timestamp(1_704_067_200);
assert!(
result.starts_with("2024-01-01"),
"expected ISO date starting with 2024-01-01, got: {result}"
);
}
#[test]
fn format_unix_timestamp_ms_converts() {
let result = format_unix_timestamp_ms(1_704_067_200_000);
assert!(
result.starts_with("2024-01-01"),
"expected ISO date, got: {result}"
);
}
#[test]
fn relative_time_just_now() {
let now = SystemTime::now()
@@ -196,10 +161,19 @@ mod tests {
#[test]
fn snowflake_creation_date_valid() {
let result = snowflake_creation_date("0");
assert!(
result.starts_with("2015-01-01"),
"expected 2015-01-01, got: {result}"
assert_eq!(snowflake_creation_date("0"), "Jan 1, 2015, 12:00 AM UTC");
}
#[test]
fn admin_timestamps_render_in_the_panel_format() {
assert_eq!(
format_admin_timestamp("2026-10-06T14:05:09.123Z"),
"Oct 6, 2026, 2:05 PM UTC"
);
assert_eq!(
format_admin_timestamp("2026-10-06T00:30:00+02:00"),
"Oct 5, 2026, 10:30 PM UTC"
);
assert_eq!(format_admin_timestamp("not a date"), "not a date");
}
}