/** * Resource Events panel — lifecycle event list. * * Each row is one ``resource_events`` row: when a GPU instance / PV was * created, transitioned metered, attached / detached, deleted, etc. * * Mounted as a stretch tab in the Usage page. Filter bar matches the Tokens * tab (date range + "filter by user" for managers); the resource-type / * event-type selects ride in the bar's ``extra`` slot. */ import { useAccess, useIntl } from '@umijs/max'; import { Input, Select, Table, Tag } from 'antd'; import dayjs from 'dayjs'; import _ from 'lodash'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { queryResourceEvents, ResourceEventItem, ResourceEventsResponse } from '../apis/resource'; import { parseRollup } from '../utils/time-buckets'; import ResourceFilterBar from './resource-filter-bar'; // Only these four are ever emitted (see resource_event_logger): create/delete // + the metering-window pair. updated/attached/detached exist as enum values // but are intentionally not recorded, so they're not offered as filters. const EVENT_COLOR: Record = { created: 'green', deleted: 'red', phase_to_metered: 'blue', phase_left_metered: 'orange' }; // Humanize a failure phase enum for display, e.g. "SSHPublicKeyCreateFailed" → // "SSH Public Key Create Failed" (fallback when the backend has no detail). const humanizePhase = (phase: string): string => phase .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') .replace(/([a-z\d])([A-Z])/g, '$1 $2'); const ResourceEvents: React.FC = () => { const access = useAccess(); const intl = useIntl(); const canManageUsers = !!access.canSeeOrgAdmin; const scope = canManageUsers ? 'all' : 'self'; // Users only ever see "Storage" in the product — never "Persistent Volume". const RESOURCE_TYPE_LABELS: Record = useMemo( () => ({ gpu_instance: intl.formatMessage({ id: 'usage.events.resource.gpuInstance' }), cpu_instance: intl.formatMessage({ id: 'usage.events.resource.cpuInstance' }), persistent_volume: intl.formatMessage({ id: 'usage.tabs.storage' }) }), [intl] ); const RESOURCE_TYPE_OPTIONS = useMemo( () => [ { value: 'gpu_instance', label: RESOURCE_TYPE_LABELS.gpu_instance }, { value: 'persistent_volume', label: RESOURCE_TYPE_LABELS.persistent_volume } ], [RESOURCE_TYPE_LABELS] ); const EVENT_TYPE_OPTIONS = useMemo( () => [ { value: 'created', label: intl.formatMessage({ id: 'usage.events.type.created' }) }, { value: 'deleted', label: intl.formatMessage({ id: 'usage.events.type.deleted' }) }, { value: 'phase_to_metered', label: intl.formatMessage({ id: 'usage.events.type.started' }) }, { value: 'phase_left_metered', label: intl.formatMessage({ id: 'usage.events.type.stopped' }) } ], [intl] ); // Raw event_type → human-readable label (the same wording as the filter). // ``phase_to_metered`` / ``phase_left_metered`` bracket the metering window — // when the resource starts / stops accruing metered uptime (the OSS build // only meters, it doesn't charge). Users shouldn't see the internal enum // names. const EVENT_LABEL: Record = useMemo( () => Object.fromEntries(EVENT_TYPE_OPTIONS.map((o) => [o.value, o.label])), [EVENT_TYPE_OPTIONS] ); // All filters live in one query-params object; ``resourceType`` is single // (the backend filters by one) and ``nameQuery`` is the debounced substring. type QueryParams = { dateRange: [dayjs.Dayjs, dayjs.Dayjs]; resourceType?: string; eventTypes: string[]; nameQuery: string; page: number; }; const [queryParams, setQueryParams] = useState({ dateRange: [dayjs().subtract(29, 'day'), dayjs()], resourceType: undefined, eventTypes: [], nameQuery: '', page: 1 }); const [data, setData] = useState(null); // Latest params, so the stable debounced name handler reads current values. const queryRef = useRef(queryParams); queryRef.current = queryParams; // Single fetch entry point: merge the patch into the current params, persist // them, then request — triggered from each handler rather than from effect // dependencies, so there's exactly one request per user action. const fetchEvents = useCallback( async (patch: Partial) => { const params = { ...queryRef.current, ...patch }; setQueryParams(params); try { const res = await queryResourceEvents({ start_date: params.dateRange[0].format('YYYY-MM-DD'), end_date: params.dateRange[1].format('YYYY-MM-DD'), scope, resource_types: params.resourceType ? [params.resourceType] : undefined, resource_name: params.nameQuery || undefined, event_types: params.eventTypes, page: params.page, perPage: 50 }); setData(res); } catch { // Keep last response on failure. } }, [scope] ); // First load only — subsequent fetches are driven by the handlers below. useEffect(() => { fetchEvents({}); }, []); // Debounced name search; a filter change always resets to page 1. const onNameChange = useMemo( () => _.debounce( (value: string) => fetchEvents({ nameQuery: value.trim(), page: 1 }), 400 ), [fetchEvents] ); const columns = useMemo( () => [ { title: intl.formatMessage({ id: 'usage.events.col.time' }), dataIndex: 'occurred_at', key: 'occurred_at', // Backend sends the rollup-tz instant with its offset; parseRollup keeps // it (no conversion to the browser tz). render: (v: string) => v ? parseRollup(v).format('YYYY-MM-DD HH:mm:ss') : '-', width: 200 }, { title: intl.formatMessage({ id: 'usage.events.col.resource' }), dataIndex: 'resource_type', key: 'resource_type', render: (v: string) => RESOURCE_TYPE_LABELS[v] || v, width: 160 }, { title: intl.formatMessage({ id: 'usage.table.name' }), dataIndex: 'resource_name', key: 'resource_name' }, { title: intl.formatMessage({ id: 'usage.events.col.event' }), dataIndex: 'event_type', key: 'event_type', render: (v: string) => ( {EVENT_LABEL[v] ?? v} ), width: 180 }, { title: intl.formatMessage({ id: 'usage.events.col.message' }), dataIndex: 'event_message', key: 'event_message', render: (v?: string, row?: ResourceEventItem) => { // A failure phase (…Failed) is the one thing not already shown in the // Event column — surface it (with its detail) as an error message. if (row?.phase && /failed$/i.test(row.phase)) { return ( {row.phase_message || humanizePhase(row.phase)} ); } return v ?? '-'; } } ], [intl, RESOURCE_TYPE_LABELS, EVENT_LABEL] ); return (
fetchEvents({ dateRange: dates, page: 1 })} // No user filter here — the events list has no User column. Scope is // still applied to the query (members see only their own events). canManageUsers={false} userOptions={[]} selectedUsers={[]} onUsersChange={() => {}} onRefresh={() => fetchEvents({})} extra={ <> onNameChange(e.target.value)} style={{ width: 200 }} /> fetchEvents({ eventTypes: v, page: 1 })} options={EVENT_TYPE_OPTIONS} style={{ minWidth: 240 }} /> } /> fetchEvents({ page: p }) }} /> ); }; export default ResourceEvents;