Skip to content

Commit 3fec6b2

Browse files
authored
Add live decision expiration countdowns and keep longest active duplicate decision visible (#345)
* Keep longest active duplicate decision visible When duplicate decision values are hidden, choose the active row with the latest expiration instead of the lowest numeric ID. This keeps the collapsed Decisions view aligned with the longest active ban for an IP. Possibly solves #344. * Add live decision expiration countdowns
1 parent 8ea6972 commit 3fec6b2

8 files changed

Lines changed: 337 additions & 29 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, test } from 'vitest';
2+
import type { DecisionListItem } from '../types';
3+
import { formatRemainingDuration, getDecisionExpirationState } from './decisionExpiration';
4+
5+
function createDecision(overrides: Partial<DecisionListItem> = {}): DecisionListItem {
6+
const base: DecisionListItem = {
7+
id: 1,
8+
created_at: '2026-07-05T12:00:00.000Z',
9+
value: '1.2.3.4',
10+
expired: false,
11+
is_duplicate: false,
12+
detail: {
13+
origin: 'manual',
14+
duration: '4h',
15+
expiration: '2026-07-05T16:00:00.000Z',
16+
},
17+
};
18+
19+
return {
20+
...base,
21+
...overrides,
22+
detail: {
23+
...base.detail,
24+
...overrides.detail,
25+
},
26+
};
27+
}
28+
29+
describe('decision expiration formatting', () => {
30+
test('formats remaining milliseconds as CrowdSec-style duration text', () => {
31+
expect(formatRemainingDuration(3 * 3_600_000 + 10 * 60_000 + 20_000)).toBe('3h10m20s');
32+
expect(formatRemainingDuration(6_000)).toBe('6s');
33+
expect(formatRemainingDuration(1)).toBe('1s');
34+
expect(formatRemainingDuration(-1_000)).toBe('0s');
35+
});
36+
37+
test('calculates live remaining time from the absolute expiration timestamp', () => {
38+
const state = getDecisionExpirationState(
39+
createDecision(),
40+
Date.parse('2026-07-05T12:21:15.000Z'),
41+
);
42+
43+
expect(state).toMatchObject({
44+
isExpired: false,
45+
label: '3h38m45s',
46+
expiresAtMs: Date.parse('2026-07-05T16:00:00.000Z'),
47+
});
48+
});
49+
50+
test('marks decisions expired once the expiration timestamp passes', () => {
51+
const state = getDecisionExpirationState(
52+
createDecision(),
53+
Date.parse('2026-07-05T16:00:01.000Z'),
54+
);
55+
56+
expect(state.isExpired).toBe(true);
57+
expect(state.label).toBe('0s');
58+
});
59+
60+
test('falls back to server duration when no absolute expiration is available', () => {
61+
const state = getDecisionExpirationState(createDecision({
62+
detail: { origin: 'manual', duration: '44m40s', expiration: undefined },
63+
}));
64+
65+
expect(state).toEqual({
66+
isExpired: false,
67+
label: '44m40s',
68+
expiresAtMs: null,
69+
});
70+
});
71+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { DecisionListItem } from '../types';
2+
3+
export interface DecisionExpirationState {
4+
isExpired: boolean;
5+
label: string;
6+
expiresAtMs: number | null;
7+
}
8+
9+
export function formatRemainingDuration(remainingMs: number): string {
10+
const clampedMs = Math.max(0, remainingMs);
11+
const totalSeconds = clampedMs > 0 ? Math.ceil(clampedMs / 1_000) : 0;
12+
const hours = Math.floor(totalSeconds / 3_600);
13+
const minutes = Math.floor((totalSeconds % 3_600) / 60);
14+
const seconds = totalSeconds % 60;
15+
16+
return `${hours > 0 ? `${hours}h` : ''}${minutes > 0 || hours > 0 ? `${minutes}m` : ''}${seconds}s`;
17+
}
18+
19+
export function getDecisionExpirationState(decision: DecisionListItem, nowMs = Date.now()): DecisionExpirationState {
20+
const expiresAtMs = decision.detail.expiration ? Date.parse(decision.detail.expiration) : Number.NaN;
21+
22+
if (Number.isFinite(expiresAtMs)) {
23+
const remainingMs = expiresAtMs - nowMs;
24+
return {
25+
isExpired: remainingMs <= 0,
26+
label: formatRemainingDuration(remainingMs),
27+
expiresAtMs,
28+
};
29+
}
30+
31+
if (decision.expired) {
32+
return {
33+
isExpired: true,
34+
label: '0s',
35+
expiresAtMs: null,
36+
};
37+
}
38+
39+
return {
40+
isExpired: false,
41+
label: decision.detail.duration || 'N/A',
42+
expiresAtMs: null,
43+
};
44+
}

client/src/pages/Alerts.test.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -544,9 +544,8 @@ describe('Alerts page', () => {
544544
</MemoryRouter>,
545545
);
546546

547-
await waitFor(() => expect(screen.getByRole('columnheader', { name: 'Machine' })).toBeInTheDocument());
547+
await waitFor(() => expect(screen.getByText('Alert Details #1')).toBeInTheDocument());
548548
expect(screen.getAllByText('host-a').length).toBeGreaterThan(1);
549-
expect(screen.getByText('Alert Details #1')).toBeInTheDocument();
550549
expect(screen.getAllByText('Machine').length).toBeGreaterThan(1);
551550
});
552551

client/src/pages/Alerts.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ScenarioName } from "../components/ScenarioName";
1212
import { TimeDisplay } from "../components/TimeDisplay";
1313
import { EventCard } from "../components/EventCard";
1414
import { getCountryName } from "../lib/utils";
15+
import { getDecisionExpirationState } from "../lib/decisionExpiration";
1516
import { loadStoredTableColumnPreferences, saveStoredTableColumnPreferences } from "../lib/tableColumns";
1617
import { TABLE_COLUMN_DEFINITIONS } from "../../../shared/contracts";
1718
import { resolveMachineName } from "../../../shared/machine";
@@ -138,6 +139,7 @@ export function Alerts() {
138139
const [showColumnsModal, setShowColumnsModal] = useState(false);
139140
const [searchDraft, setSearchDraft] = useState(initialQueryParam);
140141
const [debouncedSearchDraft, setDebouncedSearchDraft] = useState(initialQueryParam);
142+
const [nowMs, setNowMs] = useState(() => Date.now());
141143
const [showSearchSyntaxModal, setShowSearchSyntaxModal] = useState(false);
142144
const [initialLoading, setInitialLoading] = useState(true);
143145
const [hasLoadedAlerts, setHasLoadedAlerts] = useState(false);
@@ -405,6 +407,11 @@ export function Alerts() {
405407
loadAlertsRef.current = loadAlerts;
406408
}, [loadAlerts]);
407409

410+
useEffect(() => {
411+
const intervalId = window.setInterval(() => setNowMs(Date.now()), 1_000);
412+
return () => window.clearInterval(intervalId);
413+
}, []);
414+
408415
const lastAlertElementRef = useCallback((node: HTMLTableRowElement | null) => {
409416
if (initialLoading || backgroundLoading || loadingMore || !hasMoreAlerts) return;
410417
if (observer.current) observer.current.disconnect();
@@ -1333,17 +1340,8 @@ export function Alerts() {
13331340
</td>
13341341
</tr>
13351342
) : modalDecisions.map((decision, idx) => {
1336-
// Check if this specific decision is active or expired
1337-
const isActive = (() => {
1338-
if (decision.expired !== undefined) {
1339-
return !decision.expired;
1340-
}
1341-
1342-
if (decision.detail.expiration) {
1343-
return new Date(decision.detail.expiration) > new Date();
1344-
}
1345-
return true; // Assume active if no stop_at and not definitely expired
1346-
})();
1343+
const expirationState = getDecisionExpirationState(decision, nowMs);
1344+
const isActive = !expirationState.isExpired;
13471345
return (
13481346
<tr
13491347
key={`${decision.id}-${decision.detail.duration ?? idx}`}
@@ -1353,7 +1351,7 @@ export function Alerts() {
13531351
<td className="px-4 py-2 text-sm"><Badge variant="danger">{decision.detail.type || decision.detail.action || "ban"}</Badge></td>
13541352
<td className="px-4 py-2 text-sm font-mono">{decision.value}</td>
13551353
<td className="px-4 py-2 text-sm">
1356-
{isActive ? decision.detail.duration : "0s"}
1354+
{expirationState.label}
13571355
{!isActive && <span className="ml-2 text-xs text-red-500 dark:text-red-400">{t('pages.decisions.expired')}</span>}
13581356
</td>
13591357
<td className="px-4 py-2 text-sm">{decision.detail.origin || "-"}</td>

client/src/pages/Decisions.test.tsx

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,69 @@ vi.mock('../lib/api', () => {
169169

170170
beforeEach(() => {
171171
vi.mocked(api.fetchConfig).mockResolvedValue(createDefaultConfigResponse());
172+
vi.mocked(api.fetchDecisionsPaginated).mockImplementation(async (page: number, pageSize = 50, filters?: Record<string, string>) => {
173+
let decisions: DecisionListItem[] = [
174+
{
175+
id: 10,
176+
created_at: '2026-03-23T10:00:00.000Z',
177+
machine: 'host-a',
178+
value: '1.2.3.4',
179+
expired: false,
180+
is_duplicate: false,
181+
simulated: false,
182+
detail: {
183+
origin: 'manual',
184+
reason: 'crowdsecurity/ssh-bf',
185+
country: 'DE',
186+
as: 'Hetzner',
187+
action: 'ban',
188+
duration: '4h',
189+
alert_id: 1,
190+
target: 'ssh',
191+
},
192+
},
193+
{
194+
id: 20,
195+
created_at: '2026-03-24T11:00:00.000Z',
196+
machine: 'machine-2',
197+
value: '5.6.7.8',
198+
expired: false,
199+
is_duplicate: false,
200+
simulated: true,
201+
detail: {
202+
origin: 'CAPI',
203+
reason: 'crowdsecurity/nginx-bf',
204+
country: 'US',
205+
as: 'AWS',
206+
action: 'ban',
207+
duration: '4h',
208+
alert_id: 2,
209+
target: 'nginx',
210+
},
211+
},
212+
];
213+
214+
if (filters?.simulation === 'simulated') {
215+
decisions = decisions.filter((decision) => decision.simulated === true);
216+
}
217+
if (filters?.q) {
218+
const compiledSearch = compileDecisionSearch(filters.q, {
219+
machineEnabled: true,
220+
originEnabled: true,
221+
});
222+
if (!compiledSearch.ok) {
223+
throw new Error(compiledSearch.error.message);
224+
}
225+
decisions = decisions.filter(compiledSearch.predicate);
226+
}
227+
228+
return toPaginatedDecisions(decisions, page, pageSize, 2);
229+
});
172230
});
173231

174232
afterEach(() => {
175233
refreshSignalMock = 0;
234+
vi.useRealTimers();
176235
window.localStorage.clear();
177236
vi.unstubAllGlobals();
178237
vi.restoreAllMocks();
@@ -302,6 +361,48 @@ describe('Decisions page', () => {
302361
expect(screen.queryByRole('columnheader', { name: 'Origin' })).not.toBeInTheDocument();
303362
});
304363

364+
test('counts decision expiration down live from the absolute stop time', async () => {
365+
const expiresAt = new Date(Date.now() + 2_500).toISOString();
366+
vi.mocked(api.fetchDecisionsPaginated).mockImplementationOnce(async (page, pageSize) =>
367+
toPaginatedDecisions([
368+
{
369+
id: 10,
370+
created_at: new Date(Date.now() - 60_000).toISOString(),
371+
value: '1.2.3.4',
372+
expired: false,
373+
is_duplicate: false,
374+
simulated: false,
375+
detail: {
376+
origin: 'manual',
377+
reason: 'crowdsecurity/ssh-bf',
378+
country: 'DE',
379+
as: 'Hetzner',
380+
action: 'ban',
381+
duration: '4m10s',
382+
expiration: expiresAt,
383+
alert_id: 1,
384+
},
385+
},
386+
], page, pageSize),
387+
);
388+
389+
render(
390+
<MemoryRouter initialEntries={['/decisions']}>
391+
<Decisions />
392+
</MemoryRouter>,
393+
);
394+
395+
await waitFor(() => expect(screen.getByText(/^[123]s$/)).toBeInTheDocument());
396+
expect(screen.queryByText('4m10s')).not.toBeInTheDocument();
397+
398+
await act(async () => {
399+
await new Promise((resolve) => setTimeout(resolve, 2_600));
400+
});
401+
await waitFor(() => expect(screen.getByText('0s')).toBeInTheDocument());
402+
await waitFor(() => expect(screen.getByText(/Expired/)).toBeInTheDocument());
403+
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Select decision 10' })).toBeDisabled());
404+
});
405+
305406
test('saves decision table columns from the modal', async () => {
306407
render(
307408
<MemoryRouter initialEntries={['/decisions']}>
@@ -362,7 +463,7 @@ describe('Decisions page', () => {
362463
);
363464

364465
await waitFor(() => expect(screen.getByRole('columnheader', { name: 'Machine' })).toBeInTheDocument());
365-
expect(screen.getByText('host-a')).toBeInTheDocument();
466+
await waitFor(() => expect(screen.getByText('host-a')).toBeInTheDocument());
366467

367468
fireEvent.change(screen.getByPlaceholderText('Filter decisions...'), { target: { value: 'host-a' } });
368469
await flushDecisionSearchDebounce();
@@ -395,7 +496,7 @@ describe('Decisions page', () => {
395496

396497
await waitFor(() => expect(screen.getByRole('columnheader', { name: 'Origin' })).toBeInTheDocument());
397498
await waitFor(() => expect(screen.getByText('manual')).toBeInTheDocument());
398-
expect(screen.getByText('CAPI')).toBeInTheDocument();
499+
await waitFor(() => expect(screen.getByText('CAPI')).toBeInTheDocument());
399500

400501
fireEvent.change(screen.getByPlaceholderText('Filter decisions...'), { target: { value: 'manual' } });
401502
await flushDecisionSearchDebounce();

0 commit comments

Comments
 (0)