Files
MikrotikManager/components/alerts-outbox-panel.tsx
T

57 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import {useCallback,useEffect,useRef,useState,useSyncExternalStore} from "react"
import type {AlertsOutboxOverview} from "@mmapp/contracts/alerts"
import {can} from "@/lib/auth"
import {getAlertsOutbox,retryAlertsOutbox} from "@/shared/api/alerts-outbox"
import {OpsPanel} from "@/components/ops-panel"
import {Button} from "@/components/ui/button"
import {ApiClientError} from "@/shared/api/http-client"
import {outboxPending,subscribeOutboxPending,setOutboxPending} from "@/lib/alerts-outbox-checkpoint"
import {AlertDialog,AlertDialogContent,AlertDialogHeader,AlertDialogTitle,AlertDialogDescription,AlertDialogFooter} from "@/components/ui/alert-dialog"
export function AlertsOutboxPanel({base,operationsBusy,available,beginOperation,endOperation}:{base:string;operationsBusy:boolean;available:boolean;beginOperation:()=>boolean;endOperation:()=>void}){
const source=JSON.stringify(["live",base]),unknown=useSyncExternalStore(subscribeOutboxPending,()=>outboxPending(source),()=>false)
const [selected,setSelected]=useState<AlertsOutboxOverview["items"][number]|null>(null),[ack,setAck]=useState(false),[reviewed,setReviewed]=useState(false),[mutationBusy,setMutationBusy]=useState(false),[mutationError,setMutationError]=useState<string|null>(null),[message,setMessage]=useState<string|null>(null)
const mutationLock=useRef(false),writeController=useRef<AbortController|null>(null),writeRevision=useRef(0)
const writable=can("mm:alerts:write")&&available&&!operationsBusy&&!unknown&&!mutationBusy
const [data,setData]=useState<AlertsOutboxOverview|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null),[receivedAt,setReceivedAt]=useState<string|null>(null)
const active=useRef(true),revision=useRef(0),controller=useRef<AbortController|null>(null)
const refresh=useCallback(async()=>{
if(!active.current||!can("mm:alerts:read"))return
controller.current?.abort();controller.current=new AbortController();const requestRevision=++revision.current;setBusy(true);setError(null)
const current=()=>active.current&&requestRevision===revision.current&&can("mm:alerts:read")
try{const result=await getAlertsOutbox(base,{signal:controller.current.signal});if(current()){setData(result);setReceivedAt(new Date().toISOString())}}catch(e){if(current())setError(e instanceof Error?e.message:"Не удалось получить очередь")}
finally{if(current())setBusy(false)}
},[base])
useEffect(()=>{const generation=revision,writeGeneration=writeRevision;active.current=true;let pending=true;queueMicrotask(()=>{if(pending)void refresh()});return()=>{pending=false;active.current=false;generation.current++;writeGeneration.current++;controller.current?.abort();writeController.current?.abort()}},[refresh])
async function retry(){
if(!selected||!ack||!writable||mutationLock.current||!beginOperation())return
mutationLock.current=true;setMutationBusy(true);setMutationError(null);setMessage(null);controller.current?.abort();revision.current++;setBusy(false)
const requestRevision=++writeRevision.current,abort=new AbortController();writeController.current=abort;setOutboxPending(source,true)
const current=()=>active.current&&requestRevision===writeRevision.current
try{await retryAlertsOutbox(base,selected.id,{expectedVersion:selected.resolutionVersion,acknowledgedDuplicateRisk:true},{signal:abort.signal});if(current()){setOutboxPending(source,false);setSelected(null);setMessage("Сообщение поставлено в очередь. Отправка и доставка ещё не подтверждены.");void refresh()}}
catch(e){if(current()){
const rejected=e instanceof ApiClientError&&[400,401,403,409,422].includes(e.status)
if(rejected)setOutboxPending(source,false)
const detail=e instanceof Error?e.message:"Ответ не получен"
setMutationError(rejected?detail:"Результат повтора не подтверждён. Автоматический повтор заблокирован. Проверьте очередь и чат перед новой попыткой. "+detail)
}}
finally{mutationLock.current=false;endOperation();if(current())setMutationBusy(false)}
}
return <section aria-label="Очередь Telegram" className="min-w-0"><OpsPanel title="Очередь Telegram" description="Приём сообщения API и доставка в чат — разные состояния." headerRight={<Button type="button" variant="outline" size="sm" aria-label="Обновить очередь Telegram" disabled={busy||mutationBusy} onClick={()=>void refresh()}>Обновить</Button>} contentClassName="space-y-3 px-5 py-4">
{error?<p role="alert" className="text-xs text-destructive break-words">{error}{data?". Показан последний успешный снимок очереди.":""}</p>:null}
<p role="status" className="text-xs text-muted-foreground">{busy?"Обновление очереди…":data?"Последний успешный ответ: "+new Date(receivedAt!).toLocaleString("ru-RU"):"Данные очереди ещё не получены."}</p>
{message?<p role="status" className="text-xs break-words">{message}</p>:null}
{mutationError?<p role="alert" className="text-xs text-destructive break-words">{mutationError}</p>:null}
{unknown?<div className="space-y-2 text-xs"><p role="status">{mutationBusy?"Постановка в очередь выполняется…":"Результат повтора неизвестен. Автоматический повтор заблокирован. Проверьте очередь и чат: сообщение могло быть отправлено."}</p><label className="flex items-start gap-2"><input type="checkbox" checked={reviewed} disabled={mutationBusy} onChange={e=>setReviewed(e.target.checked)}/>Проверил очередь и чат</label><Button type="button" size="sm" variant="outline" disabled={!reviewed||mutationBusy} onClick={()=>{setOutboxPending(source,false);setReviewed(false);void refresh()}}>Результат повтора проверен</Button></div>:null}
{data?<>
<p className="text-sm">Ожидают: {data.counts.pending} · API принял: {data.counts.sent} · Завершены с ошибкой: {data.counts.failed}</p>
{data.counts.unknown||data.counts.processing||data.counts.unrecognized?<p role="status" className="text-xs break-words">Нужна проверка: неизвестный результат — {data.counts.unknown}, в обработке — {data.counts.processing}, нераспознанный статус — {data.counts.unrecognized}. Эти записи автоматически не переотправляются. Обработка может означать текущий запрос или прерванный процесс отправки; проверьте чат и журнал перед повтором.</p>:null}
<p className="text-xs text-muted-foreground">Показано {data.items.length} из {data.counts.processing+data.counts.unknown} удержанных записей. Повтор разрешён только для неизвестного результата после проверки чата.</p>
{data.items.length?<ul className="space-y-2">{data.items.map(item=><li key={item.id} className="break-words text-xs"><p className="font-medium">{item.ruleName||item.id}</p><p className="text-muted-foreground">{item.status==="unknown"?"Результат неизвестен":"Запрос начат, завершение не подтверждено"} · {new Date(item.attemptedAt).toLocaleString("ru-RU")}</p>{item.status==="unknown"?<Button type="button" size="sm" variant="outline" aria-label={"Повторить сообщение "+(item.ruleName||item.id)} disabled={!writable||item.resolutionVersion===2147483647} onClick={()=>{setSelected(item);setAck(false);setMutationError(null)}}>Повторить после проверки</Button>:null}</li>)}</ul>:null}
</>:null}
</OpsPanel>
<AlertDialog open={selected!==null} onOpenChange={open=>{if(!open&&!mutationLock.current)setSelected(null)}}><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Повторить сообщение?</AlertDialogTitle><AlertDialogDescription>{selected?.ruleName||selected?.id}. Предыдущая попытка могла доставить сообщение. Повтор может создать дубликат; повтор использует сохранённое сообщение и настройки подключения Telegram на момент новой попытки.</AlertDialogDescription></AlertDialogHeader><label className="flex items-start gap-2 text-sm"><input type="checkbox" checked={ack} disabled={mutationBusy} onChange={e=>setAck(e.target.checked)}/>Проверил чат и принимаю риск дубликата</label>{mutationError?<p role="alert" className="text-xs text-destructive break-words">{mutationError}</p>:null}<AlertDialogFooter><Button type="button" variant="outline" disabled={mutationBusy} onClick={()=>setSelected(null)}>Отмена</Button><Button type="button" disabled={!ack||!writable} onClick={()=>void retry()}>{mutationBusy?"Постановка в очередь…":"Поставить в очередь повторно"}</Button></AlertDialogFooter></AlertDialogContent></AlertDialog>
</section>
}