Refactor frontend date formatting helpers and mark Task 10 done
This commit is contained in:
@@ -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
|
||||
**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/JailsPage.tsx` (has `fmtSeconds()` ~L147)
|
||||
|
||||
**What to do**:
|
||||
1. Create `frontend/src/utils/formatDate.ts`.
|
||||
2. Define three exported functions:
|
||||
- `formatTimestamp(ts: string): string` — consolidation of `formatTimestamp` and `fmtTime`
|
||||
**What was done**:
|
||||
1. Added shared helper `frontend/src/utils/formatDate.ts` with `formatTimestamp()` + `formatSeconds()`.
|
||||
2. Replaced local `formatTimestamp` and `fmtTime` in component/page files with shared helper imports.
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { PageEmpty, PageError, PageLoading } from "./PageFeedback";
|
||||
import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons";
|
||||
import { useBans } from "../hooks/useBans";
|
||||
import { formatTimestamp } from "../utils/formatDate";
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type TableColumnDefinition,
|
||||
createTableColumn,
|
||||
} from "@fluentui/react-components";
|
||||
import { formatTimestamp } from "../../utils/formatDate";
|
||||
import {
|
||||
ArrowClockwiseRegular,
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -191,12 +167,16 @@ const columns: TableColumnDefinition<BanRow>[] = [
|
||||
createTableColumn<BanRow>({
|
||||
columnId: "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>({
|
||||
columnId: "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>({
|
||||
columnId: "actions",
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "@fluentui/react-icons";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useJailDetail, useJailBannedIps } from "../hooks/useJails";
|
||||
import { formatSeconds } from "../utils/formatDate";
|
||||
import type { Jail } from "../types/jail";
|
||||
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 {
|
||||
const styles = useStyles();
|
||||
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.mono}>{jail.backend}</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>{fmtSeconds(jail.ban_time)}</Text>
|
||||
<Text>{formatSeconds(jail.ban_time)}</Text>
|
||||
<Text className={styles.label}>Max retry:</Text>
|
||||
<Text>{String(jail.max_retry)}</Text>
|
||||
{jail.date_pattern && (
|
||||
@@ -413,13 +407,13 @@ function BantimeEscalationSection({ jail }: { jail: Jail }): React.JSX.Element |
|
||||
{esc.max_time !== null && (
|
||||
<>
|
||||
<Text className={styles.label}>Max time:</Text>
|
||||
<Text>{fmtSeconds(esc.max_time)}</Text>
|
||||
<Text>{formatSeconds(esc.max_time)}</Text>
|
||||
</>
|
||||
)}
|
||||
{esc.rnd_time !== null && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* geo-location details.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { formatSeconds } from "../utils/formatDate";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -42,7 +43,7 @@ import {
|
||||
SearchRegular,
|
||||
StopRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
|
||||
import type { JailSummary } from "../types/jail";
|
||||
import { ApiError } from "../api/client";
|
||||
@@ -141,15 +142,65 @@ const useStyles = makeStyles({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// Jail overview columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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))}m`;
|
||||
return `${String(Math.round(s / 3600))}h`;
|
||||
}
|
||||
const jailColumns: TableColumnDefinition<JailSummary>[] = [
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "name",
|
||||
renderHeaderCell: () => "Jail",
|
||||
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
|
||||
@@ -157,82 +208,10 @@ function fmtSeconds(s: number): string {
|
||||
|
||||
function JailOverviewSection(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
|
||||
useJails();
|
||||
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 => {
|
||||
setOpError(null);
|
||||
fn().catch((err: unknown) => {
|
||||
|
||||
@@ -130,3 +130,34 @@ export function formatRelative(
|
||||
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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user