Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions book/src/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,12 @@ action, target and timestamp. Destructive **replica stop/restart**, schedule
changes, MFA enrolment/reset/proof, and break-glass MFA bypasses are recorded
too; rows with a change diff expand to show it.

Timestamps are stored in UTC and rendered in **your browser's timezone**, so
two operators in different zones each read the local wall clock of the same
event. Hovering a timestamp shows the full date with the zone name. (The
scheduler's own times — next occurrence and last run on the Schedules page —
are still labelled UTC.)

### System

A read-only diagnostic of the running server (version, bind address,
Expand Down
64 changes: 64 additions & 0 deletions crates/ruscker-admin/templates/admin/_layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,70 @@
{% block content %}{% endblock %}
</main>

{# ── Local-time rendering (#1041) ──────────────────────────────────
Every timestamp in the DB is UTC (the right thing to store), and
chrono's `format` prints the wall clock OF THE VALUE — so a bare
day/month/year+hour interpolation renders UTC and reads as three
hours in the future for a UTC−3 operator (the audit log made this
obvious). There is no server-side timezone to convert to: box and
hugo would each need configuring, and operators can sit in
different zones anyway. So each timestamp cell ships as
`<time datetime="…Z" data-rk-time="pattern">` with the UTC text as
the no-JS fallback, and this rewrites it into the VIEWER's zone.
Patterns mirror the chrono formats they replace, so the digit
layout of each screen is unchanged — only the hour is now right.
Runs immediately (the cells above are already parsed) to keep the
wrong-time flash sub-frame; the DOMContentLoaded pass is the
belt-and-braces for anything still streaming in. #}
<script>
(function () {
function p(n) { return (n < 10 ? '0' : '') + n; }
function ymd(d) { return p(d.getDate()) + '/' + p(d.getMonth() + 1) + '/' + d.getFullYear(); }
function hms(d, secs) {
return p(d.getHours()) + ':' + p(d.getMinutes()) + (secs ? ':' + p(d.getSeconds()) : '');
}
var PATTERNS = {
'datetime-s': function (d) { return ymd(d) + ' ' + hms(d, true); }, // %d/%m/%Y %H:%M:%S
'datetime': function (d) { return ymd(d) + ' ' + hms(d, false); }, // %d/%m/%Y %H:%M
'date': function (d) { return ymd(d); }, // %d/%m/%Y
'date-iso': function (d) { // %Y-%m-%d
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
}
};

function localize(root) {
(root || document).querySelectorAll('time[data-rk-time]:not([data-rk-tz])').forEach(function (el) {
var fmt = PATTERNS[el.dataset.rkTime];
var d = new Date(el.getAttribute('datetime'));
// Unknown pattern or unparseable value: leave the server's UTC
// text alone. A wrong-but-labelled time beats a blank cell.
if (!fmt || isNaN(d.getTime())) return;
el.textContent = fmt(d);
el.dataset.rkTz = '1';
// Hover shows the full date + the zone name, in the PAGE's locale
// (not the browser's — the operator picked pt/en/es/fr in the chrome
// cluster and the tooltip should follow that choice).
try {
var lang = document.documentElement.lang || undefined;
el.title = d.toLocaleString(lang, { dateStyle: 'full', timeStyle: 'long' });
} catch (e) { /* older engine without dateStyle — the cell text is enough */ }
// Sort the column by the instant, not by the rendered digits: the
// table enhancer's numeric compare strips the separators and reads
// `29/07/2026` as one big number, i.e. it sorts by day-of-month.
var cell = el.closest('td');
if (cell) cell.dataset.sortValue = String(d.getTime());
});
}

window.ruscker = window.ruscker || {};
window.ruscker.localizeTimes = localize; // for fragments swapped in later
localize();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { localize(); });
}
})();
</script>

{# Floating toasts (#623): promote any server-rendered flash banner into a
bottom-centre toast that auto-dismisses. The banner markup is unchanged
(still produced server-side from `?flash=…`); this only re-homes it, so
Expand Down
2 changes: 1 addition & 1 deletion crates/ruscker-admin/templates/admin/activity.html
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ <h1 class="text-xl font-medium tracking-tight">{{ self.t("admin-activity-title")
<tbody>
{% for r in rows %}
<tr>
<td class="dash-mono text-[color:var(--text-faint)]" style="white-space:nowrap">{{ r.occurred_at.format("%d/%m/%Y %H:%M:%S") }}</td>
<td class="dash-mono text-[color:var(--text-faint)]" style="white-space:nowrap"><time datetime="{{ r.occurred_at.format("%Y-%m-%dT%H:%M:%SZ") }}" data-rk-time="datetime-s">{{ r.occurred_at.format("%d/%m/%Y %H:%M:%S") }}</time></td>
<td>
<span class="user-cell">
<span class="rk-avatar" data-avatar="{% match r.username %}{% when Some with (u) %}{{ u }}{% when None %}anon{% endmatch %}" aria-hidden="true"></span>
Expand Down
2 changes: 1 addition & 1 deletion crates/ruscker-admin/templates/admin/audit.html
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ <h1 class="text-xl font-medium tracking-tight">{{ self.t("admin-audit-title") }}
@keydown.enter.prevent="toggle({{ e.id }})" @keydown.space.prevent="toggle({{ e.id }})"
tabindex="0" role="button" :aria-expanded="open === {{ e.id }} ? 'true' : 'false'"
style="cursor:pointer"{% endif %}>
<td class="dash-mono text-[color:var(--text-faint)]" style="white-space:nowrap">{{ e.occurred_at.format("%d/%m/%Y %H:%M:%S") }}</td>
<td class="dash-mono text-[color:var(--text-faint)]" style="white-space:nowrap"><time datetime="{{ e.occurred_at.format("%Y-%m-%dT%H:%M:%SZ") }}" data-rk-time="datetime-s">{{ e.occurred_at.format("%d/%m/%Y %H:%M:%S") }}</time></td>
<td>
<span class="user-cell">
<span class="rk-avatar" data-avatar="{% match e.actor %}{% when Some with (a) %}{{ a }}{% when None %}system{% endmatch %}" aria-hidden="true"></span>
Expand Down
2 changes: 1 addition & 1 deletion crates/ruscker-admin/templates/admin/credentials.html
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ <h1 class="text-xl font-medium tracking-tight">{{ self.t("admin-creds-title") }}
<td class="id-cell"><span class="cred-cell"><span class="cred-ico"><i class="ti ti-key" aria-hidden="true"></i></span>{{ c.name }}</span></td>
<td class="text-[color:var(--text-muted)]">{{ c.registry }}</td>
<td>{{ c.username }}</td>
<td class="text-[color:var(--text-muted)]">{{ c.created_at.format("%d/%m/%Y") }}</td>
<td class="text-[color:var(--text-muted)]"><time datetime="{{ c.created_at.format("%Y-%m-%dT%H:%M:%SZ") }}" data-rk-time="date">{{ c.created_at.format("%d/%m/%Y") }}</time></td>
<td class="actions-cell">
<form method="post" action="{{ base }}/admin/credentials/{{ c.name }}/delete"
data-confirm="{{ self.t("admin-creds-delete-confirm") }}"
Expand Down
19 changes: 16 additions & 3 deletions crates/ruscker-admin/templates/admin/process_logs.html
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ <h1 class="text-2xl font-medium tracking-tight">{{ self.t("admin-proclog-title")
var knownApps = {}; // app name -> true, to dedupe the app <select>

function cell(cls, text) { var s = document.createElement('span'); s.className = cls; s.textContent = text; return s; }
function p2(n) { return (n < 10 ? '0' : '') + n; }

// A row matches when its level chip is on AND the app filter allows it.
function matches(el) {
Expand Down Expand Up @@ -123,9 +124,21 @@ <h1 class="text-2xl font-medium tracking-tight">{{ self.t("admin-proclog-title")
div.dataset.level = level;
div.dataset.app = app;
div.dataset.text = clean.toLowerCase();
// HH:MM:SS.mmm (keep millis like the design; drop the date + tz).
var ts = m[1].replace(/^.*T/, '').replace(/(\.\d{3})\d*Z?$/, '$1').replace(/Z$/, '');
div.appendChild(cell('log-ts', ts));
// HH:MM:SS.mmm (keep millis like the design; drop the date + tz),
// in the VIEWER's timezone (#1041): the tracing stream emits UTC,
// which reads three hours ahead for a UTC−3 operator — the same
// defect the audit table had. The raw UTC token stays on the
// cell's title for anyone correlating with the downloaded log.
var d = new Date(m[1]);
var ts = isNaN(d.getTime())
// First field isn't a timestamp we can parse — show it as-is
// rather than blanking the column.
? m[1].replace(/^.*T/, '').replace(/(\.\d{3})\d*Z?$/, '$1').replace(/Z$/, '')
: p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds()) +
'.' + ('00' + d.getMilliseconds()).slice(-3);
var tsCell = cell('log-ts', ts);
tsCell.title = m[1];
div.appendChild(tsCell);
div.appendChild(cell('log-lvl log-lvl-' + level.toLowerCase(), level));
div.appendChild(cell('log-app', app));
div.appendChild(cell('log-msg', rest));
Expand Down
2 changes: 1 addition & 1 deletion crates/ruscker-admin/templates/admin/specs.html
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ <h1 class="text-xl font-medium tracking-tight">{{ self.t("admin-specs-title") }}
<span class="pill" title="{{ self.t("admin-specs-config-defined") }}">{{ self.t("admin-specs-config-badge") }}</span>
</td>
{% else %}
<td class="text-[color:var(--text-muted)]">{{ spec.updated_at.format("%d/%m/%Y %H:%M") }}</td>
<td class="text-[color:var(--text-muted)]"><time datetime="{{ spec.updated_at.format("%Y-%m-%dT%H:%M:%SZ") }}" data-rk-time="datetime">{{ spec.updated_at.format("%d/%m/%Y %H:%M") }}</time></td>
<td class="text-[color:var(--text-muted)]">v{{ spec.version }}</td>
<td class="access-cell">
<span class="access-num">{% if spec.access_count > 0 %}{{ spec.access_count }}{% else %}<span class="text-[color:var(--text-faint)]">0</span>{% endif %}</span>
Expand Down
2 changes: 1 addition & 1 deletion crates/ruscker-admin/templates/admin/users.html
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ <h1 class="text-xl font-medium tracking-tight">{{ self.t("admin-users-title") }}
{% if let Some(celular) = u.celular %}<div class="text-xs text-[color:var(--text-muted)]">{{ celular }}</div>{% endif %}
{% if u.setor.is_none() && u.email.is_none() && u.celular.is_none() %}<span class="text-[color:var(--text-faint)]">—</span>{% endif %}
</td>
<td class="dash-mono text-[color:var(--text-muted)]">{{ u.created_at.format("%Y-%m-%d") }}</td>
<td class="dash-mono text-[color:var(--text-muted)]"><time datetime="{{ u.created_at.format("%Y-%m-%dT%H:%M:%SZ") }}" data-rk-time="date-iso">{{ u.created_at.format("%Y-%m-%d") }}</time></td>
<td style="text-align:right;white-space:nowrap">
<a href="{{ base }}/admin/users/{{ u.username }}/edit" class="dash-action"
title="{{ self.t("admin-users-edit") }}" aria-label="{{ self.t("admin-users-edit") }}">
Expand Down
42 changes: 42 additions & 0 deletions crates/ruscker-admin/tests/activity_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,45 @@ async fn paginates_server_side() {
assert_eq!(status, StatusCode::OK);
assert_eq!(row_count(&body2), 10, "second page has the remainder");
}

/// The timestamp column must ship the instant in a machine-readable
/// `<time datetime="…Z">` next to the human text, so the layout can
/// re-render it in the viewer's timezone. Without it the cell shows UTC
/// and reads three hours ahead for a UTC−3 operator (#1041).
#[tokio::test]
async fn timestamp_cell_carries_utc_instant_for_local_rendering() {
let db = open_db().await;
user_activity::insert_batch(
&db,
&[ActivityEvent::login_success(
"alice",
AuthMethod::Password,
"l1",
None,
)],
)
.await
.unwrap();
let state = app_state(db).await;
let cookie = admin_cookie(&state).await;

let (status, body) = get(state, "/admin/activity", Some(&cookie)).await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains(r#"data-rk-time="datetime-s""#),
"activity row is missing the local-time marker"
);

// The attribute is a real UTC instant — not the pt-BR display text.
let attr = body
.split(r#"<time datetime=""#)
.nth(1)
.and_then(|rest| rest.split('"').next())
.expect("a <time datetime=…> in the row");
let parsed = chrono::DateTime::parse_from_rfc3339(attr)
.unwrap_or_else(|e| panic!("datetime attr {attr:?} is not RFC-3339: {e}"));
let skew = (chrono::Utc::now() - parsed.with_timezone(&chrono::Utc))
.num_seconds()
.abs();
assert!(skew < 120, "row timestamp {attr} is {skew}s off from now");
}
53 changes: 53 additions & 0 deletions crates/ruscker-admin/tests/template_lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,56 @@ fn no_inline_onsubmit_confirm_handlers() {
offenders.join("\n")
);
}

/// A `DateTime<Utc>` formatted straight into the page prints UTC
/// wall-clock — three hours in the future for a UTC−3 operator, which
/// is exactly how the audit log was reported (#1041). Every visible
/// timestamp must ship inside a `<time datetime="…Z" data-rk-time="…">`
/// so the layout's `localizeTimes` can re-render it in the viewer's
/// zone, with the UTC text kept as the no-JS fallback.
#[test]
fn timestamps_are_wrapped_for_local_time_rendering() {
// The chrono formats that render a date to the operator. The
// machine-readable `datetime` attribute we ADD uses `%Y-%m-%dT…`,
// so it never matches these on its own.
let visible = ["format(\"%d/%m", "format(\"%Y-%m-%d\"", "format(\"%H"];
let offenders: Vec<String> = template_files()
.iter()
.flat_map(|path| {
let body = std::fs::read_to_string(path).expect("read template");
body.lines()
.enumerate()
.filter(|(_, line)| {
visible.iter().any(|pat| line.contains(pat)) && !line.contains("data-rk-time=")
})
.map(|(n, line)| format!("{}:{}: {}", path.display(), n + 1, line.trim()))
.collect::<Vec<_>>()
})
.collect();
assert!(
offenders.is_empty(),
"timestamp rendered without a <time data-rk-time=…> wrapper — it \
will display UTC instead of the viewer's local time (#1041):\n{}",
offenders.join("\n")
);
}

/// The wrapper is only half the fix: the layout must actually carry the
/// script that rewrites it. Guards against someone dropping the block
/// while the `data-rk-time` attributes stay behind, silently reverting
/// every screen to UTC.
#[test]
fn admin_layout_ships_the_local_time_script() {
let layout = Path::new(env!("CARGO_MANIFEST_DIR")).join("templates/admin/_layout.html");
let body = std::fs::read_to_string(&layout).expect("read admin layout");
assert!(
body.contains("window.ruscker.localizeTimes"),
"admin/_layout.html lost the local-time renderer (#1041)"
);
for pattern in ["datetime-s", "datetime", "date-iso", "date"] {
assert!(
body.contains(&format!("'{pattern}':")),
"local-time renderer is missing the `{pattern}` pattern (#1041)"
);
}
}