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
|
**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.
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user