Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 47 additions & 40 deletions frontend/src/components/alerts/alert-rows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ export const ALERT_COLUMN_COUNT = 5;
const CONTRACT_HEAD = 14;
const CONTRACT_TAIL = 6;

/**
* Tint and left accent for the pinned latest-critical row.
*
* ONE row carries it. The label used to sit on a strip above, which cost a
* second table row for two words and read as another entry in the list; it also
* let TableRow's bottom border run between the strip and the row it described,
* so the accent appeared to cut the block in half. With a single row the accent
* spans its full height by construction, `border-b-0` keeps the tint from being
* split by a divider, and the spacer row below separates it from the list.
*/
const PINNED_ROW =
"bg-severity-critical/25 border-l-severity-critical-foreground border-l-2 border-b-0";

/**
* Copies an identifier the table can only show truncated.
*
Expand Down Expand Up @@ -89,6 +102,28 @@ function UnclusterableBadge() {
);
}

/**
* Marks the row the table holds at the top.
*
* Shaped like UnclusterableBadge so the table speaks one visual language for
* "metadata about this row", and placed AFTER the hash rather than before it so
* every row's ID still starts on the same column: an indented hash on one row
* reads as a broken table. Critical-toned and outlined rather than filled,
* because the row it sits on already carries the filled critical tint.
*/
function PinnedCriticalMarker() {
return (
<Badge
variant="outline"
className="border-severity-critical-foreground/60 text-severity-critical-foreground gap-1 px-1.5 py-0 text-[9px] font-semibold"
title="The most recent Critical alert, pinned above the list."
>
<Pin className="h-2.5 w-2.5" />
Latest critical
</Badge>
);
}

function AttackTypeCell({ alert }: { alert: RiskAlert }) {
const Icon = ATTACK_ICON[alert.attackType] ?? AlertCircle;
return (
Expand All @@ -114,33 +149,31 @@ export function AlertRow({
onOpen: (slug: string) => void;
/** Nested inside an expanded group, so the chevron cell is a spacer. */
indented?: boolean;
/** The pinned latest-critical row. Rendered under PinnedCriticalHeaderRow. */
/** The pinned latest-critical row: tinted, accented, and marked as pinned. */
pinned?: boolean;
}) {
return (
<TableRow
onClick={() => onOpen(alert.slug)}
className={cn(
"cursor-pointer",
// A tinted band closed by a faint critical edge, with the strong
// accent scoped to the LEFT border only. Setting the border COLOUR
// unscoped also recoloured the row's own bottom border, which read
// as an accidental red underline instead of a deliberate block.
//
// The hover tint has to be restated: TableRow ships
// `hover:bg-muted/40`, and tailwind-merge does not drop it for a
// plain `bg-*`, so without this the row turns grey on hover while
// the strip above stays red and the block visibly comes apart at
// the moment the analyst is pointing at it.
pinned &&
"bg-severity-critical/25 hover:bg-severity-critical/30 border-l-severity-critical-foreground border-b-severity-critical-foreground/30 border-l-2",
// plain `bg-*`, so without this the row turns grey the moment the
// analyst points at it and stops reading as pinned.
pinned && `${PINNED_ROW} hover:bg-severity-critical/30`,
)}
>
<TableCell className={cn("w-8", indented && "pl-6")} />
<TableCell>
<div className="text-foreground flex items-center gap-2 font-mono text-[13px] uppercase">
<span>{alert.id}</span>
<div className="flex items-center gap-2">
{/* Mono and uppercase belong to the hash, not to the cell: the marker
beside it is prose and would inherit both. */}
<span className="text-foreground font-mono text-[13px] uppercase">
{alert.id}
</span>
<CopyButton value={alert.fullHash} label="Copy transaction hash" />
{pinned && <PinnedCriticalMarker />}
</div>
</TableCell>
<TableCell className="text-foreground">{alert.date}</TableCell>
Expand Down Expand Up @@ -261,33 +294,7 @@ export function ContractGroupRow({
);
}

/**
* Section label for the pinned latest-critical row.
*
* A strip ABOVE the row rather than a badge inside it: being pinned is a
* property of the row's placement, so a badge sitting beside the hash reads as
* an attribute of that transaction instead.
*/
export function PinnedCriticalHeaderRow() {
return (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={ALERT_COLUMN_COUNT}
className="bg-severity-critical/25 border-t-severity-critical-foreground/30 text-severity-critical-foreground border-t py-1.5"
>
<div className="flex items-center gap-1.5 text-[10px] font-medium tracking-wider uppercase">
<Pin className="h-3 w-3" />
Latest critical
<span className="text-muted-foreground font-normal tracking-normal normal-case">
kept in view regardless of the sort below
</span>
</div>
</TableCell>
</TableRow>
);
}

/** Air below the pinned block, so it and the sorted list read as two things. */
/** Air below the pinned row, so it and the sorted list read as two things. */
export function PinnedCriticalSpacerRow() {
return (
<TableRow className="hover:bg-transparent">
Expand Down
142 changes: 138 additions & 4 deletions frontend/src/components/attack-detail/tx-detail-panels.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* bound rather than presented as exact.
*/
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";

import {
Expand Down Expand Up @@ -109,15 +109,19 @@ describe("ValueTransferredPanel", () => {
expect(screen.getByText("×1,234")).toBeInTheDocument();
});

it("truncates a long asset name instead of echoing it whole", () => {
it("truncates a long HEX asset name instead of echoing it whole", () => {
// The asset name is truncated head-only (it reads head-first and has no
// meaningful suffix), which passes tail = 0 to shortHash. `slice(-0)` is
// `slice(0)`, so that used to render head + "..." + the FULL name: longer
// than the untruncated unit and reading as two assets run together. The
// name here is 30 chars, past shortHash's break-even point; the previous
// fixture's 10-char name was under it and could not catch this.
//
// The first byte is 0x01, deliberately non-printable: a printable name is
// now decoded to text and shown in full, so only a name that STAYS hex
// still exercises truncation.
const policy = "d".repeat(56);
const name = "4361726461536e656b4e4654313233";
const name = "01" + "61726461536e656b4e46543132";
render(
<ValueTransferredPanel
tx={tx({
Expand All @@ -135,7 +139,137 @@ describe("ValueTransferredPanel", () => {
);
const rendered = screen.getByTitle(`${policy}.${name}`).textContent ?? "";
expect(rendered).not.toContain(name);
expect(rendered).toContain("4361726461...");
expect(rendered).toContain("0161726461...");
});

it("shows a printable asset name as text, keeping the hex in the title", () => {
// 42414e4b is BANK. An operator recognises the ticker, not the bytes, which
// is why every explorer decodes this.
const policy = "d".repeat(56);
const name = "42414e4b";
render(
<ValueTransferredPanel
tx={tx({
outputs: [
{
index: 0,
address: PAYMENT_ADDR,
amount: LOVELACE,
assets: { [`${policy}.${name}`]: 5 },
is_collateral: false,
},
],
})}
/>,
);
expect(screen.getByText("BANK")).toBeInTheDocument();
const row = screen.getByText("BANK").closest("div");
expect(row?.getAttribute("title") ?? "").toContain(name);
});

it("keeps a non-printable asset name as hex rather than half-decoding it", () => {
// One unprintable byte and the whole name stays hex: a partial decode would
// let arbitrary bytes put control characters on screen, and a half-decoded
// name is less trustworthy than an honest hex string.
const policy = "d".repeat(56);
const name = "4241004b";
render(
<ValueTransferredPanel
tx={tx({
outputs: [
{
index: 0,
address: PAYMENT_ADDR,
amount: LOVELACE,
assets: { [`${policy}.${name}`]: 1 },
is_collateral: false,
},
],
})}
/>,
);
const rendered = screen.getByTitle(`${policy}.${name}`).textContent ?? "";
expect(rendered).toContain(name);
expect(screen.queryByText("BANK")).not.toBeInTheDocument();
});

it("strips a CIP-68 label before decoding the name under it", () => {
// 0014df10 is CIP-67 label (333). Decoding the label bytes along with the
// name yields mojibake, so the label is recognised and named instead.
const policy = "d".repeat(56);
const name = "0014df1042414e4b";
render(
<ValueTransferredPanel
tx={tx({
outputs: [
{
index: 0,
address: PAYMENT_ADDR,
amount: LOVELACE,
assets: { [`${policy}.${name}`]: 7 },
is_collateral: false,
},
],
})}
/>,
);
expect(screen.getByText("BANK")).toBeInTheDocument();
const title =
screen.getByText("BANK").closest("div")?.getAttribute("title") ?? "";
expect(title).toContain("CIP-68 (333)");
});

it("counts the assets on a UTxO and collapses a long list", () => {
// Without the count and the collapse, one output holding 20 tokens rendered
// 20 lines indistinguishable from output lines, so "Outputs (N)" read as a
// wrong count and the sibling outputs were pushed off the panel.
const assets: Record<string, number> = {};
for (let i = 0; i < 9; i++) {
assets[`${i.toString().repeat(56)}.42414e4b`] = i + 1;
}
render(
<ValueTransferredPanel
tx={tx({
outputs: [
{
index: 0,
address: PAYMENT_ADDR,
amount: LOVELACE,
assets,
is_collateral: false,
},
],
})}
/>,
);
expect(screen.getByText("9 assets")).toBeInTheDocument();
// Six shown, three behind the toggle.
expect(screen.getAllByText("BANK")).toHaveLength(6);
fireEvent.click(screen.getByText("show all 9"));
expect(screen.getAllByText("BANK")).toHaveLength(9);
fireEvent.click(screen.getByText("show fewer"));
expect(screen.getAllByText("BANK")).toHaveLength(6);
});

it("singularises a single asset and offers no toggle", () => {
const policy = "d".repeat(56);
render(
<ValueTransferredPanel
tx={tx({
outputs: [
{
index: 0,
address: PAYMENT_ADDR,
amount: LOVELACE,
assets: { [`${policy}.42414e4b`]: 1 },
is_collateral: false,
},
],
})}
/>,
);
expect(screen.getByText("1 asset")).toBeInTheDocument();
expect(screen.queryByText(/show all/)).not.toBeInTheDocument();
});

it("labels the input total as a lower bound when a parent was unresolved", () => {
Expand Down
Loading
Loading