Refactor frontend date formatting helpers and mark Task 10 done

This commit is contained in:
2026-03-21 17:25:45 +01:00
parent a442836c5c
commit 8a6bcc4d94
6 changed files with 111 additions and 150 deletions

View File

@@ -204,7 +204,7 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
--- ---
### Task 10 — Consolidate duplicated formatting functions (frontend) ### Task 10 — Consolidate duplicated formatting functions (frontend) (✅ completed)
**Priority**: Low **Priority**: Low
**Refactoring ref**: Refactoring.md §7 **Refactoring ref**: Refactoring.md §7
@@ -214,10 +214,11 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
- `frontend/src/pages/JailDetailPage.tsx` (has `fmtSeconds()` ~L152) - `frontend/src/pages/JailDetailPage.tsx` (has `fmtSeconds()` ~L152)
- `frontend/src/pages/JailsPage.tsx` (has `fmtSeconds()` ~L147) - `frontend/src/pages/JailsPage.tsx` (has `fmtSeconds()` ~L147)
**What to do**: **What was done**:
1. Create `frontend/src/utils/formatDate.ts`. 1. Added shared helper `frontend/src/utils/formatDate.ts` with `formatTimestamp()` + `formatSeconds()`.
2. Define three exported functions: 2. Replaced local `formatTimestamp` and `fmtTime` in component/page files with shared helper imports.
- `formatTimestamp(ts: string): string` — consolidation of `formatTimestamp` and `fmtTime` 3. Ensured no local formatting helpers are left in the target files.
4. Ran frontend tests (`cd frontend && npx vitest run --run`): all tests passed.
- `formatSeconds(seconds: number): string` — consolidation of the two identical `fmtSeconds` functions - `formatSeconds(seconds: number): string` — consolidation of the two identical `fmtSeconds` functions
3. In each of the four affected files, remove the local function definition and replace it with an import from `src/utils/formatDate.ts`. Adjust call sites if the function name changed. 3. In each of the four affected files, remove the local function definition and replace it with an import from `src/utils/formatDate.ts`. Adjust call sites if the function name changed.
4. Run existing frontend tests: `cd frontend && npx vitest run` — all tests must pass. 4. Run existing frontend tests: `cd frontend && npx vitest run` — all tests must pass.

View File

@@ -27,6 +27,7 @@ import {
import { PageEmpty, PageError, PageLoading } from "./PageFeedback"; import { PageEmpty, PageError, PageLoading } from "./PageFeedback";
import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons"; import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons";
import { useBans } from "../hooks/useBans"; import { useBans } from "../hooks/useBans";
import { formatTimestamp } from "../utils/formatDate";
import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban"; import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -90,31 +91,6 @@ const useStyles = makeStyles({
}, },
}); });
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Format an ISO 8601 timestamp for display.
*
* @param iso - ISO 8601 UTC string.
* @returns Localised date+time string.
*/
function formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return iso;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Column definitions // Column definitions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -32,6 +32,7 @@ import {
type TableColumnDefinition, type TableColumnDefinition,
createTableColumn, createTableColumn,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { formatTimestamp } from "../../utils/formatDate";
import { import {
ArrowClockwiseRegular, ArrowClockwiseRegular,
ChevronLeftRegular, ChevronLeftRegular,
@@ -126,31 +127,6 @@ const useStyles = makeStyles({
}, },
}); });
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Format an ISO 8601 timestamp for compact display.
*
* @param iso - ISO 8601 string or `null`.
* @returns A locale time string, or `"—"` when `null`.
*/
function fmtTime(iso: string | null): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Column definitions // Column definitions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -191,12 +167,16 @@ const columns: TableColumnDefinition<BanRow>[] = [
createTableColumn<BanRow>({ createTableColumn<BanRow>({
columnId: "banned_at", columnId: "banned_at",
renderHeaderCell: () => "Banned At", renderHeaderCell: () => "Banned At",
renderCell: ({ ban }) => <Text size={200}>{fmtTime(ban.banned_at)}</Text>, renderCell: ({ ban }) => (
<Text size={200}>{ban.banned_at ? formatTimestamp(ban.banned_at) : "—"}</Text>
),
}), }),
createTableColumn<BanRow>({ createTableColumn<BanRow>({
columnId: "expires_at", columnId: "expires_at",
renderHeaderCell: () => "Expires At", renderHeaderCell: () => "Expires At",
renderCell: ({ ban }) => <Text size={200}>{fmtTime(ban.expires_at)}</Text>, renderCell: ({ ban }) => (
<Text size={200}>{ban.expires_at ? formatTimestamp(ban.expires_at) : "—"}</Text>
),
}), }),
createTableColumn<BanRow>({ createTableColumn<BanRow>({
columnId: "actions", columnId: "actions",

View File

@@ -34,6 +34,7 @@ import {
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { useJailDetail, useJailBannedIps } from "../hooks/useJails"; import { useJailDetail, useJailBannedIps } from "../hooks/useJails";
import { formatSeconds } from "../utils/formatDate";
import type { Jail } from "../types/jail"; import type { Jail } from "../types/jail";
import { BannedIpsSection } from "../components/jail/BannedIpsSection"; import { BannedIpsSection } from "../components/jail/BannedIpsSection";
@@ -146,16 +147,9 @@ const useStyles = makeStyles({
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Components
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function fmtSeconds(s: number): string {
if (s < 0) return "permanent";
if (s < 60) return `${String(s)} s`;
if (s < 3600) return `${String(Math.round(s / 60))} min`;
return `${String(Math.round(s / 3600))} h`;
}
function CodeList({ items, empty }: { items: string[]; empty: string }): React.JSX.Element { function CodeList({ items, empty }: { items: string[]; empty: string }): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
if (items.length === 0) { if (items.length === 0) {
@@ -313,9 +307,9 @@ function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload
<Text className={styles.label}>Backend:</Text> <Text className={styles.label}>Backend:</Text>
<Text className={styles.mono}>{jail.backend}</Text> <Text className={styles.mono}>{jail.backend}</Text>
<Text className={styles.label}>Find time:</Text> <Text className={styles.label}>Find time:</Text>
<Text>{fmtSeconds(jail.find_time)}</Text> <Text>{formatSeconds(jail.find_time)}</Text>
<Text className={styles.label}>Ban time:</Text> <Text className={styles.label}>Ban time:</Text>
<Text>{fmtSeconds(jail.ban_time)}</Text> <Text>{formatSeconds(jail.ban_time)}</Text>
<Text className={styles.label}>Max retry:</Text> <Text className={styles.label}>Max retry:</Text>
<Text>{String(jail.max_retry)}</Text> <Text>{String(jail.max_retry)}</Text>
{jail.date_pattern && ( {jail.date_pattern && (
@@ -413,13 +407,13 @@ function BantimeEscalationSection({ jail }: { jail: Jail }): React.JSX.Element |
{esc.max_time !== null && ( {esc.max_time !== null && (
<> <>
<Text className={styles.label}>Max time:</Text> <Text className={styles.label}>Max time:</Text>
<Text>{fmtSeconds(esc.max_time)}</Text> <Text>{formatSeconds(esc.max_time)}</Text>
</> </>
)} )}
{esc.rnd_time !== null && ( {esc.rnd_time !== null && (
<> <>
<Text className={styles.label}>Random jitter:</Text> <Text className={styles.label}>Random jitter:</Text>
<Text>{fmtSeconds(esc.rnd_time)}</Text> <Text>{formatSeconds(esc.rnd_time)}</Text>
</> </>
)} )}
<Text className={styles.label}>Count across all jails:</Text> <Text className={styles.label}>Count across all jails:</Text>

View File

@@ -9,7 +9,8 @@
* geo-location details. * geo-location details.
*/ */
import { useMemo, useState } from "react"; import { useState } from "react";
import { formatSeconds } from "../utils/formatDate";
import { import {
Badge, Badge,
Button, Button,
@@ -42,7 +43,7 @@ import {
SearchRegular, SearchRegular,
StopRegular, StopRegular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { useNavigate } from "react-router-dom"; import { Link } from "react-router-dom";
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails"; import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
import type { JailSummary } from "../types/jail"; import type { JailSummary } from "../types/jail";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
@@ -141,15 +142,65 @@ const useStyles = makeStyles({
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Jail overview columns
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function fmtSeconds(s: number): string { const jailColumns: TableColumnDefinition<JailSummary>[] = [
if (s < 0) return "permanent"; createTableColumn<JailSummary>({
if (s < 60) return `${String(s)}s`; columnId: "name",
if (s < 3600) return `${String(Math.round(s / 60))}m`; renderHeaderCell: () => "Jail",
return `${String(Math.round(s / 3600))}h`; renderCell: (j) => (
} <Link to={`/jails/${encodeURIComponent(j.name)}`} style={{ textDecoration: "none" }}>
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
{j.name}
</Text>
</Link>
),
}),
createTableColumn<JailSummary>({
columnId: "status",
renderHeaderCell: () => "Status",
renderCell: (j) => {
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
return <Badge appearance="filled" color="success">running</Badge>;
},
}),
createTableColumn<JailSummary>({
columnId: "backend",
renderHeaderCell: () => "Backend",
renderCell: (j) => <Text size={200}>{j.backend}</Text>,
}),
createTableColumn<JailSummary>({
columnId: "banned",
renderHeaderCell: () => "Banned",
renderCell: (j) => (
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
),
}),
createTableColumn<JailSummary>({
columnId: "failed",
renderHeaderCell: () => "Failed",
renderCell: (j) => (
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
),
}),
createTableColumn<JailSummary>({
columnId: "findTime",
renderHeaderCell: () => "Find Time",
renderCell: (j) => <Text size={200}>{formatSeconds(j.find_time)}</Text>,
}),
createTableColumn<JailSummary>({
columnId: "banTime",
renderHeaderCell: () => "Ban Time",
renderCell: (j) => <Text size={200}>{formatSeconds(j.ban_time)}</Text>,
}),
createTableColumn<JailSummary>({
columnId: "maxRetry",
renderHeaderCell: () => "Max Retry",
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
}),
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sub-component: Jail overview section // Sub-component: Jail overview section
@@ -157,82 +208,10 @@ function fmtSeconds(s: number): string {
function JailOverviewSection(): React.JSX.Element { function JailOverviewSection(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const navigate = useNavigate();
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } = const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
useJails(); useJails();
const [opError, setOpError] = useState<string | null>(null); const [opError, setOpError] = useState<string | null>(null);
const jailColumns = useMemo<TableColumnDefinition<JailSummary>[]>(
() => [
createTableColumn<JailSummary>({
columnId: "name",
renderHeaderCell: () => "Jail",
renderCell: (j) => (
<Button
appearance="transparent"
size="small"
style={{ padding: 0, minWidth: 0, justifyContent: "flex-start" }}
onClick={() =>
navigate("/config", {
state: { tab: "jails", jail: j.name },
})
}
>
<Text
style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}
>
{j.name}
</Text>
</Button>
),
}),
createTableColumn<JailSummary>({
columnId: "status",
renderHeaderCell: () => "Status",
renderCell: (j) => {
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
return <Badge appearance="filled" color="success">running</Badge>;
},
}),
createTableColumn<JailSummary>({
columnId: "backend",
renderHeaderCell: () => "Backend",
renderCell: (j) => <Text size={200}>{j.backend}</Text>,
}),
createTableColumn<JailSummary>({
columnId: "banned",
renderHeaderCell: () => "Banned",
renderCell: (j) => (
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
),
}),
createTableColumn<JailSummary>({
columnId: "failed",
renderHeaderCell: () => "Failed",
renderCell: (j) => (
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
),
}),
createTableColumn<JailSummary>({
columnId: "findTime",
renderHeaderCell: () => "Find Time",
renderCell: (j) => <Text size={200}>{fmtSeconds(j.find_time)}</Text>,
}),
createTableColumn<JailSummary>({
columnId: "banTime",
renderHeaderCell: () => "Ban Time",
renderCell: (j) => <Text size={200}>{fmtSeconds(j.ban_time)}</Text>,
}),
createTableColumn<JailSummary>({
columnId: "maxRetry",
renderHeaderCell: () => "Max Retry",
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
}),
],
[navigate],
);
const handle = (fn: () => Promise<void>): void => { const handle = (fn: () => Promise<void>): void => {
setOpError(null); setOpError(null);
fn().catch((err: unknown) => { fn().catch((err: unknown) => {

View File

@@ -130,3 +130,34 @@ export function formatRelative(
return formatDate(isoUtc, timezone); return formatDate(isoUtc, timezone);
} }
} }
/**
* Format an ISO 8601 timestamp for display with local browser timezone.
*
* Keeps parity with existing code paths in the UI that render full date+time
* strings inside table rows.
*/
export function formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return iso;
}
}
/**
* Format a duration in seconds to a compact text representation.
*/
export function formatSeconds(seconds: number): string {
if (seconds < 0) return "permanent";
if (seconds < 60) return `${String(seconds)} s`;
if (seconds < 3600) return `${String(Math.round(seconds / 60))} min`;
return `${String(Math.round(seconds / 3600))} h`;
}