admin: ui polish pass, thirty findings from the emil audit
selected states stop changing font weight, buttons gain a pressed scale and scoped 120ms transitions with a reduced-motion override, every loading and empty state reserves its height, charts measure before first paint, hit targets rise to the 44px enhanced target where layout permits, long domains clamp to two lines on an unpadded inner span, chips and name cells truncate, tabular figures on counts and time columns, a z-index layer scale replaces magic numbers and fixes the dialog-over-confirm tie, history's empty state gains a clear-filters action, page headings balance, font smoothing and color-scheme land on the html reset.
This commit is contained in:
@@ -24,8 +24,10 @@ const styles = stylex.create({
|
||||
backIcon: {
|
||||
display: "inline-flex",
|
||||
},
|
||||
/** About the height of the filled detail surface, so it does not jump in. */
|
||||
loading: {
|
||||
marginTop: "1rem",
|
||||
minHeight: "20rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent }
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import ClientFilter, { joinClients, parseClients, useClientOptions } from "./ClientFilter";
|
||||
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
|
||||
import type { ActivitySearch } from "./search";
|
||||
@@ -57,9 +57,6 @@ const PRESETS = [
|
||||
|
||||
const CUSTOM_ITEM = "Custom…";
|
||||
|
||||
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
|
||||
const HIT_TARGET = 44;
|
||||
|
||||
const styles = stylex.create({
|
||||
toolbar: {
|
||||
marginTop: "1rem",
|
||||
@@ -87,7 +84,7 @@ const styles = stylex.create({
|
||||
},
|
||||
/** Every control in the row is a pointer target before it is anything else. */
|
||||
field: {
|
||||
minHeight: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
},
|
||||
searchInput: {
|
||||
width: "100%",
|
||||
@@ -95,8 +92,8 @@ const styles = stylex.create({
|
||||
},
|
||||
/** A button is text-sized by default; this is the hit area around the text. */
|
||||
hitTarget: {
|
||||
minHeight: HIT_TARGET,
|
||||
minWidth: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
minWidth: metrics.hitTarget,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -113,8 +110,11 @@ const styles = stylex.create({
|
||||
paddingInline: "0.625rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
minHeight: HIT_TARGET,
|
||||
minWidth: HIT_TARGET,
|
||||
// The weight is on the base, not on the selected state: a bolder label is a
|
||||
// wider label, and the row would shift under the pointer on every pick.
|
||||
fontWeight: 500,
|
||||
minHeight: metrics.hitTarget,
|
||||
minWidth: metrics.hitTarget,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -133,7 +133,6 @@ const styles = stylex.create({
|
||||
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
||||
},
|
||||
color: colors.text,
|
||||
fontWeight: 500,
|
||||
},
|
||||
segmentIdle: {
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
@@ -158,7 +157,7 @@ const styles = stylex.create({
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
whiteSpace: "nowrap",
|
||||
minHeight: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
@@ -146,6 +146,26 @@ function submitFilters() {
|
||||
fireEvent.submit(domainInput().closest("form")!);
|
||||
}
|
||||
|
||||
/**
|
||||
* The footer's count line, matched on the whole sentence.
|
||||
*
|
||||
* The number sits in a span of its own so it can carry tabular digits, so the
|
||||
* line is several text nodes and the default string matcher — which reads one
|
||||
* node at a time — cannot see it whole.
|
||||
*/
|
||||
function countMatcher(pattern: RegExp) {
|
||||
return (_: string, element: Element | null): boolean =>
|
||||
element?.tagName === "P" && pattern.test(element.textContent ?? "");
|
||||
}
|
||||
|
||||
function countLine(pattern: RegExp): HTMLElement {
|
||||
return screen.getByText(countMatcher(pattern));
|
||||
}
|
||||
|
||||
function findCountLine(pattern: RegExp): Promise<HTMLElement> {
|
||||
return screen.findByText(countMatcher(pattern));
|
||||
}
|
||||
|
||||
/** The custom range lives behind the Time menu; the two bounds only exist there. */
|
||||
function openCustomRange() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
|
||||
@@ -176,7 +196,7 @@ test("renders the first page with the seven columns filled in", async () => {
|
||||
// Route cell says how: this is the pair the old Status column could not show.
|
||||
expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
|
||||
expect(within(blocked).getByText("—")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||
expect(countLine(/Showing 2 queries/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, reading the IP out with it", async () => {
|
||||
@@ -225,7 +245,7 @@ test("load more appends the next page and stops at the end of the log", async ()
|
||||
await screen.findByText("older.example");
|
||||
|
||||
expect(screen.getByText("first.example")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||
expect(countLine(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -239,7 +259,7 @@ test("applying a filter puts it in the url, refetches, and resets the accumulate
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
await findCountLine(/Showing 1 query /);
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
expect(screen.getByText("ads.example")).toBeTruthy();
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
@@ -264,7 +284,7 @@ test("a load-more that resolves after a filter change is discarded", async () =>
|
||||
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
await findCountLine(/Showing 1 query /);
|
||||
|
||||
releaseLoadMore();
|
||||
await act(async () => {
|
||||
@@ -272,7 +292,7 @@ test("a load-more that resolves after a filter change is discarded", async () =>
|
||||
});
|
||||
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
|
||||
expect(countLine(/Showing 1 query /)).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -320,7 +340,7 @@ test("load more is disabled while a filter change shows placeholder data, then u
|
||||
await screen.findByText("ads.older.example");
|
||||
|
||||
expect(queryCalls()).toContain("/api/queries?domain=ads&before=7");
|
||||
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
expect(countLine(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
|
||||
@@ -371,7 +391,7 @@ test("a background refetch after new rows arrive leaves no gap between the loade
|
||||
await screen.findByText("n22.example");
|
||||
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
|
||||
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
|
||||
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
expect(countLine(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
|
||||
@@ -619,6 +639,38 @@ test("Clear empties the url as well as the form", async () => {
|
||||
expect(domainInput()).toHaveProperty("value", "");
|
||||
});
|
||||
|
||||
test("the empty result offers the clear it names, and it clears the same filters the toolbar does", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url === "/api/queries") return json(PAGES["/api/queries"]);
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
const { history } = renderPage("/activity?mode=history&domain=nothing&blocked=true");
|
||||
await screen.findByText("No queries match the current filters.");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(history.location.search).not.toContain("domain");
|
||||
});
|
||||
expect(history.location.search).not.toContain("blocked");
|
||||
expect(domainInput()).toHaveProperty("value", "");
|
||||
// The unfiltered log is back, so the button did the toolbar's Clear and not a
|
||||
// reset of its own that only emptied the form.
|
||||
expect(await screen.findByText("first.example")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an empty log offers no clear, because there is no filter to blame for it", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("No queries logged yet.");
|
||||
expect(screen.queryByRole("button", { name: "Clear filters" })).toBeNull();
|
||||
});
|
||||
|
||||
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
@@ -739,7 +791,7 @@ test("the coverage watermark reads under the results, never over them", async ()
|
||||
await screen.findByText("kept.example");
|
||||
|
||||
const watermark = screen.getByText(/Query history is available from/);
|
||||
const count = screen.getByText(/Showing 1 query/);
|
||||
const count = countLine(/Showing 1 query/);
|
||||
// After the count in document order, which is what "footer" means here.
|
||||
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
switch: {
|
||||
display: "flex",
|
||||
@@ -177,7 +178,7 @@ export default function ActivityPage() {
|
||||
*/}
|
||||
<TabPanel id="history" className={panelClass}>
|
||||
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
|
||||
<HistoryActivity search={search} />
|
||||
<HistoryActivity search={search} onClear={clear} />
|
||||
</TabPanel>
|
||||
<TabPanel id="live" className={panelClass}>
|
||||
<p {...stylex.props(styles.liveNote)}>
|
||||
|
||||
@@ -30,7 +30,7 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
|
||||
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { colors, metrics } from "@/ui/tokens.stylex";
|
||||
import { MAX_CLIENTS } from "./search";
|
||||
|
||||
/**
|
||||
@@ -47,9 +47,6 @@ export interface ClientOption {
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
|
||||
const HIT_TARGET = 44;
|
||||
|
||||
/**
|
||||
* How many chips are shown before the rest become a count.
|
||||
*
|
||||
@@ -72,8 +69,8 @@ const styles = stylex.create({
|
||||
color: colors.textMuted,
|
||||
},
|
||||
trigger: {
|
||||
minHeight: HIT_TARGET,
|
||||
minWidth: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
minWidth: metrics.hitTarget,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -100,7 +97,7 @@ const styles = stylex.create({
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
whiteSpace: "nowrap",
|
||||
minHeight: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
@@ -119,10 +116,12 @@ const styles = stylex.create({
|
||||
},
|
||||
chip: {
|
||||
cursor: "pointer",
|
||||
transitionProperty: metrics.transitionProperty,
|
||||
transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
minHeight: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
paddingInline: "0.625rem",
|
||||
borderRadius: "999px",
|
||||
borderWidth: 1,
|
||||
@@ -133,15 +132,24 @@ const styles = stylex.create({
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** A hostname can be longer than the toolbar; the cross stays outside the cut. */
|
||||
chipLabel: {
|
||||
maxWidth: "14rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
/** Decoration inside the button, so a click on it is a click on the button. */
|
||||
chipCross: {
|
||||
display: "inline-flex",
|
||||
pointerEvents: "none",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/** Not a button: it removes nothing, and nothing about it is pressable. */
|
||||
chipMore: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
minHeight: HIT_TARGET,
|
||||
minHeight: metrics.hitTarget,
|
||||
paddingInline: "0.625rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
@@ -352,10 +360,13 @@ export default function ClientFilter({ options, selected, onChange }: Props) {
|
||||
// The chip reads as a name and removes an address, so the name alone
|
||||
// would not say what the button does to a reader who cannot see it.
|
||||
aria-label={`Remove client ${displayFor(ip, options)}`}
|
||||
// The chip label is cut to fit the row, so the pointer can still read
|
||||
// the whole of what it names.
|
||||
title={displayFor(ip, options)}
|
||||
onClick={() => remove(ip)}
|
||||
{...stylex.props(styles.chip, shared.focusRing)}
|
||||
>
|
||||
{chipFor(ip, options)}
|
||||
<span {...stylex.props(styles.chipLabel)}>{chipFor(ip, options)}</span>
|
||||
<span aria-hidden="true" {...stylex.props(styles.chipCross)}>
|
||||
<X size={10} />
|
||||
</span>
|
||||
|
||||
@@ -24,8 +24,17 @@ import { queriesFilterOf, type ActivitySearch } from "./search";
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
marginTop: "1.5rem",
|
||||
minHeight: "6rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: "0.75rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/** About a default page of rows, so the table does not jump in under the reader. */
|
||||
loading: {
|
||||
minHeight: "24rem",
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
@@ -75,7 +84,13 @@ function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export default function HistoryActivity({ search }: { search: ActivitySearch }) {
|
||||
interface Props {
|
||||
search: ActivitySearch;
|
||||
/** The toolbar's own Clear, so the empty state offers the way out it names. */
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function HistoryActivity({ search, onClear }: Props) {
|
||||
const filter = queriesFilterOf(search);
|
||||
const base = useInfiniteQuery(queriesInfiniteQuery(filter));
|
||||
const clientNames = useClientNames();
|
||||
@@ -107,7 +122,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
}
|
||||
if (base.data === undefined) {
|
||||
return (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
<p {...stylex.props(styles.empty, styles.loading, shared.pulse)} role="status">
|
||||
Loading activity…
|
||||
</p>
|
||||
);
|
||||
@@ -126,16 +141,15 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
</p>
|
||||
)}
|
||||
{rows.length === 0 ? (
|
||||
<>
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
{coverage !== undefined && (
|
||||
<div {...stylex.props(styles.spacedTop)}>
|
||||
<CoverageNotice coverage={coverage} variant="note" />
|
||||
</div>
|
||||
<div {...stylex.props(styles.empty)}>
|
||||
<p>{filterActive ? "No queries match the current filters." : "No queries logged yet."}</p>
|
||||
{filterActive && (
|
||||
<button type="button" onClick={onClear} {...stylex.props(shared.button, shared.focusRing)}>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
@@ -169,7 +183,9 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
</div>
|
||||
<div {...stylex.props(styles.footer)}>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{/* The count is the one part of this line that moves as pages load. */}
|
||||
Showing <span {...stylex.props(shared.tabularNums)}>{rows.length}</span>{" "}
|
||||
{rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
||||
|
||||
@@ -28,6 +28,7 @@ const styles = stylex.create({
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
textWrap: "balance",
|
||||
},
|
||||
intro: {
|
||||
marginTop: "0.5rem",
|
||||
|
||||
@@ -25,6 +25,9 @@ import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
/** Wide enough for an ordinary hostname, narrow enough to leave the six other columns room. */
|
||||
const DOMAIN_MAX_WIDTH = "24rem";
|
||||
|
||||
const styles = stylex.create({
|
||||
head: {
|
||||
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||
@@ -46,6 +49,9 @@ const styles = stylex.create({
|
||||
breakAll: {
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
domainCell: {
|
||||
maxWidth: DOMAIN_MAX_WIDTH,
|
||||
},
|
||||
small: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
@@ -53,10 +59,32 @@ const styles = stylex.create({
|
||||
muted: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
/**
|
||||
* The padding makes the whole row height clickable, and the equal negative
|
||||
* margin gives that height back to the row. The clamp lives on the inner
|
||||
* span, not here: a padded `-webkit-box` can paint a third clipped line
|
||||
* inside its own padding.
|
||||
*/
|
||||
domainLink: {
|
||||
display: "block",
|
||||
maxWidth: DOMAIN_MAX_WIDTH,
|
||||
paddingBlock: "0.5rem",
|
||||
marginBlock: "-0.5rem",
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
/**
|
||||
* A domain is unbounded; the column is not. The clamp counts only the lines
|
||||
* of the element that directly holds the text, so every cell wraps its text
|
||||
* in this span — a `td` that took `display: -webkit-box` would stop being a
|
||||
* table cell.
|
||||
*/
|
||||
domainClamp: {
|
||||
display: "-webkit-box",
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 2,
|
||||
overflow: "hidden",
|
||||
},
|
||||
/**
|
||||
* The badge shape and its weight are the signal; the tint only says which
|
||||
* kind of unhappy answer this was. A monochrome or colour-blind reading of
|
||||
@@ -136,8 +164,11 @@ export function ActivityCells({
|
||||
return (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||
{renderDomain(row.id, row.domain)}
|
||||
<td
|
||||
title={row.domain}
|
||||
{...stylex.props(styles.cell, styles.small, styles.breakAll, styles.domainCell, shared.mono)}
|
||||
>
|
||||
{renderDomain(row.id, <span {...stylex.props(styles.domainClamp)}>{row.domain}</span>)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
||||
<ClientName ip={row.client_ip} names={clientNames} />
|
||||
|
||||
Reference in New Issue
Block a user