diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts
index 253ec029..c99f2909 100644
--- a/frontend/src/lib/api/types.ts
+++ b/frontend/src/lib/api/types.ts
@@ -761,12 +761,27 @@ export interface SessionSummary {
user_id: string;
created_at: string;
expires_at: string;
+ /** Wall-clock (RFC 3339) of the last authenticated request the
+ * server observed on this session. Trails the true value by at
+ * most the tracker's flush interval (30 s) on a running server;
+ * converges after graceful shutdown. Populates the "last seen X
+ * ago" tooltip on the presence dot. */
+ last_seen_at: string;
ip_address: string | null;
user_agent: string | null;
is_bound: boolean;
dpop_jkt_prefix: string | null;
is_revoked: boolean;
is_active: boolean;
+ /** Presence signal — `true` when the server observed a request on
+ * this session within the last 5 minutes AND the row is `is_active`
+ * (never `true` on revoked / expired rows). Renders as a filled
+ * green dot in the Status column; `false` on an otherwise-active
+ * row renders as an outlined idle dot with a "last seen X ago"
+ * tooltip. Distinct from `is_active`: that's a lifecycle signal,
+ * this is a presence signal. Derived server-side against the
+ * same window that drives `oxicloud_sessions_online[_users]`. */
+ is_online: boolean;
/** How this session was minted. `unknown` covers pre-migration
* rows and any origin the SPA doesn't yet render. Server enum
* is populated at INSERT (see `Session::new`) and copied on
diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte
index 037e4636..532f0dcc 100644
--- a/frontend/src/routes/admin/[[tab]]/+page.svelte
+++ b/frontend/src/routes/admin/[[tab]]/+page.svelte
@@ -3082,6 +3082,36 @@
{t('admin.sessions.expired', 'expired')}
{:else}
+
+
{t('admin.sessions.active', 'active')}
@@ -4572,6 +4602,46 @@
text-transform: uppercase;
}
+ /* Presence dot in the sessions-table Status column — filled green
+ when the row is `is_online` (a request landed in the last 5 min),
+ outlined grey when the row is active-but-idle. Only rendered on
+ active rows: a revoked-but-recently-seen row must never flash
+ green post-revocation (see the markup guard `{#if s.is_active}`).
+ The dot sits BEFORE the `active` badge with a small gap, so the
+ Status cell reads left-to-right as `● active` when online and
+ `○ active` when idle.
+
+ Tokens: `--color-success-alt` / `--color-success-border` for the
+ filled fill is the same green used by `.badge--active`, keeping
+ the presence signal visually consistent with the lifecycle one
+ without stealing the badge's own colour treatment. Grey border
+ for the idle state uses the neutral `--color-border` token so
+ both themes (light + dark, driven by `light-dark(...)`) get a
+ readable contrast. Fixed 8px / 8px sizing — the dot is a signal,
+ not a click target, so relative units would over-scale on
+ larger UI densities. */
+ .presence-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-right: var(--space-1);
+ vertical-align: middle;
+ /* No border on the filled state, so both variants render at
+ the same 8×8 footprint (the outlined variant's 1px border
+ is inset via box-sizing: border-box below). */
+ box-sizing: border-box;
+ }
+
+ .presence-dot--online {
+ background: var(--color-success-alt);
+ }
+
+ .presence-dot--idle {
+ background: transparent;
+ border: 1px solid var(--color-border);
+ }
+
/* External / grant-only account marker. Sibling of `.badge--user`
in the same cell so the two stack horizontally; the accent
colour reuses `--color-warning-*` because "external" is the
diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json
index 304e0b2e..88557131 100644
--- a/frontend/static/locales/en.json
+++ b/frontend/static/locales/en.json
@@ -1136,6 +1136,10 @@
"revoked": "revoked",
"expired": "expired",
"active": "active",
+ "online": "online",
+ "idle": "idle",
+ "presence_online_tooltip": "Online — last seen {{ago}}",
+ "presence_idle_tooltip": "Idle — last seen {{ago}}",
"revoke": "Revoke",
"empty": "No sessions match the current filter.",
"revoke_self_confirm": "⚠️ This is YOUR current session. Revoking it will log YOU out immediately and you'll have to sign back in. Continue?",
diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json
index 165f0ad2..998c1e4a 100644
--- a/frontend/static/locales/fr.json
+++ b/frontend/static/locales/fr.json
@@ -1269,6 +1269,10 @@
"revoked": "révoquée",
"expired": "expirée",
"active": "actif",
+ "online": "en ligne",
+ "idle": "inactif",
+ "presence_online_tooltip": "En ligne — vue {{ago}}",
+ "presence_idle_tooltip": "Inactif — vue {{ago}}",
"revoke": "Révoquer",
"empty": "Aucune session ne correspond au filtre actuel.",
"revoke_self_confirm": "⚠️ Il s'agit de VOTRE session actuelle. La révoquer vous déconnectera immédiatement et vous devrez vous reconnecter. Continuer ?",