306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
|
Paper, TextField, FormControl, InputLabel, Select, MenuItem, CircularProgress,
|
|
Chip, TablePagination, Link, Tooltip, Button,
|
|
} from '@mui/material';
|
|
import { adminService } from '../../api/adminService';
|
|
import type { DevTicket } from '../../api/adminService';
|
|
import { useToastStore } from '../../store/toastStore';
|
|
import { RecordDetailsDialog } from './RecordDetailsDialog';
|
|
|
|
const PAGE_SIZE = 25;
|
|
|
|
const gitStatusColor = (status: DevTicket['git_status']): 'success' | 'warning' | 'error' => {
|
|
if (status === 'success') return 'success';
|
|
if (status === 'failed') return 'error';
|
|
return 'warning';
|
|
};
|
|
|
|
// One unified lifecycle: a ticket is Resolved (from the UI or by closing
|
|
// the Gitea issue), or its record is still Under Review / already handled.
|
|
const statusChip = (t: DevTicket) => {
|
|
if (t.resolved_at) {
|
|
return (
|
|
<Tooltip title={`Resolved by ${t.resolved_by || '—'} · ${new Date(t.resolved_at).toLocaleString()}`}>
|
|
<Chip size="small" label="Resolved" color="success" />
|
|
</Tooltip>
|
|
);
|
|
}
|
|
switch (t.record_status) {
|
|
case 'under_review': return <Chip size="small" label="Under Review" color="warning" />;
|
|
case 'resolved': return <Chip size="small" label="Back in feed" color="success" variant="outlined" />;
|
|
case 'audited': return <Chip size="small" label="Audited" color="info" />;
|
|
case 'deleted': return <Chip size="small" label="Deleted" color="default" />;
|
|
default:
|
|
return (
|
|
<Tooltip title="Record not found or its org DB is unreachable">
|
|
<Chip size="small" label="Unknown" variant="outlined" />
|
|
</Tooltip>
|
|
);
|
|
}
|
|
};
|
|
|
|
const truncate = (text: string, max = 80) => (text.length > max ? `${text.slice(0, max)}…` : text);
|
|
|
|
export const DevTickets: React.FC = () => {
|
|
const [search, setSearch] = useState('');
|
|
const [gitStatus, setGitStatus] = useState<'' | 'pending' | 'success' | 'failed'>('');
|
|
const [dateFrom, setDateFrom] = useState('');
|
|
const [dateTo, setDateTo] = useState('');
|
|
const [records, setRecords] = useState<DevTicket[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// dateTo is a bare date; created_at is a full timestamp, and both fields
|
|
// are independently editable, so normalize the order and extend "to" to
|
|
// end-of-day - see Allocation.tsx/AuditHistory.tsx for the same fix after
|
|
// hitting this exact bug there (an inverted range silently matches zero
|
|
// rows instead of erroring).
|
|
const [effectiveDateFrom, effectiveDateTo] = (() => {
|
|
if (!dateFrom || !dateTo) return [dateFrom || undefined, dateTo ? `${dateTo}T23:59:59` : undefined];
|
|
return dateFrom <= dateTo
|
|
? [dateFrom, `${dateTo}T23:59:59`]
|
|
: [dateTo, `${dateFrom}T23:59:59`];
|
|
})();
|
|
|
|
// Live Gitea issue state (open/closed) - git_status only ever records
|
|
// whether we succeeded in *filing* the issue, never what happened to it
|
|
// afterward, so this is fetched separately from the main list.
|
|
const [issueStates, setIssueStates] = useState<Record<number, 'open' | 'closed' | null>>({});
|
|
const [issueStatesLoading, setIssueStatesLoading] = useState(false);
|
|
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
|
const [detailsTicket, setDetailsTicket] = useState<DevTicket | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const result = await adminService.getDevTickets({
|
|
search: search || undefined,
|
|
gitStatus: gitStatus || undefined,
|
|
dateFrom: effectiveDateFrom,
|
|
dateTo: effectiveDateTo,
|
|
limit: PAGE_SIZE,
|
|
offset: page * PAGE_SIZE,
|
|
});
|
|
setRecords(result.records);
|
|
setTotal(result.total);
|
|
} catch (err) {
|
|
useToastStore.getState().showToast('Failed to load dev tickets.', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [search, gitStatus, effectiveDateFrom, effectiveDateTo, page]);
|
|
|
|
useEffect(() => {
|
|
const timeout = setTimeout(load, 300);
|
|
return () => clearTimeout(timeout);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [search, gitStatus, effectiveDateFrom, effectiveDateTo, page]);
|
|
|
|
useEffect(() => {
|
|
setPage(0);
|
|
}, [search, gitStatus, effectiveDateFrom, effectiveDateTo]);
|
|
|
|
useEffect(() => {
|
|
// Keyed by ticket id, not issue number - the backend resolves each
|
|
// ticket's own stored git_issue_url, so this stays correct even for
|
|
// tickets filed before a repo migration.
|
|
const ticketIds = records
|
|
.filter((t) => t.git_status === 'success' && t.git_issue_number != null)
|
|
.map((t) => t.id);
|
|
|
|
if (ticketIds.length === 0) {
|
|
setIssueStates({});
|
|
return;
|
|
}
|
|
|
|
setIssueStatesLoading(true);
|
|
adminService.getIssueStates(ticketIds)
|
|
.then(setIssueStates)
|
|
.catch(() => useToastStore.getState().showToast('Failed to check live Gitea issue status.', 'error'))
|
|
.finally(() => setIssueStatesLoading(false));
|
|
}, [records]);
|
|
|
|
const handleResolve = async (t: DevTicket) => {
|
|
setResolvingId(t.id);
|
|
try {
|
|
const res = await adminService.resolveTicket(t.id);
|
|
useToastStore.getState().showToast(
|
|
res.recordReleased
|
|
? `Resolved — record #${t.anomaly_id} returned to the feed${res.issueClosed ? ', git issue closed' : ''}.`
|
|
: `Resolved — record was already handled${res.issueClosed ? '; git issue closed' : ''}.`,
|
|
'success'
|
|
);
|
|
load();
|
|
} catch (err: any) {
|
|
useToastStore.getState().showToast(err?.response?.data?.message || 'Failed to resolve ticket.', 'error');
|
|
} finally {
|
|
setResolvingId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box>
|
|
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
<TextField
|
|
label="Search (dev, reporter, message, anomaly ID)"
|
|
size="small"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
sx={{ minWidth: 300 }}
|
|
/>
|
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
|
<InputLabel>Git status</InputLabel>
|
|
<Select label="Git status" value={gitStatus} onChange={(e) => setGitStatus(e.target.value as any)}>
|
|
<MenuItem value="">All</MenuItem>
|
|
<MenuItem value="pending">Pending</MenuItem>
|
|
<MenuItem value="success">Success</MenuItem>
|
|
<MenuItem value="failed">Failed</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<TextField
|
|
label="Created from"
|
|
type="date"
|
|
size="small"
|
|
value={dateFrom}
|
|
onChange={(e) => setDateFrom(e.target.value)}
|
|
slotProps={{ inputLabel: { shrink: true } }}
|
|
/>
|
|
<TextField
|
|
label="Created to"
|
|
type="date"
|
|
size="small"
|
|
value={dateTo}
|
|
onChange={(e) => setDateTo(e.target.value)}
|
|
slotProps={{ inputLabel: { shrink: true } }}
|
|
/>
|
|
</Box>
|
|
|
|
{loading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
|
) : (
|
|
<TableContainer component={Paper}>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Anomaly ID</TableCell>
|
|
<TableCell>Org</TableCell>
|
|
<TableCell>Asset / Plaza</TableCell>
|
|
<TableCell>Dev</TableCell>
|
|
<TableCell>Reporter</TableCell>
|
|
<TableCell>Message</TableCell>
|
|
<TableCell>RocketChat</TableCell>
|
|
<TableCell>Git</TableCell>
|
|
<TableCell>Issue status</TableCell>
|
|
<TableCell>Status</TableCell>
|
|
<TableCell>Created</TableCell>
|
|
<TableCell align="right">Action</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{records.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={12} align="center" sx={{ color: 'text.secondary' }}>
|
|
No dev tickets found.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{records.map((t) => {
|
|
const liveState = t.git_status === 'success' && t.git_issue_number != null ? issueStates[t.id] : undefined;
|
|
return (
|
|
<TableRow
|
|
key={t.id}
|
|
hover
|
|
onClick={() => setDetailsTicket(t)}
|
|
sx={{ cursor: 'pointer' }}
|
|
>
|
|
<TableCell>{t.anomaly_id}</TableCell>
|
|
<TableCell>{t.org_db_name || '—'}</TableCell>
|
|
<TableCell>{[t.asset, t.plaza].filter(Boolean).join(' / ') || '—'}</TableCell>
|
|
<TableCell>@{t.dev_handle}</TableCell>
|
|
<TableCell>{t.reporter || '—'}</TableCell>
|
|
<TableCell>
|
|
<Tooltip title={t.message_text}>
|
|
<span>{truncate(t.message_text)}</span>
|
|
</Tooltip>
|
|
{t.duplicate_of_id && (
|
|
<Chip size="small" label={`dup of #${t.duplicate_of_id}`} sx={{ ml: 1 }} variant="outlined" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{t.rocketchat_sent ? (
|
|
<Chip size="small" label="Sent" color="success" />
|
|
) : (
|
|
<Tooltip title={t.rocketchat_error || 'Not sent'}>
|
|
<Chip size="small" label="Failed" color="error" />
|
|
</Tooltip>
|
|
)}
|
|
</TableCell>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
{t.git_issue_url ? (
|
|
<Link href={t.git_issue_url} target="_blank" rel="noopener noreferrer">
|
|
<Chip size="small" label={`#${t.git_issue_number}`} color={gitStatusColor(t.git_status)} clickable />
|
|
</Link>
|
|
) : (
|
|
<Tooltip title={t.git_error || ''}>
|
|
<Chip size="small" label={t.git_status} color={gitStatusColor(t.git_status)} />
|
|
</Tooltip>
|
|
)}
|
|
{t.retry_count > 0 && (
|
|
<Chip size="small" label={`retries: ${t.retry_count}`} sx={{ ml: 1 }} variant="outlined" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{t.git_status !== 'success' ? (
|
|
'—'
|
|
) : issueStatesLoading && liveState === undefined ? (
|
|
<CircularProgress size={14} />
|
|
) : liveState === 'closed' ? (
|
|
<Chip size="small" label="Closed" color="default" />
|
|
) : liveState === 'open' ? (
|
|
<Chip size="small" label="Open" color="info" />
|
|
) : (
|
|
<Tooltip title="Could not reach Gitea to check">
|
|
<Chip size="small" label="Unknown" variant="outlined" />
|
|
</Tooltip>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>{statusChip(t)}</TableCell>
|
|
<TableCell>{new Date(t.created_at).toLocaleString()}</TableCell>
|
|
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
disabled={!!t.resolved_at || t.record_status !== 'under_review' || resolvingId === t.id}
|
|
onClick={() => handleResolve(t)}
|
|
>
|
|
{resolvingId === t.id ? <CircularProgress size={16} /> : 'Resolve'}
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
<TablePagination
|
|
component="div"
|
|
count={total}
|
|
page={page}
|
|
onPageChange={(_, newPage) => setPage(newPage)}
|
|
rowsPerPage={PAGE_SIZE}
|
|
rowsPerPageOptions={[PAGE_SIZE]}
|
|
/>
|
|
</TableContainer>
|
|
)}
|
|
|
|
<RecordDetailsDialog
|
|
ticket={detailsTicket}
|
|
liveIssueState={detailsTicket ? issueStates[detailsTicket.id] : undefined}
|
|
onClose={() => setDetailsTicket(null)}
|
|
/>
|
|
</Box>
|
|
);
|
|
};
|