filters with search autofill
This commit is contained in:
@@ -144,6 +144,10 @@ pub struct EventQueryParams {
|
|||||||
#[param(example = "core.webhook")]
|
#[param(example = "core.webhook")]
|
||||||
pub trigger_ref: Option<String>,
|
pub trigger_ref: Option<String>,
|
||||||
|
|
||||||
|
/// Filter by rule reference
|
||||||
|
#[param(example = "core.on_webhook")]
|
||||||
|
pub rule_ref: Option<String>,
|
||||||
|
|
||||||
/// Filter by source ID
|
/// Filter by source ID
|
||||||
#[param(example = 1)]
|
#[param(example = 1)]
|
||||||
pub source: Option<Id>,
|
pub source: Option<Id>,
|
||||||
|
|||||||
@@ -237,6 +237,16 @@ pub async fn list_events(
|
|||||||
filtered_events.retain(|e| e.source == Some(source_id));
|
filtered_events.retain(|e| e.source == Some(source_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(rule_ref) = &query.rule_ref {
|
||||||
|
let rule_ref_lower = rule_ref.to_lowercase();
|
||||||
|
filtered_events.retain(|e| {
|
||||||
|
e.rule_ref
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| r.to_lowercase().contains(&rule_ref_lower))
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate pagination
|
// Calculate pagination
|
||||||
let total = filtered_events.len() as u64;
|
let total = filtered_events.len() as u64;
|
||||||
let start = query.offset() as usize;
|
let start = query.offset() as usize;
|
||||||
|
|||||||
@@ -2,86 +2,92 @@
|
|||||||
/* istanbul ignore file */
|
/* istanbul ignore file */
|
||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import type { ApiResponse_EventResponse } from '../models/ApiResponse_EventResponse';
|
import type { ApiResponse_EventResponse } from "../models/ApiResponse_EventResponse";
|
||||||
import type { i64 } from '../models/i64';
|
import type { i64 } from "../models/i64";
|
||||||
import type { PaginatedResponse_EventSummary } from '../models/PaginatedResponse_EventSummary';
|
import type { PaginatedResponse_EventSummary } from "../models/PaginatedResponse_EventSummary";
|
||||||
import type { CancelablePromise } from '../core/CancelablePromise';
|
import type { CancelablePromise } from "../core/CancelablePromise";
|
||||||
import { OpenAPI } from '../core/OpenAPI';
|
import { OpenAPI } from "../core/OpenAPI";
|
||||||
import { request as __request } from '../core/request';
|
import { request as __request } from "../core/request";
|
||||||
export class EventsService {
|
export class EventsService {
|
||||||
|
/**
|
||||||
|
* List all events with pagination and optional filters
|
||||||
|
* @returns PaginatedResponse_EventSummary List of events
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static listEvents({
|
||||||
|
trigger,
|
||||||
|
triggerRef,
|
||||||
|
ruleRef,
|
||||||
|
source,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
}: {
|
||||||
/**
|
/**
|
||||||
* List all events with pagination and optional filters
|
* Filter by trigger ID
|
||||||
* @returns PaginatedResponse_EventSummary List of events
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static listEvents({
|
trigger?: null | i64;
|
||||||
trigger,
|
|
||||||
triggerRef,
|
|
||||||
source,
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
}: {
|
|
||||||
/**
|
|
||||||
* Filter by trigger ID
|
|
||||||
*/
|
|
||||||
trigger?: (null | i64),
|
|
||||||
/**
|
|
||||||
* Filter by trigger reference
|
|
||||||
*/
|
|
||||||
triggerRef?: string | null,
|
|
||||||
/**
|
|
||||||
* Filter by source ID
|
|
||||||
*/
|
|
||||||
source?: (null | i64),
|
|
||||||
/**
|
|
||||||
* Page number (1-indexed)
|
|
||||||
*/
|
|
||||||
page?: number,
|
|
||||||
/**
|
|
||||||
* Items per page
|
|
||||||
*/
|
|
||||||
perPage?: number,
|
|
||||||
}): CancelablePromise<PaginatedResponse_EventSummary> {
|
|
||||||
return __request(OpenAPI, {
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/v1/events',
|
|
||||||
query: {
|
|
||||||
'trigger': trigger,
|
|
||||||
'trigger_ref': triggerRef,
|
|
||||||
'source': source,
|
|
||||||
'page': page,
|
|
||||||
'per_page': perPage,
|
|
||||||
},
|
|
||||||
errors: {
|
|
||||||
401: `Unauthorized`,
|
|
||||||
500: `Internal server error`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Get a single event by ID
|
* Filter by trigger reference
|
||||||
* @returns ApiResponse_EventResponse Event details
|
|
||||||
* @throws ApiError
|
|
||||||
*/
|
*/
|
||||||
public static getEvent({
|
triggerRef?: string | null;
|
||||||
id,
|
/**
|
||||||
}: {
|
* Filter by rule reference
|
||||||
/**
|
*/
|
||||||
* Event ID
|
ruleRef?: string | null;
|
||||||
*/
|
/**
|
||||||
id: number,
|
* Filter by source ID
|
||||||
}): CancelablePromise<ApiResponse_EventResponse> {
|
*/
|
||||||
return __request(OpenAPI, {
|
source?: null | i64;
|
||||||
method: 'GET',
|
/**
|
||||||
url: '/api/v1/events/{id}',
|
* Page number (1-indexed)
|
||||||
path: {
|
*/
|
||||||
'id': id,
|
page?: number;
|
||||||
},
|
/**
|
||||||
errors: {
|
* Items per page
|
||||||
401: `Unauthorized`,
|
*/
|
||||||
404: `Event not found`,
|
perPage?: number;
|
||||||
500: `Internal server error`,
|
}): CancelablePromise<PaginatedResponse_EventSummary> {
|
||||||
},
|
return __request(OpenAPI, {
|
||||||
});
|
method: "GET",
|
||||||
}
|
url: "/api/v1/events",
|
||||||
|
query: {
|
||||||
|
trigger: trigger,
|
||||||
|
trigger_ref: triggerRef,
|
||||||
|
rule_ref: ruleRef,
|
||||||
|
source: source,
|
||||||
|
page: page,
|
||||||
|
per_page: perPage,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
401: `Unauthorized`,
|
||||||
|
500: `Internal server error`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get a single event by ID
|
||||||
|
* @returns ApiResponse_EventResponse Event details
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static getEvent({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
/**
|
||||||
|
* Event ID
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
}): CancelablePromise<ApiResponse_EventResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/events/{id}",
|
||||||
|
path: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
401: `Unauthorized`,
|
||||||
|
404: `Event not found`,
|
||||||
|
500: `Internal server error`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
216
web/src/components/common/AutocompleteInput.tsx
Normal file
216
web/src/components/common/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback, memo } from "react";
|
||||||
|
|
||||||
|
interface AutocompleteInputProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
suggestions: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text input with dropdown autocomplete suggestions.
|
||||||
|
* Allows free-text entry — selecting a suggestion simply fills the input.
|
||||||
|
* Memoized so it only re-renders when its own props change, not when
|
||||||
|
* sibling components (e.g. a results table) update.
|
||||||
|
*/
|
||||||
|
const AutocompleteInput = memo(
|
||||||
|
({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
suggestions,
|
||||||
|
placeholder = "",
|
||||||
|
}: AutocompleteInputProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
|
// Filter suggestions based on current input value (case-insensitive substring)
|
||||||
|
const filtered =
|
||||||
|
value.length === 0
|
||||||
|
? suggestions
|
||||||
|
: suggestions.filter((s) =>
|
||||||
|
s.toLowerCase().includes(value.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clamp the highlighted index when the filtered list shrinks
|
||||||
|
const safeIndex =
|
||||||
|
highlightedIndex >= filtered.length ? -1 : highlightedIndex;
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scroll highlighted item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (safeIndex >= 0 && listRef.current) {
|
||||||
|
const items = listRef.current.children;
|
||||||
|
if (items[safeIndex]) {
|
||||||
|
(items[safeIndex] as HTMLElement).scrollIntoView({
|
||||||
|
block: "nearest",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [safeIndex]);
|
||||||
|
|
||||||
|
const selectSuggestion = useCallback(
|
||||||
|
(suggestion: string) => {
|
||||||
|
onChange(suggestion);
|
||||||
|
setIsOpen(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
// Keep focus on input after selection
|
||||||
|
inputRef.current?.focus();
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen || filtered.length === 0) {
|
||||||
|
// Open dropdown on arrow down even if closed
|
||||||
|
if (e.key === "ArrowDown" && filtered.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex((prev) =>
|
||||||
|
prev < filtered.length - 1 ? prev + 1 : 0,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex((prev) =>
|
||||||
|
prev > 0 ? prev - 1 : filtered.length - 1,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault();
|
||||||
|
if (safeIndex >= 0 && safeIndex < filtered.length) {
|
||||||
|
selectSuggestion(filtered[safeIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
break;
|
||||||
|
case "Tab":
|
||||||
|
setIsOpen(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isOpen, filtered, safeIndex, selectSuggestion],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
if (suggestions.length > 0) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [suggestions.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOpen && filtered.length > 0 && (
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
className="absolute z-50 mt-1 w-full bg-white border border-gray-300 rounded-md shadow-lg max-h-48 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{filtered.map((suggestion, index) => {
|
||||||
|
const isHighlighted = index === safeIndex;
|
||||||
|
// Highlight the matching substring
|
||||||
|
const matchIndex = suggestion
|
||||||
|
.toLowerCase()
|
||||||
|
.indexOf(value.toLowerCase());
|
||||||
|
const before =
|
||||||
|
matchIndex >= 0
|
||||||
|
? suggestion.slice(0, matchIndex)
|
||||||
|
: suggestion;
|
||||||
|
const match =
|
||||||
|
matchIndex >= 0
|
||||||
|
? suggestion.slice(matchIndex, matchIndex + value.length)
|
||||||
|
: "";
|
||||||
|
const after =
|
||||||
|
matchIndex >= 0
|
||||||
|
? suggestion.slice(matchIndex + value.length)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={suggestion}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Use mousedown instead of click to fire before input blur
|
||||||
|
e.preventDefault();
|
||||||
|
selectSuggestion(suggestion);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
className={`px-3 py-2 text-sm cursor-pointer ${
|
||||||
|
isHighlighted ? "bg-blue-50 text-blue-900" : "text-gray-900"
|
||||||
|
} hover:bg-blue-50`}
|
||||||
|
>
|
||||||
|
{value.length > 0 && matchIndex >= 0 ? (
|
||||||
|
<>
|
||||||
|
{before}
|
||||||
|
<span className="font-semibold">{match}</span>
|
||||||
|
{after}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
suggestion
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
AutocompleteInput.displayName = "AutocompleteInput";
|
||||||
|
|
||||||
|
export default AutocompleteInput;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||||
import { EventsService, EnforcementsService, EnforcementStatus } from "@/api";
|
import { EventsService, EnforcementsService, EnforcementStatus } from "@/api";
|
||||||
import type { i64 } from "@/api";
|
import type { i64 } from "@/api";
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ interface EventsQueryParams {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
trigger?: i64 | null;
|
trigger?: i64 | null;
|
||||||
triggerRef?: string | null;
|
triggerRef?: string | null;
|
||||||
|
ruleRef?: string | null;
|
||||||
source?: i64 | null;
|
source?: i64 | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,10 +30,12 @@ export function useEvents(params?: EventsQueryParams) {
|
|||||||
perPage: params?.pageSize || 50,
|
perPage: params?.pageSize || 50,
|
||||||
trigger: params?.trigger,
|
trigger: params?.trigger,
|
||||||
triggerRef: params?.triggerRef,
|
triggerRef: params?.triggerRef,
|
||||||
|
ruleRef: params?.ruleRef,
|
||||||
source: params?.source,
|
source: params?.source,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
staleTime: 30000, // 30 seconds
|
staleTime: 30000, // 30 seconds
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@ export function useEnforcements(params?: EnforcementsQueryParams) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||||
import { ExecutionsService } from "@/api";
|
import { ExecutionsService } from "@/api";
|
||||||
import type { ExecutionStatus } from "@/api";
|
import type { ExecutionStatus } from "@/api";
|
||||||
|
|
||||||
@@ -43,6 +43,8 @@ export function useExecutions(params?: ExecutionsQueryParams) {
|
|||||||
staleTime: hasFilters ? 5000 : 30000,
|
staleTime: hasFilters ? 5000 : 30000,
|
||||||
// Refetch in background when filters change to get latest data
|
// Refetch in background when filters change to get latest data
|
||||||
refetchOnMount: hasFilters ? "always" : true,
|
refetchOnMount: hasFilters ? "always" : true,
|
||||||
|
// Keep previous results visible while new data loads (prevents flash of empty state)
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
91
web/src/hooks/useFilterSuggestions.ts
Normal file
91
web/src/hooks/useFilterSuggestions.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
PacksService,
|
||||||
|
RulesService,
|
||||||
|
ActionsService,
|
||||||
|
TriggersService,
|
||||||
|
} from "@/api";
|
||||||
|
import type {
|
||||||
|
PaginatedResponse_PackSummary,
|
||||||
|
PaginatedResponse_RuleSummary,
|
||||||
|
PaginatedResponse_ActionSummary,
|
||||||
|
PaginatedResponse_TriggerSummary,
|
||||||
|
} from "@/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all packs, rules, actions, and triggers and returns sorted
|
||||||
|
* arrays of their refs for use as autocomplete suggestions.
|
||||||
|
*
|
||||||
|
* Data is cached with a long staleTime (5 minutes) since entity definitions
|
||||||
|
* change infrequently. Individual pages can augment these base suggestions
|
||||||
|
* with refs discovered via WebSocket notifications.
|
||||||
|
*/
|
||||||
|
export function useFilterSuggestions() {
|
||||||
|
const { data: packsData } = useQuery<PaginatedResponse_PackSummary>({
|
||||||
|
queryKey: ["filter-suggestions", "packs"],
|
||||||
|
queryFn: () => PacksService.listPacks({ page: 1, pageSize: 200 }),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: rulesData } = useQuery<PaginatedResponse_RuleSummary>({
|
||||||
|
queryKey: ["filter-suggestions", "rules"],
|
||||||
|
queryFn: () => RulesService.listRules({ page: 1, pageSize: 200 }),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: actionsData } = useQuery<PaginatedResponse_ActionSummary>({
|
||||||
|
queryKey: ["filter-suggestions", "actions"],
|
||||||
|
queryFn: () => ActionsService.listActions({ page: 1, pageSize: 200 }),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: triggersData } = useQuery<PaginatedResponse_TriggerSummary>({
|
||||||
|
queryKey: ["filter-suggestions", "triggers"],
|
||||||
|
queryFn: () => TriggersService.listTriggers({ page: 1, pageSize: 200 }),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const packNames = useMemo(() => {
|
||||||
|
const refs = packsData?.data?.map((p) => p.ref) || [];
|
||||||
|
return [...new Set(refs)].sort();
|
||||||
|
}, [packsData]);
|
||||||
|
|
||||||
|
const ruleRefs = useMemo(() => {
|
||||||
|
const refs = rulesData?.data?.map((r) => r.ref) || [];
|
||||||
|
return [...new Set(refs)].sort();
|
||||||
|
}, [rulesData]);
|
||||||
|
|
||||||
|
const actionRefs = useMemo(() => {
|
||||||
|
const refs = actionsData?.data?.map((a) => a.ref) || [];
|
||||||
|
return [...new Set(refs)].sort();
|
||||||
|
}, [actionsData]);
|
||||||
|
|
||||||
|
const triggerRefs = useMemo(() => {
|
||||||
|
const refs = triggersData?.data?.map((t) => t.ref) || [];
|
||||||
|
return [...new Set(refs)].sort();
|
||||||
|
}, [triggersData]);
|
||||||
|
|
||||||
|
return { packNames, ruleRefs, actionRefs, triggerRefs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge base suggestion arrays with additional refs discovered at runtime
|
||||||
|
* (e.g. from WebSocket notifications or loaded page data).
|
||||||
|
* Returns a new sorted, deduplicated array only when the inputs change.
|
||||||
|
*/
|
||||||
|
export function useMergedSuggestions(
|
||||||
|
base: string[],
|
||||||
|
...additionalSets: string[][]
|
||||||
|
): string[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
const hasAdditional = additionalSets.some((s) => s.length > 0);
|
||||||
|
if (!hasAdditional) return base;
|
||||||
|
const merged = new Set(base);
|
||||||
|
for (const set of additionalSets) {
|
||||||
|
for (const item of set) merged.add(item);
|
||||||
|
}
|
||||||
|
return [...merged].sort();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [base, ...additionalSets]);
|
||||||
|
}
|
||||||
@@ -5,8 +5,13 @@ import { EnforcementStatus } from "@/api";
|
|||||||
import { useState, useMemo, memo, useCallback, useEffect } from "react";
|
import { useState, useMemo, memo, useCallback, useEffect } from "react";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import MultiSelect from "@/components/common/MultiSelect";
|
import MultiSelect from "@/components/common/MultiSelect";
|
||||||
|
import AutocompleteInput from "@/components/common/AutocompleteInput";
|
||||||
|
import {
|
||||||
|
useFilterSuggestions,
|
||||||
|
useMergedSuggestions,
|
||||||
|
} from "@/hooks/useFilterSuggestions";
|
||||||
|
|
||||||
// Memoized filter input component to prevent re-render on WebSocket updates
|
// Memoized filter input component for non-ref fields (e.g. Event ID)
|
||||||
const FilterInput = memo(
|
const FilterInput = memo(
|
||||||
({
|
({
|
||||||
label,
|
label,
|
||||||
@@ -43,10 +48,287 @@ const STATUS_OPTIONS = [
|
|||||||
{ value: EnforcementStatus.DISABLED, label: "Disabled" },
|
{ value: EnforcementStatus.DISABLED, label: "Disabled" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const getStatusColor = (status: EnforcementStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case EnforcementStatus.PROCESSED:
|
||||||
|
return "bg-green-100 text-green-800";
|
||||||
|
case EnforcementStatus.DISABLED:
|
||||||
|
return "bg-gray-100 text-gray-800";
|
||||||
|
case EnforcementStatus.CREATED:
|
||||||
|
return "bg-blue-100 text-blue-800";
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-800";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConditionBadge = (condition: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
all: "bg-purple-100 text-purple-800",
|
||||||
|
any: "bg-indigo-100 text-indigo-800",
|
||||||
|
};
|
||||||
|
return colors[condition] || "bg-gray-100 text-gray-800";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp: string) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
|
||||||
|
if (diff < 60000) return "just now";
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoized results table component - only re-renders when query data changes,
|
||||||
|
// NOT when the user types in filter inputs.
|
||||||
|
const EnforcementsResultsTable = memo(
|
||||||
|
({
|
||||||
|
enforcements,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
hasActiveFilters,
|
||||||
|
clearFilters,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
enforcements: any[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
hasActiveFilters: boolean;
|
||||||
|
clearFilters: () => void;
|
||||||
|
page: number;
|
||||||
|
setPage: (page: number) => void;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
}) => {
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
// Initial load (no cached data yet)
|
||||||
|
if (isLoading && enforcements.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white shadow rounded-lg">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error with no cached data to show
|
||||||
|
if (error && enforcements.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white shadow rounded-lg">
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
|
<p>Error: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty results
|
||||||
|
if (enforcements.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-12 text-center rounded-lg shadow">
|
||||||
|
<p>No enforcements found</p>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="mt-3 text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Inline loading overlay - shows on top of previous results while fetching */}
|
||||||
|
{isFetching && (
|
||||||
|
<div className="absolute inset-0 bg-white/60 z-10 flex items-center justify-center rounded-lg">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Non-fatal error banner (data still shown from cache) */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
|
<p>Error refreshing: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||||
|
<table className="min-w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
ID
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Rule
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Trigger
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Event
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Condition
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{enforcements.map((enforcement: any) => (
|
||||||
|
<tr key={enforcement.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 font-mono text-sm">
|
||||||
|
<Link
|
||||||
|
to={`/enforcements/${enforcement.id}`}
|
||||||
|
className="text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
#{enforcement.id}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{enforcement.rule ? (
|
||||||
|
<Link
|
||||||
|
to={`/rules/${enforcement.rule}`}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
{enforcement.rule_ref}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-900">
|
||||||
|
{enforcement.rule_ref}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
{enforcement.trigger_ref}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{enforcement.event ? (
|
||||||
|
<Link
|
||||||
|
to={`/events/${enforcement.event}`}
|
||||||
|
className="text-sm font-mono text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
#{enforcement.event}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400 italic">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded ${getConditionBadge(enforcement.condition)}`}
|
||||||
|
>
|
||||||
|
{enforcement.condition}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded ${getStatusColor(enforcement.status)}`}
|
||||||
|
>
|
||||||
|
{enforcement.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{formatTime(enforcement.created)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{formatDate(enforcement.created)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
|
||||||
|
<div className="flex-1 flex justify-between sm:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{(page - 1) * pageSize + 1}
|
||||||
|
</span>{" "}
|
||||||
|
to{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{Math.min(page * pageSize, total)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium">{total}</span> enforcements
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
EnforcementsResultsTable.displayName = "EnforcementsResultsTable";
|
||||||
|
|
||||||
export default function EnforcementsPage() {
|
export default function EnforcementsPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
// Initialize filters from URL query parameters
|
// --- Filter input state (updates immediately on keystroke) ---
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const [searchFilters, setSearchFilters] = useState({
|
const [searchFilters, setSearchFilters] = useState({
|
||||||
@@ -59,28 +341,28 @@ export default function EnforcementsPage() {
|
|||||||
return status ? [status] : [];
|
return status ? [status] : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debounced filter state for API calls
|
// --- Debounced filter state (drives API calls, updates after delay) ---
|
||||||
const [debouncedFilters, setDebouncedFilters] = useState(searchFilters);
|
const [debouncedFilters, setDebouncedFilters] = useState(searchFilters);
|
||||||
const [debouncedStatuses, setDebouncedStatuses] = useState(selectedStatuses);
|
const [debouncedStatuses, setDebouncedStatuses] = useState(selectedStatuses);
|
||||||
|
|
||||||
// Debounce filter changes (500ms delay)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedFilters(searchFilters);
|
setDebouncedFilters(searchFilters);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchFilters]);
|
}, [searchFilters]);
|
||||||
|
|
||||||
// Debounce status changes (300ms delay - shorter since it's a selection)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedStatuses(selectedStatuses);
|
setDebouncedStatuses(selectedStatuses);
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [selectedStatuses]);
|
}, [selectedStatuses]);
|
||||||
|
|
||||||
|
// --- Autocomplete suggestions ---
|
||||||
|
const baseSuggestions = useFilterSuggestions();
|
||||||
|
|
||||||
|
// --- Build query params from debounced state ---
|
||||||
const queryParams = useMemo(() => {
|
const queryParams = useMemo(() => {
|
||||||
const params: any = { page, pageSize };
|
const params: any = { page, pageSize };
|
||||||
if (debouncedFilters.trigger) params.triggerRef = debouncedFilters.trigger;
|
if (debouncedFilters.trigger) params.triggerRef = debouncedFilters.trigger;
|
||||||
@@ -90,27 +372,45 @@ export default function EnforcementsPage() {
|
|||||||
params.event = eventId;
|
params.event = eventId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include status filter if exactly one status is selected
|
|
||||||
// API only supports single status, so we use the first one for filtering
|
|
||||||
// and show all results if multiple are selected
|
|
||||||
if (debouncedStatuses.length === 1) {
|
if (debouncedStatuses.length === 1) {
|
||||||
params.status = debouncedStatuses[0] as EnforcementStatus;
|
params.status = debouncedStatuses[0] as EnforcementStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
}, [page, pageSize, debouncedFilters, debouncedStatuses]);
|
}, [page, pageSize, debouncedFilters, debouncedStatuses]);
|
||||||
|
|
||||||
const { data, isLoading, error } = useEnforcements(queryParams);
|
const { data, isLoading, isFetching, error } = useEnforcements(queryParams);
|
||||||
|
|
||||||
// Subscribe to real-time updates for all enforcements
|
|
||||||
const { isConnected } = useEnforcementStream({ enabled: true });
|
const { isConnected } = useEnforcementStream({ enabled: true });
|
||||||
|
|
||||||
const enforcements = data?.data || [];
|
const enforcements = useMemo(() => data?.data || [], [data]);
|
||||||
const total = data?.pagination?.total_items || 0;
|
const total = data?.pagination?.total_items || 0;
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
|
||||||
|
|
||||||
// Client-side filtering for multiple status selection and rule_ref (when > 1 status selected)
|
// Derive refs from currently-loaded enforcement data (no setState needed)
|
||||||
|
const loadedRefs = useMemo(() => {
|
||||||
|
const rules = new Set<string>();
|
||||||
|
const triggers = new Set<string>();
|
||||||
|
|
||||||
|
for (const enf of enforcements) {
|
||||||
|
if (enf.rule_ref) rules.add(enf.rule_ref as string);
|
||||||
|
if (enf.trigger_ref) triggers.add(enf.trigger_ref as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rules: [...rules],
|
||||||
|
triggers: [...triggers],
|
||||||
|
};
|
||||||
|
}, [enforcements]);
|
||||||
|
|
||||||
|
// Merge base entity suggestions + loaded data refs
|
||||||
|
const ruleSuggestions = useMergedSuggestions(
|
||||||
|
baseSuggestions.ruleRefs,
|
||||||
|
loadedRefs.rules,
|
||||||
|
);
|
||||||
|
const triggerSuggestions = useMergedSuggestions(
|
||||||
|
baseSuggestions.triggerRefs,
|
||||||
|
loadedRefs.triggers,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Client-side filtering for rule_ref and multiple status selection
|
||||||
const filteredEnforcements = useMemo(() => {
|
const filteredEnforcements = useMemo(() => {
|
||||||
let filtered = enforcements;
|
let filtered = enforcements;
|
||||||
|
|
||||||
@@ -135,87 +435,26 @@ export default function EnforcementsPage() {
|
|||||||
|
|
||||||
const handleFilterChange = useCallback((field: string, value: string) => {
|
const handleFilterChange = useCallback((field: string, value: string) => {
|
||||||
setSearchFilters((prev) => ({ ...prev, [field]: value }));
|
setSearchFilters((prev) => ({ ...prev, [field]: value }));
|
||||||
setPage(1); // Reset to first page on filter change
|
setPage(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
setSearchFilters({
|
setSearchFilters({ rule: "", trigger: "", event: "" });
|
||||||
rule: "",
|
|
||||||
trigger: "",
|
|
||||||
event: "",
|
|
||||||
});
|
|
||||||
setSelectedStatuses([]);
|
setSelectedStatuses([]);
|
||||||
setPage(1); // Reset to first page
|
setPage(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
Object.values(searchFilters).some((v) => v !== "") ||
|
Object.values(searchFilters).some((v) => v !== "") ||
|
||||||
selectedStatuses.length > 0;
|
selectedStatuses.length > 0;
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
||||||
<p>Error: {(error as Error).message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusColor = (status: EnforcementStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case EnforcementStatus.PROCESSED:
|
|
||||||
return "bg-green-100 text-green-800";
|
|
||||||
case EnforcementStatus.DISABLED:
|
|
||||||
return "bg-gray-100 text-gray-800";
|
|
||||||
case EnforcementStatus.CREATED:
|
|
||||||
return "bg-blue-100 text-blue-800";
|
|
||||||
default:
|
|
||||||
return "bg-gray-100 text-gray-800";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConditionBadge = (condition: string) => {
|
|
||||||
const colors = {
|
|
||||||
all: "bg-purple-100 text-purple-800",
|
|
||||||
any: "bg-indigo-100 text-indigo-800",
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
colors[condition as keyof typeof colors] || "bg-gray-100 text-gray-800"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (timestamp: string) => {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
const now = new Date();
|
|
||||||
const diff = now.getTime() - date.getTime();
|
|
||||||
|
|
||||||
if (diff < 60000) return "just now";
|
|
||||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
||||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
||||||
return date.toLocaleDateString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
|
{/* Header - always visible */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Enforcements</h1>
|
<h1 className="text-3xl font-bold">Enforcements</h1>
|
||||||
{isLoading && hasActiveFilters && (
|
{isFetching && hasActiveFilters && (
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
Searching enforcements...
|
Searching enforcements...
|
||||||
</p>
|
</p>
|
||||||
@@ -229,7 +468,7 @@ export default function EnforcementsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Filters */}
|
{/* Filter section - always mounted, never unmounts during loading */}
|
||||||
<div className="bg-white shadow rounded-lg p-4 mb-6">
|
<div className="bg-white shadow rounded-lg p-4 mb-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -247,16 +486,18 @@ export default function EnforcementsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<FilterInput
|
<AutocompleteInput
|
||||||
label="Rule"
|
label="Rule"
|
||||||
value={searchFilters.rule}
|
value={searchFilters.rule}
|
||||||
onChange={(value) => handleFilterChange("rule", value)}
|
onChange={(value) => handleFilterChange("rule", value)}
|
||||||
|
suggestions={ruleSuggestions}
|
||||||
placeholder="e.g., core.on_timer"
|
placeholder="e.g., core.on_timer"
|
||||||
/>
|
/>
|
||||||
<FilterInput
|
<AutocompleteInput
|
||||||
label="Trigger"
|
label="Trigger"
|
||||||
value={searchFilters.trigger}
|
value={searchFilters.trigger}
|
||||||
onChange={(value) => handleFilterChange("trigger", value)}
|
onChange={(value) => handleFilterChange("trigger", value)}
|
||||||
|
suggestions={triggerSuggestions}
|
||||||
placeholder="e.g., core.webhook"
|
placeholder="e.g., core.webhook"
|
||||||
/>
|
/>
|
||||||
<FilterInput
|
<FilterInput
|
||||||
@@ -277,177 +518,19 @@ export default function EnforcementsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredEnforcements.length === 0 ? (
|
{/* Results section - isolated from filter state, only depends on query results */}
|
||||||
<div className="bg-white p-12 text-center rounded-lg shadow">
|
<EnforcementsResultsTable
|
||||||
<p>
|
enforcements={filteredEnforcements}
|
||||||
{enforcements.length === 0
|
isLoading={isLoading}
|
||||||
? "No enforcements found"
|
isFetching={isFetching}
|
||||||
: "No enforcements match the selected filters"}
|
error={error as Error | null}
|
||||||
</p>
|
hasActiveFilters={hasActiveFilters}
|
||||||
{enforcements.length > 0 && hasActiveFilters && (
|
clearFilters={clearFilters}
|
||||||
<button
|
page={page}
|
||||||
onClick={clearFilters}
|
setPage={setPage}
|
||||||
className="mt-3 text-sm text-blue-600 hover:text-blue-800"
|
pageSize={pageSize}
|
||||||
>
|
total={total}
|
||||||
Clear filters
|
/>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
|
||||||
<table className="min-w-full">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
||||||
ID
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
||||||
Rule
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
||||||
Trigger
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
||||||
Event
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
||||||
Condition
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
||||||
Created
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{filteredEnforcements.map((enforcement: any) => (
|
|
||||||
<tr key={enforcement.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 font-mono text-sm">
|
|
||||||
<Link
|
|
||||||
to={`/enforcements/${enforcement.id}`}
|
|
||||||
className="text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
#{enforcement.id}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
{enforcement.rule ? (
|
|
||||||
<Link
|
|
||||||
to={`/rules/${enforcement.rule}`}
|
|
||||||
className="text-sm text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
{enforcement.rule_ref}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-gray-900">
|
|
||||||
{enforcement.rule_ref}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="text-sm text-gray-700">
|
|
||||||
{enforcement.trigger_ref}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
{enforcement.event ? (
|
|
||||||
<Link
|
|
||||||
to={`/events/${enforcement.event}`}
|
|
||||||
className="text-sm font-mono text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
#{enforcement.event}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-gray-400 italic">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 text-xs rounded ${getConditionBadge(enforcement.condition)}`}
|
|
||||||
>
|
|
||||||
{enforcement.condition}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 text-xs rounded ${getStatusColor(enforcement.status)}`}
|
|
||||||
>
|
|
||||||
{enforcement.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="text-sm text-gray-900">
|
|
||||||
{formatTime(enforcement.created)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{formatDate(enforcement.created)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
|
|
||||||
<div className="flex-1 flex justify-between sm:hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page - 1)}
|
|
||||||
disabled={page === 1}
|
|
||||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page === totalPages}
|
|
||||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Showing{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{(page - 1) * pageSize + 1}
|
|
||||||
</span>{" "}
|
|
||||||
to{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{Math.min(page * pageSize, total)}
|
|
||||||
</span>{" "}
|
|
||||||
of <span className="font-medium">{total}</span> enforcements
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page - 1)}
|
|
||||||
disabled={page === 1}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page === totalPages}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useMemo, memo, useEffect } from "react";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEvents } from "@/hooks/useEvents";
|
import { useEvents } from "@/hooks/useEvents";
|
||||||
@@ -6,25 +6,322 @@ import {
|
|||||||
useEntityNotifications,
|
useEntityNotifications,
|
||||||
Notification,
|
Notification,
|
||||||
} from "@/contexts/WebSocketContext";
|
} from "@/contexts/WebSocketContext";
|
||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
import AutocompleteInput from "@/components/common/AutocompleteInput";
|
||||||
|
import {
|
||||||
|
useFilterSuggestions,
|
||||||
|
useMergedSuggestions,
|
||||||
|
} from "@/hooks/useFilterSuggestions";
|
||||||
import type { EventSummary } from "@/api";
|
import type { EventSummary } from "@/api";
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp: string) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
|
||||||
|
if (diff < 60000) return "just now";
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoized results table component - only re-renders when query data changes,
|
||||||
|
// NOT when the user types in filter inputs.
|
||||||
|
const EventsResultsTable = memo(
|
||||||
|
({
|
||||||
|
events,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
hasActiveFilters,
|
||||||
|
clearFilters,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
events: EventSummary[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
hasActiveFilters: boolean;
|
||||||
|
clearFilters: () => void;
|
||||||
|
page: number;
|
||||||
|
setPage: (page: number) => void;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
}) => {
|
||||||
|
const totalPages = total ? Math.ceil(total / pageSize) : 0;
|
||||||
|
|
||||||
|
// Initial load (no cached data yet)
|
||||||
|
if (isLoading && events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white shadow rounded-lg">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||||
|
<p className="ml-4 text-gray-600">Loading events...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error with no cached data to show
|
||||||
|
if (error && events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white shadow rounded-lg p-12 text-center">
|
||||||
|
<p className="text-red-600">Failed to load events</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-2">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty results
|
||||||
|
if (events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white shadow rounded-lg p-12 text-center">
|
||||||
|
<svg
|
||||||
|
className="mx-auto h-12 w-12 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="mt-4 text-gray-600">No events found</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{hasActiveFilters
|
||||||
|
? "Try adjusting your filters"
|
||||||
|
: "Events will appear here when triggers fire"}
|
||||||
|
</p>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="mt-3 text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Inline loading overlay - shows on top of previous results while fetching */}
|
||||||
|
{isFetching && (
|
||||||
|
<div className="absolute inset-0 bg-white/60 z-10 flex items-center justify-center rounded-lg">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Non-fatal error banner (data still shown from cache) */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
|
<p>Error refreshing: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
ID
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Trigger
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Rule
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Source
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{events.map((event) => (
|
||||||
|
<tr key={event.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<Link
|
||||||
|
to={`/events/${event.id}`}
|
||||||
|
className="text-sm font-mono text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
#{event.id}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium text-gray-900">
|
||||||
|
{event.trigger_ref}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500 text-xs">
|
||||||
|
ID: {event.trigger || "N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{event.rule_ref ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
<Link
|
||||||
|
to={`/rules/${event.rule}`}
|
||||||
|
className="font-medium text-blue-600 hover:text-blue-900"
|
||||||
|
>
|
||||||
|
{event.rule_ref}
|
||||||
|
</Link>
|
||||||
|
<div className="text-gray-500 text-xs">
|
||||||
|
ID: {event.rule}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400 italic">
|
||||||
|
No rule
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{event.source_ref ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium text-gray-900">
|
||||||
|
{event.source_ref}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500 text-xs">
|
||||||
|
ID: {event.source || "N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400 italic">
|
||||||
|
No source
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{formatTime(event.created)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{formatDate(event.created)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
|
||||||
|
<div className="flex-1 flex justify-between sm:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Page <span className="font-medium">{page}</span> of{" "}
|
||||||
|
<span className="font-medium">{totalPages}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
EventsResultsTable.displayName = "EventsResultsTable";
|
||||||
|
|
||||||
export default function EventsPage() {
|
export default function EventsPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// --- Filter input state (updates immediately on keystroke) ---
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [triggerFilter, setTriggerFilter] = useState<string>(
|
|
||||||
searchParams.get("trigger_ref") || "",
|
|
||||||
);
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
|
const [searchFilters, setSearchFilters] = useState({
|
||||||
|
trigger: searchParams.get("trigger_ref") || "",
|
||||||
|
rule: searchParams.get("rule_ref") || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Debounced filter state (drives API calls, updates after delay) ---
|
||||||
|
const [debouncedFilters, setDebouncedFilters] = useState(searchFilters);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedFilters(searchFilters);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchFilters]);
|
||||||
|
|
||||||
|
// --- Autocomplete suggestions ---
|
||||||
|
const baseSuggestions = useFilterSuggestions();
|
||||||
|
|
||||||
|
// Additional refs discovered via WebSocket notifications (accumulated over time)
|
||||||
|
const [wsRefs, setWsRefs] = useState<{
|
||||||
|
triggers: string[];
|
||||||
|
rules: string[];
|
||||||
|
}>({ triggers: [], rules: [] });
|
||||||
|
|
||||||
|
// --- Build query params from debounced state ---
|
||||||
|
const queryParams = useMemo(() => {
|
||||||
|
const params: any = { page, pageSize };
|
||||||
|
if (debouncedFilters.trigger) params.triggerRef = debouncedFilters.trigger;
|
||||||
|
if (debouncedFilters.rule) params.ruleRef = debouncedFilters.rule;
|
||||||
|
return params;
|
||||||
|
}, [page, pageSize, debouncedFilters]);
|
||||||
|
|
||||||
// Set up WebSocket for real-time event updates with stable callback
|
// Set up WebSocket for real-time event updates with stable callback
|
||||||
const handleEventNotification = useCallback(
|
const handleEventNotification = useCallback(
|
||||||
(notification: Notification) => {
|
(notification: Notification) => {
|
||||||
// Extract event data from notification payload (flat structure)
|
|
||||||
if (notification.notification_type === "event_created") {
|
if (notification.notification_type === "event_created") {
|
||||||
const payload = notification.payload as any;
|
const payload = notification.payload as any;
|
||||||
|
|
||||||
// Create EventSummary from notification data
|
|
||||||
const newEvent: EventSummary = {
|
const newEvent: EventSummary = {
|
||||||
id: payload.id,
|
id: payload.id,
|
||||||
trigger: payload.trigger,
|
trigger: payload.trigger,
|
||||||
@@ -38,45 +335,69 @@ export default function EventsPage() {
|
|||||||
created: payload.created,
|
created: payload.created,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the query cache directly instead of invalidating
|
// Augment autocomplete suggestions with new refs from notification
|
||||||
queryClient.setQueryData(
|
setWsRefs((prev) => {
|
||||||
[
|
const newTriggers = new Set(prev.triggers);
|
||||||
"events",
|
const newRules = new Set(prev.rules);
|
||||||
{ page, pageSize, triggerRef: triggerFilter || undefined },
|
let changed = false;
|
||||||
],
|
|
||||||
(oldData: any) => {
|
|
||||||
if (!oldData) return oldData;
|
|
||||||
|
|
||||||
// Check if filtering and event matches filter
|
if (newEvent.trigger_ref && !newTriggers.has(newEvent.trigger_ref)) {
|
||||||
if (triggerFilter && newEvent.trigger_ref !== triggerFilter) {
|
newTriggers.add(newEvent.trigger_ref);
|
||||||
return oldData;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (newEvent.rule_ref && !newRules.has(newEvent.rule_ref)) {
|
||||||
|
newRules.add(newEvent.rule_ref);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Add new event to the beginning of the list if on first page
|
if (!changed) return prev;
|
||||||
if (page === 1) {
|
return {
|
||||||
return {
|
triggers: [...newTriggers],
|
||||||
...oldData,
|
rules: [...newRules],
|
||||||
data: [newEvent, ...oldData.data].slice(0, pageSize),
|
};
|
||||||
pagination: {
|
});
|
||||||
...oldData.pagination,
|
|
||||||
total_items: (oldData.pagination?.total_items || 0) + 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other pages, just update the total count
|
queryClient.setQueryData(["events", queryParams], (oldData: any) => {
|
||||||
|
if (!oldData) return oldData;
|
||||||
|
|
||||||
|
// Check if filtering and event matches filter
|
||||||
|
if (
|
||||||
|
debouncedFilters.trigger &&
|
||||||
|
newEvent.trigger_ref !== debouncedFilters.trigger
|
||||||
|
) {
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
debouncedFilters.rule &&
|
||||||
|
newEvent.rule_ref !== debouncedFilters.rule
|
||||||
|
) {
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new event to the beginning of the list if on first page
|
||||||
|
if (page === 1) {
|
||||||
return {
|
return {
|
||||||
...oldData,
|
...oldData,
|
||||||
|
data: [newEvent, ...oldData.data].slice(0, pageSize),
|
||||||
pagination: {
|
pagination: {
|
||||||
...oldData.pagination,
|
...oldData.pagination,
|
||||||
total_items: (oldData.pagination?.total_items || 0) + 1,
|
total_items: (oldData.pagination?.total_items || 0) + 1,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
// For other pages, just update the total count
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
pagination: {
|
||||||
|
...oldData.pagination,
|
||||||
|
total_items: (oldData.pagination?.total_items || 0) + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[queryClient, page, pageSize, triggerFilter],
|
[queryClient, queryParams, page, pageSize, debouncedFilters],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { connected: wsConnected } = useEntityNotifications(
|
const { connected: wsConnected } = useEntityNotifications(
|
||||||
@@ -84,35 +405,54 @@ export default function EventsPage() {
|
|||||||
handleEventNotification,
|
handleEventNotification,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isLoading, error } = useEvents({
|
const { data, isLoading, isFetching, error } = useEvents(queryParams);
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
triggerRef: triggerFilter || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const events = data?.data || [];
|
const events = useMemo(() => data?.data || [], [data]);
|
||||||
const total = data?.pagination?.total_items || 0;
|
const total = data?.pagination?.total_items || 0;
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
// Derive refs from currently-loaded event data (no setState needed)
|
||||||
return new Date(dateString).toLocaleString();
|
const loadedRefs = useMemo(() => {
|
||||||
};
|
const triggers = new Set<string>();
|
||||||
|
const rules = new Set<string>();
|
||||||
|
|
||||||
const formatTime = (timestamp: string) => {
|
for (const event of events) {
|
||||||
const date = new Date(timestamp);
|
if (event.trigger_ref) triggers.add(event.trigger_ref);
|
||||||
const now = new Date();
|
if (event.rule_ref) rules.add(event.rule_ref);
|
||||||
const diff = now.getTime() - date.getTime();
|
}
|
||||||
|
|
||||||
if (diff < 60000) return "just now";
|
return {
|
||||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
triggers: [...triggers],
|
||||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
rules: [...rules],
|
||||||
return date.toLocaleDateString();
|
};
|
||||||
};
|
}, [events]);
|
||||||
|
|
||||||
const totalPages = total ? Math.ceil(total / pageSize) : 0;
|
// Merge base entity suggestions + loaded data refs + WebSocket refs
|
||||||
|
const triggerSuggestions = useMergedSuggestions(
|
||||||
|
baseSuggestions.triggerRefs,
|
||||||
|
loadedRefs.triggers,
|
||||||
|
wsRefs.triggers,
|
||||||
|
);
|
||||||
|
const ruleSuggestions = useMergedSuggestions(
|
||||||
|
baseSuggestions.ruleRefs,
|
||||||
|
loadedRefs.rules,
|
||||||
|
wsRefs.rules,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((field: string, value: string) => {
|
||||||
|
setSearchFilters((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
setSearchFilters({ trigger: "", rule: "" });
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasActiveFilters = Object.values(searchFilters).some((v) => v !== "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{/* Header */}
|
{/* Header - always visible */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -120,6 +460,9 @@ export default function EventsPage() {
|
|||||||
<p className="mt-2 text-gray-600">
|
<p className="mt-2 text-gray-600">
|
||||||
Event instances generated by sensors and triggers
|
Event instances generated by sensors and triggers
|
||||||
</p>
|
</p>
|
||||||
|
{isFetching && hasActiveFilters && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Searching events...</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{wsConnected && (
|
{wsConnected && (
|
||||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||||
@@ -130,237 +473,60 @@ export default function EventsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filter section - always mounted, never unmounts during loading */}
|
||||||
<div className="bg-white rounded-lg shadow mb-6 p-4">
|
<div className="bg-white shadow rounded-lg p-4 mb-6">
|
||||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex-1 max-w-md">
|
<div className="flex items-center gap-2">
|
||||||
<label
|
<Search className="h-5 w-5 text-gray-400" />
|
||||||
htmlFor="trigger-filter"
|
<h2 className="text-lg font-semibold">Filter Events</h2>
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Filter by Trigger
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="trigger-filter"
|
|
||||||
type="text"
|
|
||||||
value={triggerFilter}
|
|
||||||
onChange={(e) => {
|
|
||||||
setTriggerFilter(e.target.value);
|
|
||||||
setPage(1); // Reset to first page on filter change
|
|
||||||
}}
|
|
||||||
placeholder="e.g., core.webhook"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
{triggerFilter && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={clearFilters}
|
||||||
setTriggerFilter("");
|
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
|
|
||||||
>
|
>
|
||||||
Clear Filter
|
<X className="h-4 w-4" />
|
||||||
|
Clear Filters
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<AutocompleteInput
|
||||||
|
label="Trigger"
|
||||||
|
value={searchFilters.trigger}
|
||||||
|
onChange={(value) => handleFilterChange("trigger", value)}
|
||||||
|
suggestions={triggerSuggestions}
|
||||||
|
placeholder="e.g., core.webhook"
|
||||||
|
/>
|
||||||
|
<AutocompleteInput
|
||||||
|
label="Rule"
|
||||||
|
value={searchFilters.rule}
|
||||||
|
onChange={(value) => handleFilterChange("rule", value)}
|
||||||
|
suggestions={ruleSuggestions}
|
||||||
|
placeholder="e.g., core.on_webhook"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{data && (
|
{data && (
|
||||||
<div className="mt-3 text-sm text-gray-600">
|
<div className="mt-3 text-sm text-gray-600">
|
||||||
Showing {events.length} of {total} events
|
Showing {events.length} of {total} events
|
||||||
{triggerFilter && ` (filtered by "${triggerFilter}")`}
|
{hasActiveFilters && " (filtered)"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Events List */}
|
{/* Results section - isolated from filter state, only depends on query results */}
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<EventsResultsTable
|
||||||
{isLoading ? (
|
events={events}
|
||||||
<div className="p-12 text-center">
|
isLoading={isLoading}
|
||||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
isFetching={isFetching}
|
||||||
<p className="mt-4 text-gray-600">Loading events...</p>
|
error={error as Error | null}
|
||||||
</div>
|
hasActiveFilters={hasActiveFilters}
|
||||||
) : error ? (
|
clearFilters={clearFilters}
|
||||||
<div className="p-12 text-center">
|
page={page}
|
||||||
<p className="text-red-600">Failed to load events</p>
|
setPage={setPage}
|
||||||
<p className="text-sm text-gray-600 mt-2">
|
pageSize={pageSize}
|
||||||
{error instanceof Error ? error.message : "Unknown error"}
|
total={total}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
) : !events || events.length === 0 ? (
|
|
||||||
<div className="p-12 text-center">
|
|
||||||
<svg
|
|
||||||
className="mx-auto h-12 w-12 text-gray-400"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p className="mt-4 text-gray-600">No events found</p>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
{triggerFilter
|
|
||||||
? "Try adjusting your filter"
|
|
||||||
: "Events will appear here when triggers fire"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
ID
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Trigger
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Rule
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Source
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Created
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{events.map((event) => (
|
|
||||||
<tr key={event.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className="text-sm font-mono text-gray-900">
|
|
||||||
{event.id}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="text-sm">
|
|
||||||
<div className="font-medium text-gray-900">
|
|
||||||
{event.trigger_ref}
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-500 text-xs">
|
|
||||||
ID: {event.trigger || "N/A"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
{event.rule_ref ? (
|
|
||||||
<div className="text-sm">
|
|
||||||
<Link
|
|
||||||
to={`/rules/${event.rule}`}
|
|
||||||
className="font-medium text-blue-600 hover:text-blue-900"
|
|
||||||
>
|
|
||||||
{event.rule_ref}
|
|
||||||
</Link>
|
|
||||||
<div className="text-gray-500 text-xs">
|
|
||||||
ID: {event.rule}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-gray-400 italic">
|
|
||||||
No rule
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
{event.source_ref ? (
|
|
||||||
<div className="text-sm">
|
|
||||||
<div className="font-medium text-gray-900">
|
|
||||||
{event.source_ref}
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-500 text-xs">
|
|
||||||
ID: {event.source || "N/A"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-gray-400 italic">
|
|
||||||
No source
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-900">
|
|
||||||
{formatTime(event.created)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{formatDate(event.created)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<Link
|
|
||||||
to={`/events/${event.id}`}
|
|
||||||
className="text-blue-600 hover:text-blue-900"
|
|
||||||
>
|
|
||||||
View Details
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
|
|
||||||
<div className="flex-1 flex justify-between sm:hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page - 1)}
|
|
||||||
disabled={page === 1}
|
|
||||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page === totalPages}
|
|
||||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Page <span className="font-medium">{page}</span> of{" "}
|
|
||||||
<span className="font-medium">{totalPages}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page - 1)}
|
|
||||||
disabled={page === 1}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page === totalPages}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import { ExecutionStatus } from "@/api";
|
|||||||
import { useState, useMemo, memo, useCallback, useEffect } from "react";
|
import { useState, useMemo, memo, useCallback, useEffect } from "react";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import MultiSelect from "@/components/common/MultiSelect";
|
import MultiSelect from "@/components/common/MultiSelect";
|
||||||
|
import AutocompleteInput from "@/components/common/AutocompleteInput";
|
||||||
|
import {
|
||||||
|
useFilterSuggestions,
|
||||||
|
useMergedSuggestions,
|
||||||
|
} from "@/hooks/useFilterSuggestions";
|
||||||
|
|
||||||
// Memoized filter input component to prevent re-render on WebSocket updates
|
// Memoized filter input component for non-ref fields (e.g. Executor ID)
|
||||||
const FilterInput = memo(
|
const FilterInput = memo(
|
||||||
({
|
({
|
||||||
label,
|
label,
|
||||||
@@ -50,10 +55,246 @@ const STATUS_OPTIONS = [
|
|||||||
{ value: ExecutionStatus.ABANDONED, label: "Abandoned" },
|
{ value: ExecutionStatus.ABANDONED, label: "Abandoned" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const getStatusColor = (status: ExecutionStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case ExecutionStatus.COMPLETED:
|
||||||
|
return "bg-green-100 text-green-800";
|
||||||
|
case ExecutionStatus.FAILED:
|
||||||
|
case ExecutionStatus.TIMEOUT:
|
||||||
|
return "bg-red-100 text-red-800";
|
||||||
|
case ExecutionStatus.RUNNING:
|
||||||
|
return "bg-blue-100 text-blue-800";
|
||||||
|
case ExecutionStatus.SCHEDULED:
|
||||||
|
case ExecutionStatus.SCHEDULING:
|
||||||
|
case ExecutionStatus.REQUESTED:
|
||||||
|
return "bg-yellow-100 text-yellow-800";
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-800";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoized results table component - only re-renders when query data changes,
|
||||||
|
// NOT when the user types in filter inputs.
|
||||||
|
const ExecutionsResultsTable = memo(
|
||||||
|
({
|
||||||
|
executions,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
hasActiveFilters,
|
||||||
|
clearFilters,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
executions: any[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
hasActiveFilters: boolean;
|
||||||
|
clearFilters: () => void;
|
||||||
|
page: number;
|
||||||
|
setPage: (page: number) => void;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
}) => {
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
// Initial load (no cached data yet)
|
||||||
|
if (isLoading && executions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white shadow rounded-lg">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error with no cached data to show
|
||||||
|
if (error && executions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white shadow rounded-lg">
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
|
<p>Error: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty results
|
||||||
|
if (executions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-12 text-center rounded-lg shadow">
|
||||||
|
<p>No executions found</p>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="mt-3 text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Inline loading overlay - shows on top of previous results while fetching */}
|
||||||
|
{isFetching && (
|
||||||
|
<div className="absolute inset-0 bg-white/60 z-10 flex items-center justify-center rounded-lg">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Non-fatal error banner (data still shown from cache) */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
|
<p>Error refreshing: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||||
|
<table className="min-w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
ID
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Action
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Rule
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Trigger
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{executions.map((exec: any) => (
|
||||||
|
<tr key={exec.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 font-mono text-sm">
|
||||||
|
<Link
|
||||||
|
to={`/executions/${exec.id}`}
|
||||||
|
className="text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
#{exec.id}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-sm text-gray-900">
|
||||||
|
{exec.action_ref}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{exec.rule_ref ? (
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
{exec.rule_ref}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400 italic">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{exec.trigger_ref ? (
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
{exec.trigger_ref}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400 italic">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded ${getStatusColor(exec.status)}`}
|
||||||
|
>
|
||||||
|
{exec.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
{new Date(exec.created).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
|
||||||
|
<div className="flex-1 flex justify-between sm:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{(page - 1) * pageSize + 1}
|
||||||
|
</span>{" "}
|
||||||
|
to{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{Math.min(page * pageSize, total)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium">{total}</span> executions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ExecutionsResultsTable.displayName = "ExecutionsResultsTable";
|
||||||
|
|
||||||
export default function ExecutionsPage() {
|
export default function ExecutionsPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
// Initialize filters from URL query parameters
|
// --- Filter input state (updates immediately on keystroke) ---
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const [searchFilters, setSearchFilters] = useState({
|
const [searchFilters, setSearchFilters] = useState({
|
||||||
@@ -68,28 +309,28 @@ export default function ExecutionsPage() {
|
|||||||
return status ? [status] : [];
|
return status ? [status] : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debounced filter state for API calls
|
// --- Debounced filter state (drives API calls, updates after delay) ---
|
||||||
const [debouncedFilters, setDebouncedFilters] = useState(searchFilters);
|
const [debouncedFilters, setDebouncedFilters] = useState(searchFilters);
|
||||||
const [debouncedStatuses, setDebouncedStatuses] = useState(selectedStatuses);
|
const [debouncedStatuses, setDebouncedStatuses] = useState(selectedStatuses);
|
||||||
|
|
||||||
// Debounce filter changes (500ms delay)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedFilters(searchFilters);
|
setDebouncedFilters(searchFilters);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchFilters]);
|
}, [searchFilters]);
|
||||||
|
|
||||||
// Debounce status changes (300ms delay - shorter since it's a selection)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedStatuses(selectedStatuses);
|
setDebouncedStatuses(selectedStatuses);
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [selectedStatuses]);
|
}, [selectedStatuses]);
|
||||||
|
|
||||||
|
// --- Autocomplete suggestions ---
|
||||||
|
const baseSuggestions = useFilterSuggestions();
|
||||||
|
|
||||||
|
// --- Build query params from debounced state ---
|
||||||
const queryParams = useMemo(() => {
|
const queryParams = useMemo(() => {
|
||||||
const params: any = { page, pageSize };
|
const params: any = { page, pageSize };
|
||||||
if (debouncedFilters.pack) params.packName = debouncedFilters.pack;
|
if (debouncedFilters.pack) params.packName = debouncedFilters.pack;
|
||||||
@@ -98,33 +339,64 @@ export default function ExecutionsPage() {
|
|||||||
if (debouncedFilters.trigger) params.triggerRef = debouncedFilters.trigger;
|
if (debouncedFilters.trigger) params.triggerRef = debouncedFilters.trigger;
|
||||||
if (debouncedFilters.executor)
|
if (debouncedFilters.executor)
|
||||||
params.executor = parseInt(debouncedFilters.executor, 10);
|
params.executor = parseInt(debouncedFilters.executor, 10);
|
||||||
|
|
||||||
// Include status filter if exactly one status is selected
|
|
||||||
// API only supports single status, so we use the first one for filtering
|
|
||||||
// and show all results if multiple are selected
|
|
||||||
if (debouncedStatuses.length === 1) {
|
if (debouncedStatuses.length === 1) {
|
||||||
params.status = debouncedStatuses[0] as ExecutionStatus;
|
params.status = debouncedStatuses[0] as ExecutionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
}, [page, pageSize, debouncedFilters, debouncedStatuses]);
|
}, [page, pageSize, debouncedFilters, debouncedStatuses]);
|
||||||
|
|
||||||
const { data, isLoading, error } = useExecutions(queryParams);
|
const { data, isLoading, isFetching, error } = useExecutions(queryParams);
|
||||||
|
|
||||||
// Subscribe to real-time updates for all executions
|
|
||||||
const { isConnected } = useExecutionStream({ enabled: true });
|
const { isConnected } = useExecutionStream({ enabled: true });
|
||||||
|
|
||||||
const executions = data?.data || [];
|
const executions = useMemo(() => data?.data || [], [data]);
|
||||||
const total = data?.pagination?.total_items || 0;
|
const total = data?.pagination?.total_items || 0;
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
|
||||||
|
// Derive refs from currently-loaded execution data (no setState needed)
|
||||||
|
const loadedRefs = useMemo(() => {
|
||||||
|
const packs = new Set<string>();
|
||||||
|
const rules = new Set<string>();
|
||||||
|
const actions = new Set<string>();
|
||||||
|
const triggers = new Set<string>();
|
||||||
|
|
||||||
|
for (const exec of executions) {
|
||||||
|
if (exec.action_ref) {
|
||||||
|
const pack = (exec.action_ref as string).split(".")[0];
|
||||||
|
if (pack) packs.add(pack);
|
||||||
|
actions.add(exec.action_ref as string);
|
||||||
|
}
|
||||||
|
if (exec.rule_ref) rules.add(exec.rule_ref as string);
|
||||||
|
if (exec.trigger_ref) triggers.add(exec.trigger_ref as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
packs: [...packs],
|
||||||
|
rules: [...rules],
|
||||||
|
actions: [...actions],
|
||||||
|
triggers: [...triggers],
|
||||||
|
};
|
||||||
|
}, [executions]);
|
||||||
|
|
||||||
|
// Merge base entity suggestions + loaded data refs
|
||||||
|
const packSuggestions = useMergedSuggestions(
|
||||||
|
baseSuggestions.packNames,
|
||||||
|
loadedRefs.packs,
|
||||||
|
);
|
||||||
|
const ruleSuggestions = useMergedSuggestions(
|
||||||
|
baseSuggestions.ruleRefs,
|
||||||
|
loadedRefs.rules,
|
||||||
|
);
|
||||||
|
const actionSuggestions = useMergedSuggestions(
|
||||||
|
baseSuggestions.actionRefs,
|
||||||
|
loadedRefs.actions,
|
||||||
|
);
|
||||||
|
const triggerSuggestions = useMergedSuggestions(
|
||||||
|
baseSuggestions.triggerRefs,
|
||||||
|
loadedRefs.triggers,
|
||||||
|
);
|
||||||
|
|
||||||
// Client-side filtering for multiple status selection (when > 1 selected)
|
// Client-side filtering for multiple status selection (when > 1 selected)
|
||||||
const filteredExecutions = useMemo(() => {
|
const filteredExecutions = useMemo(() => {
|
||||||
// If no statuses selected or only one (already filtered by API), show all
|
if (debouncedStatuses.length <= 1) return executions;
|
||||||
if (debouncedStatuses.length <= 1) {
|
|
||||||
return executions;
|
|
||||||
}
|
|
||||||
// If multiple statuses selected, filter client-side
|
|
||||||
return executions.filter((exec: any) =>
|
return executions.filter((exec: any) =>
|
||||||
debouncedStatuses.includes(exec.status),
|
debouncedStatuses.includes(exec.status),
|
||||||
);
|
);
|
||||||
@@ -132,7 +404,7 @@ export default function ExecutionsPage() {
|
|||||||
|
|
||||||
const handleFilterChange = useCallback((field: string, value: string) => {
|
const handleFilterChange = useCallback((field: string, value: string) => {
|
||||||
setSearchFilters((prev) => ({ ...prev, [field]: value }));
|
setSearchFilters((prev) => ({ ...prev, [field]: value }));
|
||||||
setPage(1); // Reset to first page on filter change
|
setPage(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
@@ -144,57 +416,20 @@ export default function ExecutionsPage() {
|
|||||||
executor: "",
|
executor: "",
|
||||||
});
|
});
|
||||||
setSelectedStatuses([]);
|
setSelectedStatuses([]);
|
||||||
setPage(1); // Reset to first page
|
setPage(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
Object.values(searchFilters).some((v) => v !== "") ||
|
Object.values(searchFilters).some((v) => v !== "") ||
|
||||||
selectedStatuses.length > 0;
|
selectedStatuses.length > 0;
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
||||||
<p>Error: {(error as Error).message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusColor = (status: ExecutionStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case ExecutionStatus.COMPLETED:
|
|
||||||
return "bg-green-100 text-green-800";
|
|
||||||
case ExecutionStatus.FAILED:
|
|
||||||
case ExecutionStatus.TIMEOUT:
|
|
||||||
return "bg-red-100 text-red-800";
|
|
||||||
case ExecutionStatus.RUNNING:
|
|
||||||
return "bg-blue-100 text-blue-800";
|
|
||||||
case ExecutionStatus.SCHEDULED:
|
|
||||||
case ExecutionStatus.SCHEDULING:
|
|
||||||
case ExecutionStatus.REQUESTED:
|
|
||||||
return "bg-yellow-100 text-yellow-800";
|
|
||||||
default:
|
|
||||||
return "bg-gray-100 text-gray-800";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
|
{/* Header - always visible */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Executions</h1>
|
<h1 className="text-3xl font-bold">Executions</h1>
|
||||||
{isLoading && hasActiveFilters && (
|
{isFetching && hasActiveFilters && (
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
Searching executions...
|
Searching executions...
|
||||||
</p>
|
</p>
|
||||||
@@ -208,7 +443,7 @@ export default function ExecutionsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Filters */}
|
{/* Filter section - always mounted, never unmounts during loading */}
|
||||||
<div className="bg-white shadow rounded-lg p-4 mb-6">
|
<div className="bg-white shadow rounded-lg p-4 mb-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -226,28 +461,32 @@ export default function ExecutionsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||||
<FilterInput
|
<AutocompleteInput
|
||||||
label="Pack"
|
label="Pack"
|
||||||
value={searchFilters.pack}
|
value={searchFilters.pack}
|
||||||
onChange={(value) => handleFilterChange("pack", value)}
|
onChange={(value) => handleFilterChange("pack", value)}
|
||||||
|
suggestions={packSuggestions}
|
||||||
placeholder="e.g., core"
|
placeholder="e.g., core"
|
||||||
/>
|
/>
|
||||||
<FilterInput
|
<AutocompleteInput
|
||||||
label="Rule"
|
label="Rule"
|
||||||
value={searchFilters.rule}
|
value={searchFilters.rule}
|
||||||
onChange={(value) => handleFilterChange("rule", value)}
|
onChange={(value) => handleFilterChange("rule", value)}
|
||||||
|
suggestions={ruleSuggestions}
|
||||||
placeholder="e.g., core.on_timer"
|
placeholder="e.g., core.on_timer"
|
||||||
/>
|
/>
|
||||||
<FilterInput
|
<AutocompleteInput
|
||||||
label="Action"
|
label="Action"
|
||||||
value={searchFilters.action}
|
value={searchFilters.action}
|
||||||
onChange={(value) => handleFilterChange("action", value)}
|
onChange={(value) => handleFilterChange("action", value)}
|
||||||
|
suggestions={actionSuggestions}
|
||||||
placeholder="e.g., core.echo"
|
placeholder="e.g., core.echo"
|
||||||
/>
|
/>
|
||||||
<FilterInput
|
<AutocompleteInput
|
||||||
label="Trigger"
|
label="Trigger"
|
||||||
value={searchFilters.trigger}
|
value={searchFilters.trigger}
|
||||||
onChange={(value) => handleFilterChange("trigger", value)}
|
onChange={(value) => handleFilterChange("trigger", value)}
|
||||||
|
suggestions={triggerSuggestions}
|
||||||
placeholder="e.g., core.timer"
|
placeholder="e.g., core.timer"
|
||||||
/>
|
/>
|
||||||
<FilterInput
|
<FilterInput
|
||||||
@@ -268,153 +507,19 @@ export default function ExecutionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredExecutions.length === 0 ? (
|
{/* Results section - isolated from filter state, only depends on query results */}
|
||||||
<div className="bg-white p-12 text-center rounded-lg shadow">
|
<ExecutionsResultsTable
|
||||||
<p>
|
executions={filteredExecutions}
|
||||||
{executions.length === 0
|
isLoading={isLoading}
|
||||||
? "No executions found"
|
isFetching={isFetching}
|
||||||
: "No executions match the selected filters"}
|
error={error as Error | null}
|
||||||
</p>
|
hasActiveFilters={hasActiveFilters}
|
||||||
{executions.length > 0 && hasActiveFilters && (
|
clearFilters={clearFilters}
|
||||||
<button
|
page={page}
|
||||||
onClick={clearFilters}
|
setPage={setPage}
|
||||||
className="mt-3 text-sm text-blue-600 hover:text-blue-800"
|
pageSize={pageSize}
|
||||||
>
|
total={total}
|
||||||
Clear filters
|
/>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
|
||||||
<table className="min-w-full">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
||||||
ID
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
||||||
Action
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
||||||
Rule
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
||||||
Trigger
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
|
||||||
Created
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{filteredExecutions.map((exec: any) => (
|
|
||||||
<tr key={exec.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 font-mono text-sm">
|
|
||||||
<Link
|
|
||||||
to={`/executions/${exec.id}`}
|
|
||||||
className="text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
#{exec.id}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="text-sm text-gray-900">
|
|
||||||
{exec.action_ref}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
{exec.rule_ref ? (
|
|
||||||
<span className="text-sm text-gray-700">
|
|
||||||
{exec.rule_ref}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-gray-400 italic">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
{exec.trigger_ref ? (
|
|
||||||
<span className="text-sm text-gray-700">
|
|
||||||
{exec.trigger_ref}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-gray-400 italic">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 text-xs rounded ${getStatusColor(exec.status)}`}
|
|
||||||
>
|
|
||||||
{exec.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-500">
|
|
||||||
{new Date(exec.created).toLocaleString()}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
|
|
||||||
<div className="flex-1 flex justify-between sm:hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page - 1)}
|
|
||||||
disabled={page === 1}
|
|
||||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page === totalPages}
|
|
||||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Showing{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{(page - 1) * pageSize + 1}
|
|
||||||
</span>{" "}
|
|
||||||
to
|
|
||||||
<span className="font-medium">
|
|
||||||
{Math.min(page * pageSize, total)}
|
|
||||||
</span>{" "}
|
|
||||||
of
|
|
||||||
<span className="font-medium">{total}</span> executions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page - 1)}
|
|
||||||
disabled={page === 1}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page === totalPages}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user