feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
import {useState} from "react"
|
||||
import type {AlertGroup} from "@mmapp/contracts/alerts"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Button} from "@/components/ui/button"
|
||||
export function AlertsGroupForm({group,disabled,onSave,onDelete}:{group:AlertGroup;disabled:boolean;onSave:(patch:Partial<AlertGroup>)=>Promise<boolean>;onDelete:()=>void}){
|
||||
const [draft,setDraft]=useState<Partial<AlertGroup>>({})
|
||||
const value={...group,...draft},dirty=Object.keys(draft).some(key=>value[key as keyof AlertGroup]!==group[key as keyof AlertGroup])
|
||||
return <section aria-label={"Группа "+group.name} className="rounded-lg border border-border p-3 space-y-3">
|
||||
<fieldset disabled={disabled} className="space-y-3 min-w-0">
|
||||
<Input aria-label="Имя группы" value={value.name} onChange={e=>setDraft(previous=>({...previous,name:e.target.value}))} className="text-xs"/>
|
||||
<label className="flex flex-wrap items-center gap-2 text-xs">Режим<select aria-label="Режим группы" value={value.combineMode} onChange={e=>setDraft(previous=>({...previous,combineMode:e.target.value==="all"?"all":"any"}))} className="h-8 border border-input rounded-md bg-background px-2"><option value="any">ANY (или)</option><option value="all">ALL (и)</option></select></label>
|
||||
<label className="flex flex-wrap items-center gap-2 text-xs">Cooldown<select aria-label="Cooldown группы" value={value.cooldownOverride??""} onChange={e=>setDraft(previous=>({...previous,cooldownOverride:e.target.value===""?null:e.target.value as AlertGroup["cooldownOverride"]}))} className="h-8 border border-input rounded-md bg-background px-2"><option value="">По умолчанию</option>{["1м","5м","15м","1ч","4ч","24ч"].map(c=><option key={c} value={c}>{c}</option>)}</select></label>
|
||||
<label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={value.enabled} onChange={e=>setDraft(previous=>({...previous,enabled:e.target.checked}))}/>Группа включена</label>
|
||||
<div className="flex flex-wrap gap-2"><Button type="button" size="sm" disabled={!dirty||!value.name.trim()} onClick={async()=>{if(await onSave(draft))setDraft({})}}>Сохранить группу</Button><Button type="button" size="sm" variant="ghost" disabled={!dirty} onClick={()=>setDraft({})}>Сбросить</Button><Button type="button" size="sm" variant="outline" onClick={onDelete}>Удалить группу</Button></div>
|
||||
</fieldset>
|
||||
{dirty?<p role="status" className="text-xs text-muted-foreground">Изменения группы ещё не сохранены.</p>:null}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"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>
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Link from "next/link"
|
||||
import { TriangleAlertIcon } from "lucide-react"
|
||||
import type { AlertTargetIssue } from "@mmapp/contracts/alerts"
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||
|
||||
const kinds = { server: "Доступность сервера", traffic: "Трафик", probe: "Ping-проба" }
|
||||
const reasons = {
|
||||
ambiguous: "Несколько включённых объектов имеют эту подпись. События не подтверждаются.",
|
||||
"disabled-object": "Объект или его сервер выключен. Опрос не выполняется.",
|
||||
"disabled-source": "Сбор данных этого типа выключен.",
|
||||
}
|
||||
export function AlertsTargetIssues({ issues }: { issues: AlertTargetIssue[] | undefined }) {
|
||||
if (!issues?.length) return null
|
||||
const grouped = new Map<string, { target: string; reason: AlertTargetIssue["reason"]; kinds: string[] }>()
|
||||
for (const issue of issues) {
|
||||
const key = `${issue.reason}\0${issue.target}`
|
||||
const group = grouped.get(key) ?? { target: issue.target, reason: issue.reason, kinds: [] }
|
||||
if (!group.kinds.includes(kinds[issue.kind])) group.kinds.push(kinds[issue.kind])
|
||||
grouped.set(key, group)
|
||||
}
|
||||
return <Alert role="status" variant="warning" aria-label="Проблемы целей оповещений">
|
||||
<TriangleAlertIcon />
|
||||
<AlertTitle>Некоторые цели оповещений требуют проверки</AlertTitle>
|
||||
<AlertDescription>
|
||||
<details open={grouped.size <= 5}>
|
||||
<summary className="cursor-pointer">Проверить цели: {grouped.size}</summary>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{[...grouped].map(([key, group]) => <li key={key} className="[overflow-wrap:anywhere]">
|
||||
<strong>{group.target}</strong> · {group.kinds.join(" / ")} — {reasons[group.reason]}
|
||||
</li>)}
|
||||
</ul>
|
||||
</details>
|
||||
{issues.some(i => i.reason === "ambiguous") && <p>Задайте разные имена объектов, затем обновите цели существующих правил.</p>}
|
||||
<p className="mt-2 flex flex-wrap gap-x-4 gap-y-1">
|
||||
<Link className="underline underline-offset-4" href="/servers">Проверить серверы</Link>
|
||||
{issues.some(i => i.kind === "probe") && <Link className="underline underline-offset-4" href="/uptime">Проверить пробы</Link>}
|
||||
{issues.some(i => i.reason === "disabled-source") && <Link className="underline underline-offset-4" href="/data-collection">Проверить сбор данных</Link>}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import {OpsPanel} from "@/components/ops-panel"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import type {useAlertsTargets} from "@/hooks/use-alerts-targets"
|
||||
|
||||
export function AlertsTargetsStatus({catalogs}:{catalogs:ReturnType<typeof useAlertsTargets>}){
|
||||
const rows=[{key:"bgp" as const,label:"BGP",note:"API не сообщает о полноте опроса устройств."},{key:"servers" as const,label:"Серверы",note:"Подписи устройств для целей GRE."},{key:"gre" as const,label:"GRE",note:"Без статуса API состояние туннеля неизвестно."}]
|
||||
return <OpsPanel title="Каталоги целей" description="Обновляются независимо от правил и журнала." contentClassName="divide-y">
|
||||
{rows.map(({key,label,note})=>{const state=catalogs[key];const count=key==="gre"?catalogs.gre.data?.tunnels.length:key==="bgp"?catalogs.bgp.data?.length:catalogs.servers.data?.length
|
||||
return <section key={key} aria-label={"Каталог "+label} className="flex flex-wrap items-start justify-between gap-3 px-5 py-3">
|
||||
<div className="min-w-0 flex-1 basis-48 space-y-1 break-words">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p role="status" className="text-xs text-muted-foreground">{state.loading?"Обновление…":state.data===undefined?"Данные ещё не получены.":"Получено объектов: "+count}{state.receivedAt?" Последний успешный ответ: "+new Date(state.receivedAt).toLocaleString("ru-RU"):""}</p>
|
||||
{state.error?<p role="alert" className="text-xs text-destructive">{state.error}{state.data!==undefined?". Показан последний успешный каталог.":""}</p>:null}
|
||||
<p className="text-xs text-muted-foreground">{note}</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" disabled={state.loading} onClick={()=>void state.refresh()} aria-label={"Обновить каталог "+label}>Обновить</Button>
|
||||
</section>
|
||||
})}
|
||||
</OpsPanel>
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect,useRef,useState,useSyncExternalStore} from "react"
|
||||
import {EyeIcon,EyeOffIcon} from "lucide-react"
|
||||
import type {PutTelegramBody} from "@mmapp/contracts/alerts"
|
||||
import {telegramPending,subscribeTelegramPending,setTelegramPending as checkpoint} from "@/lib/alerts-telegram-checkpoint"
|
||||
import {can} from "@/lib/auth"
|
||||
import {ApiClientError} from "@/shared/api/http-client"
|
||||
import {saveAlertsTelegram,testAlertsTelegram} from "@/shared/api/alerts"
|
||||
import {OpsPanel} from "@/components/ops-panel"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
|
||||
export type TelegramDraft={token:string;chatId:string;messageThreadId:string;connected:boolean}
|
||||
function thread(value:string){if(!value.trim())return null;if(!/^\d+$/.test(value.trim()))throw Error("ID темы: укажите положительное целое число или оставьте поле пустым");const n=Number(value);if(!Number.isSafeInteger(n)||n<1||n>2147483647)throw Error("ID темы должен быть от 1 до 2147483647");return n}
|
||||
|
||||
export function AlertsTelegramForm({initial,tokenConfigured,base,live,available,onBegin,onEnd,onSaved}:{initial:TelegramDraft;tokenConfigured:boolean;base:string;live:boolean;available:boolean;onBegin:()=>boolean;onEnd:()=>void;onSaved:(value:TelegramDraft,configured:boolean)=>void}){
|
||||
const source=JSON.stringify([live,base])
|
||||
const [draft,setDraft]=useState<Partial<Pick<TelegramDraft,"token"|"chatId"|"messageThreadId">>>({})
|
||||
const [showToken,setShowToken]=useState(false),[busy,setBusy]=useState<"save"|"test"|null>(null),[error,setError]=useState<string|null>(null),[outcome,setOutcome]=useState<string|null>(null),[ready,setReady]=useState(false)
|
||||
const unknown=useSyncExternalStore(subscribeTelegramPending,()=>telegramPending(source),()=>false)
|
||||
const active=useRef(true),lock=useRef(false),controller=useRef<AbortController|null>(null)
|
||||
useEffect(()=>{active.current=true;queueMicrotask(()=>{if(active.current){setReady(true)}});return()=>{active.current=false;controller.current?.abort()}},[source])
|
||||
const value={...initial,...draft},allowed=can("mm:alerts:write"),enabled=ready&&live&&available&&allowed
|
||||
const dirty=Object.keys(draft).some(key=>value[key as keyof typeof draft]!==initial[key as keyof typeof draft])
|
||||
function current(){return active.current&&can("mm:alerts:write")}
|
||||
function change(key:keyof typeof draft,value:string){setDraft(previous=>({...previous,[key]:value}));setError(null);setOutcome(null)}
|
||||
async function execute(kind:"save"|"test"){
|
||||
if(!enabled||lock.current||unknown||telegramPending(source)||!current())return
|
||||
const payload:PutTelegramBody={}
|
||||
try{
|
||||
const messageThreadId=thread(value.messageThreadId)
|
||||
if(kind==="save"){
|
||||
if(draft.chatId!==undefined&&value.chatId!==initial.chatId)payload.chatId=value.chatId
|
||||
if(draft.messageThreadId!==undefined&&value.messageThreadId!==initial.messageThreadId)payload.messageThreadId=messageThreadId
|
||||
if(value.token.trim())payload.token=value.token.trim()
|
||||
if(!Object.keys(payload).length){setError("Нет изменённых параметров Telegram");return}
|
||||
}else{
|
||||
if(!value.chatId.trim())throw Error("Укажите Chat ID для тестового сообщения")
|
||||
if(!value.token.trim()&&!tokenConfigured)throw Error("Укажите токен или сохраните его в БД")
|
||||
if(draft.messageThreadId!==undefined&&!messageThreadId&&initial.messageThreadId)throw Error("Сначала сохраните очистку ID темы: тестовый API использует сохранённую тему, если ID не указан")
|
||||
}
|
||||
}catch(e){setError(e instanceof Error?e.message:"Проверьте параметры Telegram");return}
|
||||
if(!onBegin())return
|
||||
lock.current=true;controller.current=new AbortController();setBusy(kind);setError(null);setOutcome(null)
|
||||
checkpoint(source,true)
|
||||
try{
|
||||
if(kind==="save"){
|
||||
const result=await saveAlertsTelegram(base,payload,{signal:controller.current.signal});if(!current())return
|
||||
const saved=result.telegram
|
||||
onSaved({token:"",chatId:saved.chatId,messageThreadId:saved.messageThreadId===null?"":String(saved.messageThreadId),connected:saved.connected},saved.tokenConfigured)
|
||||
setDraft({});setOutcome("Параметры Telegram сохранены в БД")
|
||||
}else{
|
||||
const messageThreadId=thread(value.messageThreadId)
|
||||
await testAlertsTelegram(base,{chatId:value.chatId.trim(),...(value.token.trim()?{token:value.token.trim()}:{}),...(messageThreadId!==null?{messageThreadId}:{})},{signal:controller.current.signal});if(!current())return
|
||||
setOutcome("Telegram API подтвердил приём тестового сообщения. Проверьте его в чате.")
|
||||
}
|
||||
checkpoint(source,false)
|
||||
}catch(e){
|
||||
const rejected=e instanceof ApiClientError&&[400,401,403,409,422].includes(e.status)
|
||||
if(rejected&¤t())checkpoint(source,false)
|
||||
if(current()){setError(e instanceof Error?e.message:"Результат Telegram не получен")}
|
||||
}finally{lock.current=false;controller.current=null;if(active.current){setBusy(null);onEnd()}}
|
||||
}
|
||||
return <section aria-label="Параметры Telegram" className="min-w-0"><OpsPanel title="Telegram" description={initial.connected?"Параметры заполнены. Доставка проверяется отдельно.":"Параметры не заполнены."} contentClassName="flex min-w-0 flex-col gap-3 px-5 py-4">
|
||||
<div className="space-y-1"><label htmlFor="alerts-bot-token" className="text-xs font-medium">Bot Token</label><div className="flex gap-2"><Input id="alerts-bot-token" type={showToken?"text":"password"} value={value.token} disabled={!enabled||!!busy} onChange={e=>change("token",e.target.value)} autoComplete="new-password" placeholder={tokenConfigured?"Токен сохранён; введите новый для замены":"Токен Telegram бота"} className="min-w-0 font-mono text-xs"/><Button type="button" variant="outline" size="icon" aria-label={showToken?"Скрыть токен":"Показать токен"} onClick={()=>setShowToken(previous=>!previous)}>{showToken?<EyeOffIcon aria-hidden/>:<EyeIcon aria-hidden/>}</Button></div></div>
|
||||
<div className="space-y-1"><label htmlFor="alerts-chat-id" className="text-xs font-medium">Chat ID по умолчанию</label><Input id="alerts-chat-id" value={value.chatId} disabled={!enabled||!!busy} onChange={e=>change("chatId",e.target.value)} className="min-w-0 font-mono text-xs"/><p className="text-xs text-muted-foreground">Для групп используйте отрицательный ID. Правило может задать свой чат.</p></div>
|
||||
<div className="space-y-1"><label htmlFor="alerts-thread-id" className="text-xs font-medium">ID темы (message_thread_id)</label><Input id="alerts-thread-id" value={value.messageThreadId} disabled={!enabled||!!busy} inputMode="numeric" onChange={e=>change("messageThreadId",e.target.value)} className="min-w-0 font-mono text-xs"/><p className="text-xs text-muted-foreground">Пустое поле при сохранении очищает тему. Только для форум-супергрупп.</p></div>
|
||||
{dirty?<p role="status" className="text-xs text-muted-foreground">Есть несохранённые изменения. Обновление страницы данных сохранит черновик.</p>:null}
|
||||
{!live?<p className="text-xs text-muted-foreground">Демо: сохранение и отправка недоступны.</p>:!allowed?<p role="status" className="text-xs text-muted-foreground">Нет права изменять оповещения.</p>:null}
|
||||
{error?<p role="alert" className="text-xs text-destructive break-words">{error}</p>:null}
|
||||
{outcome?<p role="status" className="text-xs break-words">{outcome}</p>:null}
|
||||
{busy?<p role="status" className="text-xs text-muted-foreground">Ожидаем подтверждение сервера…</p>:null}
|
||||
{unknown&&!busy?<div className="space-y-2"><p role="status" className="text-xs break-words">Результат запроса неизвестен. Он мог выполниться. Перед повтором проверьте параметры в БД или сообщение в Telegram; обновление данных не подтверждает отправку.</p><Button type="button" size="sm" variant="outline" className="h-auto whitespace-normal" disabled={!!busy||!enabled} onClick={()=>{checkpoint(source,false)}}>Я проверил результат — разрешить новый запрос</Button></div>:null}
|
||||
<Button type="button" size="sm" disabled={!enabled||!!busy||unknown||!dirty} onClick={()=>void execute("save")}>{busy==="save"?"Сохранение…":"Сохранить в БД"}</Button>
|
||||
<Button type="button" size="sm" variant="outline" disabled={!enabled||!!busy||unknown} onClick={()=>void execute("test")}>{busy==="test"?"Отправка…":"Отправить тестовое сообщение"}</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={!dirty||!!busy} onClick={()=>{setDraft({});setError(null);setOutcome(null)}}>Сбросить черновик</Button>
|
||||
</OpsPanel></section>
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { NavMain, type NavGroup } from "@/components/nav-main"
|
||||
import { AppSwitcher } from "@/components/app-switcher"
|
||||
import { NavUser } from "@/components/nav-user"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { navigationGroups } from "@/lib/navigation"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { can,ensureAuthConfig } from "@/lib/auth"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import {getSidebarCounts,getSidebarGreCount} from "@/shared/api/sidebar-counts"
|
||||
import {
|
||||
formatSidebarBadgeCount,
|
||||
mockSidebarBadgesByUrl,
|
||||
type SidebarCountsDto,
|
||||
} from "@/lib/sidebar-badges"
|
||||
|
||||
type LiveSidebarCounts = Partial<SidebarCountsDto> & {
|
||||
greTunnels?: number
|
||||
certificates?: number
|
||||
wireguard?: number
|
||||
ipsec?: number
|
||||
users?: number
|
||||
bgpSessions?: number
|
||||
vxlan?: number
|
||||
containers?: number
|
||||
}
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const evo = useEvoBGP()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [snapshot, setSnapshot] = React.useState<{source:string;counts:LiveSidebarCounts}|null>(null)
|
||||
const source=JSON.stringify([mode,backendUrl]),liveCounts=snapshot?.source===source?snapshot.counts:null
|
||||
const countsAllowed=can("mm:dashboard:read"),greAllowed=can("mm:filters:read")
|
||||
|
||||
const mockBadges = React.useMemo(() => mockSidebarBadgesByUrl(), [])
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled=false
|
||||
void ensureAuthConfig().then(()=>{if(!cancelled)setMounted(true)})
|
||||
return()=>{cancelled=true}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if(!mounted||!prefsHydrated||mode!=="live")return
|
||||
let cancelled=false,revision=0,controller:AbortController|null=null
|
||||
const load=async()=>{
|
||||
controller?.abort();controller=new AbortController();const request=++revision,signal=AbortSignal.any([controller.signal,AbortSignal.timeout(30_000)])
|
||||
const publish=(counts:LiveSidebarCounts)=>{if(cancelled||request!==revision)return;setSnapshot(previous=>({source,counts:{...(previous?.source===source?previous.counts:{}),...counts}}))}
|
||||
await Promise.allSettled([
|
||||
can("mm:dashboard:read")?getSidebarCounts(backendUrl,signal).then(counts=>{if(can("mm:dashboard:read"))publish(counts)}):Promise.resolve(),
|
||||
can("mm:filters:read")?getSidebarGreCount(backendUrl,signal).then(greTunnels=>{if(can("mm:filters:read"))publish({greTunnels})}):Promise.resolve(),
|
||||
])
|
||||
}
|
||||
void load();const timer=window.setInterval(load,120_000)
|
||||
return()=>{cancelled=true;revision++;controller?.abort();window.clearInterval(timer)}
|
||||
},[mounted,mode,backendUrl,prefsHydrated,source,countsAllowed,greAllowed])
|
||||
|
||||
const navGroups = React.useMemo((): NavGroup[] => {
|
||||
function badgeFor(url: string): string | undefined {
|
||||
if (mode !== "live") return mockBadges[url]
|
||||
|
||||
if (url === "/domains" || url === "/ip-ranges" || url === "/asns") {
|
||||
if (!evo.settingsLoaded || !evo.enabled || !can("mm:settings:admin")) return undefined
|
||||
if (!evo.snapshot) return undefined
|
||||
if (url === "/domains") return formatSidebarBadgeCount(evo.snapshot.domains.length)
|
||||
if (url === "/ip-ranges") return formatSidebarBadgeCount(evo.snapshot.ipRanges.length)
|
||||
return formatSidebarBadgeCount(evo.snapshot.asns.length)
|
||||
}
|
||||
|
||||
if (!liveCounts||(!countsAllowed&&url!=="/gre")) return undefined
|
||||
const permission=({"/servers":"mm:servers:read","/users":"mm:users:read","/filters":"mm:filters:read","/uptime":"mm:uptime:read","/gre":"mm:filters:read","/certificates":"mm:certificates:read","/wireguard":"mm:network:read","/ipsec":"mm:network:read","/bgp":"mm:bgp:read","/vxlan":"mm:network:read","/containers":"mm:dashboard:read"} as Record<string,string>)[url]
|
||||
if(permission&&!can(permission))return undefined
|
||||
const format=(value:number|undefined)=>value===undefined?undefined:formatSidebarBadgeCount(value)
|
||||
|
||||
if (url === "/servers") return format(liveCounts.servers)
|
||||
if (url === "/users") return format(liveCounts.users )
|
||||
if (url === "/filters") return format(liveCounts.filterRules)
|
||||
if (url === "/uptime") return format(liveCounts.monitoringItems)
|
||||
if (url === "/gre") return format(liveCounts.greTunnels )
|
||||
if (url === "/certificates") return format(liveCounts.certificates )
|
||||
if (url === "/wireguard") return format(liveCounts.wireguard )
|
||||
if (url === "/ipsec") return format(liveCounts.ipsec )
|
||||
if (url === "/bgp") return format(liveCounts.bgpSessions )
|
||||
if (url === "/vxlan") return format(liveCounts.vxlan )
|
||||
if (url === "/containers") return format(liveCounts.containers )
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
return navigationGroups.map((g) => ({
|
||||
label: g.label,
|
||||
items: g.items.map((it) => ({
|
||||
title: it.title,
|
||||
url: it.url,
|
||||
icon: React.createElement(it.icon, { "aria-hidden": true }),
|
||||
badge: mounted ? badgeFor(it.url) : undefined,
|
||||
})),
|
||||
}))
|
||||
}, [mounted, mode, liveCounts, countsAllowed, mockBadges, evo.settingsLoaded, evo.enabled, evo.snapshot])
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
<SidebarHeader>
|
||||
<AppSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain groups={navGroups} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
getCurrentApp,
|
||||
} from "@/lib/app-switcher-config"
|
||||
import { useAppSwitcherConfig } from "@/hooks/use-app-switcher"
|
||||
|
||||
/** Sidebar brand switcher — app-shell-12. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function AppSwitcher() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
const current = getCurrentApp(config)
|
||||
const CurrentIcon = APP_SWITCHER_ICONS[current.icon] ?? APP_SWITCHER_ICONS.server
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<CurrentIcon className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{current.name}</span>
|
||||
{current.subtitle ? (
|
||||
<span className="truncate text-xs text-sidebar-foreground/70">
|
||||
{current.subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{isLoading ? "Загрузка…" : config.menuLabel}
|
||||
</div>
|
||||
{config.apps.map((app) => {
|
||||
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
|
||||
const isCurrent = app.id === CURRENT_APP_ID
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<DropdownMenuItem key={app.id} disabled>
|
||||
<Icon />
|
||||
{app.name}
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
render={<a href={app.url} />}
|
||||
>
|
||||
<Icon />
|
||||
{app.name}
|
||||
{app.shortcut ? (
|
||||
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import { LayoutGridIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
authPortalBaseUrl,
|
||||
} from "@/lib/app-switcher-config"
|
||||
import { useAppSwitcherConfig } from "@/hooks/use-app-switcher"
|
||||
|
||||
/** Header apps grid — app-shell-12. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function AppsMenu() {
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
const portalBase = authPortalBaseUrl()
|
||||
const portalAppsUrl = portalBase ? `${portalBase}/admin/apps` : null
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" aria-label="Приложения" />
|
||||
}
|
||||
>
|
||||
<LayoutGridIcon className="size-4.5" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-72"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
{isLoading ? "Загрузка…" : config.menuLabel}
|
||||
</DropdownMenuLabel>
|
||||
<div className="grid grid-cols-3 gap-1 p-1">
|
||||
{config.apps.map((app) => {
|
||||
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
|
||||
const isCurrent = app.id === CURRENT_APP_ID
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
disabled
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
render={<a href={app.url} />}
|
||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-xs font-medium">{app.name}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{portalAppsUrl ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
render={<a href={portalAppsUrl} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настроить на портале
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react"
|
||||
import {
|
||||
ensureAuthConfig,
|
||||
getClaims,
|
||||
getToken,
|
||||
isAuthEnabled,
|
||||
redirectToPortalLogin,
|
||||
redirectToPortalLoginInteractive,
|
||||
} from "@/lib/auth"
|
||||
|
||||
export function AuthGuard({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
await ensureAuthConfig()
|
||||
if (cancelled) return
|
||||
if (!isAuthEnabled()) {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
const claims = getClaims()
|
||||
if (!getToken() || !claims) {
|
||||
const ok = redirectToPortalLogin()
|
||||
if (!ok) redirectToPortalLoginInteractive()
|
||||
return
|
||||
}
|
||||
if (!claims.apps.includes("mm")) {
|
||||
window.location.assign("/access-denied")
|
||||
return
|
||||
}
|
||||
setReady(true)
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
|
||||
Проверка сессии…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import type { Backup } from "@/lib/data"
|
||||
import { backupFileMissing } from "@/lib/backup-file-state"
|
||||
import { AlertCircleIcon, LoaderCircleIcon, TriangleAlertIcon } from "lucide-react"
|
||||
|
||||
export function BackupDeleteDialog({
|
||||
backup,
|
||||
busy,
|
||||
error,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
returnFocus,
|
||||
}: {
|
||||
backup: Backup | null
|
||||
busy: boolean
|
||||
error?: string | null
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
returnFocus?: () => HTMLElement | null
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={Boolean(backup)} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
|
||||
<AlertDialogContent finalFocus={returnFocus}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<AlertCircleIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>{backup && backupFileMissing(backup) ? "Удалить запись о бэкапе?" : "Удалить бэкап?"}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="[overflow-wrap:anywhere]">
|
||||
{backup && backupFileMissing(backup) ? <>Локальный файл <span className="font-mono text-foreground">{backup.filename}</span> отсутствует. Будет удалена только запись из истории.</> : <>Файл <span className="font-mono text-foreground">{backup?.filename}</span> будет удалён
|
||||
{backup?.storage === "s3" || backup?.storage === "both" ? " локально; объект S3 — через текущее подключение к хранилищу" : " с диска"}.</>}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{error && <p role="alert" className="text-sm text-destructive break-words">{error}</p>}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" disabled={busy} onClick={onConfirm}>
|
||||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function BackupRestoreDialog({
|
||||
backup,
|
||||
busy,
|
||||
blocked = false,
|
||||
error,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
returnFocus,
|
||||
}: {
|
||||
backup: Backup | null
|
||||
busy: boolean
|
||||
blocked?: boolean
|
||||
error?: string | null
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
returnFocus?: () => HTMLElement | null
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={Boolean(backup)} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
|
||||
<AlertDialogContent finalFocus={returnFocus}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-warning/10 text-warning">
|
||||
<TriangleAlertIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Восстановить конфигурацию?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="[overflow-wrap:anywhere]">
|
||||
<span>
|
||||
Файл <span className="font-mono text-foreground">{backup?.filename}</span> будет загружен
|
||||
на <span className="text-foreground">{backup?.server}</span> и импортирован.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlertIcon />
|
||||
<AlertTitle className="line-clamp-none">Это изменит рабочую конфигурацию роутера</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сессия может оборваться. Убедитесь, что выбран именно этот сервер и этот снимок.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AlertDialogHeader>
|
||||
{error && <p role="alert" className="text-sm text-destructive break-words">{error}</p>}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy} onClick={onCancel}>{blocked && !busy ? "Закрыть" : "Отмена"}</AlertDialogCancel>
|
||||
<AlertDialogAction disabled={busy || blocked} onClick={onConfirm}>
|
||||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
Восстановить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client"
|
||||
|
||||
import type { Server } from "@/lib/data"
|
||||
import { FormField } from "@/components/form-kit"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
Stepper,
|
||||
StepperContent,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperNav,
|
||||
StepperPanel,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from "@/components/reui-kit/accessible-stepper"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function BackupCreateSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
step,
|
||||
onStepChange,
|
||||
servers,
|
||||
selected,
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
notes,
|
||||
onNotesChange,
|
||||
destinationLabel,
|
||||
busy,
|
||||
onSubmit,
|
||||
locked = false,
|
||||
terminal = false,
|
||||
canObserve = true,
|
||||
jobId,
|
||||
jobStatus,
|
||||
result,
|
||||
error,
|
||||
onReset,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
step: number
|
||||
onStepChange: (step: number) => void
|
||||
servers: Server[]
|
||||
selected: Set<string>
|
||||
onToggle: (id: string) => void
|
||||
onSelectAll: () => void
|
||||
onClear: () => void
|
||||
notes: string
|
||||
onNotesChange: (value: string) => void
|
||||
destinationLabel: string
|
||||
busy: boolean
|
||||
locked?: boolean
|
||||
terminal?: boolean
|
||||
canObserve?: boolean
|
||||
jobId?: string
|
||||
jobStatus?: string
|
||||
result?: string
|
||||
error?: string | null
|
||||
onReset: () => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) onStepChange(1) }}>
|
||||
<SheetContent side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-md min-w-0 flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>{locked ? "Задача резервного копирования" : "Новый бэкап"}</SheetTitle>
|
||||
<SheetDescription>{locked ? "Подтверждённые файлы и ошибки выполнения" : "Снять конфигурацию вручную с выбранных серверов"}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{locked && <div role="status" className="px-6 py-3 text-sm [overflow-wrap:anywhere]"><p>ID: {jobId}</p><p>{jobStatus}</p>{result && <p>{result}</p>}<p className="text-xs text-muted-foreground">Закрытие формы не отменяет задачу. Повторная проверка не создаёт новые копии.</p></div>}
|
||||
{error && <div className="px-6 pb-3"><Alert variant="warning" aria-label="Результат задачи"><AlertTitle>{terminal ? "Результат с ограничениями" : "Запрос не подтверждён"}</AlertTitle><AlertDescription className="[overflow-wrap:anywhere]">{error}</AlertDescription></Alert></div>}
|
||||
<fieldset disabled={busy || locked} className="flex-1 min-h-0 flex flex-col">
|
||||
<Stepper value={step} onValueChange={onStepChange} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||
<StepperNav className="mb-5">
|
||||
<StepperItem step={1}>
|
||||
<StepperTrigger>
|
||||
<StepperIndicator>1</StepperIndicator>
|
||||
<StepperTitle className="sr-only">Серверы</StepperTitle>
|
||||
</StepperTrigger>
|
||||
<StepperSeparator />
|
||||
</StepperItem>
|
||||
<StepperItem step={2}>
|
||||
<StepperTrigger>
|
||||
<StepperIndicator>2</StepperIndicator>
|
||||
<StepperTitle className="sr-only">Заметка</StepperTitle>
|
||||
</StepperTrigger>
|
||||
<StepperSeparator />
|
||||
</StepperItem>
|
||||
<StepperItem step={3}>
|
||||
<StepperTrigger>
|
||||
<StepperIndicator>3</StepperIndicator>
|
||||
<StepperTitle className="sr-only">Подтверждение</StepperTitle>
|
||||
</StepperTrigger>
|
||||
</StepperItem>
|
||||
</StepperNav>
|
||||
<StepperPanel className="flex-1 overflow-y-auto">
|
||||
<StepperContent value={1} className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-sm font-medium">Выберите серверы</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={onSelectAll} className="text-xs text-primary hover:underline">
|
||||
Все
|
||||
</button>
|
||||
<span className="text-border">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{servers.map((s) => {
|
||||
const checked = selected.has(s.id)
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors",
|
||||
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() => onToggle(s.id)}
|
||||
aria-label={`Выбрать ${s.name}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{s.name}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
</div>
|
||||
{s.status === "offline" && (
|
||||
<span className="text-xs text-muted-foreground">недоступен</span>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</StepperContent>
|
||||
<StepperContent value={2} className="flex flex-col gap-4">
|
||||
<FormField label="Заметка">
|
||||
<Input
|
||||
placeholder="Например: перед обновлением BGP"
|
||||
aria-label="Заметка резервной копии"
|
||||
maxLength={500}
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</StepperContent>
|
||||
<StepperContent value={3} className="flex flex-col gap-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
{locked ? "Запрошены резервные копии для" : "Будет создан бэкап для"} <strong className="text-foreground">{selected.size}</strong> серверов.
|
||||
</p>
|
||||
{!locked && <p className="text-muted-foreground">
|
||||
Куда сохранится: <strong className="text-foreground">{destinationLabel}</strong>
|
||||
</p>}
|
||||
{notes ? <p className="text-muted-foreground">Заметка: {notes}</p> : null}
|
||||
</StepperContent>
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||||
{!locked && step > 1 && (
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={() => onStepChange(step - 1)}>
|
||||
Назад
|
||||
</Button>
|
||||
)}
|
||||
{locked ? <><Button type="button" disabled={!canObserve || busy || terminal} onClick={onSubmit}>Проверить задачу</Button>{terminal && <Button type="button" onClick={onReset}>Новая задача</Button>}</> : step < 3 ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={step === 1 && selected.size === 0}
|
||||
onClick={() => onStepChange(step + 1)}
|
||||
>
|
||||
Далее
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={selected.size === 0 || busy}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Снять бэкап ({selected.size})
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { QueryFilters } from "@/components/data-grids/query-filters"
|
||||
import type { FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
import type { Backup } from "@/lib/data"
|
||||
import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
|
||||
import {
|
||||
BACKUP_FILTER_ACCESSORS,
|
||||
BACKUP_FILTER_FIELDS,
|
||||
} from "@/lib/data-filters/backup-filter-fields"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PlusIcon } from "lucide-react"
|
||||
|
||||
type KindFilter = "all" | "auto" | "manual"
|
||||
|
||||
export function BackupsHistory({
|
||||
backups,
|
||||
kindFilter,
|
||||
onKindFilterChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onDownload,
|
||||
onRestore,
|
||||
onDelete,
|
||||
onCreate,
|
||||
canCreate = true,
|
||||
canRestore = true,
|
||||
canWrite = true,
|
||||
canDownload = true,
|
||||
}: {
|
||||
backups: Backup[]
|
||||
kindFilter: KindFilter
|
||||
onKindFilterChange: (value: KindFilter) => void
|
||||
search: string
|
||||
onSearchChange: (value: string) => void
|
||||
filters: FilterQuery
|
||||
onFiltersChange: (filters: FilterQuery) => void
|
||||
onDownload: (id: string, filename: string) => void
|
||||
onRestore: (backup: Backup) => void
|
||||
onDelete: (backup: Backup) => void
|
||||
canCreate?: boolean
|
||||
canRestore?: boolean
|
||||
canWrite?: boolean
|
||||
canDownload?: boolean
|
||||
onCreate: () => void
|
||||
}) {
|
||||
const autoCount = backups.filter((b) => b.kind === "auto").length
|
||||
const manualCount = backups.filter((b) => b.kind === "manual").length
|
||||
const byKind = kindFilter === "all" ? backups : backups.filter((b) => b.kind === kindFilter)
|
||||
const q = search.trim().toLowerCase()
|
||||
const searched = q
|
||||
? byKind.filter((b) =>
|
||||
[b.filename, b.server, b.notes].some((v) => v.toLowerCase().includes(q)),
|
||||
)
|
||||
: byKind
|
||||
const filtered = applyFilterQuery(searched, filters, BACKUP_FILTER_ACCESSORS)
|
||||
const isEmptyAll = backups.length === 0
|
||||
|
||||
return (
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
segmented={{
|
||||
value: kindFilter,
|
||||
onChange: onKindFilterChange,
|
||||
options: [
|
||||
{ value: "all", label: "Все", count: backups.length },
|
||||
{ value: "auto", label: "Авто", count: autoCount },
|
||||
{ value: "manual", label: "Вручную", count: manualCount },
|
||||
],
|
||||
}}
|
||||
filterControl={<QueryFilters fields={BACKUP_FILTER_FIELDS} query={filters} onQueryChange={onFiltersChange} />}
|
||||
search={search}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder="Поиск по файлу, серверу, заметке…"
|
||||
countLabel={`${filtered.length} бэкапов`}
|
||||
/>
|
||||
<BackupsDataGrid
|
||||
backups={filtered}
|
||||
onDownload={onDownload}
|
||||
onRestore={onRestore}
|
||||
onDelete={onDelete}
|
||||
canRestore={canRestore}
|
||||
canWrite={canWrite}
|
||||
canDownload={canDownload}
|
||||
emptyTitle={isEmptyAll ? "Нет бэкапов" : "Ничего не найдено"}
|
||||
emptyDescription={
|
||||
isEmptyAll
|
||||
? "Создайте первый бэкап вручную или настройте расписание"
|
||||
: "Измените фильтры или поисковый запрос"
|
||||
}
|
||||
emptyAction={
|
||||
isEmptyAll && canCreate ? (
|
||||
<Button type="button" size="sm" onClick={onCreate}>
|
||||
<PlusIcon className="size-4" />
|
||||
Новый бэкап
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</DataPageCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
"use client"
|
||||
|
||||
import type { Server } from "@/lib/data"
|
||||
import type { BackupStorageSettingsDto } from "@mmapp/contracts/backups"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LoaderCircleIcon } from "lucide-react"
|
||||
|
||||
export const WEEK_DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||||
|
||||
export type BackupFreq = "daily" | "weekly" | "monthly"
|
||||
export type StorageProvider = "local" | "s3"
|
||||
|
||||
export type BackupScheduleForm = {
|
||||
enabled: boolean
|
||||
frequency: BackupFreq
|
||||
hour: number | string
|
||||
minute: number | string
|
||||
weekDay: number | string
|
||||
monthDay: number | string
|
||||
keepCount: number | string
|
||||
format: "rsc" | "backup"
|
||||
}
|
||||
|
||||
export type BackupStorageForm = {
|
||||
provider: StorageProvider
|
||||
s3Endpoint: string
|
||||
s3Region: string
|
||||
s3Bucket: string
|
||||
s3Prefix: string
|
||||
s3AccessKeyId: string
|
||||
s3SecretAccessKey: string
|
||||
s3ForcePathStyle: boolean
|
||||
keepLocalCopy: boolean
|
||||
showPassword: boolean
|
||||
}
|
||||
|
||||
export const defaultSchedule: BackupScheduleForm = {
|
||||
enabled: true,
|
||||
frequency: "daily",
|
||||
hour: 3,
|
||||
minute: 0,
|
||||
weekDay: 0,
|
||||
monthDay: 1,
|
||||
keepCount: 7,
|
||||
format: "rsc",
|
||||
}
|
||||
|
||||
export const defaultStorageForm: BackupStorageForm = {
|
||||
provider: "local",
|
||||
s3Endpoint: "",
|
||||
s3Region: "us-east-1",
|
||||
s3Bucket: "",
|
||||
s3Prefix: "mikrotik",
|
||||
s3AccessKeyId: "",
|
||||
s3SecretAccessKey: "",
|
||||
s3ForcePathStyle: true,
|
||||
keepLocalCopy: true,
|
||||
showPassword: false,
|
||||
}
|
||||
|
||||
function storageStatus(saved: BackupStorageSettingsDto | null, dirty: boolean) {
|
||||
if (dirty) return { label: "Не сохранено", variant: "warning-light" as const }
|
||||
if (!saved) return { label: "Демонстрация", variant: "secondary" as const }
|
||||
if (saved.provider === "local") {
|
||||
return { label: "Локально", variant: "secondary" as const }
|
||||
}
|
||||
if (saved?.lastTestError) {
|
||||
return { label: "Ошибка", variant: "destructive-light" as const }
|
||||
}
|
||||
if (saved?.lastTestAt && !saved.lastTestError) {
|
||||
return { label: "Проверено ранее", variant: "success-light" as const }
|
||||
}
|
||||
if (saved?.secretConfigured && saved.s3Bucket) {
|
||||
return { label: "Не проверено", variant: "warning-light" as const }
|
||||
}
|
||||
return { label: "Не настроено", variant: "secondary" as const }
|
||||
}
|
||||
|
||||
export function BackupsSettings({
|
||||
schedule,
|
||||
onScheduleChange,
|
||||
storage,
|
||||
onStorageChange,
|
||||
savedStorage,
|
||||
servers,
|
||||
selectedServers,
|
||||
onToggleServer,
|
||||
onSelectAll,
|
||||
onClearServers,
|
||||
onSave,
|
||||
onSaveStorage,
|
||||
scheduleKnown = true,
|
||||
storageKnown = true,
|
||||
storageDirty = false,
|
||||
scheduleWritable = true,
|
||||
storageWritable = true,
|
||||
allEnabled = false,
|
||||
onAllEnabledChange,
|
||||
onTest,
|
||||
onSync,
|
||||
saveBusy,
|
||||
testBusy,
|
||||
syncBusy,
|
||||
}: {
|
||||
schedule: BackupScheduleForm
|
||||
onScheduleChange: <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => void
|
||||
storage: BackupStorageForm
|
||||
onStorageChange: <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) => void
|
||||
savedStorage: BackupStorageSettingsDto | null
|
||||
servers: Server[]
|
||||
selectedServers: Set<string>
|
||||
onToggleServer: (id: string) => void
|
||||
onSelectAll: () => void
|
||||
onClearServers: () => void
|
||||
onSaveStorage: () => void
|
||||
scheduleKnown?: boolean
|
||||
storageKnown?: boolean
|
||||
storageDirty?: boolean
|
||||
scheduleWritable?: boolean
|
||||
storageWritable?: boolean
|
||||
allEnabled?: boolean
|
||||
onAllEnabledChange: (value: boolean) => void
|
||||
onSave: () => void
|
||||
onTest: () => void
|
||||
onSync: () => void
|
||||
saveBusy: boolean
|
||||
testBusy: boolean
|
||||
syncBusy: boolean
|
||||
}) {
|
||||
const status = storageStatus(savedStorage, storageDirty)
|
||||
const secretPlaceholder = savedStorage?.secretConfigured ? "•••••••• (сохранён)" : "••••••••"
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{!scheduleKnown ? <p role="status">Расписание пока неизвестно</p> : <fieldset disabled={!scheduleWritable || saveBusy || testBusy || syncBusy} className="min-w-0">
|
||||
<OpsPanel title="Расписание" description="Автоматический съём конфигурации" contentClassName="px-5 py-5 flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Автоматический бэкап</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
||||
</div>
|
||||
<FormToggle ariaLabel="Автоматический бэкап" checked={schedule.enabled} onChange={(v) => onScheduleChange("enabled", v)} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField label="Частота">
|
||||
<SegmentedControl
|
||||
className="w-full flex-wrap"
|
||||
value={schedule.frequency}
|
||||
onChange={(v) => onScheduleChange("frequency", v)}
|
||||
options={[
|
||||
{ value: "daily", label: "Ежедневно" },
|
||||
{ value: "weekly", label: "Еженедельно" },
|
||||
{ value: "monthly", label: "Ежемесячно" },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{schedule.frequency === "weekly" && (
|
||||
<FormField label="День недели">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{WEEK_DAYS.map((d, i) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => onScheduleChange("weekDay", i)}
|
||||
className={cn(
|
||||
"w-9 h-9 rounded text-sm font-medium border transition-colors",
|
||||
schedule.weekDay === i
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{schedule.frequency === "monthly" && (
|
||||
<FormField label="День месяца" hint="1–28">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={28}
|
||||
className="font-mono w-24"
|
||||
aria-label="День месяца"
|
||||
value={schedule.monthDay}
|
||||
onChange={(e) => onScheduleChange("monthDay", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField label="Время запуска">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
className="font-mono w-20 text-center"
|
||||
aria-label="Час запуска"
|
||||
value={schedule.hour}
|
||||
onChange={(e) => onScheduleChange("hour", e.target.value)}
|
||||
/>
|
||||
<span className="text-muted-foreground font-mono text-lg">:</span>
|
||||
<Input aria-label="Минута запуска" type="number" min={0} max={59} className="font-mono w-20 text-center" value={schedule.minute} onChange={e => onScheduleChange("minute", e.target.value)} />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[0, 15, 30, 45].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => onScheduleChange("minute", m)}
|
||||
className={cn(
|
||||
"px-2.5 py-1.5 rounded text-xs font-mono border transition-colors",
|
||||
schedule.minute === m
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{String(m).padStart(2, "0")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Хранить бэкапов" hint="На каждый сервер">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
className="font-mono w-24"
|
||||
aria-label="Хранить копий"
|
||||
value={schedule.keepCount}
|
||||
onChange={(e) => onScheduleChange("keepCount", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Формат файла" hint="Плановый binary .backup пока недоступен">
|
||||
<select aria-label="Формат файла" className="h-9 rounded-md border px-3 bg-background" value={schedule.format} onChange={e => onScheduleChange("format", e.target.value as "rsc" | "backup")}>
|
||||
<option value="rsc">Текстовый экспорт .rsc</option>
|
||||
<option value="backup" disabled>Binary .backup — недоступен</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</OpsPanel>
|
||||
</fieldset>}
|
||||
|
||||
{!storageKnown ? <p role="status">Хранилище пока неизвестно</p> : <fieldset disabled={!storageWritable || saveBusy || testBusy || syncBusy} className="min-w-0">
|
||||
<OpsPanel
|
||||
title="Хранилище"
|
||||
description="Локальный диск приложения или S3-compatible бакет"
|
||||
headerRight={
|
||||
<Badge variant={status.variant} size="sm" radius="full">
|
||||
{status.label}
|
||||
</Badge>
|
||||
}
|
||||
contentClassName="px-5 py-5 flex flex-col gap-5"
|
||||
>
|
||||
{savedStorage?.lastTestAt && <p className="text-xs text-muted-foreground">Последняя проверка: {new Date(savedStorage.lastTestAt).toLocaleString("ru-RU")}. Текущая доступность может измениться.</p>}
|
||||
<FormField label="Тип хранилища">
|
||||
<SegmentedControl
|
||||
className="w-full flex-wrap"
|
||||
value={storage.provider}
|
||||
onChange={(v) => onStorageChange("provider", v)}
|
||||
options={[
|
||||
{ value: "local", label: "Локально" },
|
||||
{ value: "s3", label: "S3" },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{storage.provider === "local" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Файлы пишутся в каталог приложения <span className="font-mono text-foreground">storage/backups</span>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField label="Endpoint" hint="Пусто для AWS. Для R2/MinIO/Selectel — полный URL">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="https://s3.amazonaws.com"
|
||||
aria-label="Endpoint"
|
||||
value={storage.s3Endpoint}
|
||||
onChange={(e) => onStorageChange("s3Endpoint", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField label="Region">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="us-east-1"
|
||||
aria-label="Region"
|
||||
value={storage.s3Region}
|
||||
onChange={(e) => onStorageChange("s3Region", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Bucket" required>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="mikrotik-backups"
|
||||
aria-label="Bucket"
|
||||
value={storage.s3Bucket}
|
||||
onChange={(e) => onStorageChange("s3Bucket", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Prefix">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="mikrotik"
|
||||
aria-label="Prefix"
|
||||
value={storage.s3Prefix}
|
||||
onChange={(e) => onStorageChange("s3Prefix", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField label="Access key" required>
|
||||
<Input
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
aria-label="Access key"
|
||||
value={storage.s3AccessKeyId}
|
||||
onChange={(e) => onStorageChange("s3AccessKeyId", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Secret key">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={storage.showPassword ? "text" : "password"}
|
||||
className="font-mono pr-14"
|
||||
autoComplete="new-password"
|
||||
placeholder={secretPlaceholder}
|
||||
aria-label="Secret key"
|
||||
value={storage.s3SecretAccessKey}
|
||||
onChange={(e) => onStorageChange("s3SecretAccessKey", e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStorageChange("showPassword", !storage.showPassword)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{storage.showPassword ? "скрыть" : "показ"}
|
||||
</button>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Path-style</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Нужен для MinIO и части совместимых API</p>
|
||||
</div>
|
||||
<FormToggle ariaLabel="Path-style" checked={storage.s3ForcePathStyle} onChange={(v) => onStorageChange("s3ForcePathStyle", v)} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Оставлять локальную копию</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">После успешной загрузки в S3</p>
|
||||
</div>
|
||||
<FormToggle ariaLabel="Оставлять локальную копию" checked={storage.keepLocalCopy} onChange={(v) => onStorageChange("keepLocalCopy", v)} />
|
||||
</div>
|
||||
{savedStorage?.lastTestError ? (
|
||||
<p className="text-xs text-destructive">{savedStorage.lastTestError}</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onTest} disabled={testBusy}>
|
||||
{testBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : null}
|
||||
Проверить
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onSync} disabled={syncBusy}>
|
||||
{syncBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : null}
|
||||
Синхронизировать из бакета
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OpsPanel>
|
||||
</fieldset>}
|
||||
|
||||
<fieldset disabled={!scheduleKnown || !scheduleWritable || saveBusy || testBusy || syncBusy} className="min-w-0 lg:col-span-2">
|
||||
<OpsPanel
|
||||
className="lg:col-span-2"
|
||||
title="Серверы для бэкапа"
|
||||
headerRight={
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button type="button" onClick={onSelectAll} className="text-xs text-primary hover:underline">
|
||||
Выбрать все
|
||||
</button>
|
||||
<span className="text-border">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearServers}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
contentClassName="px-5 py-5 flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm"><Checkbox aria-label="Все включённые серверы" checked={allEnabled} onCheckedChange={value => onAllEnabledChange(value === true)} /><span>Все включённые серверы, включая добавленные позже</span></div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
||||
{servers.map((s) => {
|
||||
const checked = selectedServers.has(s.id)
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors",
|
||||
checked
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-border hover:border-border/80 hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
disabled={allEnabled}
|
||||
checked={checked}
|
||||
onCheckedChange={() => onToggleServer(s.id)}
|
||||
aria-label={`Выбрать ${s.name}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{s.name}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">{s.site}</span>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{allEnabled ? "Все включённые устройства, включая добавленные позже" : "Выбрано " + selectedServers.size + " из " + servers.length + " серверов"}
|
||||
</p>
|
||||
</OpsPanel>
|
||||
</fieldset>
|
||||
|
||||
<div className="lg:col-span-2 flex flex-wrap items-center gap-3">
|
||||
<Button type="button" onClick={onSave} className="gap-2" disabled={!scheduleKnown || !scheduleWritable || saveBusy || testBusy || syncBusy}>
|
||||
{saveBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
Сохранить расписание
|
||||
</Button>
|
||||
<Button type="button" onClick={onSaveStorage} disabled={!storageKnown || !storageWritable || saveBusy || testBusy || syncBusy}>Сохранить хранилище</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { putAcmeCloudflareSettingsSchema, type AcmeCloudflareSettingsDto } from "@mmapp/contracts/certificates"
|
||||
import { getAcmeSettings, putAcmeSettings, testAcmeSettings } from "@/shared/api/certificates"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/** Reuses Settings 16 composition: https://reui.io/preview/base/settings-16?ref=mcp */
|
||||
export function CertificateAcmeSettings({ backendUrl, liveReady, canRead, canWrite }: { backendUrl: string; liveReady: boolean; canRead: boolean; canWrite: boolean }) {
|
||||
const [confirmed, setConfirmed] = useState<AcmeCloudflareSettingsDto | null>(null)
|
||||
const [directory, setDirectory] = useState("")
|
||||
const [zone, setZone] = useState("")
|
||||
const [token, setToken] = useState("")
|
||||
const [loading, setLoading] = useState(liveReady && canRead)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [testResult, setTestResult] = useState<string | null>(null)
|
||||
const dirty = useRef(false)
|
||||
const lock = useRef(false)
|
||||
const alive = useRef(true)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!liveReady || !canRead || lock.current || !alive.current) return
|
||||
controller.current?.abort()
|
||||
const abort = new AbortController()
|
||||
controller.current = abort
|
||||
setLoading(true)
|
||||
try {
|
||||
const saved = await getAcmeSettings(backendUrl, { signal: abort.signal })
|
||||
if (!alive.current || abort.signal.aborted) return
|
||||
setConfirmed(saved)
|
||||
if (!dirty.current) { setDirectory(saved.directoryUrl); setZone(saved.defaultZoneId ?? "") }
|
||||
setError(null)
|
||||
} catch (reason) {
|
||||
if (alive.current && !abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Не удалось прочитать ACME настройки")
|
||||
} finally {
|
||||
if (alive.current && !abort.signal.aborted) setLoading(false)
|
||||
}
|
||||
}, [backendUrl, liveReady, canRead])
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
const cancel = () => { alive.current = false; controller.current?.abort() }
|
||||
return cancel
|
||||
}, [])
|
||||
useEffect(() => { queueMicrotask(() => { if (alive.current) void load() }) }, [load])
|
||||
|
||||
async function operate(action: "save" | "test") {
|
||||
if (!liveReady || !canRead || !canWrite || !confirmed || loading || lock.current || !alive.current) return
|
||||
const payload = { directoryUrl: directory.trim(), defaultZoneId: zone.trim() || null, cloudflareApiToken: token.trim() || undefined }
|
||||
if (action === "save" && !putAcmeCloudflareSettingsSchema.safeParse(payload).success) { setError("Укажите корректный URL ACME directory."); return }
|
||||
if (action === "test" && !token.trim() && !confirmed.tokenConfigured) { setError("Введите токен для проверки. Сохранённого токена нет."); return }
|
||||
lock.current = true
|
||||
setBusy(true)
|
||||
setTestResult(null)
|
||||
const abort = new AbortController()
|
||||
controller.current = abort
|
||||
try {
|
||||
if (action === "save") {
|
||||
const saved = await putAcmeSettings(backendUrl, payload, { signal: abort.signal })
|
||||
if (!alive.current || abort.signal.aborted) return
|
||||
setConfirmed(saved)
|
||||
if (payload.cloudflareApiToken && !saved.tokenConfigured) throw new Error("Сохранение токена не подтверждено")
|
||||
setDirectory(saved.directoryUrl); setZone(saved.defaultZoneId ?? ""); setToken("")
|
||||
dirty.current = false
|
||||
setError(null)
|
||||
toast.success("Настройки ACME сохранены")
|
||||
} else {
|
||||
const result = await testAcmeSettings(backendUrl, { cloudflareApiToken: token.trim() || undefined }, { signal: abort.signal })
|
||||
if (!alive.current || abort.signal.aborted) return
|
||||
if (!result.ok) throw new Error(result.message ?? "Проверка Cloudflare не прошла")
|
||||
setError(null)
|
||||
setTestResult(result.message ?? "Cloudflare API доступен")
|
||||
}
|
||||
} catch (reason) {
|
||||
if (alive.current && !abort.signal.aborted) setError((reason instanceof Error ? reason.message : "Операция не выполнена") + (action === "save" ? ". Результат сохранения не подтверждён; черновик сохранён в форме." : ". Проверка не сохраняет настройки."))
|
||||
} finally {
|
||||
lock.current = false
|
||||
if (alive.current && !abort.signal.aborted) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const disabled = !liveReady || !canRead || !canWrite || !confirmed || loading || busy
|
||||
function edit(set: (value: string) => void, value: string) { dirty.current = true; setTestResult(null); set(value) }
|
||||
return (
|
||||
<OpsPanel title="ACME · Cloudflare DNS-01" description="Публичные Let's Encrypt выпускаются на backend и импортируются на выбранный RouterOS 7.22+." contentClassName="px-5 py-4 flex flex-col gap-3">
|
||||
{error && <p role="alert" aria-label="Настройки ACME">{error}</p>}
|
||||
{loading && <p role="status">Загрузка настроек ACME…</p>}
|
||||
{!confirmed && <p role="status">Настройки ACME пока неизвестны. Сохранение и проверка недоступны до успешного чтения.</p>}
|
||||
{!liveReady && <p role="status">Нет подключения к API.</p>}
|
||||
{!canRead && <p role="status">Нет права mm:certificates:read.</p>}
|
||||
{!canWrite && <p role="status">Настройки ACME доступны только для чтения.</p>}
|
||||
<p className="text-xs text-muted-foreground">{confirmed ? confirmed.tokenConfigured ? "Токен сохранён на backend." : "Сохранённого токена нет." : "Состояние токена неизвестно."}</p>
|
||||
{testResult && <p role="status">{testResult}. Настройки не изменены.</p>}
|
||||
{liveReady && canRead && <Button variant="outline" size="sm" disabled={busy || loading} onClick={() => void load()}>Перечитать настройки ACME</Button>}
|
||||
<form className="grid gap-3 md:grid-cols-2" autoComplete="off" onSubmit={event => event.preventDefault()}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="certificate-acme-directory" className="text-sm font-medium">ACME directory URL</label>
|
||||
<Input id="certificate-acme-directory" name="acme-directory-url" autoComplete="off" disabled={disabled} value={directory} onChange={event => edit(setDirectory, event.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="certificate-acme-zone" className="text-sm font-medium">Cloudflare zone id (опционально)</label>
|
||||
<Input id="certificate-acme-zone" name="cloudflare-zone-id" autoComplete="off" disabled={disabled} value={zone} onChange={event => edit(setZone, event.target.value)} placeholder="Авто по домену" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 md:col-span-2">
|
||||
<label htmlFor="certificate-acme-token" className="text-sm font-medium">Cloudflare API token</label>
|
||||
<Input id="certificate-acme-token" name="cloudflare-api-token" type="password" autoComplete="new-password" disabled={disabled} value={token} onChange={event => edit(setToken, event.target.value)} placeholder={confirmed?.tokenConfigured ? "Введите новый для замены; пустое поле сохранит прежний" : "API token с правом DNS"} />
|
||||
<p className="text-xs text-muted-foreground">Проверка использует введённый токен или сохранённый на backend. Сохранение выполняется отдельно.</p>
|
||||
</div>
|
||||
</form>
|
||||
{canWrite && <div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" disabled={disabled} onClick={() => void operate("test")}>Проверить Cloudflare</Button>
|
||||
<Button size="sm" disabled={disabled} onClick={() => void operate("save")}>Сохранить настройки</Button>
|
||||
</div>}
|
||||
</OpsPanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { FormField, FormToggle } from "@/components/form-kit"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
getCertificateRenewSettings,
|
||||
putCertificateRenewSettings,
|
||||
} from "@/shared/api/certificates"
|
||||
import type { CertificateRenewSettingsDto } from "@mmapp/contracts/certificates"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/**
|
||||
* Автообновление сертификатов через MM (ACME DNS-01).
|
||||
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/alert · https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function CertificateRenewSettingsPanel({
|
||||
backendUrl,
|
||||
liveReady,
|
||||
canRead = true,
|
||||
canWrite = true,
|
||||
}: {
|
||||
backendUrl: string
|
||||
liveReady: boolean
|
||||
canRead?: boolean
|
||||
canWrite?: boolean
|
||||
}) {
|
||||
const [confirmed, setConfirmed] = useState<CertificateRenewSettingsDto | null>(null)
|
||||
const enabled = confirmed?.enabled ?? false
|
||||
const [intervalDraft, setIntervalDraft] = useState("")
|
||||
const [daysDraft, setDaysDraft] = useState("")
|
||||
const [loading, setLoading] = useState(liveReady && canRead)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const alive = useRef(true)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
const writeLock = useRef(false)
|
||||
const dirty = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!liveReady || !canRead || writeLock.current || !alive.current) return
|
||||
controller.current?.abort()
|
||||
const abort = new AbortController()
|
||||
controller.current = abort
|
||||
setLoading(true)
|
||||
try {
|
||||
const saved = await getCertificateRenewSettings(backendUrl, { signal: abort.signal })
|
||||
if (!alive.current || abort.signal.aborted) return
|
||||
setConfirmed(saved)
|
||||
if (!dirty.current) {
|
||||
setIntervalDraft(String(saved.intervalSec))
|
||||
setDaysDraft(String(saved.renewBeforeDays))
|
||||
}
|
||||
setError(null)
|
||||
} catch (reason) {
|
||||
if (alive.current && !abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Не удалось прочитать настройки")
|
||||
} finally {
|
||||
if (alive.current && !abort.signal.aborted) setLoading(false)
|
||||
}
|
||||
}, [backendUrl, liveReady, canRead])
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
const cancel = () => { alive.current = false; controller.current?.abort() }
|
||||
return cancel
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => { if (alive.current) void load() })
|
||||
}, [load])
|
||||
|
||||
async function save(patch: { enabled?: boolean; intervalSec?: number; renewBeforeDays?: number }) {
|
||||
if (!liveReady || !canRead || !canWrite || !confirmed || loading || writeLock.current || !alive.current) return
|
||||
writeLock.current = true
|
||||
setBusy(true)
|
||||
const abort = new AbortController()
|
||||
controller.current = abort
|
||||
try {
|
||||
const saved = await putCertificateRenewSettings(backendUrl, patch, { signal: abort.signal })
|
||||
if (!alive.current || abort.signal.aborted) return
|
||||
setConfirmed(saved)
|
||||
if (patch.intervalSec !== undefined) {
|
||||
setIntervalDraft(String(saved.intervalSec))
|
||||
setDaysDraft(String(saved.renewBeforeDays))
|
||||
dirty.current = false
|
||||
}
|
||||
setError(null)
|
||||
toast.success(patch.enabled !== undefined ? (saved.enabled ? "Автообновление включено" : "Автообновление выключено") : "Расписание автообновления сохранено")
|
||||
} catch (reason) {
|
||||
if (alive.current && !abort.signal.aborted) setError((reason instanceof Error ? reason.message : "Не удалось сохранить") + ". Подтверждённые настройки и черновик сохранены в форме; результат операции не подтверждён.")
|
||||
} finally {
|
||||
writeLock.current = false
|
||||
if (alive.current && !abort.signal.aborted) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnabledChange(next: boolean) { void save({ enabled: next }) }
|
||||
function handleSaveSchedule() {
|
||||
if (!/^[0-9]+$/.test(intervalDraft) || !/^[0-9]+$/.test(daysDraft) || !Number.isSafeInteger(Number(intervalDraft)) || Number(intervalDraft) < 300 || Number(daysDraft) < 1 || Number(daysDraft) > 90) {
|
||||
setError("Введите целые числа: интервал от 300 секунд, срок от 1 до 90 дней.")
|
||||
return
|
||||
}
|
||||
void save({ intervalSec: Number(intervalDraft), renewBeforeDays: Number(daysDraft) })
|
||||
}
|
||||
|
||||
const interactionsOff = !liveReady || !canRead || !canWrite || busy || loading || !confirmed
|
||||
const lastCollectedAt = confirmed?.lastCollectedAt
|
||||
const lastError = confirmed?.lastError
|
||||
|
||||
return (
|
||||
<OpsPanel
|
||||
title="Автообновление через MikrotikManager"
|
||||
description="Фоновый выпуск Let's Encrypt (Cloudflare DNS-01) для сертификатов, выпущенных из этой панели. Ручной выпуск не зависит от переключателя."
|
||||
headerRight={
|
||||
<Badge
|
||||
size="sm"
|
||||
variant={!liveReady || !confirmed ? "warning-light" : enabled ? "success-light" : "secondary"}
|
||||
className={liveReady && confirmed && enabled ? "text-[color:var(--status-online-fg)]" : undefined}
|
||||
>
|
||||
{!confirmed ? "Неизвестно" : enabled ? "Включено" : "Выключено"}
|
||||
</Badge>
|
||||
}
|
||||
contentClassName="px-5 py-4 flex flex-col gap-4"
|
||||
>
|
||||
{error && <p role="alert" aria-label="Настройки автообновления">{error}</p>}
|
||||
{loading && <p role="status">Загрузка настроек автообновления…</p>}
|
||||
{!canRead && <p role="status">Нет права mm:certificates:read.</p>}
|
||||
{!canWrite && <p role="status">Настройки доступны только для чтения.</p>}
|
||||
{liveReady && canRead && <Button variant="outline" size="sm" disabled={busy || loading} onClick={() => void load()}>Перечитать настройки автообновления</Button>}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Обновлять сертификаты из MM</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
Если ACME уже крутит RouterOS — выключите, чтобы не было двойного перевыпуска.
|
||||
</p>
|
||||
</div>
|
||||
<FormToggle ariaLabel="Автообновление сертификатов" checked={enabled} onChange={handleEnabledChange} disabled={interactionsOff} />
|
||||
</div>
|
||||
|
||||
{!liveReady ? (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Нет подключения к API</AlertTitle>
|
||||
<AlertDescription>
|
||||
Переключатель станет активен, когда backend доступен. Планировщик читает тот же флаг, что и страница «Сбор данных».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : !confirmed ? (
|
||||
<p role="status">Состояние автообновления пока неизвестно. Сохранение недоступно до успешного чтения.</p>
|
||||
) : enabled ? (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Не смешивайте с ACME RouterOS</AlertTitle>
|
||||
<AlertDescription>
|
||||
MM обновляет только сертификаты, выпущенные через эту страницу. Встроенный Let's Encrypt на
|
||||
устройстве для тех же имён лучше не включать одновременно.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant="info">
|
||||
<AlertTitle>Автообновление MM выключено</AlertTitle>
|
||||
<AlertDescription>
|
||||
Планировщик MM больше не проверяет срок и не перевыпускает сертификаты. Ручной выпуск и импорт
|
||||
остаются доступны.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className={cn("flex flex-col gap-4", confirmed && !enabled && "opacity-60")}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<FormField label="Интервал проверки" hint="Секунды, минимум 300">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
value={intervalDraft}
|
||||
aria-label="Интервал проверки сертификатов"
|
||||
onChange={(e) => { dirty.current = true; setIntervalDraft(e.target.value) }}
|
||||
disabled={interactionsOff || !enabled}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Обновлять за" hint="Дней до истечения, 1–90">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
value={daysDraft}
|
||||
aria-label="Дней до истечения сертификата"
|
||||
onChange={(e) => { dirty.current = true; setDaysDraft(e.target.value) }}
|
||||
disabled={interactionsOff || !enabled}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={interactionsOff || !enabled} onClick={() => void handleSaveSchedule()}>
|
||||
Сохранить расписание
|
||||
</Button>
|
||||
<Link href="/data-collection" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 text-xs")}>
|
||||
Журнал планировщика →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastCollectedAt || lastError ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Последний прогон:{" "}
|
||||
{lastCollectedAt ? new Date(lastCollectedAt).toLocaleString("ru-RU") : "ещё не было"}
|
||||
{lastError ? ` · ошибка: ${lastError}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</OpsPanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { navigationGroups } from "@/lib/navigation"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||
|
||||
/** Keyboard search from the App Shell 12 composition, using installed Base UI primitives. */
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState("")
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null)
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
setQuery("")
|
||||
}
|
||||
setOpen(!open)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setOpen(false))
|
||||
}, [pathname])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
initialFocus={inputRef}
|
||||
finalFocus={returnFocusRef}
|
||||
className="flex h-[min(32rem,calc(100dvh-2rem))] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
|
||||
>
|
||||
<DialogTitle className="sr-only">Поиск страниц</DialogTitle>
|
||||
<DialogDescription className="sr-only">Найдите раздел по названию или ключевым словам. Стрелки выбирают страницу, Enter открывает её, Escape закрывает поиск.</DialogDescription>
|
||||
<Command className="min-h-0 flex-1" label="Поиск страниц">
|
||||
<CommandInput ref={inputRef} aria-label="Поиск страниц" placeholder="Название страницы или ключевое слово…" value={query} onValueChange={setQuery} />
|
||||
<CommandList className="min-h-0 max-h-none flex-1" label="Найденные страницы">
|
||||
<CommandEmpty><p role="status">Ничего не найдено. Попробуйте другое название.</p></CommandEmpty>
|
||||
{navigationGroups.map(group => (
|
||||
<CommandGroup key={group.label} heading={group.label}>
|
||||
{group.items.map(item => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<CommandItem key={item.id} value={item.title} keywords={item.keywords} onSelect={() => {
|
||||
setOpen(false)
|
||||
router.push(item.url)
|
||||
}}>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{item.title}</span>
|
||||
{pathname === item.url && <span className="ml-auto text-xs text-muted-foreground">Текущая</span>}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
<p className="shrink-0 border-t px-3 py-2 text-xs text-muted-foreground">↑↓ выбрать · Enter открыть · Esc закрыть</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useRef, useState, type ComponentProps } from "react"
|
||||
import { HistoryIcon, LoaderCircleIcon, RotateCcwIcon } from "lucide-react"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from "@/components/reui/timeline"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { TriangleAlertIcon } from "lucide-react"
|
||||
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
||||
|
||||
const SOURCE_LABEL: Record<ConfigRevisionDto["source"], string> = {
|
||||
apply: "Изменение",
|
||||
rollback: "Откат",
|
||||
observed: "С роутера",
|
||||
copy: "Копирование",
|
||||
}
|
||||
|
||||
const SOURCE_BADGE: Record<ConfigRevisionDto["source"], ComponentProps<typeof Badge>["variant"]> = {
|
||||
apply: "primary-light",
|
||||
rollback: "warning-light",
|
||||
observed: "secondary",
|
||||
copy: "success-light",
|
||||
}
|
||||
|
||||
function formatWhen(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" })
|
||||
}
|
||||
|
||||
export function ConfigHistorySheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
itemLabel,
|
||||
revisions,
|
||||
loading,
|
||||
loadError,
|
||||
onRetry,
|
||||
restoring,
|
||||
onRestore,
|
||||
restoreDisabled = true,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
itemLabel: string
|
||||
revisions: ConfigRevisionDto[]
|
||||
loading: boolean
|
||||
loadError?: string | null
|
||||
onRetry?: () => void
|
||||
restoring: boolean
|
||||
restoreDisabled: boolean
|
||||
onRestore: (id: string) => Promise<void> | void
|
||||
}) {
|
||||
const [pending, setPending] = useState<ConfigRevisionDto | null>(null)
|
||||
const restoreLock = useRef(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [restoreError, setRestoreError] = useState<string | null>(null)
|
||||
const busy = restoring || submitting
|
||||
async function confirmRestore() {
|
||||
if (!pending || restoreLock.current || restoring || restoreDisabled) return
|
||||
restoreLock.current = true
|
||||
setSubmitting(true)
|
||||
setRestoreError(null)
|
||||
try {
|
||||
await onRestore(pending.id)
|
||||
setPending(null)
|
||||
} catch (error) {
|
||||
setRestoreError(error instanceof Error ? error.message : "Не удалось восстановить версию")
|
||||
} finally {
|
||||
restoreLock.current = false
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
const newestFirst = useMemo(() => revisions, [revisions])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={v => {
|
||||
if (restoreLock.current || restoring) return
|
||||
if (!v) {
|
||||
setPending(null)
|
||||
setRestoreError(null)
|
||||
}
|
||||
onOpenChange(v)
|
||||
}}>
|
||||
<SheetContent showCloseButton={!busy} side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Снапшоты managed-объектов. Откат применяет выбранную версию на CHR.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5">
|
||||
{restoreDisabled && !busy && <p role="status" className="px-4 text-sm text-muted-foreground">Откат недоступен. Проверьте права на изменение и состояние выбранного устройства.</p>}
|
||||
{loading ? (
|
||||
<div role="status" className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
Загрузка истории…
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p role="alert" className="text-sm text-destructive">Не удалось загрузить историю: {loadError}</p>
|
||||
{onRetry && <Button type="button" variant="outline" disabled={busy} onClick={onRetry}>Повторить загрузку</Button>}
|
||||
</div>
|
||||
) : newestFirst.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<HistoryIcon className="size-4" />}
|
||||
title="Истории пока нет"
|
||||
description="Снапшот появится после первого чтения или изменения на роутере"
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<Frame>
|
||||
<FramePanel>
|
||||
<Timeline value={newestFirst.length} className="px-1">
|
||||
{newestFirst.map((rev, idx) => (
|
||||
<TimelineItem key={rev.id} step={newestFirst.length - idx}>
|
||||
<TimelineHeader>
|
||||
<TimelineDate dateTime={rev.createdAt}>{formatWhen(rev.createdAt)}</TimelineDate>
|
||||
<TimelineTitle className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={SOURCE_BADGE[rev.source]} size="sm">
|
||||
{SOURCE_LABEL[rev.source]}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground font-normal tabular-nums">
|
||||
{rev.itemCount} {itemLabel}
|
||||
</span>
|
||||
</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineIndicator />
|
||||
<TimelineSeparator />
|
||||
<TimelineContent>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || restoreDisabled}
|
||||
onClick={() => { setRestoreError(null); setPending(rev) }}
|
||||
>
|
||||
<RotateCcwIcon className="size-3.5" />
|
||||
Откатить
|
||||
</Button>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog open={Boolean(pending)} onOpenChange={(v) => { if (!v && !restoreLock.current && !restoring) { setPending(null); setRestoreError(null) } }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-warning/10 text-warning">
|
||||
<TriangleAlertIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Откатить на эту версию?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="flex flex-col gap-3">
|
||||
<span>
|
||||
На CHR будут применены {pending?.itemCount ?? 0} {itemLabel} от{" "}
|
||||
{pending ? formatWhen(pending.createdAt) : ""}.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlertIcon />
|
||||
<AlertTitle>Изменятся только объекты MikrotikManager</AlertTitle>
|
||||
<AlertDescription>
|
||||
Чужие правила и маршруты на роутере не удаляются.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AlertDialogHeader>
|
||||
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание результата восстановления…</p>}
|
||||
{restoreError && <p role="alert" className="text-sm text-destructive">{restoreError}</p>}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy} onClick={() => setPending(null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={busy || restoreDisabled || !pending}
|
||||
type="button"
|
||||
onClick={() => void confirmRestore()}
|
||||
>
|
||||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
Откатить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
interface BandwidthChartProps {
|
||||
rx: number[]
|
||||
tx: number[]
|
||||
}
|
||||
|
||||
export function BandwidthChart({ rx, tx }: BandwidthChartProps) {
|
||||
const W = 600, H = 200
|
||||
const pad = { l: 44, r: 12, t: 12, b: 28 }
|
||||
const iw = W - pad.l - pad.r
|
||||
const ih = H - pad.t - pad.b
|
||||
|
||||
const maxVal = Math.max(...rx, ...tx) * 1.15
|
||||
const xAt = (i: number, n: number) => pad.l + (i / (n - 1)) * iw
|
||||
const yAt = (v: number) => pad.t + (1 - v / maxVal) * ih
|
||||
|
||||
const areaPath = (arr: number[]) => {
|
||||
const top = arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ")
|
||||
return `M ${pad.l},${pad.t + ih} L ${top} L ${pad.l + iw},${pad.t + ih} Z`
|
||||
}
|
||||
const linePath = (arr: number[]) =>
|
||||
arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: 200, display: "block" }}>
|
||||
<defs>
|
||||
<linearGradient id="rx-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" style={{ stopColor: "var(--chart-rx)", stopOpacity: 0.25 }} />
|
||||
<stop offset="100%" style={{ stopColor: "var(--chart-rx)", stopOpacity: 0 }} />
|
||||
</linearGradient>
|
||||
<linearGradient id="tx-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" style={{ stopColor: "var(--chart-tx)", stopOpacity: 0.20 }} />
|
||||
<stop offset="100%" style={{ stopColor: "var(--chart-tx)", stopOpacity: 0 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((p, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={pad.l} x2={W - pad.r}
|
||||
y1={pad.t + ih * p} y2={pad.t + ih * p}
|
||||
style={{ stroke: "var(--border)" }}
|
||||
strokeDasharray={p === 1 ? "0" : "2 4"}
|
||||
/>
|
||||
<text
|
||||
x={pad.l - 8} y={pad.t + ih * p + 4}
|
||||
textAnchor="end" fontSize="10"
|
||||
style={{ fill: "var(--muted-foreground)" }}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
{Math.round(maxVal * (1 - p))}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
<path d={areaPath(rx)} fill="url(#rx-grad)" />
|
||||
<polyline points={linePath(rx)} fill="none"
|
||||
style={{ stroke: "var(--chart-rx)" }}
|
||||
strokeWidth="1.6" strokeLinejoin="round" />
|
||||
|
||||
<path d={areaPath(tx)} fill="url(#tx-grad)" />
|
||||
<polyline points={linePath(tx)} fill="none"
|
||||
style={{ stroke: "var(--chart-tx)" }}
|
||||
strokeWidth="1.6" strokeLinejoin="round" />
|
||||
|
||||
{[0, 15, 30, 45, 59].map((i) => (
|
||||
<text
|
||||
key={i} x={xAt(i, 60)} y={H - 6}
|
||||
textAnchor="middle" fontSize="10"
|
||||
style={{ fill: "var(--muted-foreground)" }}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
-{60 - i}м
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-4 pt-2 pl-11">
|
||||
{[
|
||||
{ key: "rx", label: "RX ↓", color: "var(--chart-rx)" },
|
||||
{ key: "tx", label: "TX ↑", color: "var(--chart-tx)" },
|
||||
].map(({ key, label, color }) => (
|
||||
<div key={key} className="flex items-center gap-1.5 text-xs">
|
||||
<span className="w-3 h-0.5 rounded" style={{ background: color }} />
|
||||
<span className="font-mono text-foreground">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DASHBOARD_SOURCES, type DashboardSource, type DashboardSources } from "@/lib/dashboard-source-state"
|
||||
|
||||
export function DashboardDataStatus({ sources, refreshing, onRefresh }: {
|
||||
sources: DashboardSources
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const failed = Object.values(sources).filter(source => source.error)
|
||||
const summary = failed.length ? `Не обновлены источники: ${failed.length}` : "Состояние источников"
|
||||
return <div className="flex flex-col gap-2">
|
||||
{failed.length > 0 && <Alert variant="destructive">
|
||||
<AlertTitle>{summary}</AlertTitle>
|
||||
<AlertDescription>Последние полученные данные сохранены. Если источник ещё не загрузился, его показатели неизвестны.</AlertDescription>
|
||||
</Alert>}
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<details className="min-w-0 text-sm" open={failed.length > 0 || undefined}>
|
||||
<summary className="cursor-pointer">Источники и время получения</summary>
|
||||
<ul className="mt-2 flex flex-col gap-1 text-xs">
|
||||
{(Object.keys(DASHBOARD_SOURCES) as DashboardSource[]).map(key => {
|
||||
const source = sources[key]
|
||||
return <li key={key} data-source={key} className={`break-words ${source?.error ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{DASHBOARD_SOURCES[key]}: {source?.error ? `ошибка обновления — ${source.error}. ` : ""}
|
||||
{source?.receivedAt != null ? <>Получено <time dateTime={new Date(source.receivedAt).toISOString()}>{new Date(source.receivedAt).toLocaleString("ru-RU")}</time>{source.error ? " · данные могут быть устаревшими" : ""}</> : "Успешных загрузок ещё нет"}
|
||||
{source?.sampledAt && Number.isFinite(Date.parse(source.sampledAt)) && <> · замер <time dateTime={source.sampledAt}>{new Date(source.sampledAt).toLocaleString("ru-RU")}</time></>}
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
<div className="flex items-center gap-2">
|
||||
<span role="status" className="text-xs text-muted-foreground">{refreshing ? "Обновление данных…" : "Обновление завершено"}</span>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onRefresh} disabled={refreshing}>Обновить данные</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from "@/components/reui/timeline"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { EventItem } from "@mmapp/contracts/events"
|
||||
|
||||
function formatEventAge(iso: string): string {
|
||||
const ts = Date.parse(iso)
|
||||
if (!Number.isFinite(ts)) return "—"
|
||||
const diffMs = Math.max(0, Date.now() - ts)
|
||||
const minutes = Math.floor(diffMs / 60_000)
|
||||
if (minutes < 1) return "сейчас"
|
||||
if (minutes < 60) return `${minutes}м`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}ч`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}д`
|
||||
}
|
||||
|
||||
const LEVEL_DOT: Record<EventItem["level"], string> = {
|
||||
critical: "border-destructive bg-destructive/20 group-data-completed/timeline-item:border-destructive",
|
||||
warning: "border-warning bg-warning/20 group-data-completed/timeline-item:border-warning",
|
||||
info: "border-info bg-info/20 group-data-completed/timeline-item:border-info",
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact activity timeline.
|
||||
* Preview: https://reui.io/preview/base/timeline-3
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
*/
|
||||
export function DashboardEventsTimeline({
|
||||
events,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
events: EventItem[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
}) {
|
||||
if (loading && events.length === 0) {
|
||||
return <p className="text-muted-foreground px-5 py-6 text-sm">Загрузка событий…</p>
|
||||
}
|
||||
if (error && events.length === 0) {
|
||||
return (
|
||||
<div className="px-5 py-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>События недоступны</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground px-5 py-6 text-sm">
|
||||
Событий пока нет.{" "}
|
||||
<Link href="/alerts" className="underline underline-offset-2">
|
||||
Оповещения
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Timeline defaultValue={events.length} className="px-5 py-4">
|
||||
{events.map((event, index) => (
|
||||
<TimelineItem key={event.id} step={index + 1}>
|
||||
<TimelineHeader>
|
||||
<TimelineSeparator />
|
||||
<TimelineDate>{formatEventAge(event.createdAt)}</TimelineDate>
|
||||
<TimelineTitle className="text-[13px] leading-tight">{event.title}</TimelineTitle>
|
||||
<TimelineIndicator className={cn(LEVEL_DOT[event.level])} />
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="text-xs leading-snug">{event.message}</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||
import { Maximize2Icon, ZoomInIcon, ZoomOutIcon } from "lucide-react"
|
||||
|
||||
type Pt = { x: number; y: number }
|
||||
type ServerKind = "home-router" | "jump-host" | "exit-node"
|
||||
|
||||
const W = 1060
|
||||
const H = 420
|
||||
const ZOOM_MIN = 0.2
|
||||
const ZOOM_MAX = 6
|
||||
|
||||
const STATUS_STYLE = {
|
||||
unknown: { fill: "var(--muted)", stroke: "var(--muted-foreground)", glow: "transparent" },
|
||||
online: { fill: "#0f2d1f", stroke: "#4ade80", glow: "rgba(74,222,128,0.15)" },
|
||||
degraded: { fill: "#2d1e06", stroke: "#fbbf24", glow: "rgba(251,191,36,0.15)" },
|
||||
offline: { fill: "#2d0f0f", stroke: "#f87171", glow: "transparent" },
|
||||
}
|
||||
|
||||
const TYPE_STYLE: Record<ServerKind, { label: string; fill: string; r: number }> = {
|
||||
"home-router": { label: "HR", fill: "#16a34a", r: 40 },
|
||||
"jump-host": { label: "JH", fill: "#7c3aed", r: 34 },
|
||||
"exit-node": { label: "EN", fill: "#0369a1", r: 30 },
|
||||
}
|
||||
|
||||
const WAN_COLORS = ["#0ea5e9", "#f97316", "#a855f7", "#ec4899", "#14b8a6"]
|
||||
|
||||
function pingColor(ms: number | null) {
|
||||
if (ms == null) return "#f87171"
|
||||
if (ms < 15) return "#4ade80"
|
||||
if (ms < 50) return "#fbbf24"
|
||||
return "#fb923c"
|
||||
}
|
||||
|
||||
function edgeBadgePosition(x1: number, y1: number, x2: number, y2: number, t: number, normalPx: number): { mx: number; my: number } {
|
||||
const px = x1 + (x2 - x1) * t
|
||||
const py = y1 + (y2 - y1) * t
|
||||
const dx = x2 - x1
|
||||
const dy = y2 - y1
|
||||
const len = Math.hypot(dx, dy) || 1
|
||||
const nx = -dy / len
|
||||
const ny = dx / len
|
||||
return { mx: px + nx * normalPx, my: py + ny * normalPx }
|
||||
}
|
||||
|
||||
function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: {
|
||||
zoom: number
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onFit: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-0 rounded-xl border border-white/10 bg-black/75 backdrop-blur-md shadow-xl overflow-hidden">
|
||||
<button onClick={onZoomOut} className="px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors">
|
||||
<ZoomOutIcon className="size-3.5" />
|
||||
</button>
|
||||
<span className="px-3 py-2 text-xs font-mono text-white/70 min-w-[52px] text-center border-x border-white/10 select-none">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button onClick={onZoomIn} className="px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors">
|
||||
<ZoomInIcon className="size-3.5" />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-white/10" />
|
||||
<button onClick={onFit} className="px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors">
|
||||
<Maximize2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PingBadge({
|
||||
mx,
|
||||
my,
|
||||
ping,
|
||||
dl,
|
||||
ul,
|
||||
color,
|
||||
monitored,
|
||||
}: {
|
||||
mx: number
|
||||
my: number
|
||||
ping: number | null
|
||||
dl: number | null
|
||||
ul: number | null
|
||||
color: string
|
||||
monitored?: boolean
|
||||
}) {
|
||||
const hasSpeed = dl != null || ul != null
|
||||
const dlText = dl != null ? Math.round(dl) : "—"
|
||||
const ulText = ul != null ? Math.round(ul) : "—"
|
||||
return (
|
||||
<g transform={`translate(${mx},${my})`}>
|
||||
<rect x="-34" y={-20} width="68" height={hasSpeed ? 42 : 24} rx="4" fill="#060d1a" stroke={color} strokeWidth="0.7" opacity="0.92" />
|
||||
<text textAnchor="middle" y={hasSpeed ? "-5" : "4"} fontSize="8.5" fontWeight="600" fill={color} fontFamily="ui-monospace,monospace">
|
||||
{ping == null ? "—" : `${ping} мс`}
|
||||
</text>
|
||||
{hasSpeed && (
|
||||
<text textAnchor="middle" y="8" fontSize="7" fill="#64748b" fontFamily="ui-monospace,monospace">
|
||||
{`↓${dlText} ↑${ulText}`}
|
||||
</text>
|
||||
)}
|
||||
{monitored && (
|
||||
<text textAnchor="middle" y={hasSpeed ? "17" : "14"} fontSize="6" fill="#38bdf8" fontFamily="ui-monospace,monospace">
|
||||
mon
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerNode({
|
||||
pos,
|
||||
type,
|
||||
name,
|
||||
site,
|
||||
country,
|
||||
status,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
pos: Pt
|
||||
type: ServerKind
|
||||
name: string
|
||||
site: string
|
||||
country: string
|
||||
status: "online" | "offline" | "degraded" | "unknown"
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const ss = STATUS_STYLE[status]
|
||||
const ts = TYPE_STYLE[type]
|
||||
return (
|
||||
<g transform={`translate(${pos.x},${pos.y})`} style={{ cursor: "pointer" }} onClick={onClick}>
|
||||
{status === "online" && (
|
||||
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
|
||||
<animate attributeName="r" values={`${ts.r + 10};${ts.r + 18};${ts.r + 10}`} dur="3s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.8;0.15;0.8" dur="3s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
)}
|
||||
{selected && (
|
||||
<circle r={ts.r + 10} fill="none" stroke="rgba(255,255,255,0.45)" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||
)}
|
||||
<circle r={ts.r} fill={ss.fill} stroke={ss.stroke} strokeWidth={selected ? 2.5 : 1.8} />
|
||||
<g transform={`translate(${ts.r - 10},-${ts.r - 10})`}>
|
||||
<circle r="10" fill={ts.fill} />
|
||||
<text textAnchor="middle" y="4" fontSize="6.5" fontWeight="bold" fill="#fff">{ts.label}</text>
|
||||
</g>
|
||||
<foreignObject x={-110} y={-22} width={220} height={28} style={{ overflow: "visible", pointerEvents: "none" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 6, width: "100%", height: "100%", fontFamily: "ui-monospace, monospace" }} {...({ xmlns: "http://www.w3.org/1999/xhtml" } as Record<string, string>)}>
|
||||
<span style={{ flexShrink: 0, lineHeight: 0 }}>
|
||||
<Flag code={country || "UN"} size={16} />
|
||||
</span>
|
||||
<span style={{ maxWidth: 170, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: 700, fontSize: 11, color: "#f1f5f9", lineHeight: 1.25 }}>
|
||||
{site || name}
|
||||
</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text textAnchor="middle" y={ts.r + 18} fontSize="8.5" fill="#94a3b8" fontFamily="ui-monospace,monospace">
|
||||
{name}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export function InternetPathMapCanvas({ model }: { model: InternetPathViewModel | null }) {
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<"home" | "jh" | "en" | "wan" | null>("home")
|
||||
|
||||
const mapData = useMemo(() => {
|
||||
if (!model) return null
|
||||
const hop = model.currentHop ?? model.primaryHop ?? (
|
||||
model.activeWanUplink && model.fallbackJumpHost && model.fallbackExitNode
|
||||
? {
|
||||
home: model.homeRouter,
|
||||
wan: model.activeWanUplink,
|
||||
jumpHost: model.fallbackJumpHost,
|
||||
exitNode: model.fallbackExitNode,
|
||||
wanJhMetrics: {
|
||||
pingMs: null,
|
||||
dlMbps: null,
|
||||
ulMbps: null,
|
||||
fromMonitoring: false,
|
||||
},
|
||||
jhExitMetrics: {
|
||||
pingMs: null,
|
||||
dlMbps: null,
|
||||
ulMbps: null,
|
||||
fromMonitoring: false,
|
||||
},
|
||||
}
|
||||
: null
|
||||
)
|
||||
if (!hop) return null
|
||||
const axisY = 220
|
||||
const homePos = { x: 180, y: axisY }
|
||||
const wanPos = { x: 400, y: axisY }
|
||||
const jhPos = { x: 660, y: axisY }
|
||||
const enPos = { x: 900, y: axisY }
|
||||
const directPos = { x: 900, y: axisY + 110 }
|
||||
const pathDiff =
|
||||
model.primaryPath &&
|
||||
model.currentPath &&
|
||||
(model.primaryPath.wanId !== model.currentPath.wanId || model.primaryPath.jhId !== model.currentPath.jhId || model.primaryPath.exitId !== model.currentPath.exitId)
|
||||
return { hop, homePos, wanPos, jhPos, enPos, directPos, pathDiff, directWan: model.directWan }
|
||||
}, [model])
|
||||
|
||||
function onWheel(e: React.WheelEvent<SVGSVGElement>) {
|
||||
e.preventDefault()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const px = e.clientX - rect.left
|
||||
const py = e.clientY - rect.top
|
||||
const worldX = (px - pan.x) / zoom
|
||||
const worldY = (py - pan.y) / zoom
|
||||
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1
|
||||
const next = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoom * factor))
|
||||
setZoom(next)
|
||||
setPan({ x: px - worldX * next, y: py - worldY * next })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{!model && (
|
||||
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
|
||||
Недостаточно данных для построения маршрута
|
||||
</div>
|
||||
)}
|
||||
{model && mapData && (
|
||||
<div
|
||||
className="relative rounded-lg border border-[#1f2a3d] bg-gradient-to-b from-[#0a1424] to-[#060d17] overflow-hidden overscroll-contain"
|
||||
onWheelCapture={(e) => {
|
||||
// Когда курсор над картой, колесо управляет только картой (без прокрутки страницы).
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className={cn("w-full h-[320px] select-none", isDragging ? "cursor-grabbing" : "cursor-grab")}
|
||||
onMouseDown={(e) => {
|
||||
setIsDragging(true)
|
||||
setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y })
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (!isDragging || !dragStart) return
|
||||
setPan({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y })
|
||||
}}
|
||||
onMouseUp={() => { setIsDragging(false); setDragStart(null) }}
|
||||
onMouseLeave={() => { setIsDragging(false); setDragStart(null) }}
|
||||
onWheel={onWheel}
|
||||
>
|
||||
<g transform={`translate(${pan.x} ${pan.y}) scale(${zoom})`}>
|
||||
<line x1={mapData.homePos.x} y1={mapData.homePos.y} x2={mapData.wanPos.x} y2={mapData.wanPos.y} stroke={WAN_COLORS[0]} strokeWidth="2.2" opacity="0.75" />
|
||||
<line
|
||||
x1={mapData.wanPos.x}
|
||||
y1={mapData.wanPos.y}
|
||||
x2={mapData.jhPos.x}
|
||||
y2={mapData.jhPos.y}
|
||||
stroke={mapData.pathDiff ? "#fbbf24" : "#4ade80"}
|
||||
strokeWidth={3.2}
|
||||
/>
|
||||
<line
|
||||
x1={mapData.jhPos.x}
|
||||
y1={mapData.jhPos.y}
|
||||
x2={mapData.enPos.x}
|
||||
y2={mapData.enPos.y}
|
||||
stroke={mapData.pathDiff ? "#fbbf24" : "#4ade80"}
|
||||
strokeWidth={3.2}
|
||||
/>
|
||||
{mapData.directWan.enabled && (
|
||||
<line
|
||||
x1={mapData.wanPos.x}
|
||||
y1={mapData.wanPos.y}
|
||||
x2={mapData.directPos.x}
|
||||
y2={mapData.directPos.y}
|
||||
stroke="#38bdf8"
|
||||
strokeWidth="2.4"
|
||||
opacity="0.9"
|
||||
/>
|
||||
)}
|
||||
|
||||
<g transform={`translate(${mapData.wanPos.x},${mapData.wanPos.y})`} onClick={() => setSelectedNode("wan")} style={{ cursor: "pointer" }}>
|
||||
<circle r={24} fill={`${WAN_COLORS[0]}1a`} stroke={WAN_COLORS[0]} strokeWidth="2" />
|
||||
<path d="M -5 2 Q 0 -5 5 2" fill="none" stroke={WAN_COLORS[0]} strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M -8 5 Q 0 -10 8 5" fill="none" stroke={WAN_COLORS[0]} strokeWidth="1" strokeLinecap="round" opacity="0.6" />
|
||||
<circle cx="0" cy="4" r="2" fill={WAN_COLORS[0]} />
|
||||
<text textAnchor="middle" y="36" fontSize="8" fontWeight="700" fill={WAN_COLORS[0]} fontFamily="ui-monospace,monospace">
|
||||
{mapData.hop.wan.name}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<ServerNode
|
||||
pos={mapData.homePos}
|
||||
type="home-router"
|
||||
name={mapData.hop.home.name}
|
||||
site={mapData.hop.home.site}
|
||||
country={mapData.hop.home.country}
|
||||
status={mapData.hop.home.status}
|
||||
selected={selectedNode === "home"}
|
||||
onClick={() => setSelectedNode("home")}
|
||||
/>
|
||||
{mapData.directWan.enabled && (
|
||||
<g transform={`translate(${mapData.directPos.x},${mapData.directPos.y})`}>
|
||||
<circle r={22} fill="#08243a" stroke="#38bdf8" strokeWidth="1.8" />
|
||||
<text textAnchor="middle" y="4" fontSize="10" fontWeight="700" fill="#38bdf8">NET</text>
|
||||
<text textAnchor="middle" y="34" fontSize="8" fill="#7dd3fc" fontFamily="ui-monospace,monospace">
|
||||
{mapData.directWan.provider ?? "Direct WAN"}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
<ServerNode
|
||||
pos={mapData.jhPos}
|
||||
type="jump-host"
|
||||
name={mapData.hop.jumpHost.name}
|
||||
site={mapData.hop.jumpHost.site}
|
||||
country={mapData.hop.jumpHost.country}
|
||||
status={mapData.hop.jumpHost.status}
|
||||
selected={selectedNode === "jh"}
|
||||
onClick={() => setSelectedNode("jh")}
|
||||
/>
|
||||
<ServerNode
|
||||
pos={mapData.enPos}
|
||||
type="exit-node"
|
||||
name={mapData.hop.exitNode.name}
|
||||
site={mapData.hop.exitNode.site}
|
||||
country={mapData.hop.exitNode.country}
|
||||
status={mapData.hop.exitNode.status}
|
||||
selected={selectedNode === "en"}
|
||||
onClick={() => setSelectedNode("en")}
|
||||
/>
|
||||
|
||||
<PingBadge
|
||||
{...edgeBadgePosition(mapData.wanPos.x, mapData.wanPos.y, mapData.jhPos.x, mapData.jhPos.y, 0.52, -24)}
|
||||
ping={mapData.hop.wanJhMetrics.pingMs}
|
||||
dl={mapData.hop.wanJhMetrics.dlMbps}
|
||||
ul={mapData.hop.wanJhMetrics.ulMbps}
|
||||
color={pingColor(mapData.hop.wanJhMetrics.pingMs)}
|
||||
monitored={mapData.hop.wanJhMetrics.fromMonitoring}
|
||||
/>
|
||||
<PingBadge
|
||||
{...edgeBadgePosition(mapData.jhPos.x, mapData.jhPos.y, mapData.enPos.x, mapData.enPos.y, 0.5, -24)}
|
||||
ping={mapData.hop.jhExitMetrics.pingMs}
|
||||
dl={mapData.hop.jhExitMetrics.dlMbps}
|
||||
ul={mapData.hop.jhExitMetrics.ulMbps}
|
||||
color={pingColor(mapData.hop.jhExitMetrics.pingMs)}
|
||||
monitored={mapData.hop.jhExitMetrics.fromMonitoring}
|
||||
/>
|
||||
{mapData.directWan.enabled && (
|
||||
<PingBadge
|
||||
{...edgeBadgePosition(mapData.wanPos.x, mapData.wanPos.y, mapData.directPos.x, mapData.directPos.y, 0.55, -22)}
|
||||
ping={null}
|
||||
dl={null}
|
||||
ul={null}
|
||||
color="#38bdf8"
|
||||
monitored={false}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
<ZoomControls
|
||||
zoom={zoom}
|
||||
onZoomOut={() => setZoom((z) => Math.max(ZOOM_MIN, z * 0.9))}
|
||||
onZoomIn={() => setZoom((z) => Math.min(ZOOM_MAX, z * 1.1))}
|
||||
onFit={() => { setZoom(1); setPan({ x: 0, y: 0 }) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{model && !mapData && (
|
||||
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
|
||||
Нет полного набора узлов для визуализации пути
|
||||
</div>
|
||||
)}
|
||||
{model && (
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
|
||||
<div className={cn("rounded-md border p-2", model.pathState === "failover" ? "border-amber-500/40 bg-amber-500/5" : "border-emerald-500/40 bg-emerald-500/5")}>
|
||||
<p className="font-medium">Рекомендация оптимизатора</p>
|
||||
<p className="text-muted-foreground mt-1">{model.primaryPath?.reason ?? "Не определен"}</p>
|
||||
</div>
|
||||
<div className={cn("rounded-md border p-2", model.pathState === "failover" ? "border-amber-500/40 bg-amber-500/5" : "border-emerald-500/40 bg-emerald-500/5")}>
|
||||
<p className="font-medium">Наблюдаемый default route</p>
|
||||
<p className="text-muted-foreground mt-1">{model.currentPath?.reason ?? "Не определен"}</p>
|
||||
</div>
|
||||
{model.directWan.enabled && (
|
||||
<div className="rounded-md border p-2 border-sky-500/40 bg-sky-500/5 md:col-span-2">
|
||||
<p className="font-medium">Direct WAN path</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{`gateway: ${model.directWan.gateway ?? "—"} · iface: ${model.directWan.iface ?? "—"} · dhcp ip: ${model.directWan.leasedIp ?? "—"} · isp: ${model.directWan.provider ?? "—"}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||
import { InternetPathMapCanvas } from "@/components/dashboard/internet-path-map"
|
||||
import { InternetPathSummary } from "@/components/dashboard/internet-path-summary"
|
||||
|
||||
const PATH_MAP_OPEN_LS = "mm:dashboard-path-map-open"
|
||||
|
||||
function readMapOpen(): boolean {
|
||||
if (typeof window === "undefined") return true
|
||||
try {
|
||||
const raw = localStorage.getItem(PATH_MAP_OPEN_LS)
|
||||
if (raw === "0") return false
|
||||
if (raw === "1") return true
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function InternetPathPanel({
|
||||
model,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
model: InternetPathViewModel | null
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
}) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setOpen(readMapOpen())
|
||||
setHydrated(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
setOpen(next)
|
||||
try {
|
||||
localStorage.setItem(PATH_MAP_OPEN_LS, next ? "1" : "0")
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OpsPanel
|
||||
title="Internet path"
|
||||
description="Home → WAN → JH → Exit"
|
||||
headerRight={
|
||||
<Link href="/network-map" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
|
||||
Карта сети →
|
||||
</Link>
|
||||
}
|
||||
contentClassName="flex flex-col gap-3 px-5 pb-4"
|
||||
>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Не удалось загрузить путь</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{loading && !model ? (
|
||||
<div className="h-20 animate-pulse rounded-md bg-muted/40" />
|
||||
) : (
|
||||
<InternetPathSummary model={model} />
|
||||
)}
|
||||
|
||||
<Collapsible open={hydrated ? open : true} onOpenChange={handleOpenChange}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-7 w-fit gap-1.5 text-xs")}
|
||||
>
|
||||
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
|
||||
{open ? "Скрыть карту" : "Показать карту"}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<InternetPathMapCanvas model={model} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</OpsPanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Flag } from "@/components/flag"
|
||||
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||
import type { ServerStatus } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowRightIcon, HomeIcon, RadioIcon, ServerIcon, GlobeIcon } from "lucide-react"
|
||||
|
||||
function pathStateToServerStatus(state: InternetPathViewModel["pathState"]): ServerStatus {
|
||||
if (state === "healthy") return "online"
|
||||
if (state === "failover" || state === "degraded") return "degraded"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function HopChip({
|
||||
label,
|
||||
name,
|
||||
country,
|
||||
icon,
|
||||
iconClassName,
|
||||
}: {
|
||||
label: string
|
||||
name: string
|
||||
country?: string
|
||||
icon: ReactNode
|
||||
iconClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 px-2 py-1.5">
|
||||
<IconTile variant="elevated" size="sm" className={cn("shrink-0", iconClassName)} aria-hidden="true">
|
||||
{icon}
|
||||
</IconTile>
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">{label}</p>
|
||||
<p className="flex items-center gap-1 truncate text-sm font-medium">
|
||||
{country ? <Flag code={country} className="shrink-0" /> : null}
|
||||
<span className="truncate">{name}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact live path strip (Frame-friendly). Canvas lives separately.
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export function InternetPathSummary({ model }: { model: InternetPathViewModel | null }) {
|
||||
if (!model) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Недостаточно данных для пути. Добавьте home-router и проверьте{" "}
|
||||
<Link href="/network-map" className="underline underline-offset-2">
|
||||
карту сети
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const hop = model.currentHop ?? model.primaryHop
|
||||
const wanName = hop?.wan.name ?? model.activeWanUplink?.name ?? "WAN"
|
||||
const wanIsp = hop?.wan.isp ?? model.activeWanUplink?.isp ?? "—"
|
||||
const ping = hop?.wanJhMetrics.pingMs
|
||||
const dl = hop?.wanJhMetrics.dlMbps
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={pathStateToServerStatus(model.pathState)} />
|
||||
{model.pathState === "failover" ? (
|
||||
<Badge variant="warning-light" size="sm">failover</Badge>
|
||||
) : null}
|
||||
{ping != null ? (
|
||||
<Badge variant="outline" size="sm" className="tabular-nums">
|
||||
{ping} мс
|
||||
</Badge>
|
||||
) : null}
|
||||
{dl != null ? (
|
||||
<Badge variant="outline" size="sm" className="tabular-nums">
|
||||
{Math.round(dl)} ↓ Мбит/с
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">JH/Exit — схема кандидатов. Default route подтверждает только WAN. Метрики — последние подтверждённые пробы участков.</p>
|
||||
<div className="flex flex-col gap-2 @3xl:flex-row @3xl:items-center">
|
||||
<HopChip
|
||||
label="Home"
|
||||
name={model.homeRouter.name}
|
||||
country={model.homeRouter.country}
|
||||
icon={<HomeIcon />}
|
||||
iconClassName="text-success"
|
||||
/>
|
||||
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
|
||||
<HopChip
|
||||
label="WAN"
|
||||
name={`${wanName} · ${wanIsp}`}
|
||||
icon={<RadioIcon />}
|
||||
iconClassName="text-info"
|
||||
/>
|
||||
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
|
||||
<HopChip
|
||||
label="JH"
|
||||
name={hop?.jumpHost.name ?? model.fallbackJumpHost?.name ?? "—"}
|
||||
country={hop?.jumpHost.country ?? model.fallbackJumpHost?.country}
|
||||
icon={<ServerIcon />}
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
|
||||
<HopChip
|
||||
label="Exit"
|
||||
name={hop?.exitNode.name ?? model.fallbackExitNode?.name ?? "—"}
|
||||
country={hop?.exitNode.country ?? model.fallbackExitNode?.country}
|
||||
icon={<GlobeIcon />}
|
||||
iconClassName="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{model.currentPath?.reason ?? "Текущий маршрут не подтверждён"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
import {CartesianGrid,Line,LineChart,XAxis,YAxis} from "recharts"
|
||||
import {ChartContainer,ChartTooltip,ChartTooltipContent,type ChartConfig} from "@/components/ui/chart"
|
||||
import {formatRangeAgo} from "@/lib/fmt-rate"
|
||||
const PALETTE=["var(--chart-line-1)","var(--chart-line-2)","var(--chart-line-3)","var(--chart-line-4)","var(--chart-1)","var(--chart-2)","var(--chart-3)","var(--chart-4)"]
|
||||
const LEGACY_LABELS:Record<string,string>={msk:"MSK",spb:"SPB",fra:"FRA",ams:"AMS"}
|
||||
interface LatencyChartProps {series:Record<string,(number|null)[]>;labels?:Record<string,string>}
|
||||
/** Reuse the installed ChartContainer / multi-line composition of ReUI chart-23. */
|
||||
export function LatencyChart({series,labels:labelsProp}:LatencyChartProps){
|
||||
const entries=Object.entries(series).filter(([,values])=>values.some(v=>typeof v==="number"&&Number.isFinite(v)))
|
||||
const count=Math.max(1,...entries.map(([,values])=>values.length))
|
||||
const data=Array.from({length:count},(_,i)=>({time:formatRangeAgo(Math.round((1-i/Math.max(count-1,1))*60)),...Object.fromEntries(entries.map(([key,values])=>[key,typeof values[i]==="number"&&Number.isFinite(values[i])?values[i]:null]))}))
|
||||
const label=(key:string)=>labelsProp?.[key]??LEGACY_LABELS[key]??key.replace(/^src-/,"")
|
||||
const config=Object.fromEntries(entries.map(([key],index)=>[key,{label:label(key),color:PALETTE[index%PALETTE.length]}])) satisfies ChartConfig
|
||||
const dot=(key:string)=>function IsolatedDot({cx,cy,index}:{cx?:number;cy?:number;index?:number}){const values=series[key];if(index==null||cx==null||cy==null||values[index]==null||values[index-1]!=null||values[index+1]!=null)return <g/>;return <circle cx={cx} cy={cy} r={3} fill={config[key].color} data-latency-isolated="true"/>}
|
||||
if(!entries.length)return <p role="status" className="py-10 text-center text-sm text-muted-foreground">Нет измеренной истории RTT</p>
|
||||
return <div role="region" aria-label="История RTT по источникам" className="flex flex-col gap-3">
|
||||
<ChartContainer config={config} className="aspect-auto h-[220px] w-full"><LineChart data={data} margin={{top:8,right:8,left:0,bottom:0}}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="4 8" stroke="var(--border)"/>
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} tick={{fontSize:11}} tickMargin={8} minTickGap={16}/>
|
||||
<YAxis axisLine={false} tickLine={false} tick={{fontSize:11}} width={52} tickFormatter={(v:number)=>String(v)+" мс"}/>
|
||||
<ChartTooltip content={<ChartTooltipContent formatter={(value,name)=><div className="flex min-w-0 items-center gap-2 text-xs"><span>{config[String(name)]?.label??name}</span><span className="font-mono">{typeof value==="number"?String(value)+" мс":"—"}</span></div>}/>}/>
|
||||
{entries.map(([key])=><Line key={key} dataKey={key} type="linear" stroke={config[key].color} strokeWidth={1.6} connectNulls={false} dot={dot(key)} isAnimationActive={false}/>)}
|
||||
</LineChart></ChartContainer>
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">{entries.map(([key])=><span key={key} data-latency-source={key} className="flex items-center gap-1.5 text-xs"><span aria-hidden className="h-0.5 w-3 rounded" style={{background:config[key].color}}/><span className="text-foreground">{label(key)}</span></span>)}</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Asn } from "@/lib/data"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { FilterIcon, NetworkIcon } from "lucide-react"
|
||||
|
||||
interface AsnsDataGridProps {
|
||||
asns: Asn[]
|
||||
isLoading?: boolean
|
||||
emptyDescription?: string
|
||||
pagination?: boolean
|
||||
}
|
||||
|
||||
function AsnsDataGrid({ asns, isLoading, pagination = false, emptyDescription }: AsnsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, Asn>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "asn",
|
||||
accessorKey: "asn",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" className="ml-1" />,
|
||||
cell: ({ row }) => <span className="font-mono font-semibold">{row.original.asn}</span>,
|
||||
meta: {
|
||||
headerTitle: "ASN",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "org",
|
||||
accessorKey: "org",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Имя / организация" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={row.original.org}>
|
||||
{row.original.org}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Имя / организация",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixes",
|
||||
accessorKey: "prefixes",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Префиксов" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums">{row.original.prefixes?.toLocaleString("ru") ?? "—"}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Префиксов",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "filter",
|
||||
accessorKey: "filter",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />
|
||||
{row.original.filter}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Фильтр",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
accessorKey: "updated",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
|
||||
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
|
||||
meta: {
|
||||
headerTitle: "Обновлён",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.original.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: !pagination,
|
||||
data: asns,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={asns.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
pagination={pagination}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет ASN"
|
||||
description={emptyDescription ?? "Добавьте автономные системы или импортируйте каталог"}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { AsnsDataGrid, type AsnsDataGridProps }
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Backup } from "@/lib/data"
|
||||
import { backupFileMissing, backupLocalFileDescription } from "@/lib/backup-file-state"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
DownloadIcon,
|
||||
HardDriveIcon,
|
||||
RefreshCwIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
interface BackupsDataGridProps {
|
||||
backups: Backup[]
|
||||
onDownload: (id: string, filename: string) => void
|
||||
onRestore: (backup: Backup) => void
|
||||
onDelete: (backup: Backup) => void
|
||||
canRestore?: boolean
|
||||
canWrite?: boolean
|
||||
canDownload?: boolean
|
||||
emptyAction?: ReactNode
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
function storageBadge(storage: Backup["storage"]) {
|
||||
if (storage === "s3") return { label: "S3", variant: "info-light" as const }
|
||||
if (storage === "both") return { label: "Локально + S3", variant: "success-light" as const }
|
||||
return { label: "Локально", variant: "secondary" as const }
|
||||
}
|
||||
|
||||
function BackupsDataGrid({
|
||||
backups,
|
||||
onDownload,
|
||||
onRestore,
|
||||
onDelete,
|
||||
canRestore = true,
|
||||
canWrite = true,
|
||||
canDownload = true,
|
||||
emptyAction,
|
||||
emptyTitle = "Нет бэкапов",
|
||||
emptyDescription = "Создайте первый бэкап вручную или настройте расписание",
|
||||
}: BackupsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, Backup>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "filename",
|
||||
accessorKey: "filename",
|
||||
size: 110,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Файл" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<span title={b.filename} className="font-mono text-xs font-medium block break-all">{b.filename}</span>
|
||||
{backupLocalFileDescription(b) && <span id={`backup-file-${encodeURIComponent(b.id)}`} className="text-xs text-muted-foreground block">{backupLocalFileDescription(b)}</span>}
|
||||
{b.uploadError ? (
|
||||
<span className="text-[11px] text-destructive truncate block">{b.uploadError}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Файл",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "server",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">{row.original.server}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "size",
|
||||
accessorFn: (row) => row.sizeBytes,
|
||||
sortUndefined: "last",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Размер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.size}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Размер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
accessorKey: "kind",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.kind === "manual" ? "info-light" : "secondary"}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{row.original.kind === "auto" ? "авто" : "вручную"}
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
accessorKey: "storage",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Хранилище" />,
|
||||
cell: ({ row }) => {
|
||||
const backup = row.original
|
||||
const badge = backupFileMissing(backup)
|
||||
? { label: "Нет файла", variant: "warning-light" as const }
|
||||
: backup.localFileStatus === "missing" ? { label: "S3", variant: "warning-light" as const }
|
||||
: backup.localFileStatus === "unknown" ? { label: "Файл не проверен", variant: "warning-light" as const }
|
||||
: storageBadge(backup.storage)
|
||||
return (
|
||||
<Badge variant={badge.variant} size="sm" radius="full">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Хранилище",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
accessorKey: "notes",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Заметки" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground max-w-[200px] truncate block">
|
||||
{row.original.notes || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Заметки",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
accessorKey: "created",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Создан" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">{row.original.created}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Создан",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end sm:opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 min-w-[44px] min-h-[44px] sm:size-7 sm:min-w-0 sm:min-h-0"
|
||||
title={backupFileMissing(b) ? "Локальный файл отсутствует" : "Скачать"}
|
||||
aria-label={`Скачать ${b.filename}`}
|
||||
disabled={!canDownload || backupFileMissing(b)}
|
||||
aria-describedby={backupLocalFileDescription(b) ? `backup-file-${encodeURIComponent(b.id)}` : undefined}
|
||||
onClick={() => onDownload(b.id, b.filename)}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 min-w-[44px] min-h-[44px] sm:size-7 sm:min-w-0 sm:min-h-0"
|
||||
title={backupFileMissing(b) ? "Локальный файл отсутствует" : "Восстановить"}
|
||||
aria-label={`Восстановить ${b.filename}`}
|
||||
disabled={!canWrite || !canRestore || !b.serverId || backupFileMissing(b)}
|
||||
aria-describedby={backupLocalFileDescription(b) ? `backup-file-${encodeURIComponent(b.id)}` : undefined}
|
||||
onClick={() => onRestore(b)}
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 min-w-[44px] min-h-[44px] sm:size-7 sm:min-w-0 sm:min-h-0 text-destructive hover:text-destructive"
|
||||
title="Удалить"
|
||||
aria-label={`Удалить ${b.filename}`}
|
||||
disabled={!canWrite}
|
||||
onClick={() => onDelete(b)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 176,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[onDelete, onDownload, onRestore, canWrite, canRestore, canDownload],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["filename"], end: [] } },
|
||||
data: backups,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(backups.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [backups.length, pageSize, pageIndex, table])
|
||||
|
||||
if (backups.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<HardDriveIcon className="size-5" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={emptyAction}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <DataGridShell table={table} recordCount={backups.length}
|
||||
pagination={backups.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ ...DEFAULT_DATA_GRID_LAYOUT, columnsPinnable: true }}>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
}
|
||||
|
||||
export { BackupsDataGrid, type BackupsDataGridProps }
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
import type { BgpSessionRow } from "@/lib/bgp/types"
|
||||
import { fmtBgpNum } from "@/lib/bgp/helpers"
|
||||
|
||||
function BgpSessionDetail({ session }: { session: BgpSessionRow }) {
|
||||
return <div className="sticky left-0 w-[calc(100vw-2rem)] max-w-full md:static md:w-auto px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
|
||||
{session.state === "Unknown" && <p role="status" className="mb-3 text-xs text-muted-foreground">Состояние сессии неизвестно. Значения относятся к последнему полученному снимку.</p>}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
{[
|
||||
{ label: "Router ID", value: session.routerId || "—" },
|
||||
{ label: "Hold / KA", value: (session.holdTime === null ? "—" : session.holdTime + "s") + " / " + (session.keepalive === null ? "—" : session.keepalive + "s") },
|
||||
{ label: "AFI/SAFI", value: session.afi },
|
||||
{ label: "Сообщения ↓/↑", value: fmtBgpNum(session.inputMessages) + " / " + fmtBgpNum(session.outputMessages) },
|
||||
{ label: "Префиксов получено", value: fmtBgpNum(session.prefixesRx) },
|
||||
{ label: "Префиксов отправлено", value: fmtBgpNum(session.prefixesTx) },
|
||||
{ label: "Активных маршрутов", value: session.prefixesActive === null ? "Нет данных API" : fmtBgpNum(session.prefixesActive) },
|
||||
].map(({ label, value }) => <div key={label}><p className="text-[10px] text-muted-foreground mb-0.5">{label}</p><p className="text-xs font-mono font-medium break-words">{value}</p></div>)}
|
||||
</div>
|
||||
{session.capabilities.length > 0 && <div className="mb-4"><p className="text-[10px] text-muted-foreground mb-1.5">Capabilities</p><div className="flex flex-wrap gap-1.5">{session.capabilities.map((cap, i) => <span key={i} className="rounded px-1.5 py-0.5 text-[10px] border bg-muted/60 text-muted-foreground">{cap}</span>)}</div></div>}
|
||||
{session.lastError && <p className="mb-4 rounded-md border border-destructive/20 px-3 py-2 text-xs font-mono text-destructive break-words">{session.lastError}</p>}
|
||||
<p className="text-xs text-muted-foreground">API возвращает состояние сессии. Конфигурация connection, routing-table и фильтров для экспорта здесь не получена.</p>
|
||||
</div>
|
||||
}
|
||||
export { BgpSessionDetail }
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { BgpSessionDetail } from "@/components/data-grids/bgp-session-detail"
|
||||
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||
import { fmtBgpNum } from "@/lib/bgp/helpers"
|
||||
import { ChevronDownIcon, ChevronRightIcon, NetworkIcon } from "lucide-react"
|
||||
|
||||
const STATE_DOT: Record<BgpState, string> = {
|
||||
Unknown: "bg-muted-foreground", Established: "bg-success", Active: "bg-warning",
|
||||
Idle: "bg-muted-foreground", Connect: "bg-info", OpenSent: "bg-info", OpenConfirm: "bg-info",
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: BgpState }) {
|
||||
return <Badge variant="invert-light" size="sm">
|
||||
<span aria-hidden="true" className={cn("size-1.5 rounded-full shrink-0", STATE_DOT[state])} />
|
||||
{state === "Unknown" ? "Неизвестно" : state}
|
||||
</Badge>
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: BgpType }) {
|
||||
return <Badge variant="invert-light" size="sm">{type === "Unknown" ? "Тип неизвестен" : type}</Badge>
|
||||
}
|
||||
|
||||
interface BgpSessionsDataGridProps {
|
||||
sessions: BgpSessionRow[]
|
||||
emptyTitle?: string
|
||||
selectedSessionId?: string | null
|
||||
onSelectSession?: (id: string | null) => void
|
||||
}
|
||||
|
||||
function BgpSessionsDataGrid({ sessions, emptyTitle, selectedSessionId, onSelectSession }: BgpSessionsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, BgpSessionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "serverLabel",
|
||||
accessorKey: "serverLabel",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon-xs" aria-label={(expanded ? "Закрыть" : "Открыть") + " сессию " + row.original.serverLabel + " · " + row.original.peerIp} aria-expanded={expanded}
|
||||
onClick={event => { event.stopPropagation(); if (onSelectSession) onSelectSession(expanded ? null : row.id); else row.toggleExpanded() }}>
|
||||
{expanded ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
||||
</Button>
|
||||
<span className="font-mono whitespace-nowrap">{row.original.serverLabel}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Роутер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: BgpSessionRow) => <BgpSessionDetail session={row} />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "peerIp",
|
||||
accessorKey: "peerIp",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Peer IP" />,
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.peerIp}</span>,
|
||||
meta: {
|
||||
headerTitle: "Peer IP",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remoteAs",
|
||||
accessorKey: "remoteAs",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Remote AS" />,
|
||||
cell: ({ row }) => {
|
||||
const as = row.original.remoteAs
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
<span>{as === null ? "AS неизвестна" : "AS" + as}</span>
|
||||
{as !== null && BGP_AS_NAMES[as] && (
|
||||
<span className="text-muted-foreground text-[10px]">{BGP_AS_NAMES[as]}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Remote AS",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Описание" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground max-w-[180px] truncate block">
|
||||
{row.original.description}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Описание",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <TypeBadge type={row.original.type} />,
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
accessorKey: "state",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => <StateBadge state={row.original.state} />,
|
||||
meta: {
|
||||
headerTitle: "Состояние",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorKey: "uptime",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-muted-foreground">
|
||||
{row.original.uptime ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixesRx",
|
||||
accessorKey: "prefixesRx",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Prefixes ↓" />,
|
||||
cell: ({ row }) => {
|
||||
const n = row.original.prefixesRx
|
||||
return n !== null ? (
|
||||
<span className="font-mono tabular-nums text-right block">
|
||||
{fmtBgpNum(n)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Prefixes ↓",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixesTx",
|
||||
accessorKey: "prefixesTx",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Prefixes ↑" />,
|
||||
cell: ({ row }) => {
|
||||
const n = row.original.prefixesTx
|
||||
return n !== null ? (
|
||||
<span className="font-mono tabular-nums text-[var(--chart-tx)] text-right block">
|
||||
{fmtBgpNum(n)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Prefixes ↑",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
],
|
||||
[onSelectSession],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 } },
|
||||
data: sessions,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
...(onSelectSession ? { state: { expanded: selectedSessionId ? { [selectedSessionId]: true } : {} } } : {}),
|
||||
})
|
||||
|
||||
const {pageIndex,pageSize}=table.state.pagination
|
||||
const sorting=table.state.sorting
|
||||
useEffect(()=>{
|
||||
const last=Math.max(0,Math.ceil(sessions.length/pageSize)-1)
|
||||
if(pageIndex>last)table.setPageIndex(last)
|
||||
},[sessions.length,pageSize,pageIndex,table])
|
||||
const setPageIndex = table.setPageIndex
|
||||
const selectedIndex = selectedSessionId ? table.getPrePaginatedRowModel().rows.findIndex(row => row.id === selectedSessionId) : -1
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0) setPageIndex(Math.floor(selectedIndex / pageSize))
|
||||
}, [selectedSessionId, selectedIndex, pageSize, sorting, setPageIndex])
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title={emptyTitle ?? "Нет BGP-сессий"}
|
||||
description="Измените фильтры или проверьте подключение к роутерам"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={sessions.length}
|
||||
pagination={sessions.length>50 || pageSize!==50}
|
||||
onRowClick={(row) => onSelectSession ? onSelectSession(row.id === selectedSessionId ? null : row.id) : table.getRow(row.id).toggleExpanded()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { BgpSessionsDataGrid, type BgpSessionsDataGridProps }
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import type { CertificateView } from "@/lib/certificate-view"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function daysLeftColor(days: number | null): string {
|
||||
if (days === null) return "text-muted-foreground"
|
||||
if (days <= 7) return "text-destructive"
|
||||
if (days <= 30) return "text-warning"
|
||||
return "text-success"
|
||||
}
|
||||
|
||||
export function CertDaysCell({ cert }: { cert: CertificateView }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-1 text-xs">
|
||||
<span className={cn("font-mono font-medium", daysLeftColor(cert.daysLeft))}>
|
||||
{cert.daysLeft === null
|
||||
? "Срок неизвестен"
|
||||
: cert.daysLeft < 0
|
||||
? "Истёк " + -cert.daysLeft + "д назад"
|
||||
: cert.daysLeft + "д осталось"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px] break-all">
|
||||
{cert.validUntil ?? "Дата окончания неизвестна"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import type { CertificateView } from "@/lib/certificate-view"
|
||||
|
||||
function CertificateExpandedDetail({ cert }: { cert: CertificateView }) {
|
||||
return (
|
||||
<div className="min-w-0 [overflow-wrap:anywhere] px-5 sm:px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Размер ключа</p>
|
||||
<p className="font-mono font-medium">{cert.keySize === null ? "Неизвестен" : cert.keySize + " бит"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Начало срока</p>
|
||||
<p className="font-mono">{cert.validFrom ?? "Дата неизвестна"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Дополнительные имена (SAN)</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cert.sans.length > 0
|
||||
? cert.sans.map((s) => (
|
||||
<span key={s} className="font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{s}
|
||||
</span>
|
||||
))
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Доверие</p>
|
||||
<p className={cert.trusted === null ? "text-muted-foreground" : cert.trusted ? "text-success" : "text-destructive"}>
|
||||
{cert.trusted === null ? "Неизвестно" : cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
|
||||
</p>
|
||||
</div>
|
||||
{"rawValidFrom" in cert && !cert.validFrom && cert.rawValidFrom && <p className="col-span-2 sm:col-span-4 text-muted-foreground">Начало срока не удалось определить. Значение RouterOS: <span className="font-mono">{cert.rawValidFrom}</span></p>}
|
||||
{"rawValidUntil" in cert && !cert.validUntil && cert.rawValidUntil && <p className="col-span-2 sm:col-span-4 text-muted-foreground">Окончание срока не удалось определить. Значение RouterOS: <span className="font-mono">{cert.rawValidUntil}</span></p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { CertificateExpandedDetail }
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import type { CertificateObserved } from "@mmapp/contracts/certificates"
|
||||
import { CertificateExpandedDetail } from "./certificate-expanded-detail"
|
||||
import { CertDaysCell } from "./certificate-days-cell"
|
||||
|
||||
const cert: CertificateObserved = {
|
||||
id: "1:*A", rosId: "*A", name: "unknown-cert", serverId: "1", commonName: "test.example",
|
||||
sans: [], issuedBy: "—", validFrom: null, validUntil: null, rawValidFrom: null,
|
||||
rawValidUntil: null, daysLeft: null, keySize: null, trusted: null, status: "unknown", usage: [],
|
||||
}
|
||||
|
||||
test("unknown observed fields are explicit and do not become zero or untrusted", () => {
|
||||
const details = renderToStaticMarkup(<CertificateExpandedDetail cert={cert} />)
|
||||
const days = renderToStaticMarkup(<CertDaysCell cert={cert} />)
|
||||
assert.match(details, /Неизвестен/)
|
||||
assert.match(details, /Дата неизвестна/)
|
||||
assert.match(details, /Неизвестно/)
|
||||
assert.ok(!details.includes("0 бит"))
|
||||
assert.ok(!details.includes("Нет (не доверенный)"))
|
||||
assert.match(days, /Срок неизвестен/)
|
||||
assert.match(days, /Дата окончания неизвестна/)
|
||||
assert.doesNotMatch(days, /0д осталось/)
|
||||
})
|
||||
|
||||
test("known false trust and zero remaining days are preserved", () => {
|
||||
const known = { ...cert, trusted: false, keySize: 2048, daysLeft: 0, validUntil: "2026-10-01T00:00:00Z" }
|
||||
const details = renderToStaticMarkup(<CertificateExpandedDetail cert={known} />)
|
||||
assert.ok(details.includes("Нет (не доверенный)"))
|
||||
assert.match(details, /2048 бит/)
|
||||
const days = renderToStaticMarkup(<CertDaysCell cert={known} />)
|
||||
assert.match(days, /0д осталось/)
|
||||
assert.match(days, /2026-10-01T00:00:00Z/)
|
||||
})
|
||||
|
||||
test("expired and positive observed days stay distinct", () => {
|
||||
assert.match(renderToStaticMarkup(<CertDaysCell cert={{ ...cert, daysLeft: -3 }} />), /Истёк 3д назад/)
|
||||
assert.match(renderToStaticMarkup(<CertDaysCell cert={{ ...cert, daysLeft: 40 }} />), /40д осталось/)
|
||||
})
|
||||
|
||||
test("unparsed RouterOS dates remain explicit raw values without claiming valid dates", () => {
|
||||
const details = renderToStaticMarkup(<CertificateExpandedDetail cert={{ ...cert, rawValidFrom: "oct/01/2026 12:00:00", rawValidUntil: "oct/01/2027 12:00:00" }} />)
|
||||
assert.match(details, /Начало срока не удалось определить/)
|
||||
assert.match(details, /Окончание срока не удалось определить/)
|
||||
assert.ok(details.includes("oct/01/2026 12:00:00"))
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import type { CertificateView } from "@/lib/certificate-view"
|
||||
import { CertDaysCell } from "@/components/data-grids/certificate-days-cell"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import {
|
||||
CertificateExpandedDetail,
|
||||
} from "@/components/data-grids/certificate-expanded-detail"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
BadgeCheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ServerIcon,
|
||||
ShieldAlertIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldOffIcon,
|
||||
CircleHelpIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
CertificateView["status"],
|
||||
{ label: string; icon: ReactNode; badge: string; row: string }
|
||||
> = {
|
||||
unknown: {
|
||||
label: "Неизвестен",
|
||||
icon: <CircleHelpIcon aria-hidden="true" className="size-4 text-muted-foreground" />,
|
||||
badge: "bg-muted text-muted-foreground border-border",
|
||||
row: "",
|
||||
},
|
||||
valid: {
|
||||
label: "Действителен",
|
||||
icon: <BadgeCheckIcon aria-hidden="true" className="size-4 text-success" />,
|
||||
badge: "bg-success/10 text-success border-success/20",
|
||||
row: "",
|
||||
},
|
||||
expired: {
|
||||
label: "Истёк",
|
||||
icon: <ShieldOffIcon aria-hidden="true" className="size-4 text-destructive" />,
|
||||
badge: "bg-destructive/10 text-destructive border-destructive/20",
|
||||
row: "bg-destructive/5",
|
||||
},
|
||||
revoked: {
|
||||
label: "Отозван",
|
||||
icon: <ShieldAlertIcon aria-hidden="true" className="size-4 text-warning" />,
|
||||
badge: "bg-warning/10 text-warning border-warning/20",
|
||||
row: "bg-warning/5",
|
||||
},
|
||||
}
|
||||
|
||||
interface CertificatesDataGridProps {
|
||||
certificates: CertificateView[]
|
||||
serverMap: Map<string, Server>
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
function CertificatesDataGrid({ certificates, serverMap, isLoading, emptyTitle = "Нет сертификатов", emptyDescription = "Выпустите или импортируйте сертификат" }: CertificatesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, CertificateView>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
size: 160,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Сертификат" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const cert = row.original
|
||||
const cfg = STATUS_CONFIG[cert.status]
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-11 sm:size-8 shrink-0" aria-label={"Детали сертификата " + cert.name} aria-expanded={expanded} onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
|
||||
{expanded ? (
|
||||
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="hidden sm:inline shrink-0">{cfg.icon}</span>
|
||||
<span className="font-medium text-sm truncate" title={cert.name}>
|
||||
{cert.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate" title={cert.commonName}>
|
||||
{cert.commonName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сертификат",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: CertificateView) => <CertificateExpandedDetail cert={row} />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "serverId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => {
|
||||
const cert = row.original
|
||||
const server = serverMap.get(cert.serverId)
|
||||
return server ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
<Flag code={server.country} size={12} />
|
||||
<span className="font-mono truncate">{server.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
<ServerIcon className="size-3.5" />
|
||||
<span className="font-mono truncate">{cert.serverName ?? cert.serverId}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "issuedBy",
|
||||
accessorKey: "issuedBy",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Издатель" />,
|
||||
cell: ({ row }) => (
|
||||
<p className="text-xs text-muted-foreground truncate min-w-0" title={row.original.issuedBy}>
|
||||
{row.original.issuedBy}
|
||||
</p>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Издатель",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "daysLeft",
|
||||
accessorKey: "daysLeft",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Срок" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0">
|
||||
<CertDaysCell cert={row.original} />
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Срок",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
accessorKey: "usage",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Использование</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1 min-w-0 overflow-hidden">
|
||||
{row.original.usage.map((u) => (
|
||||
<span
|
||||
key={u}
|
||||
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border"
|
||||
>
|
||||
{u}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Использование",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 120,
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => {
|
||||
const cfg = STATUS_CONFIG[row.original.status]
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
|
||||
cfg.badge,
|
||||
)}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[serverMap],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
autoResetExpanded: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["name"], end: [] } },
|
||||
data: certificates,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(certificates.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [certificates.length, pageSize, pageIndex, table])
|
||||
|
||||
if (!isLoading && certificates.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ShieldCheckIcon className="size-4" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={certificates.length}
|
||||
tableLayout={{ ...DEFAULT_DATA_GRID_LAYOUT, columnsPinnable: true }}
|
||||
pagination={certificates.length > 50 || pageSize !== 50}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal">
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { CertificatesDataGrid, STATUS_CONFIG, type CertificatesDataGridProps }
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
ServerIcon,
|
||||
TagIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export type CommunityType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
|
||||
|
||||
export interface CommunityRow {
|
||||
id: string
|
||||
value: string
|
||||
name: string
|
||||
description: string
|
||||
type: CommunityType
|
||||
filterIds: string[]
|
||||
serverCount: number
|
||||
prefixCount: number | null
|
||||
action: "permit" | "deny" | "local-pref" | "metric"
|
||||
actionValue?: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<CommunityType, string> = {
|
||||
standard: "Стандартный",
|
||||
"no-export": "No-export",
|
||||
"no-advertise": "No-advertise",
|
||||
"local-as": "Local-AS",
|
||||
custom: "Кастомный",
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<CommunityRow["action"], string> = {
|
||||
permit: "Permit",
|
||||
deny: "Deny",
|
||||
"local-pref": "Local-pref",
|
||||
metric: "MED/Metric",
|
||||
}
|
||||
|
||||
const ACTION_COLOR: Record<CommunityRow["action"], string> = {
|
||||
permit: "text-emerald-500",
|
||||
deny: "text-red-500",
|
||||
"local-pref": "text-blue-500",
|
||||
metric: "text-amber-500",
|
||||
}
|
||||
|
||||
interface CommunitiesDataGridProps {
|
||||
communities: CommunityRow[]
|
||||
selectedId?: string | null
|
||||
copying?: boolean
|
||||
copiedValue?: string | null
|
||||
onSelect: (community: CommunityRow) => void
|
||||
onCopy: (value: string) => void
|
||||
}
|
||||
|
||||
function CommunitiesDataGrid({
|
||||
communities,
|
||||
selectedId,
|
||||
copiedValue,
|
||||
copying = false,
|
||||
onSelect,
|
||||
onCopy,
|
||||
}: CommunitiesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, CommunityRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "value",
|
||||
size: 192,
|
||||
accessorKey: "value",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Community" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
data-community-disabled={!c.enabled ? true : undefined}
|
||||
>
|
||||
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs font-medium break-all bg-muted px-1.5 py-0.5 rounded">
|
||||
{c.value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCopy(c.value)
|
||||
}}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:bg-muted"
|
||||
aria-label={`Скопировать community: ${c.value}`}
|
||||
disabled={copying}
|
||||
>
|
||||
{copiedValue === c.value ? (
|
||||
<CheckIcon className="size-3" />
|
||||
) : (
|
||||
<CopyIcon className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Community",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
size: 240,
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Имя / описание" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-xs">{row.original.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{row.original.description}</p>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Имя / описание",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
size: 128,
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{TYPE_LABELS[row.original.type]}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
size: 128,
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Действие" />,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
|
||||
{ACTION_LABELS[c.action]}
|
||||
{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Действие",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixCount",
|
||||
size: 112,
|
||||
accessorKey: "prefixCount",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Маршрутов" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums text-right block">
|
||||
{row.original.prefixCount?.toLocaleString("ru-RU") ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Маршрутов",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "serverCount",
|
||||
size: 104,
|
||||
accessorKey: "serverCount",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Серверов" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<ServerIcon className="size-3 text-muted-foreground" />
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{row.original.serverCount.toLocaleString("ru-RU")}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Серверов",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chevron",
|
||||
header: () => <span className="sr-only">Детали</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<Button type="button" variant="ghost" size="icon" className="size-11" aria-label={`Детали community: ${row.original.value}`} onClick={event => { event.stopPropagation(); onSelect(row.original) }}>
|
||||
<ChevronRightIcon aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
),
|
||||
size: 64,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[copiedValue, copying, onCopy, onSelect],
|
||||
)
|
||||
|
||||
const rowSelection = useMemo(
|
||||
() => (selectedId ? { [selectedId]: true as const } : {}),
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["value"], end: [] } },
|
||||
data: communities,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
enableRowSelection: true,
|
||||
state: { rowSelection },
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(communities.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [communities.length, pageIndex, pageSize, table])
|
||||
|
||||
if (communities.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<TagIcon className="size-4" />}
|
||||
title="Ничего не найдено"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={communities.length}
|
||||
pagination={communities.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
onRowClick={(row) => onSelect(row)}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn(
|
||||
"group/row",
|
||||
"data-[state=selected]:bg-primary/5",
|
||||
"[&:has([data-community-disabled=true])]:opacity-50",
|
||||
),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
CommunitiesDataGrid,
|
||||
type CommunitiesDataGridProps,
|
||||
TYPE_LABELS,
|
||||
ACTION_LABELS,
|
||||
ACTION_COLOR,
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect, type ReactNode, type CSSProperties } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { InboxIcon } from "lucide-react"
|
||||
|
||||
export interface CompactDataGridColumn<T> {
|
||||
id: string
|
||||
header: string
|
||||
accessorKey?: keyof T & string
|
||||
cell?: (row: T) => ReactNode
|
||||
enableSorting?: boolean
|
||||
size?: number
|
||||
headerClassName?: string
|
||||
cellClassName?: string
|
||||
}
|
||||
|
||||
interface CompactDataGridProps<T extends { id: string }> {
|
||||
data: T[]
|
||||
columns: CompactDataGridColumn<T>[]
|
||||
isLoading?: boolean
|
||||
horizontalScroll?: boolean
|
||||
pinnedColumnId?: string
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
onRowClick?: (row: T) => void
|
||||
getExpandedContent?: (row: T) => ReactNode
|
||||
}
|
||||
|
||||
function CompactDataGrid<T extends { id: string }>({
|
||||
data,
|
||||
columns,
|
||||
isLoading,
|
||||
horizontalScroll = false,
|
||||
pinnedColumnId,
|
||||
emptyTitle = "Нет записей",
|
||||
emptyDescription,
|
||||
onRowClick,
|
||||
getExpandedContent,
|
||||
}: CompactDataGridProps<T>) {
|
||||
const columnDefs = useMemo<ColumnDef<DataGridFeatures, T>[]>(
|
||||
() =>
|
||||
columns.map((col, index) => ({
|
||||
id: col.id,
|
||||
size: col.size,
|
||||
accessorKey: col.accessorKey ?? col.id,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title={col.header} className={index === 0 ? "ml-1" : undefined} />
|
||||
),
|
||||
cell: ({ row }) => (col.cell ? col.cell(row.original) : String(row.getValue(col.id) ?? "—")),
|
||||
enableSorting: col.enableSorting !== false,
|
||||
meta: {
|
||||
headerTitle: col.header,
|
||||
headerClassName:
|
||||
col.headerClassName ??
|
||||
(index === 0
|
||||
? DATA_GRID_CELL_PAD_FIRST
|
||||
: index === columns.length - 1
|
||||
? DATA_GRID_CELL_PAD_LAST
|
||||
: DATA_GRID_CELL_PAD),
|
||||
cellClassName:
|
||||
col.cellClassName ??
|
||||
(index === 0
|
||||
? DATA_GRID_CELL_PAD_FIRST
|
||||
: index === columns.length - 1
|
||||
? DATA_GRID_CELL_PAD_LAST
|
||||
: DATA_GRID_CELL_PAD),
|
||||
...(getExpandedContent && index === 0
|
||||
? { expandedContent: getExpandedContent }
|
||||
: {}),
|
||||
},
|
||||
})),
|
||||
[columns, getExpandedContent],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: pinnedColumnId ? [pinnedColumnId] : [], end: [] } },
|
||||
data,
|
||||
columns: columnDefs,
|
||||
getRowId: (row) => row.id,
|
||||
...(getExpandedContent ? { getRowCanExpand: () => true } : {}),
|
||||
})
|
||||
|
||||
const {pageIndex,pageSize}=table.state.pagination
|
||||
useEffect(()=>{
|
||||
const last=Math.max(0,Math.ceil(data.length/pageSize)-1)
|
||||
if(pageIndex>last)table.setPageIndex(last)
|
||||
},[data.length,pageSize,pageIndex,table])
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
tableLayout={pinnedColumnId ? { ...DEFAULT_DATA_GRID_LAYOUT, columnsPinnable: true } : undefined}
|
||||
pagination={data.length > 50 || table.state.pagination.pageSize !== 50}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
onRowClick={
|
||||
onRowClick ??
|
||||
(getExpandedContent
|
||||
? (row) => table.getRow(row.id).toggleExpanded()
|
||||
: undefined)
|
||||
}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<InboxIcon className="size-4" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{horizontalScroll ? (
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea
|
||||
orientation="horizontal"
|
||||
className={pinnedColumnId ? "[&_[data-slot=scroll-area-viewport]]:[scroll-padding-inline-start:var(--pinned-column-width)]" : undefined}
|
||||
style={pinnedColumnId ? { "--pinned-column-width": `${table.getColumn(pinnedColumnId)?.getSize() ?? 0}px` } as CSSProperties : undefined}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
) : undefined}
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { CompactDataGrid, type CompactDataGridProps }
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import type { Server, ServerType } from "@/lib/data"
|
||||
import type {DashboardProbe} from "@/lib/dashboard-probes"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FilterIcon } from "lucide-react"
|
||||
|
||||
function formatLossPct(loss: number|null): string {
|
||||
if (loss===null || !Number.isFinite(loss)) return "—"
|
||||
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function TypeChip({ type }: { type: ServerType }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||
type === "home-router"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: type === "jump-host"
|
||||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}
|
||||
>
|
||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeSourceCell({ probe, catalog }: { probe: DashboardProbe; catalog: Server[] }) {
|
||||
const srv = catalog.find((s) => s.id === probe.srcServerId)
|
||||
const iface = (probe.srcInterface ?? "").trim() || "auto"
|
||||
|
||||
if (!srv) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status="unknown" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium text-muted-foreground truncate">
|
||||
Сервер <span className="font-mono tabular-nums">{probe.srcServerId}</span>
|
||||
</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground truncate mt-0.5" title={iface}>
|
||||
{iface}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status={srv.status} pulse={srv.status === "online"} />
|
||||
</span>
|
||||
<div className="flex gap-2 min-w-0 flex-1">
|
||||
<Flag code={srv.country} size={16} className="shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[13px] font-medium leading-tight truncate">{srv.name}</span>
|
||||
<TypeChip type={srv.type} />
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate" title={`Интерфейс: ${iface}`}>
|
||||
<span className="font-mono tabular-nums">{iface}</span>
|
||||
{srv.site && srv.site !== "—" && (
|
||||
<span className="text-muted-foreground/90"> · {srv.site}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DashboardActiveProbesDataGridProps {
|
||||
probes: DashboardProbe[]
|
||||
catalog: Server[]
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
function DashboardActiveProbesDataGrid({
|
||||
probes,
|
||||
catalog,
|
||||
isLoading,
|
||||
emptyTitle = "Нет активных проб",
|
||||
emptyDescription,
|
||||
}: DashboardActiveProbesDataGridProps) {
|
||||
const columns = useMemo<CompactDataGridColumn<DashboardProbe>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "source",
|
||||
header: "Источник",
|
||||
enableSorting: false,
|
||||
cell: (p) => <ProbeSourceCell probe={p} catalog={catalog} />,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
header: "Проба",
|
||||
accessorKey: "name",
|
||||
cell: (p) => <span className="font-medium">{p.name}</span>,
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
header: "Цель",
|
||||
accessorKey: "target",
|
||||
cell: (p) => <span className="font-mono text-xs text-muted-foreground">{p.target}</span>,
|
||||
},
|
||||
{
|
||||
id: "filter",
|
||||
header: "Фильтр",
|
||||
accessorKey: "filter",
|
||||
enableSorting: false,
|
||||
cell: (p) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />
|
||||
{p.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "rtt",
|
||||
header: "RTT",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "font-mono text-right",
|
||||
cell: (p) => (p.rtt == null ? "—" : `${p.rtt} мс`),
|
||||
},
|
||||
{
|
||||
id: "loss",
|
||||
header: "Потери",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "font-mono text-right",
|
||||
cell: (p) => (
|
||||
<span
|
||||
className={cn(
|
||||
(p.loss??0) > 5 ? "text-red-500" : (p.loss??0) > 0 ? "text-amber-500" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{formatLossPct(p.loss)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "series",
|
||||
header: "RTT · 1 ч",
|
||||
enableSorting: false,
|
||||
headerClassName: "w-36",
|
||||
cell: (p) => {
|
||||
const sparkColor =
|
||||
p.status === "down"
|
||||
? "hsl(0 84% 60%)"
|
||||
: p.status === "warn"
|
||||
? "hsl(32 94% 44%)"
|
||||
: "hsl(142 76% 36%)"
|
||||
return <Sparkline data={p.series} width={120} height={24} color={sparkColor} />
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Статус",
|
||||
enableSorting: false,
|
||||
cell: (p) => (
|
||||
<span title={p.observationError ?? undefined}><StatusBadge
|
||||
status={p.status === "unknown" ? "unknown" : p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||
/></span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[catalog],
|
||||
)
|
||||
|
||||
return (
|
||||
<CompactDataGrid
|
||||
data={probes}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
emptyTitle={emptyTitle}
|
||||
emptyDescription={emptyDescription}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { DashboardActiveProbesDataGrid, type DashboardActiveProbesDataGridProps }
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { RefreshCwIcon } from "lucide-react"
|
||||
|
||||
export interface SchedulerJobGridRow {
|
||||
id: string
|
||||
jobKey: string
|
||||
label: string
|
||||
description: string
|
||||
fixedSchedule: boolean
|
||||
enabled: boolean
|
||||
settingsKnown: boolean
|
||||
intervalValue: string
|
||||
intervalReadOnly: boolean
|
||||
intervalDisabled: boolean
|
||||
defaultInterval: number
|
||||
job?: SchedulerJobStatusDto
|
||||
onEnabledChange?: (enabled: boolean) => void
|
||||
onIntervalChange: (value: string) => void
|
||||
onRunNow: () => void
|
||||
runNowDisabled: boolean
|
||||
runNowLoading: boolean
|
||||
saveBusy: boolean
|
||||
}
|
||||
|
||||
interface DataCollectionSchedulerDataGridProps {
|
||||
rows: SchedulerJobGridRow[]
|
||||
}
|
||||
|
||||
function DataCollectionSchedulerDataGrid({ rows }: DataCollectionSchedulerDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, SchedulerJobGridRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "task",
|
||||
accessorKey: "label",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Задача</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="align-top">
|
||||
<span className="font-medium text-sm">{row.original.label}</span>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
|
||||
{row.original.description}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono mt-1">{row.original.jobKey}</p>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_FIRST, "align-top"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground text-center block">Вкл</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const { fixedSchedule, enabled, saveBusy, onEnabledChange, settingsKnown } = row.original
|
||||
if (!settingsKnown) return <span className="text-muted-foreground" aria-label="Настройки не получены">—</span>
|
||||
return (
|
||||
<div className="text-center align-top">
|
||||
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
||||
<FormToggle
|
||||
ariaLabel={`Включить: ${row.original.label}`}
|
||||
checked={enabled}
|
||||
disabled={fixedSchedule || saveBusy}
|
||||
onChange={(v) => {
|
||||
if (fixedSchedule || saveBusy) return
|
||||
onEnabledChange?.(v)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 56,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||
},
|
||||
{
|
||||
id: "interval",
|
||||
accessorKey: "intervalValue",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Интервал (с)</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="w-28 align-top">
|
||||
<Input
|
||||
value={r.intervalValue}
|
||||
onChange={(e) => r.onIntervalChange(e.target.value)}
|
||||
className="h-8 text-sm tabular-nums"
|
||||
inputMode="numeric"
|
||||
readOnly={r.intervalReadOnly}
|
||||
disabled={r.intervalDisabled}
|
||||
placeholder={r.settingsKnown ? String(r.defaultInterval) : "—"}
|
||||
aria-label={`Интервал: ${r.label}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||
},
|
||||
{
|
||||
id: "lastRun",
|
||||
accessorFn: (row) => row.job?.lastFinishedAt ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Последний прогон</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const j = row.original.job
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground align-top">
|
||||
{j?.lastFinishedAt ? new Date(j.lastFinishedAt).toLocaleString("ru-RU") : "—"}
|
||||
{j?.lastDurationMs != null && (
|
||||
<span className="block text-[11px]">{j.lastDurationMs} мс</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorFn: (row) => row.job?.lastStatus ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Статус</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const j = row.original.job
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 align-top">
|
||||
{j?.running ? (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
выполняется
|
||||
</Badge>
|
||||
) : null}
|
||||
{j?.lastStatus ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
j.lastStatus === "ok" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
||||
j.lastStatus === "error" && "border-destructive/50 text-destructive",
|
||||
)}
|
||||
>
|
||||
{j.lastStatus}
|
||||
</Badge>
|
||||
) : null}
|
||||
{j?.lastError ? (
|
||||
<span className="text-[10px] text-destructive max-w-[200px] truncate block" title={j.lastError}>
|
||||
{j.lastError}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||
},
|
||||
{
|
||||
id: "runNow",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground text-right block">Сейчас</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="text-right align-top">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={r.runNowDisabled || r.runNowLoading}
|
||||
onClick={r.onRunNow}
|
||||
aria-label={`Запустить: ${r.label}`}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-3.5", r.runNowLoading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "align-top"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
data: rows,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
tableClassNames={{ bodyRow: "group/row hover:bg-muted/40 text-sm" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataCollectionSchedulerDataGrid, type DataCollectionSchedulerDataGridProps }
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Domain } from "@/lib/data"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { FilterIcon, GlobeIcon } from "lucide-react"
|
||||
|
||||
interface DomainsDataGridProps {
|
||||
domains: Domain[]
|
||||
isLoading?: boolean
|
||||
emptyDescription?: string
|
||||
pagination?: boolean
|
||||
}
|
||||
|
||||
function DomainsDataGrid({ domains, isLoading, pagination = false, emptyDescription }: DomainsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, Domain>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "domain",
|
||||
accessorKey: "domain",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Домен" className="ml-1" />,
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.domain}</span>,
|
||||
meta: {
|
||||
headerTitle: "Домен",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "resolvedIp",
|
||||
accessorKey: "resolvedIp",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Resolved IP" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.resolvedIp}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Resolved IP",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "asn",
|
||||
accessorKey: "asn",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.asn}</span>,
|
||||
meta: {
|
||||
headerTitle: "ASN",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "purpose",
|
||||
accessorKey: "purpose",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.purpose}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "filter",
|
||||
accessorKey: "filter",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />
|
||||
{row.original.filter}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Фильтр",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
accessorKey: "updated",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
|
||||
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
|
||||
meta: {
|
||||
headerTitle: "Обновлён",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.original.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: !pagination,
|
||||
data: domains,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={domains.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
pagination={pagination}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<GlobeIcon className="size-4" />}
|
||||
title="Нет доменов"
|
||||
description={emptyDescription ?? "Добавьте домены или импортируйте каталог"}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { DomainsDataGrid, type DomainsDataGridProps }
|
||||
@@ -0,0 +1,406 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { FilterRule, GreTunnel, Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PencilIcon,
|
||||
RouteIcon,
|
||||
StarIcon,
|
||||
TrashIcon,
|
||||
FilterIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface RecursiveRouteLite {
|
||||
id: string
|
||||
dstAddress: string
|
||||
gateway: string
|
||||
distance: number
|
||||
routingTable: string
|
||||
comment: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
function innerIpToGateway(ip: string) {
|
||||
return ip.split("/")[0]
|
||||
}
|
||||
|
||||
function gatewayFromRecursiveDst(dstAddress: string) {
|
||||
return innerIpToGateway((dstAddress ?? "").trim())
|
||||
}
|
||||
|
||||
function pickRecursiveRouteByGatewayHop(routes: RecursiveRouteLite[], hopIp: string) {
|
||||
const hop = hopIp.trim()
|
||||
if (!hop) return undefined
|
||||
const candidates = routes.filter(
|
||||
(r) => !r.disabled && gatewayFromRecursiveDst(r.dstAddress) === hop,
|
||||
)
|
||||
if (candidates.length === 0) return undefined
|
||||
return candidates.reduce((a, b) => (a.distance <= b.distance ? a : b))
|
||||
}
|
||||
|
||||
function isRecursiveGatewayRef(ref: string) {
|
||||
return ref.startsWith("rec:")
|
||||
}
|
||||
|
||||
function FilterGatewayCell({
|
||||
rule,
|
||||
tunnelsList,
|
||||
serversList,
|
||||
recursiveRoutes,
|
||||
}: {
|
||||
rule: FilterRule
|
||||
tunnelsList: GreTunnel[]
|
||||
serversList: Server[]
|
||||
recursiveRoutes: RecursiveRouteLite[]
|
||||
}) {
|
||||
const isBlackhole = rule.action === "blackhole"
|
||||
const isRecRef = !isBlackhole && isRecursiveGatewayRef(rule.gatewayTunnelId)
|
||||
const recRowByRef = isRecRef
|
||||
? recursiveRoutes.find((r) => r.id === rule.gatewayTunnelId.slice(4))
|
||||
: undefined
|
||||
const recRowByHop =
|
||||
!isBlackhole && !recRowByRef && (isRecRef || !(rule.gatewayTunnelId ?? "").trim()) && rule.gateway.trim()
|
||||
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
|
||||
: undefined
|
||||
const recRow = recRowByRef ?? recRowByHop
|
||||
const treatAsRecursive = !isBlackhole && (isRecRef || !!recRowByHop)
|
||||
const tunnel =
|
||||
!isBlackhole && !treatAsRecursive
|
||||
? tunnelsList.find((t) => t.id === rule.gatewayTunnelId)
|
||||
: undefined
|
||||
const remoteSrv = tunnel ? serversList.find((s) => s.host === tunnel.remoteAddress) : undefined
|
||||
|
||||
if (isBlackhole) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-1.5 rounded-full shrink-0 bg-red-500 animate-pulse" />
|
||||
<span className="font-mono text-xs font-medium text-red-600 dark:text-red-400">type=blackhole</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
treatAsRecursive
|
||||
? "bg-sky-500"
|
||||
: tunnel?.status === "up"
|
||||
? "bg-[var(--status-online)]"
|
||||
: tunnel?.status === "degraded"
|
||||
? "bg-[var(--status-degraded)]"
|
||||
: "bg-[var(--status-offline)]",
|
||||
)}
|
||||
/>
|
||||
{treatAsRecursive ? (
|
||||
<>
|
||||
<RouteIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs font-medium">
|
||||
{recRow ? gatewayFromRecursiveDst(recRow.dstAddress) : rule.gateway}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground border border-border rounded px-1 uppercase tracking-wide">
|
||||
recursive
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{remoteSrv && <Flag code={remoteSrv.country} />}
|
||||
<span className="font-mono text-xs font-medium">{rule.gateway}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{treatAsRecursive ? (
|
||||
recRow ? (
|
||||
<p className="text-[11px] text-muted-foreground truncate pl-3">{recRow.dstAddress}</p>
|
||||
) : isRecRef ? (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 truncate pl-3">
|
||||
рекурсивный маршрут (нет строки в списке)
|
||||
</p>
|
||||
) : null
|
||||
) : tunnel ? (
|
||||
<p className="text-[11px] text-muted-foreground truncate pl-3">{tunnel.name}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterRowActions({
|
||||
disabled,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
disabled: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-11 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onEdit}
|
||||
disabled={disabled}
|
||||
aria-label="Редактировать правило"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-11 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
disabled={disabled}
|
||||
aria-label="Удалить правило"
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FiltersDataGridProps {
|
||||
rules: FilterRule[]
|
||||
orderedRules?: FilterRule[]
|
||||
mutationsLocked?: boolean
|
||||
tunnelsList: GreTunnel[]
|
||||
serversList: Server[]
|
||||
communityNameMap: Record<string, string>
|
||||
recursiveRoutes: RecursiveRouteLite[]
|
||||
enableSorting?: boolean
|
||||
onEdit: (rule: FilterRule) => void
|
||||
onDelete: (id: string) => void
|
||||
onMoveUp: (id: string) => void
|
||||
onMoveDown: (id: string) => void
|
||||
}
|
||||
|
||||
function FiltersDataGrid({
|
||||
rules,
|
||||
orderedRules = rules,
|
||||
mutationsLocked = false,
|
||||
tunnelsList,
|
||||
serversList,
|
||||
communityNameMap,
|
||||
recursiveRoutes,
|
||||
enableSorting = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
}: FiltersDataGridProps) {
|
||||
const orderedIndex = useMemo(() => new Map(orderedRules.map((rule, index) => [rule.id, index])), [orderedRules])
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, FilterRule>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "priority",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const index = orderedIndex.get(row.original.id) ?? -1
|
||||
const isLast = index === orderedRules.length - 1
|
||||
return (
|
||||
<div className="flex items-center justify-center text-[11px] font-mono text-muted-foreground/40">
|
||||
{isLast ? (
|
||||
<StarIcon className="size-3 text-amber-400 fill-amber-400" aria-label="Наивысший приоритет" />
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 32,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "reorder",
|
||||
header: () => <span className="sr-only">Порядок</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const index = orderedIndex.get(row.original.id) ?? -1
|
||||
const isLast = index === orderedRules.length - 1
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveUp(row.original.id)}
|
||||
aria-label={`Поднять правило ${row.original.community}`}
|
||||
disabled={mutationsLocked || index <= 0}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<ChevronUpIcon className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveDown(row.original.id)}
|
||||
aria-label={`Опустить правило ${row.original.community}`}
|
||||
disabled={mutationsLocked || index < 0 || isLast}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 104,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "community",
|
||||
size: 120,
|
||||
accessorKey: "community",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Community" />,
|
||||
enableSorting,
|
||||
cell: ({ row }) => {
|
||||
const rule = row.original
|
||||
const isBlackhole = rule.action === "blackhole"
|
||||
const communityName = communityNameMap[rule.community] ?? rule.communityName
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border font-mono",
|
||||
isBlackhole
|
||||
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}
|
||||
>
|
||||
{rule.community}
|
||||
</span>
|
||||
{isBlackhole && (
|
||||
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 uppercase">
|
||||
⊘ blackhole
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{communityName && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">{communityName}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "Community", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "gateway",
|
||||
size: 192,
|
||||
accessorKey: "gateway",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Gateway" />,
|
||||
enableSorting,
|
||||
cell: ({ row }) => (
|
||||
<FilterGatewayCell
|
||||
rule={row.original}
|
||||
tunnelsList={tunnelsList}
|
||||
serversList={serversList}
|
||||
recursiveRoutes={recursiveRoutes}
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: "Gateway", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
size: 240,
|
||||
accessorKey: "description",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Описание" />,
|
||||
enableSorting,
|
||||
cell: ({ row }) => (
|
||||
<p className="text-xs text-muted-foreground truncate">{row.original.description || "—"}</p>
|
||||
),
|
||||
meta: { headerTitle: "Описание", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<FilterRowActions
|
||||
disabled={mutationsLocked}
|
||||
onEdit={() => onEdit(row.original)}
|
||||
onDelete={() => onDelete(row.original.id)}
|
||||
/>
|
||||
),
|
||||
size: 112,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[
|
||||
communityNameMap,
|
||||
orderedRules,
|
||||
orderedIndex,
|
||||
mutationsLocked,
|
||||
enableSorting,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMoveDown,
|
||||
onMoveUp,
|
||||
recursiveRoutes,
|
||||
serversList,
|
||||
tunnelsList,
|
||||
],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["priority", "community"], end: [] } },
|
||||
manualSorting: !enableSorting,
|
||||
data: rules,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(rules.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [rules.length, pageIndex, pageSize, table])
|
||||
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<FilterIcon className="size-4" />}
|
||||
title="Ничего не найдено"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridShell table={table} recordCount={rules.length} pagination={rules.length > 50 || pageSize !== 50} tableLayout={{ dense: true, columnsPinnable: true }}>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
<div className="px-5 py-2 text-[11px] text-muted-foreground/40 flex items-center gap-1.5 border-t">
|
||||
<StarIcon className="size-3 text-amber-400 fill-amber-400 shrink-0" />
|
||||
Последнее правило имеет наивысший приоритет в RouterOS
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { FiltersDataGrid, type FiltersDataGridProps }
|
||||
@@ -0,0 +1,407 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { FirewallRule } from "@/lib/data"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/data-grid"
|
||||
import type { DragEndEvent } from "@dnd-kit/core"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CopyIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
PowerIcon,
|
||||
ShieldOffIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
const ACTION_STYLES: Record<string, string> = {
|
||||
accept: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
drop: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
reject: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
masquerade: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
||||
"mark-routing": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
"mark-conn": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
"fasttrack-connection": "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
|
||||
"dst-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
"src-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
"add-src-to-address-list": "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
|
||||
}
|
||||
|
||||
const CHAIN_STYLES: Record<string, string> = {
|
||||
forward: "bg-foreground/5 text-foreground/70",
|
||||
input: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
output: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
srcnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
dstnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
prerouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
postrouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
"ip6-input": "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
"ip6-forward": "bg-foreground/5 text-foreground/70",
|
||||
"ip6-output": "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
}
|
||||
|
||||
function fmtHits(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}М`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}к`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function ActionBadge({ action }: { action: string }) {
|
||||
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
|
||||
return (
|
||||
<span className={cn("text-[11px] font-mono font-medium px-2 py-0.5 rounded border whitespace-nowrap", cls)}>
|
||||
{action}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ChainBadge({ chain }: { chain: string }) {
|
||||
const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground"
|
||||
return (
|
||||
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded", cls)}>
|
||||
{chain}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface FirewallRulesDataGridProps {
|
||||
rules: FirewallRule[]
|
||||
mutationsDisabled?: boolean
|
||||
onToggle: (id: string) => void
|
||||
onEdit: (rule: FirewallRule) => void
|
||||
onDelete?: (rule: FirewallRule) => void
|
||||
onReorder?: (activeId: string, overId: string) => void
|
||||
showServer?: boolean
|
||||
}
|
||||
|
||||
function FirewallRulesDataGrid({
|
||||
rules,
|
||||
mutationsDisabled = false,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReorder,
|
||||
showServer = false,
|
||||
}: FirewallRulesDataGridProps) {
|
||||
const indexedRules = useMemo(
|
||||
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
|
||||
[rules],
|
||||
)
|
||||
const neighbours = useMemo(() => {
|
||||
const groups = new Map<string, FirewallRule[]>()
|
||||
for (const rule of rules) {
|
||||
const key = [rule.serverId, rule.family, rule.table].join(":")
|
||||
const group = groups.get(key) ?? []
|
||||
group.push(rule); groups.set(key, group)
|
||||
}
|
||||
const result = new Map<string, { up?: string; down?: string }>()
|
||||
for (const group of groups.values()) group.forEach((rule, index) => result.set(rule.id, { up: group[index - 1]?.id, down: group[index + 1]?.id }))
|
||||
return result
|
||||
}, [rules])
|
||||
const reorderable = Boolean(onReorder)
|
||||
const indexPad = reorderable ? DATA_GRID_CELL_PAD : DATA_GRID_CELL_PAD_FIRST
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, FirewallRule & { _index: number }>[]>(
|
||||
() => [
|
||||
...(reorderable
|
||||
? [{
|
||||
id: "drag",
|
||||
header: () => <span className="sr-only">Порядок</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const targets = neighbours.get(row.original.id)
|
||||
return <div className="flex items-center gap-1">
|
||||
<DataGridTableDndRowHandle className="size-11 shrink-0" />
|
||||
<Button type="button" variant="ghost" size="icon" className="size-11 shrink-0" aria-label={`Поднять правило ${row.original.rosId || row.original.id}`} disabled={!targets?.up || mutationsDisabled} onClick={() => targets?.up && onReorder?.(row.original.id, targets.up)}><ChevronUpIcon /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-11 shrink-0" aria-label={`Опустить правило ${row.original.rosId || row.original.id}`} disabled={!targets?.down || mutationsDisabled} onClick={() => targets?.down && onReorder?.(row.original.id, targets.down)}><ChevronDownIcon /></Button>
|
||||
</div>
|
||||
},
|
||||
size: 160,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
} satisfies ColumnDef<DataGridFeatures, FirewallRule & { _index: number }>]
|
||||
: []),
|
||||
{
|
||||
id: "index",
|
||||
accessorKey: "_index",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className="font-mono text-xs text-muted-foreground"
|
||||
data-rule-disabled={!row.original.enabled ? true : undefined}
|
||||
>
|
||||
{row.original._index}
|
||||
</span>
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: indexPad,
|
||||
cellClassName: indexPad,
|
||||
},
|
||||
},
|
||||
...(showServer
|
||||
? [{
|
||||
id: "server",
|
||||
accessorFn: (row: FirewallRule & { _index: number }) => row.serverName ?? row.serverId ?? "",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground truncate">
|
||||
{row.original.serverName || row.original.serverId || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Сервер", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
} satisfies ColumnDef<DataGridFeatures, FirewallRule & { _index: number }>]
|
||||
: []),
|
||||
{
|
||||
id: "chain",
|
||||
size: 112,
|
||||
accessorKey: "chain",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Цепочка" />,
|
||||
cell: ({ row }) => <ChainBadge chain={row.original.chain} />,
|
||||
meta: { headerTitle: "Цепочка", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
size: 176,
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Действие" />,
|
||||
cell: ({ row }) => <ActionBadge action={row.original.action} />,
|
||||
meta: { headerTitle: "Действие", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "src",
|
||||
size: 176,
|
||||
accessorKey: "src",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Источник" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground max-w-[140px] truncate block">
|
||||
{row.original.src || "any"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Источник", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "dst",
|
||||
size: 176,
|
||||
accessorKey: "dst",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground max-w-[140px] truncate block">
|
||||
{row.original.dst || "any"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Назначение", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "proto",
|
||||
size: 96,
|
||||
accessorKey: "proto",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Протокол" />,
|
||||
cell: ({ row }) => <span className="text-xs font-mono">{row.original.proto}</span>,
|
||||
meta: { headerTitle: "Протокол", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "port",
|
||||
size: 112,
|
||||
accessorKey: "port",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Порт" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.port || "—"}</span>
|
||||
),
|
||||
meta: { headerTitle: "Порт", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
size: 160,
|
||||
accessorKey: "iface",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.iface || "—"}</span>
|
||||
),
|
||||
meta: { headerTitle: "Интерфейс", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "hits",
|
||||
size: 112,
|
||||
accessorKey: "hits",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пакетов" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const hits = row.original.hits
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-mono tabular-nums text-right block",
|
||||
hits > 1_000_000
|
||||
? "text-emerald-600 dark:text-emerald-400 font-semibold"
|
||||
: hits > 10_000
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{fmtHits(hits)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пакетов",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
size: 64,
|
||||
accessorKey: "enabled",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Вкл</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<FormToggle ariaLabel={`Включено правило ${row.original.rosId || row.original.id}`} disabled={mutationsDisabled} checked={row.original.enabled} onChange={() => onToggle(row.original.id)} />
|
||||
</div>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-11 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"transition-colors",
|
||||
"data-popup-open:opacity-100",
|
||||
)}
|
||||
aria-label={`Действия: ${r.chain} ${r.action}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onEdit(r)}>
|
||||
<PencilIcon className="size-4" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CopyIcon className="size-4" />
|
||||
Дублировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onToggle(r.id)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{r.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={mutationsDisabled} variant="destructive" onClick={() => onDelete?.(r)}>
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить правило
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 64,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[onEdit, onToggle, onDelete, onReorder, neighbours, showServer, reorderable, indexPad, mutationsDisabled],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["index", "chain"], end: [] } },
|
||||
data: indexedRules,
|
||||
columns,
|
||||
manualSorting: reorderable,
|
||||
enableSorting: !reorderable,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(rules.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [rules.length, pageIndex, pageSize, table])
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id || !onReorder) return
|
||||
onReorder(String(active.id), String(over.id))
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ShieldOffIcon className="size-4" />}
|
||||
title="Правила не найдены"
|
||||
description="Попробуйте изменить фильтр или добавьте новое правило"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rules.length}
|
||||
pagination={rules.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
|
||||
<DataGridScrollArea>
|
||||
{reorderable ? <DataGridTableDndRows dataIds={table.getRowModel().rows.map(row => row.id)} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { FirewallRulesDataGrid, type FirewallRulesDataGridProps, ActionBadge, ChainBadge }
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { ActionBadge, ChainBadge } from "@/components/data-grids/firewall-rules-data-grid"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PowerIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
function ScenarioCell({ enabled, children }: { enabled: boolean; children: React.ReactNode }) {
|
||||
return <div className={cn(!enabled && "opacity-40")}>{children}</div>
|
||||
}
|
||||
|
||||
export interface ScenarioRuleRow {
|
||||
id: string
|
||||
chain: string
|
||||
action: string
|
||||
proto: string
|
||||
src: string
|
||||
dst: string
|
||||
port: string
|
||||
iface: string
|
||||
comment: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface FirewallScenarioRulesDataGridProps {
|
||||
rules: ScenarioRuleRow[]
|
||||
onToggleEnabled: (id: string) => void
|
||||
onMoveUp: (id: string) => void
|
||||
onMoveDown: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
function FirewallScenarioRulesDataGrid({
|
||||
rules,
|
||||
onToggleEnabled,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemove,
|
||||
}: FirewallScenarioRulesDataGridProps) {
|
||||
const indexedRules = useMemo(
|
||||
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
|
||||
[rules],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, ScenarioRuleRow & { _index: number }>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "index",
|
||||
accessorKey: "_index",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<ScenarioCell enabled={row.original.enabled}>
|
||||
<span className="font-mono text-xs text-muted-foreground tabular-nums">{row.original._index}</span>
|
||||
</ScenarioCell>
|
||||
),
|
||||
size: 40,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chain",
|
||||
size: 112,
|
||||
accessorKey: "chain",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Цепочка</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<ScenarioCell enabled={row.original.enabled}>
|
||||
<ChainBadge chain={row.original.chain} />
|
||||
</ScenarioCell>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
size: 176,
|
||||
accessorKey: "action",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Действие</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ActionBadge action={row.original.action} />,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "src",
|
||||
size: 176,
|
||||
accessorKey: "src",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Src</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground max-w-[90px] truncate block">
|
||||
{row.original.src || "any"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "dst",
|
||||
size: 176,
|
||||
accessorKey: "dst",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Dst</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground max-w-[90px] truncate block">
|
||||
{row.original.dst || "any"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "port",
|
||||
size: 112,
|
||||
accessorKey: "port",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Порт</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.port || "—"}</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
size: 160,
|
||||
accessorKey: "iface",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Iface</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.iface || "—"}</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "comment",
|
||||
size: 192,
|
||||
accessorKey: "comment",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Комментарий</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground/70 max-w-[110px] truncate block">
|
||||
{row.original.comment || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 208,
|
||||
header: () => null,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const index = row.original._index - 1
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleEnabled(row.original.id)}
|
||||
aria-label={`${row.original.enabled ? "Отключить" : "Включить"} правило ${index + 1}`}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<PowerIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveUp(row.original.id)}
|
||||
aria-label={`Поднять правило ${index + 1}`}
|
||||
disabled={index === 0}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveDown(row.original.id)}
|
||||
aria-label={`Опустить правило ${index + 1}`}
|
||||
disabled={index === rules.length - 1}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(row.original.id)}
|
||||
aria-label={`Удалить правило ${index + 1}`}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[onMoveDown, onMoveUp, onRemove, onToggleEnabled, rules.length],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["index", "chain"], end: [] } },
|
||||
manualSorting: true,
|
||||
data: indexedRules,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(rules.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [rules.length, pageIndex, pageSize, table])
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rules.length}
|
||||
pagination={rules.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
tableClassNames={{
|
||||
bodyRow: cn("group/row text-xs", "hover:bg-muted/20 transition-colors"),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
|
||||
<DataGridScrollArea><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { FirewallScenarioRulesDataGrid, type FirewallScenarioRulesDataGridProps }
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { GrePool } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { LayersIcon } from "lucide-react"
|
||||
|
||||
interface GrePoolsDataGridProps {
|
||||
pools: GrePool[]
|
||||
}
|
||||
|
||||
function GrePoolsDataGrid({ pools }: GrePoolsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, GrePool>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Имя пула" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-[13px] font-medium">{row.original.name}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Имя пула",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cidr",
|
||||
accessorKey: "cidr",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Диапазон CIDR" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.cidr}</span>,
|
||||
meta: {
|
||||
headerTitle: "Диапазон CIDR",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "allocated",
|
||||
accessorKey: "allocated",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Назначено /30" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums text-right block">{row.original.allocated}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Назначено /30",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "available",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground block text-right">
|
||||
Доступно /30
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const p = row.original
|
||||
return (
|
||||
<span className="tabular-nums text-muted-foreground text-right block">
|
||||
{p.total - p.allocated}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Использование</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const pool = row.original
|
||||
const pct = pool.total > 0 ? Math.round((pool.allocated / pool.total) * 100) : 0
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[140px]">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full", pct > 80 ? "bg-amber-500" : "bg-emerald-500")}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "comment",
|
||||
accessorKey: "comment",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">{row.original.comment}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
data: pools,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (pools.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<LayersIcon className="size-4" />}
|
||||
title="Нет IP-пулов"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={pools.length}
|
||||
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { GrePoolsDataGrid, type GrePoolsDataGridProps }
|
||||
@@ -0,0 +1,413 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type {
|
||||
GrePool,
|
||||
GreTunnel,
|
||||
GreStatus,
|
||||
IpsecEncAlg,
|
||||
IpsecAuthAlg,
|
||||
IpsecDhGroup,
|
||||
IkeVersion,
|
||||
Server,
|
||||
} from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CodeXmlIcon,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
MoreHorizontalIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
PowerIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
const ENC_LABELS: Record<IpsecEncAlg, string> = {
|
||||
"aes-128": "AES-128",
|
||||
"aes-192": "AES-192",
|
||||
"aes-256": "AES-256",
|
||||
}
|
||||
const AUTH_LABELS: Record<IpsecAuthAlg, string> = {
|
||||
sha1: "SHA-1",
|
||||
sha256: "SHA-256",
|
||||
sha512: "SHA-512",
|
||||
}
|
||||
const DH_LABELS: Record<IpsecDhGroup, string> = {
|
||||
modp1024: "DH-2 (1024)",
|
||||
modp2048: "DH-14 (2048)",
|
||||
modp4096: "DH-16 (4096)",
|
||||
ecp256: "ECP-256",
|
||||
ecp384: "ECP-384",
|
||||
ecp521: "ECP-521",
|
||||
}
|
||||
const IKE_LABELS: Record<IkeVersion, string> = { ikev1: "IKEv1", ikev2: "IKEv2" }
|
||||
|
||||
const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
|
||||
up: { label: "Up", dot: "bg-emerald-500" },
|
||||
degraded: { label: "Degraded", dot: "bg-amber-500" },
|
||||
down: { label: "Down", dot: "bg-red-500" },
|
||||
}
|
||||
|
||||
function TunnelStatus({ status }: { status: GreStatus }) {
|
||||
const s = STATUS_MAP[status] ?? STATUS_MAP.degraded
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||
<span className={cn("size-1.5 rounded-full", s.dot)} />
|
||||
{s.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function IpsecBadge({ secured }: { secured: boolean }) {
|
||||
return secured ? (
|
||||
<Badge variant="invert-light" size="sm">
|
||||
<LockIcon className="size-3" /> IPsec
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="invert-light" size="sm">
|
||||
<LockOpenIcon className="size-3" /> Открытый
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
interface GreTunnelsDataGridProps {
|
||||
tunnels: GreTunnel[]
|
||||
servers: Server[]
|
||||
pools: GrePool[]
|
||||
onCodePreview: (tunnel: GreTunnel) => void
|
||||
onEdit?: (tunnel: GreTunnel) => void
|
||||
onToggle?: (tunnel: GreTunnel) => void
|
||||
onDelete?: (tunnel: GreTunnel) => void
|
||||
mutationsLocked?: boolean
|
||||
canManage?: (serverId: string) => boolean
|
||||
unknownServerIds?: Set<string>
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
type GreActions = Pick<GreTunnelsDataGridProps, "onCodePreview" | "onEdit" | "onToggle" | "onDelete" | "mutationsLocked" | "canManage">
|
||||
|
||||
const GreActionsContext = createContext<GreActions | null>(null)
|
||||
|
||||
// Keep the cell component identity stable while permissions and callbacks update.
|
||||
function GreActionsCell({ row }: { row: { original: GreTunnel } }) {
|
||||
const actions = useContext(GreActionsContext)
|
||||
if (!actions) throw new Error("GRE actions context is missing")
|
||||
const { onCodePreview, onEdit, onToggle, onDelete, mutationsLocked, canManage } = actions
|
||||
const t = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-11 shrink-0",
|
||||
)}
|
||||
title="Предпросмотр кода RouterOS"
|
||||
aria-label={`Просмотр кода: ${t.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCodePreview(t)
|
||||
}}
|
||||
>
|
||||
<CodeXmlIcon className="size-3.5" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-11 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"opacity-100",
|
||||
"data-popup-open:opacity-100",
|
||||
)}
|
||||
aria-label={`Действия: ${t.name}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onCodePreview(t)}>
|
||||
<CodeXmlIcon className="size-4" /> Просмотр кода
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={mutationsLocked || (canManage && !canManage(t.serverId)) || !onEdit} onClick={() => onEdit?.(t)}>
|
||||
<PencilIcon className="size-4" /> Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={mutationsLocked || (canManage && !canManage(t.serverId)) || !onToggle} onClick={() => onToggle?.(t)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{t.enabled ? "Выключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={mutationsLocked || (canManage && !canManage(t.serverId)) || !onDelete}
|
||||
onClick={() => onDelete?.(t)}
|
||||
>
|
||||
<Trash2Icon className="size-4" /> Удалить туннель
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GreTunnelsDataGrid({
|
||||
tunnels,
|
||||
servers,
|
||||
pools,
|
||||
onCodePreview,
|
||||
onEdit,
|
||||
onToggle,
|
||||
onDelete,
|
||||
mutationsLocked = false,
|
||||
unknownServerIds, emptyTitle, emptyDescription, canManage,
|
||||
}: GreTunnelsDataGridProps) {
|
||||
const serverMap = useMemo(() => new Map(servers.map((s) => [s.id, s])), [servers])
|
||||
const poolMap = useMemo(() => new Map(pools.map((p) => [p.id, p])), [pools])
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, GreTunnel>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
size: 160,
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
const srv = serverMap.get(t.serverId)
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium font-mono text-[13px] break-all">{t.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
{srv && <Flag code={srv.country} />}
|
||||
{srv?.name ?? t.serverId}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Интерфейс / Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "endpoints",
|
||||
size: 144,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Эндпоинты</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
return (
|
||||
<div>
|
||||
<p className="font-mono text-xs">
|
||||
{t.localAddress === "0.0.0.0" ? (
|
||||
<span className="text-muted-foreground">авто</span>
|
||||
) : (
|
||||
t.localAddress
|
||||
)}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">→ {t.remoteAddress}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "innerIp",
|
||||
size: 136,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Внутренний IP</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
return (
|
||||
<div>
|
||||
<p className="font-mono text-xs">{t.localInnerIp}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "pool",
|
||||
size: 96,
|
||||
accessorKey: "poolId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Пул" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{poolMap.get(row.original.poolId)?.name ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Пул", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "ipsec",
|
||||
size: 112,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">IPsec</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IpsecBadge secured={!!row.original.ipsec} />,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "encryption",
|
||||
size: 160,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Шифрование</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
if (!t.ipsec) return <span className="text-xs text-muted-foreground">—</span>
|
||||
const enc = t.ipsec.encAlg ? ENC_LABELS[t.ipsec.encAlg] : undefined
|
||||
const auth = t.ipsec.authAlg ? AUTH_LABELS[t.ipsec.authAlg] : undefined
|
||||
const dh = t.ipsec.dhGroup ? DH_LABELS[t.ipsec.dhGroup] : undefined
|
||||
const ike = t.ipsec.ikeVersion ? IKE_LABELS[t.ipsec.ikeVersion] : undefined
|
||||
if (!enc && !auth && !dh && !ike) {
|
||||
return <span className="text-xs text-muted-foreground">PSK · auto</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-mono">
|
||||
{[enc, auth].filter(Boolean).join(" / ") || "PSK"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{[dh?.split(" ")[0], ike].filter(Boolean).join(" · ")}
|
||||
{t.ipsec.pfs ? " · PFS" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "mtu",
|
||||
size: 72,
|
||||
accessorKey: "mtu",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-center block">{row.original.mtu}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "MTU",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "keepalive",
|
||||
size: 104,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Keepalive</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
return (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 112,
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => unknownServerIds?.has(row.original.serverId) ? <span className="text-sm text-muted-foreground">Неизвестно</span> : <TunnelStatus status={row.original.status} />,
|
||||
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: GreActionsCell,
|
||||
size: 112,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[unknownServerIds, poolMap, serverMap],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["name"], end: [] } },
|
||||
data: tunnels,
|
||||
columns,
|
||||
getRowId: row => JSON.stringify([row.serverId, row.id]),
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(tunnels.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [tunnels.length, pageIndex, pageSize, table])
|
||||
|
||||
if (tunnels.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title={emptyTitle ?? "Нет GRE-туннелей"}
|
||||
description={emptyDescription ?? "Измените фильтр или добавьте туннель"}
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<GreActionsContext.Provider value={{ onCodePreview, onEdit, onToggle, onDelete, mutationsLocked, canManage }}>
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={tunnels.length}
|
||||
pagination={tunnels.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row" }}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
</GreActionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export { GreTunnelsDataGrid, type GreTunnelsDataGridProps }
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { IpRange } from "@/lib/data"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { FilterIcon, NetworkIcon } from "lucide-react"
|
||||
|
||||
interface IpRangesDataGridProps {
|
||||
ipRanges: IpRange[]
|
||||
isLoading?: boolean
|
||||
emptyDescription?: string
|
||||
pagination?: boolean
|
||||
}
|
||||
|
||||
function IpRangesDataGrid({ ipRanges, isLoading, pagination = false, emptyDescription }: IpRangesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, IpRange>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "cidr",
|
||||
accessorKey: "cidr",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="CIDR" className="ml-1" />,
|
||||
cell: ({ row }) => <span className="font-mono font-medium">{row.original.cidr}</span>,
|
||||
meta: {
|
||||
headerTitle: "CIDR",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "asn",
|
||||
accessorKey: "asn",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.asn}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "ASN",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "country",
|
||||
accessorKey: "country",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Страна" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.country}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Страна",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "purpose",
|
||||
accessorKey: "purpose",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.purpose}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "filter",
|
||||
accessorKey: "filter",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />
|
||||
{row.original.filter}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Фильтр",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
accessorKey: "updated",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
|
||||
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
|
||||
meta: {
|
||||
headerTitle: "Обновлён",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.original.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: !pagination,
|
||||
data: ipRanges,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={ipRanges.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
pagination={pagination}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет IP-диапазонов"
|
||||
description={emptyDescription ?? "Добавьте CIDR-блоки или импортируйте каталог"}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpRangesDataGrid, type IpRangesDataGridProps }
|
||||
@@ -0,0 +1,317 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { BfdState } from "@/lib/ospf-state"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ActivityIcon } from "lucide-react"
|
||||
|
||||
export interface BfdSessionRow {
|
||||
id: string
|
||||
dataUnknown?: boolean
|
||||
serverId: string
|
||||
serverLabel: string
|
||||
localAddr: string
|
||||
remoteAddr: string
|
||||
state: BfdState
|
||||
interval: number | null
|
||||
multiplier: number | null
|
||||
iface: string
|
||||
uptime: string | null
|
||||
multihop: boolean | null
|
||||
rxInterval: number | null
|
||||
holdTime: number | null
|
||||
packetsRx: number | null
|
||||
packetsTx: number | null
|
||||
stateChanges: number | null
|
||||
}
|
||||
|
||||
function stateClass(state: BfdSessionRow["state"]) {
|
||||
if (state === "Unknown") return "bg-muted text-muted-foreground border-border"
|
||||
if (state === "Up")
|
||||
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
|
||||
if (state === "Init")
|
||||
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
|
||||
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
|
||||
}
|
||||
|
||||
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
|
||||
color ?? "bg-muted text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtMs(ms: number | null) {
|
||||
if (ms === null) return "—"
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
interface OspfBfdDataGridProps {
|
||||
sessions: BfdSessionRow[]
|
||||
}
|
||||
|
||||
function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, BfdSessionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "serverLabel",
|
||||
size: 128,
|
||||
accessorKey: "serverLabel",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono whitespace-normal break-all">{b.serverLabel}</span>
|
||||
{b.multihop === null && <Chip>Режим неизвестен</Chip>}
|
||||
{b.multihop === true && (
|
||||
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">
|
||||
multihop
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Роутер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
accessorKey: "iface",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground whitespace-nowrap">
|
||||
{row.original.iface || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "localAddr",
|
||||
accessorKey: "localAddr",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Локальный" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono whitespace-nowrap">{row.original.localAddr}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Локальный",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remoteAddr",
|
||||
accessorKey: "remoteAddr",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Удалённый" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono whitespace-nowrap">{row.original.remoteAddr}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Удалённый",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
accessorKey: "state",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||
row.original.dataUnknown ? "bg-muted text-muted-foreground" : stateClass(row.original.state),
|
||||
)}
|
||||
>
|
||||
{row.original.dataUnknown || row.original.state === "Unknown" ? "Неизвестно" : row.original.state}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Состояние",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorKey: "uptime",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground whitespace-nowrap tabular-nums">
|
||||
{row.original.uptime ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "intervals",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">TX факт / RX минимум</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
return (
|
||||
<span className="font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{fmtMs(b.interval)} / {fmtMs(b.rxInterval)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "holdTime",
|
||||
accessorKey: "holdTime",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Hold" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{fmtMs(row.original.holdTime)}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Hold",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "multiplier",
|
||||
accessorKey: "multiplier",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Mult" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-center block">{row.original.multiplier ?? "—"}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Mult",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "packetsRx",
|
||||
accessorKey: "packetsRx",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пакеты Rx" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-right text-muted-foreground block">
|
||||
{row.original.packetsRx?.toLocaleString() ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Пакеты Rx",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "packetsTx",
|
||||
accessorKey: "packetsTx",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пакеты Tx" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-right text-muted-foreground block">
|
||||
{row.original.packetsTx?.toLocaleString() ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Пакеты Tx",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "stateChanges",
|
||||
size: 112,
|
||||
accessorKey: "stateChanges",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Переходы" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-right block">{row.original.stateChanges ?? "—"}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Переходы",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["serverLabel"], end: [] } },
|
||||
data: sessions,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(sessions.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [sessions.length, pageIndex, pageSize, table])
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ActivityIcon className="size-4" />}
|
||||
title="BFD-сессий не обнаружено"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={sessions.length}
|
||||
pagination={sessions.length > 50 || pageSize !== 50}
|
||||
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row hover:bg-muted/30" }}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { OspfBfdDataGrid, type OspfBfdDataGridProps }
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { OspfNeighborState } from "@/lib/ospf-state"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { NetworkIcon } from "lucide-react"
|
||||
|
||||
export interface OspfNeighborRow {
|
||||
id: string
|
||||
dataUnknown?: boolean
|
||||
localRouter: string
|
||||
localLabel: string
|
||||
localIface: string
|
||||
remoteRouter: string
|
||||
remoteLabel: string
|
||||
remoteRouterId: string
|
||||
area: string
|
||||
state: OspfNeighborState
|
||||
cost: number | null
|
||||
uptime: string
|
||||
priority: number | null
|
||||
}
|
||||
|
||||
function stateClass(state: OspfNeighborRow["state"]) {
|
||||
if (state === "Full")
|
||||
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
|
||||
if (state === "Unknown") return "bg-muted text-muted-foreground border-border"
|
||||
if (state !== "Down")
|
||||
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
|
||||
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
|
||||
}
|
||||
|
||||
interface OspfNeighborsDataGridProps {
|
||||
emptyTitle?: string
|
||||
neighbors: OspfNeighborRow[]
|
||||
highlightRouterId?: string | null
|
||||
selectedRouterId?: string | null
|
||||
onHighlight?: (routerId: string | null) => void
|
||||
onSelect?: (routerId: string | null) => void
|
||||
}
|
||||
|
||||
function OspfNeighborsDataGrid({
|
||||
neighbors,
|
||||
emptyTitle,
|
||||
highlightRouterId,
|
||||
selectedRouterId,
|
||||
onSelect,
|
||||
}: OspfNeighborsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, OspfNeighborRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "localLabel",
|
||||
size: 144,
|
||||
accessorKey: "localLabel",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
onSelect ? <Button type="button" variant="ghost" size="sm" className="min-h-11" aria-label={`Открыть узел ${row.original.localLabel}`} aria-expanded={selectedRouterId === row.original.localRouter} onClick={event => { event.stopPropagation(); onSelect(selectedRouterId === row.original.localRouter ? null : row.original.localRouter) }}>{row.original.localLabel}</Button> : <span className="font-mono whitespace-nowrap">{row.original.localLabel}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Роутер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "localIface",
|
||||
size: 160,
|
||||
accessorKey: "localIface",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground whitespace-nowrap">
|
||||
{row.original.localIface}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remote",
|
||||
size: 192,
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">Сосед (Router ID)</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const n = row.original
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono">
|
||||
{n.remoteLabel !== n.remoteRouterId ? n.remoteLabel : n.remoteRouterId}
|
||||
</span>
|
||||
{n.remoteLabel !== n.remoteRouterId && (
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{n.remoteRouterId}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "area",
|
||||
size: 176,
|
||||
accessorKey: "area",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Область" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground">{row.original.area}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Область",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
size: 128,
|
||||
accessorKey: "state",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||
row.original.dataUnknown ? "bg-muted text-muted-foreground" : stateClass(row.original.state),
|
||||
)}
|
||||
>
|
||||
{row.original.dataUnknown || row.original.state === "Unknown" ? "Неизвестно" : row.original.state}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Состояние",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cost",
|
||||
size: 80,
|
||||
accessorKey: "cost",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-center block">{row.original.cost ?? "—"}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Cost",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
size: 112,
|
||||
accessorKey: "uptime",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground whitespace-nowrap tabular-nums">
|
||||
{row.original.uptime}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "priority",
|
||||
size: 64,
|
||||
accessorKey: "priority",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Prio" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-center font-mono block">{row.original.priority ?? "—"}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Prio",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-center"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[onSelect, selectedRouterId],
|
||||
)
|
||||
|
||||
const rowSelection = useMemo(() => {
|
||||
if (!selectedRouterId) return {}
|
||||
const match = neighbors.find(
|
||||
(n) => n.localRouter === selectedRouterId || n.remoteRouter === selectedRouterId || selectedRouterId === `remote-${n.remoteRouterId}`,
|
||||
)
|
||||
return match ? { [match.id]: true as const } : {}
|
||||
}, [neighbors, selectedRouterId])
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["localLabel"], end: [] } },
|
||||
data: neighbors,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
enableRowSelection: true,
|
||||
state: { rowSelection },
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(neighbors.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [neighbors.length, pageIndex, pageSize, table])
|
||||
const requestedNode = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!selectedRouterId) { requestedNode.current = null; return }
|
||||
if (requestedNode.current === selectedRouterId) return
|
||||
const index = table.getSortedRowModel().rows.findIndex(({ original: n }) => n.localRouter === selectedRouterId || n.remoteRouter === selectedRouterId || selectedRouterId === `remote-${n.remoteRouterId}`)
|
||||
if (index < 0) return
|
||||
requestedNode.current = selectedRouterId
|
||||
table.setPageIndex(Math.floor(index / pageSize))
|
||||
}, [selectedRouterId, neighbors, pageSize, table])
|
||||
|
||||
if (neighbors.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title={emptyTitle ?? "Нет OSPF-соседей"}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={neighbors.length}
|
||||
pagination={neighbors.length > 50 || pageSize !== 50}
|
||||
onRowClick={(row) => {
|
||||
onSelect?.(selectedRouterId === row.localRouter ? null : row.localRouter)
|
||||
}}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn(
|
||||
"group/row cursor-pointer",
|
||||
"data-[state=selected]:bg-primary/5",
|
||||
highlightRouterId && "[&:hover]:bg-muted/30",
|
||||
),
|
||||
}}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
|
||||
<DataGridScrollArea><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { OspfNeighborsDataGrid, type OspfNeighborsDataGridProps }
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { RouteIcon } from "lucide-react"
|
||||
|
||||
export interface OspfRouteRow {
|
||||
id: string
|
||||
dataUnknown?: boolean
|
||||
destination: string
|
||||
type: "O" | "O IA" | "O E1" | "O E2" | "Unknown"
|
||||
cost: number | null
|
||||
nextHop: string
|
||||
via: string
|
||||
serverId: string
|
||||
serverLabel: string
|
||||
area: string
|
||||
}
|
||||
|
||||
function OspfRouteTypeBadge({ type, count }: { type: OspfRouteRow["type"]; count?: number }) {
|
||||
return <Badge variant="invert-light" size="sm">
|
||||
{type === "Unknown" ? "Тип неизвестен" : type}{count === undefined ? "" : ": " + count}
|
||||
</Badge>
|
||||
}
|
||||
|
||||
interface OspfRoutesDataGridProps {
|
||||
emptyTitle?: string
|
||||
routes: OspfRouteRow[]
|
||||
}
|
||||
|
||||
function OspfRoutesDataGrid({ routes, emptyTitle }: OspfRoutesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, OspfRouteRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "destination",
|
||||
size: 180,
|
||||
accessorKey: "destination",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Назначение" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => <span className="font-mono whitespace-nowrap" title={row.original.destination}>{row.original.destination}</span>,
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
size: 116,
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => (
|
||||
<OspfRouteTypeBadge type={row.original.type} />
|
||||
),
|
||||
meta: { headerTitle: "Тип", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "cost",
|
||||
size: 80,
|
||||
accessorKey: "cost",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-center block">{row.original.cost ?? "—"}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Cost",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nextHop",
|
||||
accessorKey: "nextHop",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Следующий хоп" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground">{row.original.nextHop}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Следующий хоп",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "via",
|
||||
size: 120,
|
||||
accessorKey: "via",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground">{row.original.via}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "serverLabel",
|
||||
accessorKey: "serverLabel",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Роутер" />,
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.serverLabel}{row.original.dataUnknown ? " · Неизвестно" : ""}</span>,
|
||||
meta: {
|
||||
headerTitle: "Роутер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "area",
|
||||
size: 120,
|
||||
accessorKey: "area",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Область" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground" title={row.original.area}>{row.original.area}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Область",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["destination"], end: [] } },
|
||||
data: routes,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(routes.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [routes.length, pageIndex, pageSize, table])
|
||||
|
||||
if (routes.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<RouteIcon className="size-4" />}
|
||||
title={emptyTitle ?? "Нет OSPF-маршрутов"}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={routes.length}
|
||||
pagination={routes.length > 50 || pageSize !== 50}
|
||||
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row hover:bg-muted/30" }}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { OspfRoutesDataGrid, type OspfRoutesDataGridProps, OspfRouteTypeBadge }
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ClockIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
export type SchedType = "ping" | "bandwidth" | "both"
|
||||
|
||||
export interface SchedRule {
|
||||
id: string
|
||||
srcId: string
|
||||
tunnelId: string
|
||||
type: SchedType
|
||||
intervalMin: number
|
||||
enabled: boolean
|
||||
lastRun: string | null
|
||||
nextRunMin: number | null
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<SchedType, string> = {
|
||||
ping: "Ping",
|
||||
bandwidth: "BW-тест",
|
||||
both: "Ping + BW",
|
||||
}
|
||||
|
||||
interface ProbesScheduleDataGridProps {
|
||||
rules: SchedRule[]
|
||||
serverOptions: Server[]
|
||||
tunnelName: (srcId: string, tunnelId: string) => string | undefined
|
||||
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
function ProbesScheduleDataGrid({
|
||||
rules,
|
||||
serverOptions,
|
||||
tunnelName,
|
||||
onToggleEnabled,
|
||||
onDelete,
|
||||
}: ProbesScheduleDataGridProps) {
|
||||
const serverMap = useMemo(
|
||||
() => new Map(serverOptions.map((s) => [s.id, s])),
|
||||
[serverOptions],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, SchedRule>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: () => <span className="sr-only">Вкл</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<FormToggle
|
||||
checked={row.original.enabled}
|
||||
onChange={(v) => onToggleEnabled(row.original.id, v)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tunnel",
|
||||
accessorKey: "tunnelId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Туннель" />,
|
||||
cell: ({ row }) => (
|
||||
<code className="font-mono text-xs truncate block">
|
||||
{tunnelName(row.original.srcId, row.original.tunnelId) ?? row.original.tunnelId}
|
||||
</code>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Туннель",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "srcId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{serverMap.get(row.original.srcId)?.name ?? row.original.srcId}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] px-1.5 py-0.5 rounded border font-medium w-fit",
|
||||
row.original.type === "ping"
|
||||
? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20"
|
||||
: row.original.type === "bandwidth"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
|
||||
)}
|
||||
>
|
||||
{TYPE_LABEL[row.original.type]}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "intervalMin",
|
||||
accessorKey: "intervalMin",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интервал" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">каждые {row.original.intervalMin} мин</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интервал",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lastRun",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">Последний / следующий</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const rule = row.original
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2 min-w-0">
|
||||
{rule.lastRun && <span className="truncate">{rule.lastRun}</span>}
|
||||
{rule.nextRunMin != null && rule.enabled && (
|
||||
<span className="text-sky-600 dark:text-sky-400 shrink-0">
|
||||
· через {rule.nextRunMin} мин
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Последний / следующий",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Удалить правило"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[onDelete, onToggleEnabled, serverMap, tunnelName],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
data: rules,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ClockIcon className="size-4" />}
|
||||
title="Нет правил расписания"
|
||||
description="Добавьте правило ping или bandwidth-теста"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <DataGridShell table={table} recordCount={rules.length} />
|
||||
}
|
||||
|
||||
export { ProbesScheduleDataGrid, type ProbesScheduleDataGridProps }
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ClockIcon } from "lucide-react"
|
||||
|
||||
import type {SpeedProbeApiRow} from "@/lib/probe-schedule-response"
|
||||
export type {SpeedProbeApiRow} from "@/lib/probe-schedule-response"
|
||||
|
||||
interface ProbesSpeedProbesDataGridProps {
|
||||
rows: SpeedProbeApiRow[]
|
||||
serverName: (id: string) => string
|
||||
}
|
||||
|
||||
function ProbesSpeedProbesDataGrid({ rows, serverName }: ProbesSpeedProbesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, SpeedProbeApiRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "src",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Источник" className="ml-1" />,
|
||||
accessorFn: (row) => row.srcServerId,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="truncate font-mono text-xs block">
|
||||
{serverName(r.srcServerId)}
|
||||
{r.srcInterface ? ` · ${r.srcInterface}` : ""}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Источник",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dst",
|
||||
accessorFn: (row) => row.dstServerId,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="truncate font-mono text-xs block">
|
||||
{serverName(r.dstServerId)}
|
||||
{r.dstInterface ? ` · ${r.dstInterface}` : ""}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
accessorKey: "protocol",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Протокол" />,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.protocol.toUpperCase()}</span>,
|
||||
meta: {
|
||||
headerTitle: "Протокол",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "durationSec",
|
||||
accessorKey: "durationSec",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сек" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.durationSec}s</span>,
|
||||
meta: {
|
||||
headerTitle: "Сек",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Вкл" />,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.enabled ? "да" : "нет"}</span>,
|
||||
meta: {
|
||||
headerTitle: "Вкл",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lastRunAt",
|
||||
accessorKey: "lastRunAt",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Последний запуск" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{r.lastRunAt ?? "—"}
|
||||
{r.lastStatus === "done" && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
|
||||
TX: {r.lastTxAvgMbps==null?"неизвестно":r.lastTxAvgMbps.toFixed(1)+" Мбит/с"} · RX: {r.lastRxAvgMbps==null?"неизвестно":r.lastRxAvgMbps.toFixed(1)+" Мбит/с"}
|
||||
</span>
|
||||
)}
|
||||
{r.lastStatus === "error" && r.lastError && (
|
||||
<span className="text-destructive ml-1 truncate">{r.lastError}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Последний запуск",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[serverName],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 } },
|
||||
data: rows,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(rows.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [rows.length, pageSize, pageIndex, table])
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ClockIcon className="size-4" />}
|
||||
title="Нет записей speed-test"
|
||||
description="Добавьте пробы в разделе мониторинга."
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
pagination={rows.length > 50 || table.state.pagination.pageSize !== 50}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { ProbesSpeedProbesDataGrid, type ProbesSpeedProbesDataGridProps }
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { Filters } from "@/components/reui/filters/filters"
|
||||
import type { FilterField, FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
import { FILTER_LABELS_RU, FILTER_OPERATOR_LABELS_RU } from "@/lib/data-filters/filter-labels-ru"
|
||||
|
||||
export function QueryFilters({ fields, query, onQueryChange }: {
|
||||
fields: FilterField[]
|
||||
query: FilterQuery
|
||||
onQueryChange: (query: FilterQuery) => void
|
||||
}) {
|
||||
return <Filters fields={fields} query={query} onQueryChange={onQueryChange}
|
||||
size="sm" showClear labels={FILTER_LABELS_RU} operatorLabels={FILTER_OPERATOR_LABELS_RU} />
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
PencilIcon,
|
||||
RouteIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface RecursiveRouteEndpoint {
|
||||
id: string
|
||||
gateway: string
|
||||
distance: number
|
||||
scope: number | null
|
||||
targetScope: number | null
|
||||
checkGateway: string
|
||||
country: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export interface RecursiveRouteGroup {
|
||||
id?: string
|
||||
key: string
|
||||
dstAddress: string
|
||||
routingTable: string
|
||||
comment: string
|
||||
endpoints: RecursiveRouteEndpoint[]
|
||||
}
|
||||
|
||||
const INFER_COUNTRIES = [
|
||||
{ code: "RU", keys: ["MSK", "SPB", "RTK", "MTS", "VPSVILLE", "IHOR"] },
|
||||
{ code: "SE", keys: ["SWE", "STO"] },
|
||||
{ code: "FI", keys: ["HEL", "FIN"] },
|
||||
{ code: "DE", keys: ["FRA", "GER", "DE"] },
|
||||
{ code: "NL", keys: ["AMS", "NLD", "NL"] },
|
||||
{ code: "SG", keys: ["SGP", "SIN", "SG"] },
|
||||
{ code: "TR", keys: ["TUR", "TR"] },
|
||||
{ code: "US", keys: ["USA", "US", "NYC", "LAX"] },
|
||||
]
|
||||
|
||||
function inferCountry(name: string): string | null {
|
||||
const upper = name.toUpperCase()
|
||||
for (const c of INFER_COUNTRIES) {
|
||||
if (c.keys.some((k) => upper.includes(k))) return c.code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function RouteGroupExpandedDetail({ group }: { group: RecursiveRouteGroup }) {
|
||||
const [page, setPage] = useState(0)
|
||||
const sorted = useMemo(() => [...group.endpoints].sort((a, b) => a.distance - b.distance), [group.endpoints])
|
||||
const lastPage = Math.max(0, Math.ceil(sorted.length / 50) - 1)
|
||||
if (page > lastPage) setPage(lastPage)
|
||||
const currentPage = Math.min(page, lastPage)
|
||||
const offset = currentPage * 50
|
||||
return (
|
||||
<div role="region" aria-label={`Конечные точки: ${group.dstAddress} · ${group.routingTable}`} className="sticky left-0 w-full max-w-[calc(100vw-3rem)] md:max-w-none flex flex-col gap-4 px-5 py-4 bg-muted/20">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
Route: <span className="font-mono text-foreground">{group.dstAddress}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Table:{" "}
|
||||
<span className="font-mono text-foreground">{group.routingTable || "main"}</span>
|
||||
</span>
|
||||
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
|
||||
{sorted.slice(offset, offset + 50).map((ep, idx) => (
|
||||
<div key={ep.id} data-recursive-endpoint={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{ep.country && (
|
||||
<Flag code={ep.country} size={16} />
|
||||
)}
|
||||
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Endpoint {offset + idx + 1}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
|
||||
<div className="mt-1.5 text-[11px] text-muted-foreground flex flex-wrap items-center gap-3">
|
||||
<span>scope: {ep.scope ?? "—"}</span>
|
||||
<span>t.scope: {ep.targetScope ?? "—"}</span>
|
||||
<span>check: {ep.checkGateway || "—"}</span>
|
||||
{ep.disabled && <span>Отключён</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{sorted.length > 50 && <div className="flex flex-wrap items-center gap-2">
|
||||
<span role="status" className="text-xs text-muted-foreground">Конечные точки {offset + 1}–{Math.min(offset + 50, sorted.length)} из {sorted.length}</span>
|
||||
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущие конечные точки" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Назад</Button>
|
||||
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующие конечные точки" disabled={currentPage === lastPage} onClick={() => setPage(currentPage + 1)}>Далее</Button>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface RecursiveRoutesDataGridProps {
|
||||
groups: RecursiveRouteGroup[]
|
||||
expandedKey?: string | null
|
||||
onExpandedChange?: (key: string | null) => void
|
||||
onEdit: (group: RecursiveRouteGroup) => void
|
||||
onDelete: (group: RecursiveRouteGroup) => void
|
||||
mutationsDisabled?: boolean
|
||||
emptyTitle?: string
|
||||
}
|
||||
|
||||
function RecursiveRoutesDataGrid({
|
||||
groups,
|
||||
expandedKey,
|
||||
onExpandedChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
mutationsDisabled = false,
|
||||
emptyTitle = "Нет маршрутов",
|
||||
}: RecursiveRoutesDataGridProps) {
|
||||
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null)
|
||||
|
||||
const expanded = useMemo(() => {
|
||||
if (!expandedKey) return {}
|
||||
const g = groups.find((x) => x.key === expandedKey)
|
||||
return g ? { [(g.id ?? g.key)]: true } : {}
|
||||
}, [expandedKey, groups])
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, RecursiveRouteGroup>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "route",
|
||||
size: 176,
|
||||
accessorKey: "dstAddress",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Route / Comment" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const g = row.original
|
||||
const isExpanded = expandedKey === g.key
|
||||
return (
|
||||
<button type="button" aria-label={`Раскрыть маршрут ${g.dstAddress} · ${g.routingTable}`} aria-expanded={isExpanded} onClick={event => { event.stopPropagation(); onExpandedChange?.(isExpanded ? null : g.key) }} className="flex items-start gap-2 min-h-11 min-w-0 text-left rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{g.dstAddress}</p>
|
||||
<p className="text-xs font-mono text-muted-foreground">{g.comment || "—"}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Route / Comment",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (group: RecursiveRouteGroup) => (
|
||||
<RouteGroupExpandedDetail key={group.key} group={group} />
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gateways",
|
||||
size: 208,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Gateways</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const sorted = [...row.original.endpoints].sort((a, b) => a.distance - b.distance)
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{sorted.slice(0, 3).map(ep => {
|
||||
const code = ep.country
|
||||
return (
|
||||
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
||||
<span aria-hidden="true"
|
||||
className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
ep.disabled ? "bg-muted-foreground" : "bg-info",
|
||||
)}
|
||||
/>
|
||||
{code ? (
|
||||
<Flag code={code} size={14} className="shrink-0" />
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground w-3.5 text-center shrink-0">
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
<span className="font-semibold text-info truncate min-w-0">
|
||||
{ep.gateway}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{sorted.length > 3 && <span className="text-xs text-muted-foreground">Ещё {sorted.length - 3} · раскройте маршрут</span>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "epCount",
|
||||
size: 64,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">EP</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs tabular-nums">{row.original.endpoints.length}</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "priority",
|
||||
size: 96,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Priority</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const bestDistance = Math.min(...row.original.endpoints.map((ep) => ep.distance))
|
||||
return <span className="font-mono text-xs">d{bestDistance}</span>
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "table",
|
||||
size: 128,
|
||||
accessorKey: "routingTable",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Table" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{row.original.routingTable || "main"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Table",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const g = row.original
|
||||
const confirmDel = confirmDeleteKey === g.key
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-0.5 justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button type="button" size="sm"
|
||||
variant="ghost"
|
||||
className="size-11 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Редактировать маршрут ${g.dstAddress} · ${g.routingTable}`}
|
||||
disabled={mutationsDisabled}
|
||||
onClick={() => onEdit(g)}
|
||||
>
|
||||
<PencilIcon aria-hidden="true" className="size-3.5" />
|
||||
</Button>
|
||||
<Button type="button" size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"size-11 p-0 transition-colors",
|
||||
confirmDel
|
||||
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||
: "text-muted-foreground hover:text-destructive",
|
||||
)}
|
||||
aria-label={`${confirmDel ? "Подтвердить удаление" : "Удалить маршрут"} ${g.dstAddress} · ${g.routingTable}`}
|
||||
disabled={mutationsDisabled}
|
||||
onClick={() => {
|
||||
if (mutationsDisabled) return
|
||||
if (!confirmDel) setConfirmDeleteKey(g.key)
|
||||
else {
|
||||
onDelete(g)
|
||||
setConfirmDeleteKey(null)
|
||||
}
|
||||
}}
|
||||
onBlur={() => setConfirmDeleteKey(null)}
|
||||
>
|
||||
{confirmDel ? (
|
||||
<AlertCircleIcon aria-hidden="true" className="size-3.5" />
|
||||
) : (
|
||||
<TrashIcon aria-hidden="true" className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 112,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[confirmDeleteKey, expandedKey, onDelete, onEdit, onExpandedChange, mutationsDisabled],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["route"], end: [] } },
|
||||
data: groups,
|
||||
columns,
|
||||
getRowId: (row) => row.id ?? row.key,
|
||||
getRowCanExpand: () => true,
|
||||
state: { expanded },
|
||||
})
|
||||
|
||||
const requestedExpansion = useRef<string | null>(null)
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(groups.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
if (!expandedKey) { requestedExpansion.current = null; return }
|
||||
const index = table.getSortedRowModel().rows.findIndex(row => row.original.key === expandedKey)
|
||||
if (index < 0) { requestedExpansion.current = null; return }
|
||||
if (requestedExpansion.current !== expandedKey) {
|
||||
requestedExpansion.current = expandedKey
|
||||
table.setPageIndex(Math.floor(index / pageSize))
|
||||
}
|
||||
}, [groups.length, expandedKey, pageIndex, pageSize, table])
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<RouteIcon aria-hidden="true" className="size-4" />}
|
||||
title={emptyTitle}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={groups.length}
|
||||
pagination={groups.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
onRowClick={(row) => {
|
||||
onExpandedChange?.(expandedKey === row.key ? null : row.key)
|
||||
}}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row cursor-pointer", expandedKey && "data-[state=selected]:bg-muted/30"),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { RecursiveRoutesDataGrid, type RecursiveRoutesDataGridProps, inferCountry }
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable } from "@/components/reui/data-grid"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { CommRec } from "@/lib/route-optimizer-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { ArrowRightIcon, PinIcon, RefreshCwIcon } from "lucide-react"
|
||||
|
||||
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
|
||||
color ?? "bg-muted text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbChip({ prob, best }: { prob: number | null; best?: boolean }) {
|
||||
if (prob === null) return <span className="text-muted-foreground" aria-label="Индекс неизвестен">—</span>
|
||||
return (
|
||||
<Chip
|
||||
color={
|
||||
best
|
||||
? "bg-success/10 text-success border-success/20"
|
||||
: "bg-muted text-muted-foreground border-border"
|
||||
}
|
||||
>
|
||||
{prob} б.
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
interface RouteOptimizerCommRecsDataGridProps {
|
||||
recs: CommRec[]
|
||||
homeId: string
|
||||
pinned: Set<string>
|
||||
applied: Set<string>
|
||||
applying: Set<string>
|
||||
onPin: (key: string) => void
|
||||
simulation?: boolean
|
||||
onApply: (community: string, homeId: string) => void
|
||||
}
|
||||
|
||||
function RouteOptimizerCommRecsDataGrid({
|
||||
recs,
|
||||
homeId,
|
||||
pinned,
|
||||
applied,
|
||||
applying,
|
||||
onPin,
|
||||
onApply,
|
||||
simulation = false,
|
||||
}: RouteOptimizerCommRecsDataGridProps) {
|
||||
const [query, setQuery] = useState("")
|
||||
const rows = useMemo(
|
||||
() => recs.map((r, idx) => ({ ...r, _rowKey: `${homeId}::${r.community}::${idx}` })),
|
||||
[homeId, recs],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => rows.filter(row =>
|
||||
[row.community, row.communityName, row.current?.wan, row.current?.jh, row.current?.exit, row.current?.gateway,
|
||||
row.recommended?.wan, row.recommended?.jh, row.recommended?.exit, row.recommended?.gateway]
|
||||
.join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), [rows, query])
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, (typeof rows)[number]>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "community",
|
||||
size: 180,
|
||||
accessorKey: "community",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Community" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-mono text-xs font-medium">{row.original.community}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{row.original.communityName}</div>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Community",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "current",
|
||||
size: 260,
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
Базовый расчёт (WAN → JH → Exit)
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
if (!r.current) return <span className="text-muted-foreground text-xs">—</span>
|
||||
return (
|
||||
<div className="text-xs flex items-center gap-1 flex-wrap">
|
||||
<span className="font-mono font-medium text-info ">
|
||||
{r.current.wan}
|
||||
</span>
|
||||
<ArrowRightIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
|
||||
<span>{r.current.jh}</span>
|
||||
<ArrowRightIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">{r.current.exit}</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
({r.current.gateway})
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "recommended",
|
||||
size: 260,
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">Рекомендуемый</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const pinKey = `${homeId}::${r.community}`
|
||||
const isPinned = pinned.has(pinKey)
|
||||
if (!r.recommended) return <span className="text-muted-foreground text-xs">—</span>
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs flex items-center gap-1 flex-wrap",
|
||||
r.shouldSwitch && !isPinned && "text-warning ",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono font-medium">{r.recommended.wan}</span>
|
||||
<ArrowRightIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
|
||||
<span>{r.recommended.jh}</span>
|
||||
<ArrowRightIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
|
||||
<span>{r.recommended.exit}</span>
|
||||
{r.shouldSwitch && r.current && !isPinned && (
|
||||
<span className="ml-1 text-[10px] font-bold bg-warning/10 border border-warning/20 px-1.5 py-0.5 rounded">
|
||||
+{r.recommended.prob - r.current.prob} б.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "probability",
|
||||
size: 160,
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground block text-center">
|
||||
Индекс (баз / рек)
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const pinKey = `${homeId}::${r.community}`
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<ProbChip prob={r.current?.prob ?? null} />
|
||||
<span className="text-muted-foreground text-[10px]">/</span>
|
||||
<ProbChip prob={r.recommended?.prob ?? null} best={r.shouldSwitch && !pinned.has(pinKey)} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 240,
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground block text-right">
|
||||
Действие
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const pinKey = `${homeId}::${r.community}`
|
||||
const isPinned = pinned.has(pinKey)
|
||||
const isApplied = simulation && applied.has(pinKey)
|
||||
const isApplying = simulation && applying.has(pinKey)
|
||||
const canApply = simulation && r.shouldSwitch && !isPinned && !isApplied
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
{isPinned && <PinIcon aria-hidden="true" className="size-3 text-info fill-info" />}
|
||||
<Button type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("h-7 text-xs", isPinned && "text-info border-info/30")}
|
||||
onClick={() => onPin(pinKey)}
|
||||
>
|
||||
<PinIcon aria-hidden="true" className={cn("size-3", isPinned && "fill-current")} />
|
||||
{isPinned ? "Открепить" : "Закрепить"}
|
||||
</Button>
|
||||
{canApply && (
|
||||
<Button type="button"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={isApplying}
|
||||
onClick={() => onApply(r.community, homeId)}
|
||||
>
|
||||
{isApplying ? (
|
||||
<RefreshCwIcon aria-hidden="true" className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Симулировать"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isApplied && (
|
||||
<span className="text-[10px] text-success font-medium">
|
||||
✓ симуляция завершена
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[applied, applying, homeId, onApply, onPin, pinned, simulation],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["community"], end: [] } },
|
||||
data: filtered,
|
||||
columns,
|
||||
getRowId: (row) => row._rowKey,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(filtered.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [filtered.length, pageIndex, pageSize, table])
|
||||
|
||||
return (
|
||||
<section aria-label="Рекомендации BGP Community" className="min-w-0">
|
||||
<div className="px-5 py-3 space-y-2">
|
||||
<label className="block text-xs">Поиск Community
|
||||
<Input className="mt-1" value={query} onChange={event => { setQuery(event.target.value); table.setPageIndex(0) }} placeholder="Community, название или шлюз" />
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Community: {filtered.length} из {rows.length}. Индексы и пути — расчётная модель.</p>
|
||||
</div>
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filtered.length}
|
||||
pagination={filtered.length > 50 || pageSize !== 50}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b bg-muted/20",
|
||||
bodyRow: cn(
|
||||
"group/row hover:bg-muted/30",
|
||||
"[&:has([data-comm-switch=true])]:bg-warning/5",
|
||||
"[&:has([data-comm-applied=true])]:bg-success/5",
|
||||
),
|
||||
}}
|
||||
tableLayout={{ dense: true, width: "fixed", columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteOptimizerCommRecsDataGrid, type RouteOptimizerCommRecsDataGridProps }
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { FullRoute } from "@/lib/route-optimizer-data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
|
||||
color ?? "bg-muted text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
|
||||
return (
|
||||
<Chip
|
||||
color={
|
||||
best
|
||||
? "bg-success/10 text-success border-success/20"
|
||||
: "bg-muted text-muted-foreground border-border"
|
||||
}
|
||||
>
|
||||
{prob} б.
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfChip({ conf }: { conf: string }) {
|
||||
const map: Record<string, string> = {
|
||||
HIGH: "bg-info/10 text-info border-info/20",
|
||||
MEDIUM: "bg-warning/10 text-warning border-warning/20",
|
||||
LOW: "bg-destructive/10 text-destructive border-destructive/20",
|
||||
}
|
||||
return <Chip color={map[conf] ?? map.LOW}>{conf}</Chip>
|
||||
}
|
||||
|
||||
interface RouteOptimizerFullRoutesDataGridProps {
|
||||
routes: FullRoute[]
|
||||
bestId?: string
|
||||
}
|
||||
|
||||
function RouteOptimizerFullRoutesDataGrid({
|
||||
routes,
|
||||
bestId,
|
||||
}: RouteOptimizerFullRoutesDataGridProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const visibleRoutes = expanded ? routes : routes.slice(0, 5)
|
||||
|
||||
const indexed = useMemo(
|
||||
() => visibleRoutes.map((r, index) => ({ ...r, _index: index })),
|
||||
[visibleRoutes],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, FullRoute & { _index: number }>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "index",
|
||||
size: 112,
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground"># Маршрут</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const isBest = row.original.id === bestId || row.original._index === 0
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-muted-foreground min-w-6 tabular-nums">
|
||||
{row.original._index + 1}
|
||||
</span>
|
||||
{isBest && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-wide text-success bg-success/10 border border-success/20 px-1.5 py-0.5 rounded">
|
||||
Лучший
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "wanJh",
|
||||
size: 264,
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN → JH</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="font-mono font-semibold text-info ">{r.wan.name}</span>
|
||||
<ArrowRightIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">{r.jh.label}</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
Модель: {r.hw.pingMs} мс · ↓{r.hw.dlMbps} ↑{r.hw.ulMbps}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "jhExit",
|
||||
size: 240,
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">JH → Exit</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-center gap-1 font-medium">
|
||||
<Flag code={r.exit.country} />
|
||||
{r.exit.label}
|
||||
<span className="text-[10px] text-muted-foreground">({r.exit.site})</span>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
Модель: {r.je.pingMs} мс · ↓{r.je.dlMbps} ↑{r.je.ulMbps}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "totalPing",
|
||||
size: 144,
|
||||
accessorFn: (row) => row.hw.pingMs + row.je.pingMs,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Ping модели" className="mx-auto" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const totalPing = row.original.hw.pingMs + row.original.je.pingMs
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs text-center block",
|
||||
totalPing < 40
|
||||
? "text-success "
|
||||
: totalPing < 80
|
||||
? "text-warning "
|
||||
: "text-destructive",
|
||||
)}
|
||||
>
|
||||
{totalPing} мс
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Ping модели",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bw",
|
||||
size: 144,
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground block text-center">
|
||||
BW (мин)
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const minDl = Math.min(r.hw.dlMbps, r.je.dlMbps)
|
||||
const minUl = Math.min(r.hw.ulMbps, r.je.ulMbps)
|
||||
return (
|
||||
<div className="font-mono text-xs text-muted-foreground text-center">
|
||||
<div>↓{minDl}</div>
|
||||
<div>↑{minUl}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "score",
|
||||
size: 80,
|
||||
accessorKey: "score",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Score" className="mx-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs font-semibold text-center block">
|
||||
{row.original.score}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Score",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prob",
|
||||
size: 104,
|
||||
accessorKey: "probabilityOptimal",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Индекс" className="mx-auto" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isBest = row.original.id === bestId || row.original._index === 0
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<ProbChip prob={row.original.probabilityOptimal} best={isBest} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Индекс",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "confidence",
|
||||
size: 176,
|
||||
accessorKey: "confidence",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Категория модели" className="mx-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center">
|
||||
<ConfChip conf={row.original.confidence} />
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Категория модели",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-center"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[bestId],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["index"], end: [] } },
|
||||
data: indexed,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(indexed.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [indexed.length, pageIndex, pageSize, table])
|
||||
|
||||
return (
|
||||
<section aria-label="Полные маршруты оптимизатора" className="min-w-0">
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={visibleRoutes.length}
|
||||
pagination={expanded && (visibleRoutes.length > 50 || pageSize !== 50)}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b bg-muted/20",
|
||||
bodyRow: cn("group/row hover:bg-muted/30", "has-[[data-route-best=true]]:bg-success/5"),
|
||||
}}
|
||||
tableLayout={{ dense: true, width: "fixed", columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
{routes.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setExpanded((v) => !v); table.setPageIndex(0) }}
|
||||
className="w-full min-h-11 py-2 text-xs text-muted-foreground hover:text-foreground transition-colors border-t flex items-center justify-center gap-1"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUpIcon aria-hidden="true" className="size-3" />
|
||||
Свернуть
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDownIcon aria-hidden="true" className="size-3" />
|
||||
{routes.length > 50 ? `Открыть все ${routes.length} комбинаций` : `Показать все ${routes.length} комбинаций`}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteOptimizerFullRoutesDataGrid, type RouteOptimizerFullRoutesDataGridProps }
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type {OspfOptimizerObserved} from "@mmapp/contracts"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { NetworkIcon } from "lucide-react"
|
||||
|
||||
function describeObserved(metric:OspfOptimizerObserved["ping"]|undefined,unit:string){
|
||||
if(!metric || metric.value===null)return "Измерение: —"
|
||||
const source=metric.source==="speed-probe"?"Проба":"Трафик интерфейса"
|
||||
const date=metric.measuredAt?new Date(metric.measuredAt).toLocaleString("ru-RU"):"дата неизвестна"
|
||||
return source+": "+metric.value+" "+unit+" · "+date
|
||||
}
|
||||
|
||||
export interface OspfPreviewInterfaceRow {
|
||||
observed?: OspfOptimizerObserved
|
||||
id: string
|
||||
interface: string
|
||||
currentCost: number
|
||||
optimalCost: number
|
||||
score: number
|
||||
pingMs: number
|
||||
dlMbps: number
|
||||
ulMbps: number
|
||||
}
|
||||
|
||||
interface RouteOptimizerOspfPreviewDataGridProps {
|
||||
rows: OspfPreviewInterfaceRow[]
|
||||
error?: string | null
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
function RouteOptimizerOspfPreviewDataGrid({
|
||||
rows,
|
||||
error,
|
||||
loading,
|
||||
}: RouteOptimizerOspfPreviewDataGridProps) {
|
||||
const [query, setQuery] = useState("")
|
||||
const filteredRows = useMemo(() => {
|
||||
const value = query.trim().toLowerCase()
|
||||
return value ? rows.filter(row => `${row.interface} ${row.id}`.toLowerCase().includes(value)) : rows
|
||||
}, [rows, query])
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, OspfPreviewInterfaceRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "interface",
|
||||
size: 120,
|
||||
accessorKey: "interface",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Интерфейс" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => <span className="block truncate font-mono" title={row.original.interface}>{row.original.interface}</span>,
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cost",
|
||||
size: 144,
|
||||
accessorKey: "currentCost",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="font-mono">
|
||||
<span className="text-info ">{r.currentCost}</span>
|
||||
{" → "}
|
||||
<span
|
||||
className={
|
||||
r.currentCost === r.optimalCost
|
||||
? "text-success "
|
||||
: "text-warning "
|
||||
}
|
||||
>
|
||||
{r.optimalCost}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "Cost", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "score",
|
||||
size: 96,
|
||||
accessorKey: "score",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Score" />,
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.score}</span>,
|
||||
meta: { headerTitle: "Score", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "pingMs",
|
||||
size: 288,
|
||||
accessorKey: "pingMs",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Ping модели" />,
|
||||
cell: ({ row }) => <div><span className="font-mono">{row.original.pingMs}ms</span><p className="text-[10px] text-muted-foreground">{describeObserved(row.original.observed?.ping,"мс")}</p></div>,
|
||||
meta: { headerTitle: "Ping модели", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "speed",
|
||||
size: 336,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Speed (dl/ul) · модель</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="font-mono">
|
||||
↓{r.dlMbps} / ↑{r.ulMbps}<span className="block text-[10px] text-muted-foreground">↓ {describeObserved(r.observed?.dl,"Мбит/с")}<br />↑ {describeObserved(r.observed?.ul,"Мбит/с")}</span>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["interface"], end: [] } },
|
||||
data: filteredRows,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(filteredRows.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [filteredRows.length, pageIndex, pageSize, table])
|
||||
|
||||
if (error && rows.length === 0) {
|
||||
return (
|
||||
<p role="alert" className="px-3 py-2 text-xs text-destructive">Ошибка preview: {error}</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (!loading && rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon aria-hidden="true" className="size-4" />}
|
||||
title="Интерфейсы OSPF не найдены для выбранного сервера"
|
||||
className="border-0 py-6 text-xs"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Preview интерфейсов OSPF" className="min-w-0">
|
||||
{error && <p role="alert" className="px-3 py-2 text-xs text-warning">Ошибка preview: {error}. Последний снимок; актуальность неизвестна.</p>}
|
||||
{loading && rows.length > 0 && <p role="status" className="px-3 py-2 text-xs text-muted-foreground">Обновление preview OSPF…</p>}
|
||||
<div className="flex min-w-0 flex-col gap-2 px-3 py-3">
|
||||
<Input aria-label="Поиск интерфейсов preview OSPF" placeholder="Имя интерфейса…" value={query} onChange={event => { setQuery(event.target.value); table.setPageIndex(0) }} />
|
||||
<p role="status" className="text-xs text-muted-foreground">Найдено {filteredRows.length} из {rows.length} интерфейсов. Фильтр меняет только отображение preview.</p>
|
||||
</div>
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredRows.length}
|
||||
pagination={filteredRows.length > 50 || pageSize !== 50}
|
||||
emptyMessage="Нет интерфейсов по запросу"
|
||||
isLoading={loading && rows.length === 0}
|
||||
loadingMode="skeleton"
|
||||
tableClassNames={{ headerRow: "border-b bg-muted/30", bodyRow: "group/row" }}
|
||||
tableLayout={{ dense: true, width: "fixed", columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteOptimizerOspfPreviewDataGrid, type RouteOptimizerOspfPreviewDataGridProps }
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable } from "@/components/reui/data-grid"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { HomeRouter, JumpHost, WanJhLeg } from "@/lib/route-optimizer-data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { WifiIcon } from "lucide-react"
|
||||
|
||||
function LossChip({ loss }: { loss: number }) {
|
||||
if (loss === 0) return <span className="text-success text-[11px] font-mono">0%</span>
|
||||
return (
|
||||
<span className={cn("text-[11px] font-mono", loss > 1 ? "text-destructive" : "text-warning")}>
|
||||
{loss}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface RouteOptimizerWanMatrixDataGridProps {
|
||||
home: HomeRouter
|
||||
legs: WanJhLeg[]
|
||||
jumpHosts: JumpHost[]
|
||||
}
|
||||
|
||||
function RouteOptimizerWanMatrixDataGrid({
|
||||
home,
|
||||
legs,
|
||||
jumpHosts,
|
||||
}: RouteOptimizerWanMatrixDataGridProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const [wanQuery, setWanQuery] = useState("")
|
||||
const [jhQuery, setJhQuery] = useState("")
|
||||
const [jhPage, setJhPage] = useState(0)
|
||||
const filteredWans = useMemo(() => home.wans.filter(wan =>
|
||||
[wan.name, wan.iface, wan.isp, wan.ip, wan.id].join(" ").toLocaleLowerCase().includes(wanQuery.trim().toLocaleLowerCase())), [home.wans, wanQuery])
|
||||
const filteredHosts = useMemo(() => jumpHosts.filter(jh =>
|
||||
[jh.label, jh.site, jh.ip, jh.country, jh.id].join(" ").toLocaleLowerCase().includes(jhQuery.trim().toLocaleLowerCase())), [jumpHosts, jhQuery])
|
||||
const lastHostPage = Math.max(0, Math.ceil(filteredHosts.length / 5) - 1)
|
||||
const hostPage = Math.min(jhPage, lastHostPage)
|
||||
if (jhPage !== hostPage) setJhPage(hostPage)
|
||||
const visibleHosts = useMemo(() => filteredHosts.slice(hostPage * 5, hostPage * 5 + 5), [filteredHosts, hostPage])
|
||||
const { bestScore, legMap } = useMemo(() => {
|
||||
let bestScore = legs.length ? -Infinity : 0
|
||||
const legMap = new Map<string, Map<string, WanJhLeg>>()
|
||||
for (const leg of legs) {
|
||||
bestScore = Math.max(bestScore, leg.score)
|
||||
let byHost = legMap.get(leg.wanId)
|
||||
if (!byHost) { byHost = new Map(); legMap.set(leg.wanId, byHost) }
|
||||
// Preserve the first matching leg, as the former Array.find did.
|
||||
if (!byHost.has(leg.jhId)) byHost.set(leg.jhId, leg)
|
||||
}
|
||||
return { bestScore, legMap }
|
||||
}, [legs])
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, HomeRouter["wans"][number]>[]>(() => {
|
||||
const base: ColumnDef<DataGridFeatures, HomeRouter["wans"][number]>[] = [
|
||||
{
|
||||
id: "wan",
|
||||
size: isMobile ? 112 : 180,
|
||||
accessorKey: "name",
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN-аплинк</span>,
|
||||
cell: ({ row }) => {
|
||||
const wan = row.original
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<WifiIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p title={wan.name} className="truncate font-mono text-xs font-semibold">{wan.name}</p>
|
||||
<p title={wan.iface} className="truncate text-[10px] text-muted-foreground">{wan.iface}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "isp",
|
||||
size: 160,
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">ISP / IP</span>,
|
||||
cell: ({ row }) => {
|
||||
const wan = row.original
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium">{wan.isp}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">{wan.ip}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "bandwidth",
|
||||
size: 112,
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground block text-right">
|
||||
Лимит модели
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const wan = row.original
|
||||
return (
|
||||
<div className="text-right">
|
||||
<p className="font-mono text-xs">↓{wan.maxDl}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">↑{wan.maxUl} Мбит/с</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const jhCols: ColumnDef<DataGridFeatures, HomeRouter["wans"][number]>[] = visibleHosts.map((jh) => ({
|
||||
id: `jh-${jh.id}`,
|
||||
size: isMobile ? 160 : 200,
|
||||
header: () => (
|
||||
<div className="text-center">
|
||||
<div className="text-[11px] font-medium text-muted-foreground">{jh.label}</div>
|
||||
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
|
||||
<Flag code={jh.country} />
|
||||
{jh.site} · {jh.ip}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const wan = row.original
|
||||
const leg = legMap.get(wan.id)?.get(jh.id)
|
||||
if (!leg) return <span className="text-center text-muted-foreground text-xs block">—</span>
|
||||
const isBest = leg.score === bestScore
|
||||
return (
|
||||
<div className={cn("text-center", isBest && "bg-success/5")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
|
||||
isBest ? "border border-success/20 bg-success/8" : "border border-transparent",
|
||||
)}
|
||||
>
|
||||
{isBest && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-wide text-success mb-0.5">
|
||||
★ ЛУЧШИЙ
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs font-semibold",
|
||||
leg.observed && leg.observed.pingMs === null ? "text-muted-foreground" : (leg.observed?.pingMs ?? leg.pingMs) < 10
|
||||
? "text-success "
|
||||
: (leg.observed?.pingMs ?? leg.pingMs) < 25
|
||||
? "text-foreground"
|
||||
: "text-warning ",
|
||||
)}
|
||||
>
|
||||
{leg.observed ? (leg.observed.pingMs ?? "—") : leg.pingMs} мс
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
↓{leg.observed ? (leg.observed.dlMbps ?? "—") : leg.dlMbps} ↑{leg.observed ? (leg.observed.ulMbps ?? "—") : leg.ulMbps} Мбит/с
|
||||
</span>
|
||||
{leg.observed && <span className="text-[10px] text-muted-foreground">
|
||||
{leg.observed.probeId ? <>Снимок пробы · Ping: {leg.observed.pingAt ? <time dateTime={leg.observed.pingAt}>{new Date(leg.observed.pingAt).toLocaleString("ru-RU")}</time> : "дата неизвестна"}. Скорость: дата неизвестна.</> : "Измерения отсутствуют"}
|
||||
</span>}
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] font-mono text-foreground/70">модель {leg.score}</span>
|
||||
{(leg.observed ? (leg.observed.lossPct ?? 0) : leg.loss) > 0 && <LossChip loss={leg.observed?.lossPct ?? leg.loss} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[130px] text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[130px]"),
|
||||
},
|
||||
}))
|
||||
|
||||
const last = jhCols[jhCols.length - 1]
|
||||
if (last?.meta) {
|
||||
last.meta.headerClassName = cn(last.meta.headerClassName, DATA_GRID_CELL_PAD_LAST.replace("py-3", ""))
|
||||
last.meta.cellClassName = DATA_GRID_CELL_PAD_LAST
|
||||
}
|
||||
|
||||
return [...base, ...jhCols]
|
||||
}, [bestScore, visibleHosts, legMap, isMobile])
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["wan"], end: [] } },
|
||||
manualSorting: true,
|
||||
data: filteredWans,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(filteredWans.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [filteredWans.length, pageIndex, pageSize, table])
|
||||
|
||||
return (
|
||||
<section aria-label="Матрица WAN и Jump Host" className="min-w-0">
|
||||
<div className="flex flex-wrap gap-3 px-5 py-3">
|
||||
<label className="min-w-0 flex-1 basis-48 text-xs">Поиск WAN
|
||||
<Input className="mt-1" value={wanQuery} onChange={event => { setWanQuery(event.target.value); table.setPageIndex(0) }} placeholder="Название, интерфейс, ISP или IP" />
|
||||
</label>
|
||||
<label className="min-w-0 flex-1 basis-48 text-xs">Поиск Jump Host
|
||||
<Input className="mt-1" value={jhQuery} onChange={event => { setJhQuery(event.target.value); setJhPage(0) }} placeholder="Название, площадка или IP" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 pb-3 text-xs text-muted-foreground">
|
||||
<span>WAN: {filteredWans.length} из {home.wans.length}. Jump Host: {filteredHosts.length} из {jumpHosts.length}.</span>
|
||||
{filteredHosts.length > 5 && <>
|
||||
<span>Столбцы {hostPage * 5 + 1}–{Math.min((hostPage + 1) * 5, filteredHosts.length)} из {filteredHosts.length}</span>
|
||||
<Button variant="outline" size="sm" aria-label="Предыдущие Jump Host" disabled={hostPage === 0} onClick={() => setJhPage(hostPage - 1)}>Назад</Button>
|
||||
<Button variant="outline" size="sm" aria-label="Следующие Jump Host" disabled={hostPage === lastHostPage} onClick={() => setJhPage(hostPage + 1)}>Далее</Button>
|
||||
</>}
|
||||
</div>
|
||||
{legs.some(leg=>leg.observed?.probeId) && <p className="px-5 pb-3 text-xs text-muted-foreground">Скорость — снимок пары устройств. Сопоставление с WAN расчётное. ↓ к Home, ↑ от Home.</p>}
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredWans.length}
|
||||
pagination={filteredWans.length > 50 || pageSize !== 50}
|
||||
tableClassNames={{ headerRow: "border-b bg-muted/20", bodyRow: "group/row hover:bg-muted/30" }}
|
||||
tableLayout={{ dense: true, width: "fixed", columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteOptimizerWanMatrixDataGrid, type RouteOptimizerWanMatrixDataGridProps }
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import type { Server } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { RosBadge, rosVer } from "@/components/data-grids/servers-data-grid"
|
||||
import { CheckCircleIcon, XCircleIcon, RefreshCwIcon } from "lucide-react"
|
||||
|
||||
const ROS_FEATURES = [
|
||||
{ name: "WireGuard", minVer: 701, label: "7.1+", desc: "WireGuard VPN туннели" },
|
||||
{ name: "Container", minVer: 704, label: "7.4+", desc: "Docker-совместимые контейнеры" },
|
||||
{ name: "BFD", minVer: 705, label: "7.5+", desc: "Bidirectional Forwarding Detection" },
|
||||
{ name: "Large Communities", minVer: 707, label: "7.7+", desc: "BGP Large Communities (RFC 8092)" },
|
||||
{ name: "VXLAN", minVer: 710, label: "7.10+", desc: "VXLAN overlay туннели" },
|
||||
{ name: "RPKI", minVer: 713, label: "7.13+", desc: "Route Origin Validation" },
|
||||
{ name: "BGP Flowspec", minVer: 714, label: "7.14+", desc: "BGP Flow Spec (RFC 8955)" },
|
||||
{ name: "IPv6 Firewall", minVer: 715, label: "7.15+", desc: "Расширенный IPv6 Firewall" },
|
||||
{ name: "REST API v2", minVer: 716, label: "7.16+", desc: "Обновлённый REST API" },
|
||||
{ name: "VRF Enhanced", minVer: 717, label: "7.17+", desc: "Расширенная поддержка VRF" },
|
||||
]
|
||||
|
||||
interface ServerExpandedDetailProps {
|
||||
server: Server
|
||||
isLive: boolean
|
||||
canWrite: boolean
|
||||
isPolling: boolean
|
||||
onPoll: () => void
|
||||
}
|
||||
|
||||
function ServerExpandedDetail({ server: s, isLive, isPolling, canWrite, onPoll }: ServerExpandedDetailProps) {
|
||||
const ver = rosVer(s.os)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 px-5 py-5 bg-muted/20">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
||||
{s.model && s.model !== "—" && (
|
||||
<span className="text-muted-foreground">Модель: <span className="font-mono text-foreground">{s.model}</span></span>
|
||||
)}
|
||||
{s.uptime && (
|
||||
<span className="text-muted-foreground">Uptime: <span className="font-mono text-foreground">{s.uptime}</span></span>
|
||||
)}
|
||||
{s.cpuLoad != null && (
|
||||
<span className="text-muted-foreground">CPU: <span className={cn("font-mono font-semibold", s.cpuLoad > 80 ? "text-red-400" : s.cpuLoad > 50 ? "text-amber-400" : "text-emerald-400")}>{s.cpuLoad}%</span></span>
|
||||
)}
|
||||
{s.asn && (
|
||||
<span className="text-muted-foreground">ASN: <span className="font-mono text-foreground">{s.asn}</span></span>
|
||||
)}
|
||||
{s.ipv6Address && (
|
||||
<span className="text-muted-foreground">IPv6: <span className="font-mono text-sky-400">{s.ipv6Address}</span></span>
|
||||
)}
|
||||
{s.vrfNames?.map((v) => (
|
||||
<span key={v} className="text-muted-foreground">VRF: <span className="font-mono text-foreground">{v}</span></span>
|
||||
))}
|
||||
{s.comment && <span className="text-muted-foreground italic">{s.comment}</span>}
|
||||
{s.polledAt ? (
|
||||
<span className="text-muted-foreground/50 text-[11px]">
|
||||
Опрошен: {new Date(s.polledAt).toLocaleString("ru")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-500/70 text-[11px]">⚠ Ещё не опрашивался</span>
|
||||
)}
|
||||
</div>
|
||||
{isLive && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs shrink-0"
|
||||
disabled={!canWrite || isPolling}
|
||||
onClick={(e) => { e.stopPropagation(); onPoll() }}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-3.5", isPolling && "animate-spin")} />
|
||||
{isPolling ? "Опрос…" : "Опросить сейчас"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Возможности RouterOS
|
||||
</p>
|
||||
<RosBadge os={s.os} />
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{ver >= 715
|
||||
? "✓ Актуальная версия — все ключевые фичи доступны"
|
||||
: ver >= 710
|
||||
? "⚠ Рекомендуется обновление до 7.15+"
|
||||
: s.os !== "—"
|
||||
? "✗ Устаревшая версия — требуется обновление"
|
||||
: "Нет данных — нажмите «Опросить сейчас»"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
||||
{ROS_FEATURES.map((f) => {
|
||||
const ok = ver >= f.minVer
|
||||
return (
|
||||
<div
|
||||
key={f.name}
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-md border px-3 py-2.5 transition-colors",
|
||||
ok ? "border-emerald-500/25 bg-emerald-500/5" : "border-border/40 bg-background/40 opacity-60",
|
||||
)}
|
||||
>
|
||||
{ok
|
||||
? <CheckCircleIcon className="size-3.5 text-emerald-500 shrink-0 mt-0.5" />
|
||||
: <XCircleIcon className="size-3.5 text-muted-foreground/40 shrink-0 mt-0.5" />}
|
||||
<div className="min-w-0">
|
||||
<p className={cn("text-xs font-medium leading-tight truncate", ok ? "text-foreground" : "text-muted-foreground")}>
|
||||
{f.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight mt-0.5">
|
||||
{f.label} · {f.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ServerExpandedDetail }
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { Filters } from "@/components/reui/filters/filters"
|
||||
import type { FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
import { SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields"
|
||||
import { FILTER_LABELS_RU, FILTER_OPERATOR_LABELS_RU } from "@/lib/data-filters/filter-labels-ru"
|
||||
|
||||
export function ServerFilters({ query, onQueryChange }: {
|
||||
query: FilterQuery
|
||||
onQueryChange: (query: FilterQuery) => void
|
||||
}) {
|
||||
return <Filters fields={SERVER_FILTER_FIELDS} query={query} onQueryChange={onQueryChange}
|
||||
size="sm" showClear labels={FILTER_LABELS_RU} operatorLabels={FILTER_OPERATOR_LABELS_RU} />
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server, ServerType } from "@/lib/data"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Badge, type BadgeProps } from "@/components/reui/badge"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ServerExpandedDetail } from "@/components/data-grids/server-expanded-detail"
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
ChevronDownIcon,
|
||||
HomeIcon,
|
||||
ServerIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
WifiIcon,
|
||||
MoreHorizontalIcon,
|
||||
ExternalLinkIcon,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
PowerIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
const TYPE_LABELS: Record<ServerType, string> = {
|
||||
"jump-host": "JumpHost",
|
||||
"exit-node": "Exit Node",
|
||||
"home-router": "Home Router",
|
||||
}
|
||||
|
||||
const TYPE_VARIANTS: Record<ServerType, BadgeProps["variant"]> = {
|
||||
"jump-host": "primary-light",
|
||||
"exit-node": "info-light",
|
||||
"home-router": "success-light",
|
||||
}
|
||||
|
||||
function rosVer(os: string): number {
|
||||
const m = os.match(/(\d+)\.(\d+)/)
|
||||
if (!m) return 0
|
||||
return parseInt(m[1], 10) * 100 + parseInt(m[2], 10)
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: ServerType }) {
|
||||
const icon =
|
||||
type === "jump-host" ? <ServerIcon aria-hidden="true" />
|
||||
: type === "exit-node" ? <NetworkIcon aria-hidden="true" />
|
||||
: <HomeIcon aria-hidden="true" />
|
||||
return (
|
||||
<Badge variant={TYPE_VARIANTS[type]} className="text-foreground">
|
||||
{icon}{TYPE_LABELS[type]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function RosBadge({ os }: { os: string }) {
|
||||
const v = rosVer(os)
|
||||
const cls = v >= 715
|
||||
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
|
||||
: v >= 710
|
||||
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
|
||||
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
|
||||
return <span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>{os}</span>
|
||||
}
|
||||
|
||||
interface ServerRowActionsProps {
|
||||
canWrite: boolean
|
||||
busy: boolean
|
||||
server: Server
|
||||
isLive: boolean
|
||||
isPolling: boolean
|
||||
onEdit: (server: Server) => void
|
||||
onDelete: (id: string) => void
|
||||
onPoll: (id: string) => void
|
||||
onToggleStatus: (id: string) => void
|
||||
}
|
||||
|
||||
function ServerRowActions({
|
||||
canWrite,
|
||||
busy,
|
||||
server,
|
||||
isLive,
|
||||
isPolling,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPoll,
|
||||
onToggleStatus,
|
||||
}: ServerRowActionsProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
disabled={busy}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"opacity-0 transition-[opacity,background-color,color,border-color]",
|
||||
"group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||
"data-popup-open:opacity-100 data-popup-open:bg-muted",
|
||||
"hover:bg-muted hover:text-foreground hover:border-border",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Действия: ${server.name}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-52">
|
||||
<DropdownMenuItem onClick={() => window.open(`https://${server.host}`, "_blank")}>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
Открыть WebFig
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => onEdit(server)}>
|
||||
<PencilIcon className="size-4" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
{isLive && (
|
||||
<DropdownMenuItem onClick={() => onPoll(server.id)} disabled={!canWrite || isPolling}>
|
||||
<RefreshCwIcon className={cn("size-4", isPolling && "animate-spin")} />
|
||||
{isPolling ? "Опрос…" : "Опросить"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => onToggleStatus(server.id)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{server.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!canWrite} variant="destructive" onClick={() => onDelete(server.id)}>
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServersDataGridProps {
|
||||
hasUnfilteredRows?: boolean
|
||||
canWrite: boolean
|
||||
busy?: boolean
|
||||
servers: Server[]
|
||||
isLive: boolean
|
||||
pollingIds: Set<string>
|
||||
onPoll: (id: string) => void
|
||||
onEdit: (server: Server) => void
|
||||
onDelete: (id: string) => void
|
||||
onToggleStatus: (id: string) => void
|
||||
}
|
||||
|
||||
function ServersDataGrid({
|
||||
canWrite,
|
||||
hasUnfilteredRows = false,
|
||||
busy = false,
|
||||
servers,
|
||||
isLive,
|
||||
pollingIds,
|
||||
onPoll,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleStatus,
|
||||
}: ServersDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, Server>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Имя / Хост" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<Button type="button" variant="ghost" size="icon-xs"
|
||||
aria-label={`${expanded ? "Свернуть" : "Раскрыть"}: ${s.name}`}
|
||||
aria-expanded={expanded}
|
||||
onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
|
||||
{expanded
|
||||
? <ChevronDownIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRightIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground/40" />}
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{s.name}</p>
|
||||
<p className="text-xs font-mono text-muted-foreground">{s.host}</p>
|
||||
{s.ipv6Address && (
|
||||
<p className="text-[10px] font-mono text-sky-500/70 truncate max-w-[150px]" title={s.ipv6Address}>
|
||||
{s.ipv6Address}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Имя / Хост",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: Server) => (
|
||||
<ServerExpandedDetail
|
||||
canWrite={canWrite}
|
||||
server={row}
|
||||
isLive={isLive}
|
||||
isPolling={pollingIds.has(row.id)}
|
||||
onPoll={() => onPoll(row.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <TypeBadge type={row.original.type} />,
|
||||
meta: { headerTitle: "Тип", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "model",
|
||||
accessorKey: "model",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Модель" />,
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.model}</span>,
|
||||
meta: { headerTitle: "Модель", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "os",
|
||||
accessorKey: "os",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="RouterOS" />,
|
||||
cell: ({ row }) => <RosBadge os={row.original.os} />,
|
||||
meta: { headerTitle: "RouterOS", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
accessorKey: "site",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Площадка" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Flag code={row.original.country} />
|
||||
<span className="font-medium text-sm">{row.original.site}</span>
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: "Площадка", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "wan",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">WAN / LAN</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
if (s.type === "home-router" && s.wanUplinks?.length) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{s.wanUplinks.map((w) => (
|
||||
<div key={w.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
||||
<WifiIcon className="size-3 text-sky-400 shrink-0" />
|
||||
<span className="font-semibold text-sky-600 dark:text-sky-400">{w.name}</span>
|
||||
<span className="text-muted-foreground">{w.isp}</span>
|
||||
<span className="text-muted-foreground">↓{w.maxDl}↑{w.maxUl}</span>
|
||||
</div>
|
||||
))}
|
||||
{s.lanSubnet && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">LAN {s.lanSubnet}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{s.wireGuardIfaces && s.wireGuardIfaces.length > 0 && (
|
||||
<div className="text-[11px] font-mono text-violet-500 dark:text-violet-400 flex items-center gap-1">
|
||||
<ShieldIcon className="size-3" />
|
||||
WG: {s.wireGuardIfaces.length} iface · {s.wireGuardIfaces.reduce((n, i) => n + i.peers.length, 0)} peers
|
||||
</div>
|
||||
)}
|
||||
{s.rpkiEnabled && (
|
||||
<div className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">RPKI ✓</div>
|
||||
)}
|
||||
{!s.wireGuardIfaces?.length && !s.rpkiEnabled && (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "WAN / LAN", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "latency",
|
||||
accessorKey: "latency",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Задержка" className="w-full justify-end" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
return (
|
||||
<span className={cn(
|
||||
"font-mono text-sm block text-right tabular-nums",
|
||||
s.latency == null ? "text-muted-foreground" : s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "",
|
||||
)}>
|
||||
{s.latency == null ? "—" : `${s.latency} мс`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Задержка",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <div className="flex flex-col gap-1"><StatusBadge status={row.original.status} />{!row.original.enabled && <span className="text-xs text-muted-foreground">Отключён</span>}</div>,
|
||||
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
return (
|
||||
<ServerRowActions
|
||||
canWrite={canWrite}
|
||||
busy={busy}
|
||||
server={s}
|
||||
isLive={isLive}
|
||||
isPolling={pollingIds.has(s.id)}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onPoll={onPoll}
|
||||
onToggleStatus={onToggleStatus}
|
||||
/>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 56,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[canWrite, busy, isLive, onDelete, onEdit, onPoll, onToggleStatus, pollingIds],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data: servers,
|
||||
columns,
|
||||
manualPagination: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 } },
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ServerIcon className="size-4" />}
|
||||
title={hasUnfilteredRows ? "Нет совпадений" : "Нет серверов"}
|
||||
description={hasUnfilteredRows ? "Измените поиск или сбросьте фильтры" : canWrite ? "Добавьте первый MikroTik-сервер для мониторинга" : "Серверов нет. Добавление доступно пользователю с правом записи."}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={servers.length}
|
||||
pagination={servers.length > 50 || table.state.pagination.pageSize !== 50}
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { ServersDataGrid, rosVer, RosBadge, TypeBadge }
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DataGridProps, DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
|
||||
const DATA_GRID_CELL_PAD = "py-3"
|
||||
const DATA_GRID_CELL_PAD_FIRST = "pl-5 py-3"
|
||||
const DATA_GRID_CELL_PAD_LAST = "pr-4 py-3"
|
||||
|
||||
const DEFAULT_DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>["tableLayout"]> = {
|
||||
rowBorder: true,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
columnsResizable: false,
|
||||
}
|
||||
|
||||
const DEFAULT_DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>["tableClassNames"]> = {
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: "group/row",
|
||||
}
|
||||
|
||||
const DATA_GRID_CONTAINER_CLASS = "w-full rounded-none border-0"
|
||||
|
||||
const DATA_PAGE_CARD_CLASS = "flex w-full flex-col overflow-hidden py-0 gap-0"
|
||||
|
||||
const DATA_PAGE_TOOLBAR_CLASS = "flex items-center gap-3 px-5 py-3 border-b flex-wrap"
|
||||
|
||||
export {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
DEFAULT_DATA_GRID_CLASS_NAMES,
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
DATA_PAGE_CARD_CLASS,
|
||||
DATA_PAGE_TOOLBAR_CLASS,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import type { Table } from "@tanstack/react-table"
|
||||
import { DATA_GRID_LABELS_RU } from "@/lib/data-grid-labels-ru"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
DataGridPagination,
|
||||
DataGridTable,
|
||||
type DataGridProps,
|
||||
type DataGridFeatures,
|
||||
} from "@/components/reui/data-grid"
|
||||
import {
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
DEFAULT_DATA_GRID_CLASS_NAMES,
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
|
||||
interface DataGridShellProps<TData extends object> {
|
||||
table: Table<DataGridFeatures, TData>
|
||||
recordCount: number
|
||||
children?: ReactNode
|
||||
pagination?: boolean
|
||||
isLoading?: boolean
|
||||
loadingMode?: DataGridProps<DataGridFeatures, TData>["loadingMode"]
|
||||
emptyMessage?: ReactNode
|
||||
onRowClick?: DataGridProps<DataGridFeatures, TData>["onRowClick"]
|
||||
tableLayout?: DataGridProps<DataGridFeatures, TData>["tableLayout"]
|
||||
tableClassNames?: DataGridProps<DataGridFeatures, TData>["tableClassNames"]
|
||||
}
|
||||
|
||||
function DataGridShell<TData extends object>({
|
||||
table,
|
||||
recordCount,
|
||||
children,
|
||||
pagination = false,
|
||||
isLoading,
|
||||
loadingMode,
|
||||
emptyMessage,
|
||||
onRowClick,
|
||||
tableLayout = DEFAULT_DATA_GRID_LAYOUT,
|
||||
tableClassNames = DEFAULT_DATA_GRID_CLASS_NAMES,
|
||||
}: DataGridShellProps<TData>) {
|
||||
return (
|
||||
<DataGrid
|
||||
className="w-full"
|
||||
i18n={{ labels: DATA_GRID_LABELS_RU }}
|
||||
table={table}
|
||||
recordCount={recordCount}
|
||||
isLoading={isLoading}
|
||||
loadingMode={loadingMode}
|
||||
emptyMessage={emptyMessage}
|
||||
onRowClick={onRowClick}
|
||||
tableLayout={tableLayout}
|
||||
tableClassNames={tableClassNames}
|
||||
>
|
||||
{children ?? (
|
||||
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
)}
|
||||
{pagination && recordCount > 0 && (
|
||||
<DataGridPagination className="px-5 pb-3" />
|
||||
)}
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridShell, type DataGridShellProps }
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
|
||||
import type { Column } from "@tanstack/react-table"
|
||||
import type { DataGridFeatures } from "@/components/reui/data-grid"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "lucide-react"
|
||||
|
||||
interface DataGridSortHeaderProps<TData extends object> {
|
||||
column: Column<DataGridFeatures, TData, unknown>
|
||||
title: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DataGridSortHeader<TData extends object>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataGridSortHeaderProps<TData>) {
|
||||
const sorted = column.getIsSorted()
|
||||
const canSort = column.getCanSort()
|
||||
|
||||
if (!canSort) {
|
||||
return (
|
||||
<span className={cn("text-xs font-medium text-muted-foreground", className)}>
|
||||
{title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground",
|
||||
"hover:text-foreground transition-colors rounded-md -ml-1 px-1 py-0.5 focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2",
|
||||
className,
|
||||
)}
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
>
|
||||
{title}
|
||||
{sorted === "asc" ? (
|
||||
<ArrowUpIcon aria-hidden="true" className="size-3 text-foreground" />
|
||||
) : sorted === "desc" ? (
|
||||
<ArrowDownIcon aria-hidden="true" className="size-3 text-foreground" />
|
||||
) : (
|
||||
<ArrowUpDownIcon aria-hidden="true" className="size-3 opacity-35" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridSortHeader, type DataGridSortHeaderProps }
|
||||
@@ -0,0 +1,498 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
CompactDataGrid,
|
||||
type CompactDataGridColumn,
|
||||
type CompactDataGridProps,
|
||||
} from "@/components/data-grids/compact-data-grid"
|
||||
import type {
|
||||
AlertEngineRuleDiagSnapshot,
|
||||
PingProbeSnapshot,
|
||||
ResourceServerSnapshot,
|
||||
ServerRestPingSnapshot,
|
||||
SpeedRunSnapshot,
|
||||
TrafficServerSnapshot,
|
||||
} from "@/lib/scheduler-run-snapshot"
|
||||
|
||||
type SnapshotRow = { id: string }
|
||||
|
||||
function withRowId<T extends { serverId?: number; probeId?: string; ruleId?: string; target?: string }>(
|
||||
rows: T[],
|
||||
idFn: (row: T, index: number) => string,
|
||||
): (T & SnapshotRow)[] {
|
||||
return rows.map((row, index) => ({ ...row, id: idFn(row, index) }))
|
||||
}
|
||||
|
||||
function SnapshotOkBadge({
|
||||
ok,
|
||||
okLabel = "ok",
|
||||
errLabel = "ошибка",
|
||||
}: {
|
||||
ok: boolean
|
||||
okLabel?: string
|
||||
errLabel?: string
|
||||
}) {
|
||||
return ok ? (
|
||||
<Badge variant="success-outline" size="sm" className="text-[10px]">
|
||||
{okLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive-outline" size="sm" className="text-[10px]">
|
||||
{errLabel}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotErrorCell({ error, className }: { error?: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn("text-destructive max-w-[220px] truncate block", className)}
|
||||
title={error}
|
||||
>
|
||||
{error ?? "—"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotDataGrid<T extends SnapshotRow>(
|
||||
props: CompactDataGridProps<T>,
|
||||
) {
|
||||
return <CompactDataGrid {...props} />
|
||||
}
|
||||
|
||||
function TrafficSnapshotGrid({ servers }: { servers: TrafficServerSnapshot[] }) {
|
||||
const data = withRowId(servers, (s) => String(s.serverId))
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{ id: "name", header: "Сервер", accessorKey: "name", cell: (r) => <span className="font-medium">{r.name}</span> },
|
||||
{
|
||||
id: "host",
|
||||
header: "Хост",
|
||||
accessorKey: "host",
|
||||
cell: (r) => <span className="font-mono text-muted-foreground">{r.host}</span>,
|
||||
},
|
||||
{
|
||||
id: "ok",
|
||||
header: "Результат",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotOkBadge ok={r.ok} />,
|
||||
},
|
||||
{
|
||||
id: "interfaces",
|
||||
header: "IF",
|
||||
accessorKey: "interfaces",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => r.interfaces ?? "—",
|
||||
},
|
||||
{
|
||||
id: "sumRxMbps",
|
||||
header: "Σ RX",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.sumRxMbps != null ? `${r.sumRxMbps} Мбит/с` : "—"),
|
||||
},
|
||||
{
|
||||
id: "sumTxMbps",
|
||||
header: "Σ TX",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.sumTxMbps != null ? `${r.sumTxMbps} Мбит/с` : "—"),
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotErrorCell error={r.error} />,
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов трафика" />
|
||||
}
|
||||
|
||||
function ResourcesSnapshotGrid({ servers }: { servers: ResourceServerSnapshot[] }) {
|
||||
const data = withRowId(servers, (s) => String(s.serverId))
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{
|
||||
id: "name",
|
||||
header: "Сервер",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<div>
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<span className="block font-mono text-[10px] text-muted-foreground">{r.host}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Статус",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
r.status === "online" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
||||
r.status === "offline" && "border-destructive/50 text-destructive",
|
||||
)}
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cpuLoadPct",
|
||||
header: "CPU %",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => r.cpuLoadPct ?? "—",
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
header: "Память",
|
||||
enableSorting: false,
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums whitespace-nowrap",
|
||||
cell: (r) =>
|
||||
r.memUsedMb != null && r.memTotalMb != null ? `${r.memUsedMb} / ${r.memTotalMb} МБ` : "—",
|
||||
},
|
||||
{
|
||||
id: "memUsedPct",
|
||||
header: "% RAM",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.memUsedPct != null ? `${r.memUsedPct}%` : "—"),
|
||||
},
|
||||
{
|
||||
id: "disk",
|
||||
header: "Диск своб.",
|
||||
enableSorting: false,
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums whitespace-nowrap",
|
||||
cell: (r) =>
|
||||
r.diskFreeMb != null && r.diskTotalMb != null ? `${r.diskFreeMb} / ${r.diskTotalMb} МБ` : "—",
|
||||
},
|
||||
{
|
||||
id: "uptimeSeconds",
|
||||
header: "Uptime",
|
||||
cellClassName: "tabular-nums",
|
||||
cell: (r) => (r.uptimeSeconds != null ? fmtUptimeSec(r.uptimeSeconds) : "—"),
|
||||
},
|
||||
{
|
||||
id: "board",
|
||||
header: "Плата / ROS",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<div className="max-w-[140px]">
|
||||
<span className="block truncate" title={r.boardName}>{r.boardName || "—"}</span>
|
||||
<span className="block truncate text-muted-foreground font-mono text-[10px]" title={r.rosVersion}>
|
||||
{r.rosVersion || ""}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotErrorCell error={r.error} className="max-w-[160px]" />,
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов ресурсов" />
|
||||
}
|
||||
|
||||
function ServersRestPingSnapshotGrid({ servers }: { servers: ServerRestPingSnapshot[] }) {
|
||||
const data = withRowId(servers, (s) => String(s.serverId))
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{ id: "name", header: "Сервер", accessorKey: "name", cell: (r) => <span className="font-medium">{r.name}</span> },
|
||||
{
|
||||
id: "host",
|
||||
header: "Хост",
|
||||
accessorKey: "host",
|
||||
cell: (r) => <span className="font-mono text-muted-foreground">{r.host}</span>,
|
||||
},
|
||||
{
|
||||
id: "ok",
|
||||
header: "Результат",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotOkBadge ok={r.ok} errLabel="недоступен" />,
|
||||
},
|
||||
{
|
||||
id: "latencyMs",
|
||||
header: "RTT REST",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.latencyMs != null ? `${r.latencyMs} мс` : "—"),
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotErrorCell error={r.error} />,
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов REST ping" />
|
||||
}
|
||||
|
||||
function PingSnapshotGrid({ probes }: { probes: PingProbeSnapshot[] }) {
|
||||
const data = withRowId(probes, (p) => `${p.probeId}-${p.target}`)
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{
|
||||
id: "name",
|
||||
header: "Проба",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<div>
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<span className="block font-mono text-[10px] text-muted-foreground">{r.probeId}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
header: "Цель",
|
||||
accessorKey: "target",
|
||||
cell: (r) => <span className="font-mono">{r.target}</span>,
|
||||
},
|
||||
{ id: "srcServerName", header: "Источник", accessorKey: "srcServerName" },
|
||||
{
|
||||
id: "srcInterface",
|
||||
header: "IF",
|
||||
accessorKey: "srcInterface",
|
||||
cell: (r) => <span className="font-mono text-muted-foreground">{r.srcInterface || "—"}</span>,
|
||||
},
|
||||
{
|
||||
id: "rttMs",
|
||||
header: "RTT",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.rttMs != null ? `${r.rttMs} мс` : "—"),
|
||||
},
|
||||
{
|
||||
id: "lossPct",
|
||||
header: "Loss",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => `${r.lossPct}%`,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Статус",
|
||||
enableSorting: false,
|
||||
cell: (r) => <Badge variant="outline" className="text-[10px]">{r.status}</Badge>,
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotErrorCell error={r.error} className="max-w-[180px]" />,
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов ping" />
|
||||
}
|
||||
|
||||
function SpeedSnapshotGrid({ runs }: { runs: SpeedRunSnapshot[] }) {
|
||||
const data = withRowId(runs, (r) => r.probeId)
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{
|
||||
id: "probeId",
|
||||
header: "Проба",
|
||||
accessorKey: "probeId",
|
||||
cell: (r) => <span className="font-mono">{r.probeId}</span>,
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: "Маршрут",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<span className="whitespace-nowrap">
|
||||
{r.srcServerName} <span className="text-muted-foreground">→</span> {r.dstServerName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "interfaces",
|
||||
header: "Интерфейсы",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<span className="font-mono text-[10px]">
|
||||
<span className="block">{r.srcInterface || "—"}</span>
|
||||
<span className="block text-muted-foreground">{r.dstInterface || "—"}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
header: "Протокол",
|
||||
enableSorting: false,
|
||||
cell: (r) => `${r.protocol} / ${r.direction} / ${r.durationSec}s`,
|
||||
},
|
||||
{
|
||||
id: "txAvgMbps",
|
||||
header: "TX",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.txAvgMbps != null ? `${Number(r.txAvgMbps).toFixed(1)}` : "—"),
|
||||
},
|
||||
{
|
||||
id: "rxAvgMbps",
|
||||
header: "RX",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.rxAvgMbps != null ? `${Number(r.rxAvgMbps).toFixed(1)}` : "—"),
|
||||
},
|
||||
{
|
||||
id: "pingRttMs",
|
||||
header: "Ping RTT",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.pingRttMs != null ? `${r.pingRttMs} мс` : "—"),
|
||||
},
|
||||
{
|
||||
id: "pingLossPct",
|
||||
header: "Loss",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.pingLossPct != null ? `${r.pingLossPct}%` : "—"),
|
||||
},
|
||||
{
|
||||
id: "ok",
|
||||
header: "Результат",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotOkBadge ok={r.ok} />,
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<div className="max-w-[200px]">
|
||||
<span className="text-destructive block truncate" title={r.error}>{r.error ?? ""}</span>
|
||||
{r.pingError ? (
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 block truncate" title={r.pingError ?? ""}>
|
||||
ping: {r.pingError}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет прогонов speed" />
|
||||
}
|
||||
|
||||
function transitionRu(t: AlertEngineRuleDiagSnapshot["hitTransition"]): string {
|
||||
switch (t) {
|
||||
case "problem":
|
||||
return "проблема"
|
||||
case "recovery":
|
||||
return "восстановление"
|
||||
case "neutral":
|
||||
return "нейтрально"
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
|
||||
function blockedRu(b: AlertEngineRuleDiagSnapshot["blocked"]): string {
|
||||
switch (b) {
|
||||
case "no_hit":
|
||||
return "условие не выполнено"
|
||||
case "stability":
|
||||
return "стабильность (confirmStabilitySec)"
|
||||
case "cooldown":
|
||||
return "cooldown"
|
||||
case "no_telegram":
|
||||
return "нет Telegram"
|
||||
case "dedupe_positive":
|
||||
return "дедуп восстановления"
|
||||
case "in_group":
|
||||
return "в группе (отдельно не шлём)"
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
|
||||
function AlertEngineRuleDiagGrid({ ruleDiag }: { ruleDiag: AlertEngineRuleDiagSnapshot[] }) {
|
||||
const data = withRowId(ruleDiag, (d) => d.ruleId)
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{
|
||||
id: "ruleId",
|
||||
header: "ID правила",
|
||||
accessorKey: "ruleId",
|
||||
cell: (r) => (
|
||||
<span className="font-mono max-w-[140px] truncate block" title={r.ruleId}>
|
||||
{r.ruleId}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "evalHit",
|
||||
header: "Сработало",
|
||||
cell: (r) => (r.evalHit ? "Да" : "Нет"),
|
||||
},
|
||||
{
|
||||
id: "hitTransition",
|
||||
header: "Тип срабатывания",
|
||||
cell: (r) => transitionRu(r.hitTransition),
|
||||
},
|
||||
{
|
||||
id: "stabilityOk",
|
||||
header: "Стабильность",
|
||||
cell: (r) => (r.stabilityOk ? "Да" : "Нет"),
|
||||
},
|
||||
{
|
||||
id: "cooldownOk",
|
||||
header: "Кулдаун",
|
||||
cell: (r) => (r.cooldownOk ? "Да" : "Нет"),
|
||||
},
|
||||
{
|
||||
id: "telegramOk",
|
||||
header: "Telegram",
|
||||
cell: (r) => (r.telegramOk ? "Да" : "Нет"),
|
||||
},
|
||||
{
|
||||
id: "hitMessage",
|
||||
header: "Сообщение",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<span className="font-mono max-w-[280px] truncate text-muted-foreground block" title={r.hitMessage ?? ""}>
|
||||
{r.hitMessage ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "blocked",
|
||||
header: "Причина блока",
|
||||
cell: (r) => <span className="text-muted-foreground">{blockedRu(r.blocked)}</span>,
|
||||
},
|
||||
]
|
||||
return (
|
||||
<SnapshotDataGrid
|
||||
data={data}
|
||||
columns={columns}
|
||||
emptyTitle="Нет диагностики по правилам"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtUptimeSec(sec: number): string {
|
||||
if (sec <= 0) return "—"
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
if (d > 0) return `${d}д ${h}ч`
|
||||
if (h > 0) return `${h}ч ${m}м`
|
||||
return `${m}м`
|
||||
}
|
||||
|
||||
export {
|
||||
SnapshotDataGrid,
|
||||
TrafficSnapshotGrid,
|
||||
ResourcesSnapshotGrid,
|
||||
ServersRestPingSnapshotGrid,
|
||||
PingSnapshotGrid,
|
||||
SpeedSnapshotGrid,
|
||||
AlertEngineRuleDiagGrid,
|
||||
type SnapshotRow,
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
STATISTICS_DUP_MARK,
|
||||
STATISTICS_UNBOUND_USER_ID,
|
||||
STATISTICS_WAN_MARK,
|
||||
type StatisticsBreakdownRow,
|
||||
} from "@mmapp/contracts/statistics"
|
||||
|
||||
export type StatisticsSliceKind = "users" | "servers" | "interfaces" | "countries" | "services" | "asns"
|
||||
|
||||
export function StatisticsBreakdownDataGrid({
|
||||
rows,
|
||||
kind,
|
||||
selectedId,
|
||||
onRowClick,
|
||||
isLoading,
|
||||
density = "full",
|
||||
}: {
|
||||
rows: StatisticsBreakdownRow[]
|
||||
kind: StatisticsSliceKind
|
||||
selectedId?: string
|
||||
onRowClick?: (row: StatisticsBreakdownRow) => void
|
||||
isLoading?: boolean
|
||||
density?: "full" | "mini"
|
||||
}) {
|
||||
const mini = density === "mini"
|
||||
const columns: CompactDataGridColumn<StatisticsBreakdownRow>[] = [
|
||||
{
|
||||
id: "label",
|
||||
header: "Имя",
|
||||
accessorKey: "label",
|
||||
cell: (row) => (
|
||||
<span className={cn("flex items-center gap-2", selectedId === row.id && "font-medium")}>
|
||||
{kind === "countries" && row.id !== "XX" && row.id !== STATISTICS_UNBOUND_USER_ID ? (
|
||||
<Flag code={row.id} size={16} />
|
||||
) : null}
|
||||
<span className="truncate">{row.label || row.id}</span>
|
||||
{selectedId === row.id ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
слайс
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "bytes",
|
||||
header: "Байты",
|
||||
accessorKey: "bytes",
|
||||
cell: (row) => <span className="tabular-nums">{formatBytes(row.bytes)}</span>,
|
||||
},
|
||||
...(!mini
|
||||
? [
|
||||
{
|
||||
id: "packets",
|
||||
header: "Пакеты",
|
||||
accessorKey: "packets" as const,
|
||||
cell: (row: StatisticsBreakdownRow) => (
|
||||
<span className="tabular-nums">{row.packets.toLocaleString("ru-RU")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "bps",
|
||||
header: "Средний bitrate",
|
||||
accessorKey: "bps" as const,
|
||||
cell: (row: StatisticsBreakdownRow) => <span className="tabular-nums">{fmtBps(row.bps)}</span>,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "percent",
|
||||
header: "Доля",
|
||||
accessorKey: "percent",
|
||||
cell: (row) => (
|
||||
<span className="tabular-nums">
|
||||
{row.percent === 0
|
||||
&& (row.label.includes(STATISTICS_WAN_MARK) || row.label.includes(`· ${STATISTICS_DUP_MARK}`))
|
||||
? "—"
|
||||
: `${row.percent.toFixed(1)}%`}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<CompactDataGrid
|
||||
data={rows}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
emptyTitle="Нет трафика"
|
||||
emptyDescription="За выбранный период и слайсы нет данных куба."
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { CableIcon, CopyIcon, EyeIcon, EyeOffIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
export interface SubUserRow {
|
||||
id: string
|
||||
login: string
|
||||
password: string
|
||||
description: string
|
||||
jhServerIds: string[]
|
||||
clientIp: string
|
||||
active: boolean
|
||||
lastSeen: string | null
|
||||
}
|
||||
|
||||
interface SubusersDataGridProps {
|
||||
subUsers: SubUserRow[]
|
||||
servers: Server[]
|
||||
revealedIds: Set<string>
|
||||
onToggleReveal: (id: string) => void
|
||||
onToggleActive: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
function SubusersDataGrid({
|
||||
subUsers,
|
||||
servers,
|
||||
revealedIds,
|
||||
onToggleReveal,
|
||||
onToggleActive,
|
||||
onRemove,
|
||||
}: SubusersDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, SubUserRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "login",
|
||||
accessorKey: "login",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Логин / описание" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const su = row.original
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-mono font-medium truncate">{su.login}</p>
|
||||
{su.description && (
|
||||
<p className="text-[11px] text-muted-foreground truncate">{su.description}</p>
|
||||
)}
|
||||
{su.lastSeen && (
|
||||
<p className="text-[10px] text-muted-foreground/50">{su.lastSeen}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Логин / описание",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "password",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Пароль</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const su = row.original
|
||||
const revealed = revealedIds.has(su.id)
|
||||
return (
|
||||
<div className="flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="font-mono text-[11px] truncate flex-1">
|
||||
{revealed ? su.password : "••••••••••••"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleReveal(su.id)}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors"
|
||||
>
|
||||
{revealed ? <EyeOffIcon className="size-3" /> : <EyeIcon className="size-3" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText(su.password).catch(() => {})}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors"
|
||||
>
|
||||
<CopyIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пароль",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "jhServers",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">JH-серверы</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const jhs = servers.filter((s) => row.original.jhServerIds.includes(s.id))
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 min-w-0">
|
||||
{jhs.length === 0 ? (
|
||||
<span className="text-[11px] text-muted-foreground/40">—</span>
|
||||
) : (
|
||||
jhs.map((jh) => (
|
||||
<span
|
||||
key={jh.id}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-medium bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20 rounded px-1 py-0.5"
|
||||
>
|
||||
<Flag code={jh.country} size={10} />
|
||||
{jh.name.split("-").slice(-1)[0]}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "JH-серверы",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clientIp",
|
||||
accessorKey: "clientIp",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="IP-клиента" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-[11px] text-muted-foreground truncate">
|
||||
{row.original.clientIp || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "IP-клиента",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
accessorKey: "active",
|
||||
header: () => <span className="sr-only">Активен</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<FormToggle
|
||||
checked={row.original.active}
|
||||
onChange={() => onToggleActive(row.original.id)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Удалить</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(row.original.id)}
|
||||
className="text-muted-foreground/50 hover:text-destructive transition-colors opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Удалить GRE-клиента"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
),
|
||||
size: 40,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[onRemove, onToggleActive, onToggleReveal, revealedIds, servers],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
data: subUsers,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (subUsers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<CableIcon className="size-4" />}
|
||||
title="Нет GRE-клиентов"
|
||||
description="Добавьте учётки для подключения устройств"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={subUsers.length}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { SubusersDataGrid, type SubusersDataGridProps }
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { type ColumnDef, useTable } from "@tanstack/react-table"
|
||||
import type { FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from "lucide-react"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { fmtRate } from "@/lib/fmt-rate"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ`
|
||||
return `${n} Б`
|
||||
}
|
||||
|
||||
function formatEndpoint(ip: string | undefined, port: number | undefined): string {
|
||||
const host = String(ip ?? "").trim()
|
||||
if (!host) return "—"
|
||||
return port ? `${host.includes(":") ? `[${host}]` : host}:${port}` : host
|
||||
}
|
||||
|
||||
function packetTuple(row: FlowTalkerDto): string {
|
||||
const src = formatEndpoint(row.src, row.srcPort)
|
||||
const dst = formatEndpoint(row.dst, row.dstPort)
|
||||
return `${src} → ${dst}`
|
||||
}
|
||||
|
||||
function FlowDirectionMark({ direction }: { direction: FlowTalkerDto["direction"] }) {
|
||||
if (direction === "to_client") {
|
||||
return (
|
||||
<span className="inline-flex text-muted-foreground" title="Получение: Интернет → клиент">
|
||||
<ArrowDownIcon className="size-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (direction === "from_client") {
|
||||
return (
|
||||
<span className="inline-flex text-muted-foreground" title="Отправка: клиент → Интернет">
|
||||
<ArrowUpIcon className="size-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex text-muted-foreground" title="Транзит">
|
||||
<MinusIcon className="size-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TrafficFlowsDataGrid({
|
||||
rows,
|
||||
emptyHint,
|
||||
}: {
|
||||
rows: FlowTalkerDto[]
|
||||
emptyHint?: string
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, FlowTalkerDto>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "client",
|
||||
accessorFn: (r) => r.clientName || r.clientIp || "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Клиент</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs truncate">{row.original.clientName || "—"}</span>
|
||||
{row.original.clientIp ? (
|
||||
<span className="font-mono text-[10px] text-muted-foreground truncate">{row.original.clientIp}</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "serverName",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Экспортёр</span>,
|
||||
cell: ({ row }) => <span className="text-sm font-medium">{row.original.serverName}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "direction",
|
||||
accessorFn: (r) => r.direction ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Направление</span>,
|
||||
cell: ({ row }) => <FlowDirectionMark direction={row.original.direction} />,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "internet",
|
||||
accessorFn: (r) => r.internetPeer ?? r.dst,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Интернет</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const label = formatEndpoint(r.internetPeer, r.internetPeerPort)
|
||||
const tuple = packetTuple(r)
|
||||
return (
|
||||
<span className="font-mono text-xs truncate max-w-[220px]" title={tuple}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "app",
|
||||
accessorFn: (r) => r.application ?? r.protoName,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Приложение</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="flex min-w-0 flex-col gap-0.5 text-xs">
|
||||
<span>{row.original.application ?? row.original.protoName}</span>
|
||||
{row.original.category || row.original.service ? (
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{[row.original.category, row.original.service].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "proto",
|
||||
accessorKey: "protoName",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Протокол</span>,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.protoName}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "rate",
|
||||
accessorFn: (r) => r.bps,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
|
||||
cell: ({ row }) => <span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "bytes",
|
||||
accessorKey: "bytes",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
|
||||
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "en",
|
||||
accessorFn: (r) => r.enName ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Выходной узел</span>,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.enName || "—"}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "plane",
|
||||
accessorFn: (r) => r.plane ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Плоскость</span>,
|
||||
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.plane || "—"}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
accessorKey: "inIface",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Интерфейс</span>,
|
||||
cell: ({ row }) => <span className="font-mono text-xs text-muted-foreground">{row.original.inIface || "—"}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: cn(DATA_GRID_CELL_PAD_LAST) },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
data: rows,
|
||||
columns,
|
||||
getRowId: (row, i) => `${row.serverId}-${row.src}-${row.dst}-${row.proto}-${row.srcPort}-${row.dstPort}-${i}`,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
emptyMessage={
|
||||
emptyHint
|
||||
|| "Пока нет IPFIX. Поднимите wg-flow на хосте MM и подключите jump-host одним кликом."
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { TrafficFlowsDataGrid }
|
||||
@@ -0,0 +1,399 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ClockIcon,
|
||||
CpuIcon,
|
||||
HardDriveIcon,
|
||||
SearchIcon,
|
||||
ThermometerIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface UptimeResourceRow {
|
||||
serverId: string
|
||||
server: Server
|
||||
hasData: boolean
|
||||
cpu: number
|
||||
cpuHistory: number[]
|
||||
ramUsed: number
|
||||
ramTotal: number
|
||||
ramPct: number
|
||||
hddUsed: number
|
||||
hddTotal: number
|
||||
hddPct: number
|
||||
uptimeSeconds: number
|
||||
boardName: string
|
||||
temp?: number
|
||||
}
|
||||
|
||||
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||
type === "home-router"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: type === "jump-host"
|
||||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}>
|
||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtMB(mb: number): string {
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ`
|
||||
return `${mb.toFixed(1)} МБ`
|
||||
}
|
||||
|
||||
function fmtUptime(sec: number): string {
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
if (d > 0) return `${d}д ${h}ч`
|
||||
if (h > 0) return `${h}ч ${m}м`
|
||||
return `${m}м`
|
||||
}
|
||||
|
||||
function resPctColor(pct: number, warn = 70, crit = 85): string {
|
||||
if (pct >= crit) return "text-red-600 dark:text-red-400"
|
||||
if (pct >= warn) return "text-amber-600 dark:text-amber-400"
|
||||
return "text-emerald-600 dark:text-emerald-400"
|
||||
}
|
||||
|
||||
function resBarColor(pct: number, warn = 70, crit = 85): string {
|
||||
if (pct >= crit) return "bg-red-500"
|
||||
if (pct >= warn) return "bg-amber-500"
|
||||
return "bg-emerald-500"
|
||||
}
|
||||
|
||||
function MiniBar({
|
||||
pct,
|
||||
warn = 70,
|
||||
crit = 85,
|
||||
className,
|
||||
}: {
|
||||
pct: number
|
||||
warn?: number
|
||||
crit?: number
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("h-1.5 rounded-full bg-muted overflow-hidden", className)}>
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-700", resBarColor(pct, warn, crit))}
|
||||
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface UptimeResourcesDataGridProps {
|
||||
rows: UptimeResourceRow[]
|
||||
}
|
||||
|
||||
function UptimeResourcesDataGrid({ rows }: UptimeResourcesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, UptimeResourceRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorFn: (row) => row.server.name,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Сервер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const srv = r.server
|
||||
const offline = srv.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
const isCrit =
|
||||
!noMetrics &&
|
||||
(r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isCrit && <AlertCircleIcon className="size-3.5 text-red-500 shrink-0" />}
|
||||
{!isCrit && <StatusDot status={srv.status} pulse={!offline} />}
|
||||
<Flag code={srv.country} size={16} />
|
||||
<span className="font-mono font-semibold">{srv.name}</span>
|
||||
<TypeChip type={srv.type} />
|
||||
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
|
||||
{!offline && !r.hasData && (
|
||||
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
|
||||
нет данных
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "board",
|
||||
accessorKey: "boardName",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground hidden md:inline">
|
||||
Модель · ROS
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="hidden md:flex flex-col leading-tight">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{r.hasData ? r.boardName : "—"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">{r.server.os}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: "hidden md:table-cell", cellClassName: "hidden md:table-cell px-4 py-3" },
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
accessorKey: "cpu",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CpuIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="CPU" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (noMetrics) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground/30">
|
||||
{offline ? "—" : "нет опроса"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const cpuColor =
|
||||
r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-[140px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("font-mono text-sm font-semibold tabular-nums w-10 shrink-0", resPctColor(r.cpu))}>
|
||||
{r.cpu}%
|
||||
</span>
|
||||
<MiniBar pct={r.cpu} className="flex-1" />
|
||||
</div>
|
||||
<Sparkline data={r.cpuHistory} width={120} height={18} color={cpuColor} filled />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "CPU",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[160px]"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[160px]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ram",
|
||||
accessorKey: "ramPct",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="RAM" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (noMetrics) {
|
||||
return <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={cn("font-mono font-semibold", resPctColor(r.ramPct))}>{r.ramPct}%</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||||
{fmtMB(r.ramUsed)}/{fmtMB(r.ramTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<MiniBar pct={r.ramPct} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "RAM",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "hdd",
|
||||
accessorKey: "hddPct",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="Диск" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (noMetrics) {
|
||||
return <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={cn("font-mono font-semibold", resPctColor(r.hddPct))}>{r.hddPct}%</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||||
{fmtMB(r.hddUsed)}/{fmtMB(r.hddTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<MiniBar pct={r.hddPct} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Диск",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorKey: "uptimeSeconds",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ClockIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="Uptime" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
return (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{noMetrics ? "—" : fmtUptime(r.uptimeSeconds)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "temp",
|
||||
accessorKey: "temp",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ThermometerIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="°C" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (r.temp !== undefined && !noMetrics) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-sm font-semibold tabular-nums",
|
||||
r.temp >= 70
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: r.temp >= 55
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{r.temp}°C
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span className="text-muted-foreground/30 text-xs">—</span>
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "°C",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 } },
|
||||
data: rows,
|
||||
columns,
|
||||
getRowId: (row) => row.serverId,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(rows.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [rows.length, pageSize, pageIndex, table])
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<SearchIcon className="size-4" />}
|
||||
title="Ничего не найдено"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
pagination={rows.length > 50 || table.state.pagination.pageSize !== 50}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border bg-muted/30",
|
||||
bodyRow: cn(
|
||||
"group/row hover:bg-muted/30",
|
||||
"[&:has([data-resource-offline=true])]:opacity-50",
|
||||
"[&:has([data-resource-crit=true])]:bg-red-500/3",
|
||||
),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal">
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { UptimeResourcesDataGrid, type UptimeResourcesDataGridProps }
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowRightIcon, RefreshCwIcon } from "lucide-react"
|
||||
|
||||
export interface SpeedTestRunRow {
|
||||
id: string
|
||||
startedAt: number
|
||||
srcServerId: string
|
||||
dstServerId: string
|
||||
srcInterface?: string
|
||||
dstInterface?: string
|
||||
protocol: "tcp" | "udp"
|
||||
direction: "transmit" | "receive" | "both"
|
||||
durationSec: number
|
||||
txAvgMbps: number | null
|
||||
rxAvgMbps: number | null
|
||||
status: "running" | "done" | "error" | "unknown"
|
||||
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
|
||||
srcInterfaceAddress?: string | null
|
||||
dstInterfaceAddress?: string | null
|
||||
}
|
||||
|
||||
interface UptimeSpeedHistoryDataGridProps {
|
||||
runs: SpeedTestRunRow[]
|
||||
servers: Server[]
|
||||
}
|
||||
|
||||
function UptimeSpeedHistoryDataGrid({ runs, servers }: UptimeSpeedHistoryDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, SpeedTestRunRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "startedAt",
|
||||
accessorKey: "startedAt",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Время" className="ml-1" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground whitespace-nowrap tabular-nums font-mono text-xs">
|
||||
{new Date(row.original.startedAt).toLocaleString("ru-RU", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Время",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
accessorFn: (row) => `${row.srcServerId}-${row.dstServerId}`,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Маршрут" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
const src = servers.find((s) => s.id === run.srcServerId)
|
||||
const dst = servers.find((s) => s.id === run.dstServerId)
|
||||
return (
|
||||
<div className="font-mono whitespace-nowrap text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Flag code={src?.country ?? "UN"} size={13} />
|
||||
<span>{src?.name ?? run.srcServerId}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground" />
|
||||
<Flag code={dst?.country ?? "UN"} size={13} />
|
||||
<span>{dst?.name ?? run.dstServerId}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
|
||||
{run.srcInterfaceAddress && run.dstInterfaceAddress
|
||||
? `${run.srcInterfaceAddress} → ${run.dstInterfaceAddress}`
|
||||
: "внутренние IP: auto/не указаны"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "Маршрут", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "params",
|
||||
accessorFn: (row) => `${row.protocol}-${row.direction}-${row.durationSec}`,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Параметры" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-muted-foreground whitespace-nowrap text-xs">
|
||||
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold bg-muted/60 border-border/60">
|
||||
{run.protocol.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{run.direction}</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{run.durationSec}s</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "Параметры", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
if (status === "running") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[var(--status-degraded-fg)] text-xs">
|
||||
<RefreshCwIcon className="size-3 animate-spin" />
|
||||
running
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (status === "error") {
|
||||
return <span className="text-[var(--status-offline-fg)] text-xs">error</span>
|
||||
}
|
||||
if (status === "unknown") {
|
||||
return (
|
||||
<div className="max-w-64 text-xs text-[var(--status-degraded-fg)]">
|
||||
<span>Сохранение не подтверждено</span>
|
||||
<div className="mt-1 font-mono wrap-anywhere">runId: {row.original.id}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <span className="text-[var(--status-online-fg)] text-xs">done</span>
|
||||
},
|
||||
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "txAvgMbps",
|
||||
accessorFn: row => row.txAvgMbps ?? undefined,
|
||||
sortUndefined: "last",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="TX avg" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
if (run.txAvgMbps === null) return <span className="text-xs text-muted-foreground">Неизвестно</span>
|
||||
const maxVal = Math.max(run.txAvgMbps ?? 0, run.rxAvgMbps ?? 0, 1)
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--chart-tx)]"
|
||||
style={{ width: `${(run.txAvgMbps / maxVal) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono tabular-nums text-[var(--chart-tx)] font-medium whitespace-nowrap text-xs">
|
||||
{run.txAvgMbps} Мбит/с
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "TX avg", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "rxAvgMbps",
|
||||
accessorFn: row => row.rxAvgMbps ?? undefined,
|
||||
sortUndefined: "last",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="RX avg" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
if (run.rxAvgMbps === null) return <span className="text-xs text-muted-foreground">Неизвестно</span>
|
||||
const maxVal = Math.max(run.txAvgMbps ?? 0, run.rxAvgMbps ?? 0, 1)
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--chart-rx)]"
|
||||
style={{ width: `${(run.rxAvgMbps / maxVal) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono tabular-nums text-[var(--chart-rx)] font-medium whitespace-nowrap text-xs">
|
||||
{run.rxAvgMbps} Мбит/с
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "RX avg", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "afterBtPing",
|
||||
accessorFn: (row) => row.afterBtPing?.rttMs ?? -1,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Ping после BT" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
if (run.status !== "done") return <span className="text-xs">—</span>
|
||||
if (!run.afterBtPing) return <span className="text-xs text-muted-foreground">Нет данных</span>
|
||||
if (run.afterBtPing.error) {
|
||||
return (
|
||||
<span className="text-[var(--status-offline-fg)] text-xs font-mono" title={run.afterBtPing.error}>
|
||||
ошибка
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (run.afterBtPing?.rttMs != null) {
|
||||
return (
|
||||
<span className="font-mono tabular-nums whitespace-nowrap text-xs text-violet-600 dark:text-violet-400">
|
||||
{run.afterBtPing.rttMs} мс
|
||||
{run.afterBtPing.lossPct != null && run.afterBtPing.lossPct > 0 && (
|
||||
<span className="text-amber-600 dark:text-amber-400"> · {run.afterBtPing.lossPct}%</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="font-mono tabular-nums whitespace-nowrap text-xs text-amber-600 dark:text-amber-400">
|
||||
{run.afterBtPing.lossPct === 100 ? "Нет ответов" : "RTT неизвестен"}
|
||||
{run.afterBtPing?.lossPct != null && <span> · {run.afterBtPing.lossPct}%</span>}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Ping после BT",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[servers],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
data: runs,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, sorting: [{ id: "startedAt", desc: true }] },
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(runs.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [runs.length, pageSize, pageIndex, table])
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={runs.length}
|
||||
pagination={runs.length > 50 || pageSize !== 50}
|
||||
tableClassNames={{ bodyRow: cn("group/row text-xs") }}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal">
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { UptimeSpeedHistoryDataGrid, type UptimeSpeedHistoryDataGridProps }
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { UsersExpandedDetail } from "@/components/data-grids/users-expanded-detail"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ALL_SECTIONS, ROLE_LABEL, type AppUser } from "@/lib/users"
|
||||
import {
|
||||
CableIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
|
||||
return (
|
||||
<div aria-hidden="true"
|
||||
className={cn(
|
||||
"size-8 rounded-full hidden sm:flex items-center justify-center text-foreground text-[10px] font-semibold shrink-0 bg-muted",
|
||||
!active && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{avatar}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface UsersDataGridProps {
|
||||
users: AppUser[]
|
||||
serversCount: number | null
|
||||
allSectionsCount?: number
|
||||
isLoading?: boolean
|
||||
actionsDisabled?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
onEdit: (user: AppUser) => void
|
||||
onDelete: (user: AppUser) => void
|
||||
}
|
||||
|
||||
function UsersDataGrid({
|
||||
users,
|
||||
serversCount,
|
||||
allSectionsCount = ALL_SECTIONS.length,
|
||||
isLoading,
|
||||
actionsDisabled,
|
||||
emptyTitle = "Нет пользователей",
|
||||
emptyDescription = "Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик",
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: UsersDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, AppUser>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
size: 180,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пользователь" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const u = row.original
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<Button type="button" size="sm" variant="ghost" aria-label={"Доступ и привязки пользователя " + u.name} aria-expanded={expanded} className="size-8 shrink-0 p-0" onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
|
||||
{expanded ? (
|
||||
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
</Button>
|
||||
<AvatarCircle avatar={u.avatar} active={u.active} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" title={u.name}>{u.name}</p>
|
||||
<p className="text-xs font-mono text-muted-foreground truncate" title={u.email || u.login}>{u.email || u.login}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пользователь",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: AppUser) => (
|
||||
<UsersExpandedDetail
|
||||
user={row}
|
||||
serversCount={serversCount}
|
||||
allSectionsCount={allSectionsCount}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
accessorKey: "role",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.role === "admin" ? "primary-light" : row.original.role === "operator" ? "info-light" : "outline"}
|
||||
size="sm"
|
||||
>
|
||||
{ROLE_LABEL[row.original.role]}
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Роль",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "last",
|
||||
accessorKey: "last",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Последний вход" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">{row.original.last}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Последний вход",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ifaces",
|
||||
accessorFn: (u) => u.bindings.length,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейсы" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
|
||||
<CableIcon aria-hidden="true" className="size-3.5" />
|
||||
{row.original.bindings.length}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейсы",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "active",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-medium",
|
||||
row.original.active ? "text-success" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{row.original.active ? "Активен" : "Заблокирован"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 106,
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const u = row.original
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-end gap-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-11 md:size-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onEdit(u)}
|
||||
type="button"
|
||||
disabled={actionsDisabled}
|
||||
aria-label={"Редактировать пользователя " + u.name}
|
||||
title="Редактировать"
|
||||
>
|
||||
<PencilIcon aria-hidden="true" className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(u)}
|
||||
type="button"
|
||||
disabled={actionsDisabled}
|
||||
aria-label={"Удалить пользователя " + u.name}
|
||||
title="Удалить"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Действия",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[allSectionsCount, actionsDisabled, onDelete, onEdit, serversCount],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
autoResetExpanded: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["name"], end: [] } },
|
||||
data: users,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(users.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [users.length, pageSize, pageIndex, table])
|
||||
|
||||
if (!isLoading && users.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<UsersIcon aria-hidden="true" className="size-4" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={users.length}
|
||||
pagination={users.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ ...DEFAULT_DATA_GRID_LAYOUT, columnsPinnable: true }}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { UsersDataGrid, type UsersDataGridProps }
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
ALL_SECTIONS,
|
||||
groupBindingsByServer,
|
||||
IFACE_TYPE_LABEL,
|
||||
summarizeUserAccess,
|
||||
type AppUser,
|
||||
type InterfaceType,
|
||||
} from "@/lib/users"
|
||||
import { CableIcon, KeyRoundIcon, LockIcon, NetworkIcon, ShieldIcon } from "lucide-react"
|
||||
|
||||
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
|
||||
ether: "outline",
|
||||
bridge: "info-light",
|
||||
vlan: "info-light",
|
||||
vxlan: "info-light",
|
||||
gre: "info-light",
|
||||
wg: "success-light",
|
||||
ipsec: "info-light",
|
||||
other: "secondary",
|
||||
}
|
||||
|
||||
const TYPE_ICON: Record<InterfaceType, { icon: typeof CableIcon; className: string }> = {
|
||||
ether: { icon: CableIcon, className: "text-muted-foreground" },
|
||||
bridge: { icon: NetworkIcon, className: "text-info" },
|
||||
vlan: { icon: NetworkIcon, className: "text-info" },
|
||||
vxlan: { icon: NetworkIcon, className: "text-info" },
|
||||
gre: { icon: NetworkIcon, className: "text-info" },
|
||||
wg: { icon: ShieldIcon, className: "text-success" },
|
||||
ipsec: { icon: LockIcon, className: "text-info" },
|
||||
other: { icon: CableIcon, className: "text-muted-foreground" },
|
||||
}
|
||||
|
||||
interface UsersExpandedDetailProps {
|
||||
user: AppUser
|
||||
serversCount: number | null
|
||||
allSectionsCount?: number
|
||||
}
|
||||
|
||||
function UsersExpandedDetail({
|
||||
user,
|
||||
serversCount,
|
||||
allSectionsCount = ALL_SECTIONS.length,
|
||||
}: UsersExpandedDetailProps) {
|
||||
const access = summarizeUserAccess(user, serversCount ?? 0, allSectionsCount)
|
||||
const groups = groupBindingsByServer(user.bindings)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 px-5 py-5 bg-muted/20">
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
Разделы:{" "}
|
||||
<span className="font-mono text-foreground">
|
||||
{access.sectionsGranted}/{access.sectionsTotal}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Серверы:{" "}
|
||||
<span className="font-mono text-foreground">
|
||||
{serversCount === null ? "каталог неизвестен" : access.serversGranted + "/" + access.serversTotal}
|
||||
</span>
|
||||
</span>
|
||||
{access.writeKind === "full" ? (
|
||||
<Badge variant="success-light" size="sm">Полный доступ</Badge>
|
||||
) : access.writeKind === "none" ? (
|
||||
<Badge variant="outline" size="sm">Только просмотр</Badge>
|
||||
) : (
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
{access.writeSections.map((section) => (
|
||||
<Badge key={section} variant="info-light" size="sm">{section}</Badge>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Нет привязанных интерфейсов</p>
|
||||
) : (
|
||||
groups.map((g) => {
|
||||
const items = g.types.flatMap((tg) => tg.items)
|
||||
return (
|
||||
<div key={g.serverId}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{g.serverName}
|
||||
</p>
|
||||
<Flag code={g.serverCountry} size={12} />
|
||||
<span className="text-[11px] font-mono text-muted-foreground">{g.serverSite}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
||||
{items.map((b) => {
|
||||
const meta = TYPE_ICON[b.interfaceType]
|
||||
const Icon = b.interfaceType === "wg" && b.peerPublicKey ? KeyRoundIcon : meta.icon
|
||||
const iconClass = b.interfaceType === "wg" && b.peerPublicKey ? "text-success" : meta.className
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className="flex items-start gap-2 rounded-md border px-3 py-2.5"
|
||||
>
|
||||
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", iconClass)}>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-mono font-medium leading-tight truncate">
|
||||
{b.interfaceType === "wg" && (b.peerName || b.peerPublicKey)
|
||||
? `${b.peerName || "peer"} · ${b.interfaceName}`
|
||||
: b.interfaceName}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">
|
||||
{IFACE_TYPE_LABEL[b.interfaceType]}
|
||||
</Badge>
|
||||
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
|
||||
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
|
||||
) : null}
|
||||
{b.comment ? (
|
||||
<span className="text-[10px] text-muted-foreground leading-tight truncate">
|
||||
{b.comment}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { UsersExpandedDetail, TYPE_VARIANT, type UsersExpandedDetailProps }
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo } from "react"
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server, VxlanTunnel } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CodeXmlIcon,
|
||||
MoreHorizontalIcon,
|
||||
NetworkIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
interface VxlanDataGridProps {
|
||||
tunnels: VxlanTunnel[]
|
||||
servers: Server[]
|
||||
failedServerIds?: string[]
|
||||
onExport: (tunnel: VxlanTunnel) => void
|
||||
}
|
||||
|
||||
const VxlanActionsContext = createContext<VxlanDataGridProps["onExport"] | null>(null)
|
||||
function VxlanActionsCell({ row }: { row: { original: VxlanTunnel } }) {
|
||||
const onExport = useContext(VxlanActionsContext)
|
||||
if (!onExport) throw new Error("VXLAN actions context is missing")
|
||||
const tunnel = row.original
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-11 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"transition-colors",
|
||||
"data-popup-open:opacity-100",
|
||||
)}
|
||||
aria-label={`Действия: ${tunnel.name}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={() => onExport(tunnel)}>
|
||||
<CodeXmlIcon className="size-4" />
|
||||
Экспорт .rsc
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
function VxlanDataGrid({ tunnels, servers, onExport, failedServerIds = [] }: VxlanDataGridProps) {
|
||||
const serverMap = useMemo(
|
||||
() => new Map(servers.map((s) => [s.id, s])),
|
||||
[servers],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, VxlanTunnel>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
size: 176,
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Имя / VTEP" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
return (
|
||||
<div className="min-w-0 flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full shrink-0 mt-1.5",
|
||||
failedServerIds.includes(t.serverId) ? "bg-muted-foreground" : t.status === "up" ? "bg-success" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono font-medium text-sm break-all">{t.name}</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono break-all">VTEP: {t.vtepIp}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Имя / VTEP",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
size: 128,
|
||||
accessorKey: "serverId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => {
|
||||
const srv = serverMap.get(row.original.serverId)
|
||||
if (!srv) return <span className="text-muted-foreground">—</span>
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
<Flag code={srv.country} size={12} />
|
||||
<span className="font-mono truncate">{srv.name}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "vni",
|
||||
size: 80,
|
||||
accessorKey: "vni",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="VNI" />,
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.vni}</span>,
|
||||
meta: {
|
||||
headerTitle: "VNI",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dstPort",
|
||||
size: 104,
|
||||
accessorKey: "dstPort",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Port" />,
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.dstPort}</span>,
|
||||
meta: {
|
||||
headerTitle: "Port",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mtu",
|
||||
size: 88,
|
||||
accessorKey: "mtu",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.mtu ?? "—"}</span>,
|
||||
meta: {
|
||||
headerTitle: "MTU",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remoteVteps",
|
||||
size: 160,
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">Remote</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm text-center block">{row.original.remoteVteps.length}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Remote",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "arpProxy",
|
||||
size: 104,
|
||||
accessorKey: "arpProxy",
|
||||
header: () => <span className="sr-only">ARP</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
row.original.arpProxy
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
ARP {row.original.arpProxy ? "✓" : "✗"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "macLearning",
|
||||
size: 128,
|
||||
accessorKey: "macLearning",
|
||||
header: () => <span className="sr-only">MAC</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
row.original.macLearning
|
||||
? "bg-sky-500/10 text-sky-600 dark:text-sky-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
MAC {row.original.macLearning ? "✓" : "✗"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 104,
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
|
||||
failedServerIds.includes(row.original.serverId)
|
||||
? "bg-muted text-muted-foreground border-border"
|
||||
: row.original.status === "up"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
)}
|
||||
>
|
||||
{failedServerIds.includes(row.original.serverId) ? "Неизвестно" : row.original.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: VxlanActionsCell,
|
||||
enableSorting: false,
|
||||
size: 72,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[serverMap, failedServerIds],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["name"], end: [] } },
|
||||
data: tunnels,
|
||||
columns,
|
||||
getRowId: (row) => JSON.stringify([row.serverId, row.id]),
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(tunnels.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [tunnels.length, pageIndex, pageSize, table])
|
||||
|
||||
if (tunnels.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет VXLAN-туннелей"
|
||||
description="Измените поиск или проверьте конфигурацию VXLAN в RouterOS"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VxlanActionsContext.Provider value={onExport}>
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={tunnels.length}
|
||||
pagination={tunnels.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
</VxlanActionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export { VxlanDataGrid, type VxlanDataGridProps }
|
||||
@@ -0,0 +1,336 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { WireGuardInterface } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { WireGuardPeersDetail } from "@/components/data-grids/wireguard-peers-detail"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CodeXmlIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
PowerIcon,
|
||||
ShieldCheckIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface WgIfaceWithServer extends WireGuardInterface {
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverCountry: string
|
||||
}
|
||||
|
||||
interface WireguardDataGridProps {
|
||||
interfaces: WgIfaceWithServer[]
|
||||
compactServer?: boolean
|
||||
canWrite?: boolean
|
||||
emptyAction?: ReactNode
|
||||
onExport: (iface: WgIfaceWithServer) => void
|
||||
onAddPeer?: (iface: WgIfaceWithServer) => void
|
||||
toggleBusy?: boolean
|
||||
onToggle?: (iface: WgIfaceWithServer) => void
|
||||
onDelete?: (iface: WgIfaceWithServer) => void
|
||||
onDeletePeer?: (iface: WgIfaceWithServer, peerId: string) => void
|
||||
onExportPeer?: (iface: WgIfaceWithServer, peerId: string) => void
|
||||
}
|
||||
|
||||
type WireguardActions = Pick<WireguardDataGridProps, "onExport" | "onAddPeer" | "onToggle" | "toggleBusy" | "onDelete" | "canWrite">
|
||||
const WireguardActionsContext = createContext<WireguardActions | null>(null)
|
||||
|
||||
// Preserve menu state while callbacks and the current row data update.
|
||||
function WireguardActionsCell({ row }: { row: { original: WgIfaceWithServer } }) {
|
||||
const actions = useContext(WireguardActionsContext)
|
||||
if (!actions) throw new Error("WireGuard actions context is missing")
|
||||
const { onExport, onAddPeer, onToggle, toggleBusy, onDelete, canWrite = true } = actions
|
||||
const iface = row.original
|
||||
return (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-11 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"transition-[background-color,color,border-color]",
|
||||
"group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||
"data-popup-open:opacity-100 data-popup-open:bg-muted",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Действия: ${iface.name}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => onExport(iface)}>
|
||||
<CodeXmlIcon className="size-4" />
|
||||
Экспорт
|
||||
</DropdownMenuItem>
|
||||
{onAddPeer && (
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => onAddPeer(iface)}>
|
||||
<PlusIcon className="size-4" />
|
||||
Добавить пира
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onToggle && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!canWrite || toggleBusy} onClick={() => onToggle(iface)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{iface.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!canWrite} variant="destructive" onClick={() => onDelete(iface)}>
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WireguardDataGrid({
|
||||
interfaces,
|
||||
compactServer = false,
|
||||
canWrite = true,
|
||||
emptyAction,
|
||||
onExport,
|
||||
onAddPeer,
|
||||
onToggle,
|
||||
toggleBusy = false,
|
||||
onDelete,
|
||||
onDeletePeer,
|
||||
onExportPeer,
|
||||
}: WireguardDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, WgIfaceWithServer>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
size: 176,
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader
|
||||
column={column}
|
||||
title={compactServer ? "Интерфейс" : "Интерфейс / Сервер"}
|
||||
className="ml-1"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const iface = row.original
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-11 shrink-0" aria-label={"Пиры: " + iface.name} aria-expanded={expanded} onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
|
||||
{expanded ? <ChevronDownIcon aria-hidden="true" className="size-3.5" /> : <ChevronRightIcon aria-hidden="true" className="size-3.5" />}
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
iface.status === "up" ? "bg-success animate-pulse" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 break-all font-mono font-semibold text-sm">{iface.name}</span>
|
||||
</div>
|
||||
{!compactServer ? (
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
||||
<Flag code={iface.serverCountry || "UN"} size={12} />
|
||||
{iface.serverName}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="sr-only">
|
||||
{iface.peers.length} пиров
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: compactServer ? "Интерфейс" : "Интерфейс / Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: WgIfaceWithServer) => (
|
||||
<WireGuardPeersDetail
|
||||
peers={row.peers}
|
||||
interfaceName={row.name}
|
||||
onDeletePeer={onDeletePeer ? (peerId) => onDeletePeer(row, peerId) : undefined}
|
||||
onExportPeer={onExportPeer ? (peerId) => onExportPeer(row, peerId) : undefined}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "listenPort",
|
||||
size: 100,
|
||||
accessorKey: "listenPort",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Порт" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm">{row.original.listenPort}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Порт",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mtu",
|
||||
size: 80,
|
||||
accessorKey: "mtu",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.mtu}</span>,
|
||||
meta: {
|
||||
headerTitle: "MTU",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "peers",
|
||||
size: 80,
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">Пиры</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const iface = row.original
|
||||
return (
|
||||
<span className="font-mono text-sm text-center block">
|
||||
{iface.peers.length}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пиры",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 104,
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.status === "up" ? "success-light" : "destructive-light"}
|
||||
size="sm"
|
||||
className="font-mono"
|
||||
>
|
||||
{row.original.status === "up" ? "UP" : "DOWN"}
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: WireguardActionsCell,
|
||||
enableSorting: false,
|
||||
size: 72,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[compactServer, onDeletePeer, onExportPeer],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
autoResetExpanded: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["name"], end: [] } },
|
||||
data: interfaces,
|
||||
columns,
|
||||
getRowId: (row) => JSON.stringify([row.serverId, row.id]),
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(interfaces.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [interfaces.length, pageIndex, pageSize, table])
|
||||
|
||||
if (interfaces.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ShieldCheckIcon className="size-4" />}
|
||||
title="Нет WireGuard интерфейсов"
|
||||
description="Добавьте первый интерфейс или сбросьте фильтры"
|
||||
action={emptyAction}
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<WireguardActionsContext.Provider value={{ onExport, onAddPeer, onToggle, toggleBusy, onDelete, canWrite }}>
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={interfaces.length}
|
||||
pagination={interfaces.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
onRowClick={(row) => table.getRow(JSON.stringify([row.serverId, row.id])).toggleExpanded()}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
</WireguardActionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export { WireguardDataGrid, type WireguardDataGridProps }
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import type { WireGuardPeer } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CodeXmlIcon,
|
||||
KeyRoundIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
function fmtBytes(n: number | undefined): string {
|
||||
if (n === undefined || !Number.isFinite(n) || n < 0) return "—"
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
|
||||
return `${n} Б`
|
||||
}
|
||||
|
||||
function truncKey(key: string): string {
|
||||
if (key.length <= 20) return key
|
||||
return `${key.slice(0, 8)}…${key.slice(-8)}`
|
||||
}
|
||||
|
||||
function peerKey(peer: WireGuardPeer, index: number): string {
|
||||
return peer.id ?? peer.rosId ?? peer.publicKey ?? String(index)
|
||||
}
|
||||
|
||||
function WireGuardPeersDetail({
|
||||
peers,
|
||||
interfaceName,
|
||||
onDeletePeer,
|
||||
onExportPeer,
|
||||
}: {
|
||||
peers: WireGuardPeer[]
|
||||
interfaceName: string
|
||||
onDeletePeer?: (peerId: string) => void
|
||||
onExportPeer?: (peerId: string) => void
|
||||
}) {
|
||||
const [page, setPage] = useState(0)
|
||||
const lastPage = Math.max(0, Math.ceil(peers.length / 50) - 1)
|
||||
const currentPage = Math.min(page, lastPage)
|
||||
const offset = currentPage * 50
|
||||
if (page > lastPage) setPage(lastPage)
|
||||
if (peers.length === 0) {
|
||||
return (
|
||||
<div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
|
||||
Нет пиров
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label={"Пиры: " + interfaceName} className="sticky left-0 w-full max-w-[calc(100vw-2rem)] border-t border-border/50 md:max-w-none">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-2 bg-muted/10">
|
||||
<p className="text-xs text-muted-foreground" aria-live="polite">Пиры {offset + 1}–{Math.min(offset + 50, peers.length)} из {peers.length}</p>
|
||||
{peers.length > 50 && <div className="flex gap-1">
|
||||
<Button type="button" variant="outline" className="min-h-11" disabled={currentPage === 0} aria-label="Предыдущая страница пиров" onClick={() => setPage(currentPage - 1)}>Назад</Button>
|
||||
<Button type="button" variant="outline" className="min-h-11" disabled={currentPage >= lastPage} aria-label="Следующая страница пиров" onClick={() => setPage(currentPage + 1)}>Далее</Button>
|
||||
</div>}
|
||||
</div>
|
||||
{peers.slice(offset, offset + 50).map((peer, index) => {
|
||||
const id = peerKey(peer, offset + index)
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
data-wireguard-peer={id}
|
||||
className="grid grid-cols-2 gap-3 px-4 py-3 items-center text-xs border-t border-border/50 bg-muted/20 md:grid-cols-[1fr_1fr_auto_auto_auto_auto]"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-muted-foreground break-all" title={peer.publicKey}>
|
||||
{peer.name || truncKey(peer.publicKey)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 break-all font-mono text-muted-foreground">
|
||||
<span className="sr-only">Allowed IPs: </span><span>{peer.allowedIps.join(", ")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[11px] whitespace-nowrap",
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Последнее рукопожатие: </span><span>{peer.latestHandshake?.trim() || "Нет данных"}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
|
||||
<span aria-label="RX" className="flex items-center gap-0.5">
|
||||
<ArrowDownIcon aria-hidden="true" className="size-3 text-success" />
|
||||
{fmtBytes(peer.transferRx)}
|
||||
</span>
|
||||
<span aria-label="TX" className="flex items-center gap-0.5">
|
||||
<ArrowUpIcon aria-hidden="true" className="size-3 text-info" />
|
||||
{fmtBytes(peer.transferTx)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="min-w-0 break-all font-mono text-muted-foreground/60 text-[11px]"><span>Endpoint: </span>{peer.endpoint ?? "—"}</span>
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
{onExportPeer && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label="Экспорт peer .conf"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onExportPeer(id)
|
||||
}}
|
||||
>
|
||||
<CodeXmlIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onDeletePeer && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-destructive"
|
||||
aria-label="Удалить пира"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeletePeer(id)
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { WireGuardPeersDetail, fmtBytes, truncKey }
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { WireGuardPeer } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { fmtBytes, truncKey } from "@/components/data-grids/wireguard-peers-detail"
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CodeXmlIcon,
|
||||
KeyRoundIcon,
|
||||
Trash2Icon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface WgPeerRow extends WireGuardPeer {
|
||||
id: string
|
||||
ifaceId: string
|
||||
ifaceName: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverCountry: string
|
||||
}
|
||||
|
||||
interface WireguardPeersGridProps {
|
||||
peers: WgPeerRow[]
|
||||
compactServer?: boolean
|
||||
emptyAction?: ReactNode
|
||||
onDeletePeer?: (row: WgPeerRow) => void
|
||||
onExportPeer?: (row: WgPeerRow) => void
|
||||
}
|
||||
|
||||
function WireguardPeersGrid({
|
||||
peers,
|
||||
compactServer = false,
|
||||
emptyAction,
|
||||
onDeletePeer,
|
||||
onExportPeer,
|
||||
}: WireguardPeersGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, WgPeerRow>[]>(() => {
|
||||
const cols: ColumnDef<DataGridFeatures, WgPeerRow>[] = [
|
||||
{
|
||||
id: "peer",
|
||||
size: 144,
|
||||
accessorKey: "publicKey",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пир" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const peer = row.original
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-mono text-xs" title={peer.publicKey}>
|
||||
{peer.name || truncKey(peer.publicKey)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пир",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
size: 120,
|
||||
accessorKey: "ifaceName",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm">{row.original.ifaceName}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (!compactServer) {
|
||||
cols.push({
|
||||
id: "server",
|
||||
size: 128,
|
||||
accessorKey: "serverName",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground">
|
||||
<Flag code={row.original.serverCountry || "UN"} size={12} />
|
||||
{row.original.serverName}
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
id: "allowedIps",
|
||||
size: 136,
|
||||
accessorFn: (row) => row.allowedIps.join(", "),
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Allowed IPs" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="block truncate font-mono text-xs text-muted-foreground">
|
||||
{row.original.allowedIps.join(", ") || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Allowed IPs",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "handshake",
|
||||
size: 144,
|
||||
accessorKey: "latestHandshake",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Handshake" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-nowrap font-mono text-[11px]",
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{row.original.latestHandshake?.trim() || "Нет данных"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Handshake",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "transfer",
|
||||
size: 160,
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">RX / TX</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
|
||||
<span aria-label="RX" className="flex items-center gap-0.5">
|
||||
<ArrowDownIcon aria-hidden="true" className="size-3 text-success" />
|
||||
{fmtBytes(row.original.transferRx)}
|
||||
</span>
|
||||
<span aria-label="TX" className="flex items-center gap-0.5">
|
||||
<ArrowUpIcon aria-hidden="true" className="size-3 text-info" />
|
||||
{fmtBytes(row.original.transferTx)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "RX / TX",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "endpoint",
|
||||
size: 144,
|
||||
accessorKey: "endpoint",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Endpoint" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-[11px] text-muted-foreground">
|
||||
{row.original.endpoint ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Endpoint",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
size: 112,
|
||||
cell: ({ row }) => {
|
||||
const peer = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-0.5">
|
||||
{onExportPeer ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label="Экспорт peer .conf"
|
||||
onClick={() => onExportPeer(peer)}
|
||||
>
|
||||
<CodeXmlIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{onDeletePeer ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-destructive"
|
||||
aria-label="Удалить пира"
|
||||
onClick={() => onDeletePeer(peer)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return cols
|
||||
}, [compactServer, onDeletePeer, onExportPeer])
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["peer"], end: [] } },
|
||||
data: peers,
|
||||
columns,
|
||||
getRowId: (row) => JSON.stringify([row.serverId, row.ifaceId, row.id]),
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(peers.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [peers.length, pageIndex, pageSize, table])
|
||||
|
||||
if (peers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<UsersIcon className="size-4" />}
|
||||
title="Нет пиров"
|
||||
description="Добавьте пира к интерфейсу или сбросьте поиск"
|
||||
action={emptyAction}
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={peers.length}
|
||||
pagination={peers.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row"),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { WireguardPeersGrid, type WireguardPeersGridProps }
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { DATA_PAGE_CARD_CLASS } from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface DataPageCardProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Ops list shell — ReUI Frame, not shadcn Card. Preview: https://reui.io/docs/components/base/frame */
|
||||
function DataPageCard({ children, className }: DataPageCardProps) {
|
||||
return (
|
||||
<Frame dense className={cn("w-full", className)}>
|
||||
<FramePanel className={cn(DATA_PAGE_CARD_CLASS, "p-0")}>
|
||||
{children}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataPageCard, type DataPageCardProps }
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import { ReactNode } from "react"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group"
|
||||
import { SegmentedControl } from "@/components/form-kit"
|
||||
import { DATA_PAGE_TOOLBAR_CLASS } from "@/components/data-grids/shared/data-grid-layout"
|
||||
|
||||
function DataPageToolbarFrame({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(DATA_PAGE_TOOLBAR_CLASS, className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataPageToolbarProps<T extends string = string> {
|
||||
leading?: ReactNode
|
||||
search?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
searchLabel?: string
|
||||
segmented?: {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: { value: T; label: string; count?: number }[]
|
||||
}
|
||||
filterControl?: ReactNode
|
||||
countLabel?: string
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DataPageToolbar<T extends string = string>({
|
||||
leading,
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Поиск…",
|
||||
searchLabel,
|
||||
segmented,
|
||||
filterControl,
|
||||
countLabel,
|
||||
actions,
|
||||
className,
|
||||
}: DataPageToolbarProps<T>) {
|
||||
return (
|
||||
<DataPageToolbarFrame className={className}>
|
||||
{leading}
|
||||
{segmented && (
|
||||
<SegmentedControl
|
||||
value={segmented.value}
|
||||
onChange={segmented.onChange}
|
||||
options={segmented.options}
|
||||
/>
|
||||
)}
|
||||
{filterControl}
|
||||
{onSearchChange != null && (
|
||||
<InputGroup className="min-w-[220px] max-w-sm">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-3.5" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
aria-label={searchLabel ?? searchPlaceholder}
|
||||
placeholder={searchPlaceholder}
|
||||
value={search ?? ""}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
)}
|
||||
{countLabel && (
|
||||
<span className="text-sm text-muted-foreground ml-auto">{countLabel}</span>
|
||||
)}
|
||||
{actions}
|
||||
</DataPageToolbarFrame>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataPageToolbar, DataPageToolbarFrame, type DataPageToolbarProps }
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { InboxIcon } from "lucide-react"
|
||||
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
label: string
|
||||
render: (row: T) => React.ReactNode
|
||||
enableSorting?: boolean
|
||||
}
|
||||
|
||||
interface DataTableProps<T extends { id: string }> {
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
searchPlaceholder?: string
|
||||
searchKeys?: (keyof T)[]
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
pagination?: boolean
|
||||
countLabel?: string
|
||||
}
|
||||
|
||||
export function DataTable<T extends { id: string }>({
|
||||
data,
|
||||
columns,
|
||||
searchPlaceholder = "Поиск…",
|
||||
searchKeys = [],
|
||||
isLoading = false,
|
||||
emptyTitle = "Нет записей",
|
||||
emptyDescription,
|
||||
pagination = false,
|
||||
countLabel,
|
||||
}: DataTableProps<T>) {
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!search || searchKeys.length === 0) return data
|
||||
const s = search.toLowerCase()
|
||||
return data.filter((row) =>
|
||||
searchKeys.some((k) => String(row[k]).toLowerCase().includes(s)),
|
||||
)
|
||||
}, [data, search, searchKeys])
|
||||
|
||||
const columnDefs = useMemo<ColumnDef<DataGridFeatures, T>[]>(
|
||||
() => {
|
||||
const defs: ColumnDef<DataGridFeatures, T>[] = columns.map((col, index) => ({
|
||||
id: col.key,
|
||||
accessorKey: col.key,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title={col.label} />
|
||||
),
|
||||
cell: ({ row }) => col.render(row.original),
|
||||
enableSorting: col.enableSorting !== false,
|
||||
meta: {
|
||||
headerTitle: col.label,
|
||||
headerClassName:
|
||||
index === 0
|
||||
? DATA_GRID_CELL_PAD_FIRST
|
||||
: index === columns.length - 1
|
||||
? DATA_GRID_CELL_PAD_LAST
|
||||
: DATA_GRID_CELL_PAD,
|
||||
cellClassName:
|
||||
index === 0
|
||||
? DATA_GRID_CELL_PAD_FIRST
|
||||
: index === columns.length - 1
|
||||
? DATA_GRID_CELL_PAD_LAST
|
||||
: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
}))
|
||||
return defs
|
||||
},
|
||||
[columns],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: !pagination,
|
||||
data: filteredData,
|
||||
columns: columnDefs,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const displayCount = searchKeys.length > 0 ? filteredData.length : data.length
|
||||
|
||||
return (
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
countLabel={countLabel ?? `${displayCount} записей`}
|
||||
/>
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
pagination={pagination}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<InboxIcon className="size-4" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</DataPageCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty"
|
||||
import { IconStack } from "@/components/reui/icon-stack"
|
||||
import { InboxIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* ReUI Empty + IconStack — Frame-friendly empty-state-14/12.
|
||||
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
|
||||
*/
|
||||
function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<Empty className={cn("border-0 bg-transparent py-16", className)}>
|
||||
<EmptyHeader className="gap-5">
|
||||
<EmptyMedia className="mb-0">
|
||||
<IconStack aria-hidden="true" className="h-14 w-12">
|
||||
{icon ?? <InboxIcon strokeWidth={1.9} aria-hidden="true" className="size-5" />}
|
||||
</IconStack>
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{title}</EmptyTitle>
|
||||
{description ? <EmptyDescription>{description}</EmptyDescription> : null}
|
||||
</EmptyHeader>
|
||||
{action ? <EmptyContent>{action}</EmptyContent> : null}
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
export { EmptyState, type EmptyStateProps }
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
dataGridFeatures,
|
||||
type DataGridFeatures,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
ColumnDef,
|
||||
PaginationState,
|
||||
SortingState,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
const users = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Alex Johnson",
|
||||
email: "alex@example.com",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
|
||||
initials: "AJ",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Sarah Chen",
|
||||
email: "sarah@example.com",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "SC",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Michael Rodriguez",
|
||||
email: "michael@example.com",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
|
||||
initials: "MR",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Emma Wilson",
|
||||
email: "emma@example.com",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
|
||||
initials: "EW",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "David Kim",
|
||||
email: "david@example.com",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
|
||||
initials: "DK",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "Aron Thompson",
|
||||
email: "lisa@example.com",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
|
||||
initials: "LT",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
name: "James Brown",
|
||||
email: "james@example.com",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
|
||||
initials: "JB",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
name: "Maria Garcia",
|
||||
email: "maria@example.com",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
|
||||
initials: "MG",
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
name: "Nick Johnson",
|
||||
email: "nick@example.com",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80",
|
||||
initials: "NJ",
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
name: "Liam Thompson",
|
||||
email: "liam@example.com",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80",
|
||||
initials: "LT",
|
||||
},
|
||||
]
|
||||
|
||||
interface IData {
|
||||
id: string
|
||||
name: string
|
||||
availability: "online" | "away" | "busy" | "offline"
|
||||
avatar: string
|
||||
status: "active" | "inactive"
|
||||
flag: string // Emoji flags
|
||||
email: string
|
||||
company: string
|
||||
role: string
|
||||
joined: string
|
||||
location: string
|
||||
balance: number
|
||||
}
|
||||
|
||||
const demoData: IData[] = users.map((user, index) => ({
|
||||
...user,
|
||||
availability: (["online", "away", "busy", "offline"] as const)[index % 4],
|
||||
status: (index % 2 === 0 ? "active" : "inactive") as "active" | "inactive",
|
||||
flag: (["us", "gb", "ca", "au", "de", "my", "es", "jp", "fr", "it"] as const)[
|
||||
index % 10
|
||||
],
|
||||
company: (
|
||||
[
|
||||
"Apple",
|
||||
"OpenAI",
|
||||
"Meta",
|
||||
"Tesla",
|
||||
"SAP",
|
||||
"Keenthemes",
|
||||
"BBVA",
|
||||
"Sony",
|
||||
"LVMH",
|
||||
"ENI",
|
||||
] as const
|
||||
)[index % 10],
|
||||
role: (
|
||||
[
|
||||
"CEO",
|
||||
"CTO",
|
||||
"Designer",
|
||||
"Developer",
|
||||
"Lawyer",
|
||||
"Director",
|
||||
"Product Manager",
|
||||
"Marketing Lead",
|
||||
"Data Scientist",
|
||||
"Engineer",
|
||||
] as const
|
||||
)[index % 10],
|
||||
joined: "Jan, 2024",
|
||||
location: (
|
||||
[
|
||||
"United States",
|
||||
"United Kingdom",
|
||||
"Canada",
|
||||
"Australia",
|
||||
"Germany",
|
||||
"Malaysia",
|
||||
"Spain",
|
||||
"Japan",
|
||||
"France",
|
||||
"Italy",
|
||||
] as const
|
||||
)[index % 10],
|
||||
balance: 5143.03 + index * 100,
|
||||
}))
|
||||
|
||||
export function Pattern() {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 5,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: true },
|
||||
])
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, IData>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: (info) => <>{info.getValue() as string}</>,
|
||||
size: 150,
|
||||
meta: {
|
||||
headerClassName: "",
|
||||
cellClassName: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
cell: (info) => (
|
||||
<div className="truncate">
|
||||
<a
|
||||
href={`mailto:${info.getValue()}`}
|
||||
className="hover:text-primary truncate hover:underline"
|
||||
>
|
||||
{info.getValue() as string}
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
size: 150,
|
||||
meta: {
|
||||
headerClassName: "",
|
||||
cellClassName: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "location",
|
||||
header: "Location",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<img
|
||||
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
|
||||
alt={row.original.flag}
|
||||
className="size-4 rounded-full object-cover"
|
||||
/>
|
||||
<div className="text-foreground">{row.original.location}</div>
|
||||
</div>
|
||||
),
|
||||
size: 175,
|
||||
meta: {
|
||||
headerClassName: "",
|
||||
cellClassName: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "balance",
|
||||
header: "Balance ($)",
|
||||
cell: (info) => <>${(info.getValue() as number).toFixed(2)}</>,
|
||||
size: 100,
|
||||
meta: {
|
||||
headerClassName: "text-right rtl:text-left",
|
||||
cellClassName: "text-right rtl:text-left",
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
columns,
|
||||
data: demoData,
|
||||
pageCount: Math.ceil((demoData?.length || 0) / pagination.pageSize),
|
||||
getRowId: (row: IData) => row.id,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid table={table} recordCount={demoData?.length || 0}>
|
||||
<div className="w-full space-y-2.5">
|
||||
<DataGridContainer>
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
<DataGridPagination />
|
||||
</div>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { UploadIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useFileUpload } from "@/hooks/use-file-upload"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface FileImportDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
successMessage?: string | null
|
||||
actionLabel?: string
|
||||
onImport: (files: File[]) => void | Promise<void>
|
||||
}
|
||||
|
||||
function FileImportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
accept = "*",
|
||||
multiple = false,
|
||||
onImport,
|
||||
successMessage = "Импорт выполнен",
|
||||
actionLabel = "Импортировать",
|
||||
}: FileImportDialogProps) {
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
const [{ files, isDragging, errors }, actions] = useFileUpload({
|
||||
accept,
|
||||
multiple,
|
||||
maxFiles: multiple ? 10 : 1,
|
||||
onError: (errs) => {
|
||||
errs.forEach((e) => toast.error(e))
|
||||
},
|
||||
})
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
const rawFiles = files
|
||||
.map((f) => (f.file instanceof File ? f.file : null))
|
||||
.filter((f): f is File => f != null)
|
||||
if (rawFiles.length === 0) {
|
||||
toast.error("Выберите файл для импорта")
|
||||
return
|
||||
}
|
||||
setImporting(true)
|
||||
try {
|
||||
await onImport(rawFiles)
|
||||
actions.clearFiles()
|
||||
onOpenChange(false)
|
||||
if (successMessage) toast.success(successMessage)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Ошибка импорта")
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}, [actions, files, onImport, onOpenChange, successMessage])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center transition-colors",
|
||||
isDragging ? "border-primary bg-primary/5" : "border-border",
|
||||
)}
|
||||
onDragEnter={actions.handleDragEnter}
|
||||
onDragLeave={actions.handleDragLeave}
|
||||
onDragOver={actions.handleDragOver}
|
||||
onDrop={actions.handleDrop}
|
||||
>
|
||||
<UploadIcon className="size-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Перетащите файл сюда или выберите на диске
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={actions.openFileDialog}>
|
||||
Выбрать файл
|
||||
</Button>
|
||||
<input {...actions.getInputProps()} className="sr-only" />
|
||||
{files.length > 0 && (
|
||||
<ul className="w-full text-left text-sm">
|
||||
{files.map((f) => (
|
||||
<li key={f.id} className="truncate font-mono text-xs">
|
||||
{f.file instanceof File ? f.file.name : f.file.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{errors.map((e) => (
|
||||
<p key={e} className="text-xs text-destructive">{e}</p>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="button" onClick={handleImport} disabled={importing || files.length === 0}>
|
||||
{importing ? "Импорт…" : actionLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export { FileImportDialog, type FileImportDialogProps }
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Flag — renders a country flag image from an ISO 3166-1 alpha-2 code.
|
||||
* Images served from flagcdn.com (free, no auth required).
|
||||
*/
|
||||
|
||||
const COUNTRY_NAMES: Record<string, string> = {
|
||||
RU: "Россия",
|
||||
DE: "Германия",
|
||||
NL: "Нидерланды",
|
||||
SG: "Сингапур",
|
||||
US: "США",
|
||||
GB: "Великобритания",
|
||||
FR: "Франция",
|
||||
FI: "Финляндия",
|
||||
SE: "Швеция",
|
||||
PL: "Польша",
|
||||
UA: "Украина",
|
||||
TR: "Турция",
|
||||
JP: "Япония",
|
||||
HK: "Гонконг",
|
||||
}
|
||||
|
||||
let regionNames: Intl.DisplayNames | null | undefined
|
||||
|
||||
function regionDisplayName(iso: string): string | undefined {
|
||||
try {
|
||||
if (regionNames === undefined) {
|
||||
regionNames = typeof Intl !== "undefined" && "DisplayNames" in Intl
|
||||
? new Intl.DisplayNames(["ru"], { type: "region" })
|
||||
: null
|
||||
}
|
||||
return regionNames?.of(iso) ?? undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Country name in Russian (fallback to ISO code) */
|
||||
export function countryName(code: string): string {
|
||||
const iso = code.toUpperCase()
|
||||
if (!iso) return code
|
||||
if (COUNTRY_NAMES[iso]) return COUNTRY_NAMES[iso]
|
||||
const intl = regionDisplayName(iso)
|
||||
if (intl && intl !== iso) return intl
|
||||
return iso
|
||||
}
|
||||
|
||||
interface FlagProps {
|
||||
code: string
|
||||
/** px size — width of the flag image (height auto-scales 3:2 ratio) */
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
// flagcdn.com supports only these widths
|
||||
const CDN_SIZES = [20, 40, 80, 160, 320, 640, 1280, 2560]
|
||||
|
||||
function nearestCdnSize(px: number): number {
|
||||
return CDN_SIZES.find(s => s >= px) ?? CDN_SIZES[CDN_SIZES.length - 1]
|
||||
}
|
||||
|
||||
/** CDN URL for SVG `<image href>` (flagcdn widths only). */
|
||||
export function flagCdnUrl(code: string, size = 40): string | null {
|
||||
const lower = code.toLowerCase()
|
||||
if (!/^[a-z]{2}$/.test(lower)) return null
|
||||
return `https://flagcdn.com/w${nearestCdnSize(size)}/${lower}.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a flag <img> for a given ISO 3166-1 alpha-2 country code.
|
||||
* Source: https://flagcdn.com — free CDN, no API key needed.
|
||||
*/
|
||||
export function Flag({ code, size = 20, className }: FlagProps) {
|
||||
if (!code) return null
|
||||
const lower = code.toLowerCase()
|
||||
if (!/^[a-z]{2}$/.test(lower)) return null
|
||||
const name = countryName(code.toUpperCase())
|
||||
const cdnSrc = nearestCdnSize(size)
|
||||
const cdnSrc2x = nearestCdnSize(size * 2)
|
||||
return (
|
||||
// Внешний CDN (динамический URL) — next/image без remotePatterns не подходит
|
||||
// eslint-disable-next-line @next/next/no-img-element -- flagcdn.com, размеры задаём явно
|
||||
<img
|
||||
src={`https://flagcdn.com/w${cdnSrc}/${lower}.png`}
|
||||
srcSet={`https://flagcdn.com/w${cdnSrc2x}/${lower}.png 2x`}
|
||||
width={size}
|
||||
height={Math.round(size * 0.75)}
|
||||
alt={name}
|
||||
title={name}
|
||||
className={className}
|
||||
style={{ display: "inline-block", verticalAlign: "middle", borderRadius: 2 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string
|
||||
hint?: string
|
||||
error?: string
|
||||
required?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function FormField({ label, hint, error, required, children }: FormFieldProps) {
|
||||
return (
|
||||
<Field data-invalid={!!error}>
|
||||
<FieldLabel>
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</FieldLabel>
|
||||
{children}
|
||||
{hint && !error && <FieldDescription>{hint}</FieldDescription>}
|
||||
{error && <FieldDescription className="text-destructive">{error}</FieldDescription>}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export { FormField, type FormFieldProps }
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface FormToggleProps {
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
function FormToggle({ checked, onChange, disabled, className, ariaLabel }: FormToggleProps) {
|
||||
return (
|
||||
<Switch
|
||||
aria-label={ariaLabel}
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
className={cn(className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { FormToggle, type FormToggleProps }
|
||||
@@ -0,0 +1,4 @@
|
||||
export { FormField, type FormFieldProps } from "./form-field"
|
||||
export { FormToggle, type FormToggleProps } from "./form-toggle"
|
||||
export { SectionTitle, type SectionTitleProps } from "./section-title"
|
||||
export { SegmentedControl, type SegmentedControlProps } from "./segmented-control"
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SectionTitleProps {
|
||||
children: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function SectionTitle({ children, icon, className }: SectionTitleProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 py-0.5", className)}>
|
||||
{icon}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SectionTitle, type SectionTitleProps }
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: { value: T; label: string; count?: number }[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={value === option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
|
||||
value === option.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
{option.count != null && (
|
||||
<span className="text-xs tabular-nums">{option.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SegmentedControl, type SegmentedControlProps }
|
||||
@@ -0,0 +1,262 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { IPSEC_MIN_PASSPHRASE, type IpsecCertBundle } from "@mmapp/contracts/ipsec"
|
||||
import type { IpsecExistingCertExportOptions } from "@/shared/api/ipsec"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { toast } from "sonner"
|
||||
import { DownloadIcon, CopyIcon, RefreshCwIcon } from "lucide-react"
|
||||
|
||||
function downloadBlob(filename: string, blob: Blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function downloadText(filename: string, content: string) {
|
||||
downloadBlob(filename, new Blob([content], { type: "text/plain;charset=utf-8" }))
|
||||
}
|
||||
|
||||
function downloadB64(filename: string, b64: string, mime: string) {
|
||||
const bin = atob(b64)
|
||||
const bytes = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||
downloadBlob(filename, new Blob([bytes], { type: mime }))
|
||||
}
|
||||
|
||||
function randomPassphrase(): string {
|
||||
// RouterOS требует ≥ IPSEC_MIN_PASSPHRASE символов
|
||||
const bytes = new Uint8Array(IPSEC_MIN_PASSPHRASE + 1)
|
||||
crypto.getRandomValues(bytes)
|
||||
let s = ""
|
||||
for (const b of bytes) s += "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"[b % 56]
|
||||
return s
|
||||
}
|
||||
|
||||
function IpsecCertSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
bundle,
|
||||
busy,
|
||||
error,
|
||||
onReexport,
|
||||
certificateRole,
|
||||
targetName,
|
||||
previousAttemptFailed = false,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
bundle: IpsecCertBundle | null
|
||||
busy?: boolean
|
||||
error?: string | null
|
||||
/** Перекачка с новой passphrase (серийник ключа остаётся на роутере). */
|
||||
onReexport?: (passphrase: string, options: IpsecExistingCertExportOptions) => void | Promise<void>
|
||||
certificateRole?: "ca" | "server" | "client" | "other"
|
||||
targetName?: string
|
||||
previousAttemptFailed?: boolean
|
||||
}) {
|
||||
const [confirmationOpen, setConfirmationOpen] = useState(false)
|
||||
const [privateKeyConfirmed, setPrivateKeyConfirmed] = useState(false)
|
||||
const [caKeyConfirmed, setCaKeyConfirmed] = useState(false)
|
||||
const [newAttemptConfirmed, setNewAttemptConfirmed] = useState(false)
|
||||
const submitting = useRef(false)
|
||||
const isCa = certificateRole === "ca"
|
||||
const confirmEnabled = privateKeyConfirmed && (!isCa || caKeyConfirmed) && (!previousAttemptFailed || newAttemptConfirmed) && !busy
|
||||
const openExportConfirmation = () => {
|
||||
if (busy || submitting.current) return
|
||||
setPrivateKeyConfirmed(false)
|
||||
setCaKeyConfirmed(false)
|
||||
setNewAttemptConfirmed(false)
|
||||
setConfirmationOpen(true)
|
||||
}
|
||||
const confirmExport = async () => {
|
||||
if (!confirmEnabled || !onReexport || submitting.current) return
|
||||
submitting.current = true
|
||||
setConfirmationOpen(false)
|
||||
try {
|
||||
// One key per explicitly confirmed attempt. Nothing retries this call or
|
||||
// allocates another key after an error, opening, selection, or rendering.
|
||||
const requestKey = crypto.randomUUID()
|
||||
await onReexport(randomPassphrase(), { privateKeyExport: true, caPrivateKeyExport: isCa && caKeyConfirmed, requestKey })
|
||||
} catch {
|
||||
toast.error("Экспорт не подтверждён. Перед новой попыткой проверьте состояние роутера.")
|
||||
} finally {
|
||||
submitting.current = false
|
||||
}
|
||||
}
|
||||
const passphrase = bundle?.passphrase ?? ""
|
||||
const canDownload = Boolean(bundle && !busy && passphrase.trim().length >= IPSEC_MIN_PASSPHRASE)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Сертификат {bundle?.user || targetName ? `«${bundle?.user ?? targetName}»` : ""}</SheetTitle>
|
||||
<SheetDescription>
|
||||
.p12 для Windows/macOS/iOS · .sswan для strongSwan (Android/iOS)
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||
{busy && <p role="status" className="text-sm text-muted-foreground">Загрузка сертификата…</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
{previousAttemptFailed && !busy && (
|
||||
<p role="alert" className="text-sm text-destructive">Предыдущая попытка завершилась ошибкой; её результат на роутере может быть неизвестен. Проверьте временный файл и состояние экспорта перед новым запросом.</p>
|
||||
)}
|
||||
{!bundle ? (
|
||||
!busy && onReexport ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">Экспорт .p12 включает закрытый ключ. Подтвердите его получение перед запросом к роутеру.</p>
|
||||
<Button variant="outline" onClick={openExportConfirmation}>{previousAttemptFailed ? "Подготовить новый запрос" : "Экспортировать .p12"}</Button>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Пароль архива .p12</SectionTitle>
|
||||
<FormField
|
||||
label="Passphrase"
|
||||
required
|
||||
hint={
|
||||
passphrase.trim().length > 0 && passphrase.trim().length < IPSEC_MIN_PASSPHRASE
|
||||
? `Минимум ${IPSEC_MIN_PASSPHRASE} символов — требование RouterOS`
|
||||
: `Нужна при импорте .p12 на устройстве (минимум ${IPSEC_MIN_PASSPHRASE} символов)`
|
||||
}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={passphrase}
|
||||
readOnly
|
||||
aria-label="Пароль архива .p12"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="Подтвердить экспорт с новым паролем"
|
||||
aria-label="Подтвердить экспорт с новым паролем"
|
||||
disabled={busy || !onReexport}
|
||||
onClick={openExportConfirmation}
|
||||
>
|
||||
<RefreshCwIcon className={`size-4 ${busy ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
{bundle.serverEndpoint ? (
|
||||
<p className="text-xs text-muted-foreground break-all">
|
||||
Сервер: <span className="font-mono">{bundle.serverEndpoint}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Файлы</SectionTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start min-w-0"
|
||||
disabled={!canDownload}
|
||||
onClick={() => {
|
||||
if (!bundle) return
|
||||
downloadB64(bundle.filename, bundle.contentB64, bundle.mime)
|
||||
toast.success(`Скачан ${bundle.filename}`)
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
<span className="truncate">{bundle.filename} (.p12, сертификат + ключ)</span>
|
||||
</Button>
|
||||
{bundle.sswanContent && bundle.sswanFilename ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start min-w-0"
|
||||
disabled={!canDownload}
|
||||
onClick={() => {
|
||||
if (!bundle.sswanContent || !bundle.sswanFilename) return
|
||||
downloadText(bundle.sswanFilename, bundle.sswanContent)
|
||||
toast.success(`Скачан ${bundle.sswanFilename}`)
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
<span className="truncate">{bundle.sswanFilename} (strongSwan)</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{bundle.instructions ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start text-muted-foreground"
|
||||
onClick={() => {
|
||||
if (!bundle.instructions) return
|
||||
void navigator.clipboard?.writeText(bundle.instructions)
|
||||
toast.success("Инструкция скопирована")
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
Скопировать инструкцию по подключению
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{bundle.instructions ? (
|
||||
<pre className="max-h-72 overflow-y-auto overflow-x-hidden rounded-md border bg-muted/40 p-3 text-[11px] leading-relaxed whitespace-pre-wrap break-words">
|
||||
{bundle.instructions}
|
||||
</pre>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0">
|
||||
<SheetClose render={<Button variant="outline" className="w-full" />}>
|
||||
Закрыть
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Dialog open={confirmationOpen && open} onOpenChange={setConfirmationOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Подтвердить экспорт {isCa ? "CA" : "сертификата"}</DialogTitle>
|
||||
<DialogDescription>Запрос создаст на роутере временный .p12 с закрытым ключом сертификата {targetName ? `«${targetName}»` : ""}, прочитает его и удалит временный файл. Пароль архива будет сгенерирован после подтверждения.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<label htmlFor="ipsec-export-private-key" className="flex items-start gap-2 text-sm">
|
||||
<Checkbox id="ipsec-export-private-key" checked={privateKeyConfirmed} onCheckedChange={setPrivateKeyConfirmed} />
|
||||
<span>Разрешаю экспорт и получение закрытого ключа выбранного сертификата.</span>
|
||||
</label>
|
||||
{isCa && (
|
||||
<label htmlFor="ipsec-export-ca-key" className="flex items-start gap-2 text-sm">
|
||||
<Checkbox id="ipsec-export-ca-key" checked={caKeyConfirmed} onCheckedChange={setCaKeyConfirmed} />
|
||||
<span>Отдельно разрешаю экспорт закрытого ключа центра сертификации CA. Его владелец может подписывать другие сертификаты.</span>
|
||||
</label>
|
||||
)}
|
||||
{previousAttemptFailed && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-destructive">Результат предыдущего запроса может быть неизвестен. Новый запрос не продолжает и не подтверждает предыдущую попытку.</p>
|
||||
<label htmlFor="ipsec-export-new-attempt" className="flex items-start gap-2 text-sm">
|
||||
<Checkbox id="ipsec-export-new-attempt" checked={newAttemptConfirmed} onCheckedChange={setNewAttemptConfirmed} />
|
||||
<span>Я проверил состояние роутера и явно запрашиваю новый экспорт.</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmationOpen(false)}>Отмена</Button>
|
||||
<Button disabled={!confirmEnabled} onClick={() => void confirmExport()}>{previousAttemptFailed ? "Отправить новый запрос" : "Подтвердить экспорт"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpsecCertSheet, downloadText, downloadB64 }
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
export type IpsecInitFormState = {
|
||||
serverId: string
|
||||
serverEndpoint: string
|
||||
poolCidr: string
|
||||
dns: string
|
||||
createNatRule: boolean
|
||||
}
|
||||
|
||||
export const defaultIpsecInitForm = (): IpsecInitFormState => ({
|
||||
serverId: "",
|
||||
serverEndpoint: "",
|
||||
poolCidr: "10.77.0.0/24",
|
||||
dns: "",
|
||||
createNatRule: true,
|
||||
})
|
||||
|
||||
type ServerOption = { id: string; name: string; host: string }
|
||||
|
||||
function IpsecInitSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
servers,
|
||||
busy,
|
||||
defaultServerId,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
servers: ServerOption[]
|
||||
busy?: boolean
|
||||
defaultServerId?: string
|
||||
onSubmit: (form: IpsecInitFormState) => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<IpsecInitFormState>(defaultIpsecInitForm)
|
||||
const submittingRef = useRef(false)
|
||||
const openedRef = useRef(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const pending = busy || submitting
|
||||
const set = <K extends keyof IpsecInitFormState>(k: K, v: IpsecInitFormState[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) { openedRef.current = false; return }
|
||||
if (openedRef.current) return
|
||||
openedRef.current = true
|
||||
const server = servers.find((s) => s.id === (defaultServerId ?? ""))
|
||||
queueMicrotask(() => {
|
||||
setError(null)
|
||||
setForm({
|
||||
...defaultIpsecInitForm(),
|
||||
serverId: defaultServerId ?? "",
|
||||
serverEndpoint: server?.host ?? "",
|
||||
})
|
||||
})
|
||||
}, [open, defaultServerId, servers])
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return Boolean(
|
||||
form.serverId &&
|
||||
form.serverEndpoint.trim() &&
|
||||
(/^(?:\d{1,3}\.){3}\d{1,3}$/.test(form.serverEndpoint.trim())
|
||||
? form.serverEndpoint.trim().split(".").every(part => Number(part) <= 255)
|
||||
: /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(form.serverEndpoint.trim())) &&
|
||||
/^\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}$/.test(form.poolCidr.trim()),
|
||||
)
|
||||
}, [form])
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || busy || submittingRef.current) return
|
||||
submittingRef.current = true
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onSubmit(form)
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : "Не удалось инициализировать сервер")
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={value => { if (!submittingRef.current && !busy) onOpenChange(value) }}>
|
||||
<SheetContent showCloseButton={!pending} side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Инициализация IKEv2-сервера</SheetTitle>
|
||||
<SheetDescription>
|
||||
CA + серверный сертификат, peer (ike2/passive), пул адресов и mode-config
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<Alert variant="info">
|
||||
<InfoIcon />
|
||||
<AlertTitle>Выпуск CA и сертификатов занимает до минуты</AlertTitle>
|
||||
<AlertDescription>
|
||||
Повторный запуск безопасен: существующие managed-объекты обновятся, сертификаты
|
||||
не перевыпускаются.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<fieldset disabled={pending} className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<FormField label="Сервер" required>
|
||||
<select
|
||||
aria-label="Сервер"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||
value={form.serverId}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value
|
||||
const server = servers.find((s) => s.id === id)
|
||||
setForm((f) => ({ ...f, serverId: id, serverEndpoint: server?.host ?? f.serverEndpoint }))
|
||||
}}
|
||||
>
|
||||
<option value="">Выберите сервер…</option>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Адрес сервера (CN/SAN)" required hint="Домен или IP — по нему подключаются клиенты">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="vpn.example.com"
|
||||
aria-label="Адрес сервера (CN/SAN)"
|
||||
value={form.serverEndpoint}
|
||||
onChange={(e) => set("serverEndpoint", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Подсеть клиентов" required hint="Из неё пул .2–.254 и статические IP">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="10.77.0.0/24"
|
||||
aria-label="Подсеть клиентов"
|
||||
value={form.poolCidr}
|
||||
onChange={(e) => set("poolCidr", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="DNS для клиентов" hint="Например 10.77.0.1 или 1.1.1.1">
|
||||
<Input
|
||||
className="font-mono"
|
||||
aria-label="DNS для клиентов"
|
||||
value={form.dns}
|
||||
onChange={(e) => set("dns", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Интернет клиентам (NAT)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Managed srcnat masquerade для подсети клиентов (out-interface-list WAN)
|
||||
</p>
|
||||
</div>
|
||||
<Switch aria-label="Интернет клиентам (NAT)" disabled={pending} checked={form.createNatRule} onCheckedChange={(v) => set("createNatRule", v)} />
|
||||
</div>
|
||||
</fieldset>
|
||||
{pending && <p role="status" className="text-sm text-muted-foreground">Ожидание результата инициализации…</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={pending} />}>
|
||||
Отмена
|
||||
</SheetClose>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!canSubmit || pending}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{pending ? "Инициализация…" : "Инициализировать"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpsecInitSheet }
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { IpsecPeerDto } from "@mmapp/contracts/ipsec"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
export type IpsecPeerFormState = {
|
||||
name: string
|
||||
address: string
|
||||
exchangeMode: string
|
||||
passive: boolean
|
||||
profile: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export const defaultIpsecPeerForm = (): IpsecPeerFormState => ({
|
||||
name: "",
|
||||
address: "0.0.0.0/0",
|
||||
exchangeMode: "ike2",
|
||||
passive: true,
|
||||
profile: "",
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const EXCHANGE_MODES = ["ike2", "main", "aggressive", "base"]
|
||||
|
||||
/** Редактирование существующего peer (сервера) RouterOS. */
|
||||
function IpsecPeerSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
busy,
|
||||
editing,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
busy?: boolean
|
||||
editing: IpsecPeerDto | null
|
||||
onSubmit: (form: IpsecPeerFormState) => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<IpsecPeerFormState>(defaultIpsecPeerForm)
|
||||
const lock = useRef(false)
|
||||
const opened = useRef(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const pending = busy || submitting
|
||||
const set = <K extends keyof IpsecPeerFormState>(k: K, v: IpsecPeerFormState[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) { opened.current = false; return }
|
||||
if (!editing || opened.current) return
|
||||
opened.current = true
|
||||
queueMicrotask(() => setError(null))
|
||||
queueMicrotask(() => setForm({
|
||||
name: editing.name,
|
||||
address: editing.address ?? "",
|
||||
exchangeMode: editing.exchangeMode ?? "ike2",
|
||||
passive: editing.passive ?? false,
|
||||
profile: editing.profile ?? "",
|
||||
disabled: editing.disabled,
|
||||
}))
|
||||
}, [open, editing])
|
||||
|
||||
const canSubmit = useMemo(() => form.name.trim().length > 0, [form.name])
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || busy || lock.current) return
|
||||
lock.current = true
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try { await onSubmit(form) }
|
||||
catch (error) { setError(error instanceof Error ? error.message : "Не удалось сохранить peer") }
|
||||
finally { lock.current = false; setSubmitting(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={value => { if (!lock.current && !busy) onOpenChange(value) }}>
|
||||
<SheetContent showCloseButton={!pending} side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>{editing ? `Peer «${editing.name}»` : "Peer"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{editing?.managed
|
||||
? "Managed peer: изменения применяются напрямую на роутере"
|
||||
: "Существующий peer RouterOS: изменения применяются напрямую"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<fieldset disabled={pending} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<FormField label="Имя" required>
|
||||
<Input aria-label="Имя" value={form.name} onChange={(e) => set("name", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Адрес" hint="Обычно 0.0.0.0/0 для road-warrior">
|
||||
<Input className="font-mono" aria-label="Адрес" value={form.address} onChange={(e) => set("address", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Exchange mode">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||
aria-label="Exchange mode" value={form.exchangeMode}
|
||||
onChange={(e) => set("exchangeMode", e.target.value)}
|
||||
>
|
||||
{EXCHANGE_MODES.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<p className="text-xs text-muted-foreground">Сертификат аутентификации задаётся в identity. Эта форма изменяет только peer.</p>
|
||||
<FormField label="Profile" hint="Пусто — не менять">
|
||||
<Input className="font-mono" aria-label="Profile" value={form.profile} onChange={(e) => set("profile", e.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Состояние</SectionTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">Passive</p>
|
||||
<Switch aria-label="Passive" disabled={pending} checked={form.passive} onCheckedChange={(v) => set("passive", v)} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">Отключён</p>
|
||||
<Switch aria-label="Отключён" disabled={pending} checked={form.disabled} onCheckedChange={(v) => set("disabled", v)} />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{pending && <p role="status" className="text-sm text-muted-foreground">Ожидание результата сохранения…</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={pending} />}>
|
||||
Отмена
|
||||
</SheetClose>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!canSubmit || pending}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{pending ? "Сохранение…" : "Сохранить"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpsecPeerSheet }
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client"
|
||||
|
||||
import type { IpsecCertInfoDto, IpsecPeerDto, IpsecServerSummaryDto } from "@mmapp/contracts/ipsec"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
DownloadIcon,
|
||||
GlobeIcon,
|
||||
KeyRoundIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
ShieldCheckIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
function Row({ label, value, mono = true }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3 py-1">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className={cn("truncate text-xs", mono ? "font-mono" : "")} title={value}>
|
||||
{value || "—"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OriginBadge({ managed }: { managed: boolean }) {
|
||||
return managed ? null : (
|
||||
<Badge variant="outline" className="text-[10px]">RouterOS</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function PeerRow({
|
||||
peer,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
peer: IpsecPeerDto
|
||||
onEdit?: (peer: IpsecPeerDto) => void
|
||||
onDelete?: (peer: IpsecPeerDto) => void
|
||||
}) {
|
||||
const summary = [
|
||||
peer.exchangeMode,
|
||||
peer.address,
|
||||
peer.passive ? "passive" : undefined,
|
||||
peer.certificate,
|
||||
peer.profile,
|
||||
].filter(Boolean).join(" · ")
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2.5 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-xs font-medium">{peer.name}</span>
|
||||
<OriginBadge managed={peer.managed} />
|
||||
{peer.disabled ? <Badge variant="outline" className="text-[10px]">выкл</Badge> : null}
|
||||
</div>
|
||||
<p className="truncate font-mono text-[10px] text-muted-foreground">{summary || "—"}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-0.5">
|
||||
{onEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label="Изменить peer"
|
||||
title="Изменить peer"
|
||||
onClick={() => onEdit(peer)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 text-destructive"
|
||||
aria-label="Удалить peer"
|
||||
title="Удалить peer"
|
||||
onClick={() => onDelete(peer)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CertRow({
|
||||
cert,
|
||||
onDelete,
|
||||
onExport,
|
||||
}: {
|
||||
cert: IpsecCertInfoDto
|
||||
onDelete?: (cert: IpsecCertInfoDto) => void
|
||||
onExport?: (cert: IpsecCertInfoDto) => void
|
||||
}) {
|
||||
const roleLabel = cert.role === "ca" ? "CA" : cert.role === "server" ? "сервер" : cert.role === "client" ? "клиент" : "прочий"
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2.5 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs font-medium">{cert.name}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{roleLabel}</Badge>
|
||||
<OriginBadge managed={cert.managed} />
|
||||
{cert.trusted === false ? <Badge variant="outline" className="text-[10px]">не trusted</Badge> : null}
|
||||
</div>
|
||||
<p className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
{[
|
||||
cert.commonName,
|
||||
cert.signedBy ? `подписан ${cert.signedBy}` : undefined,
|
||||
cert.expiresAt ? `до ${cert.expiresAt}` : undefined,
|
||||
].filter(Boolean).join(" · ") || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-0.5">
|
||||
{onExport ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label="Скачать .p12"
|
||||
title="Скачать .p12 (сертификат + ключ)"
|
||||
onClick={() => onExport(cert)}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 shrink-0 text-destructive"
|
||||
aria-label="Удалить сертификат"
|
||||
title="Удалить сертификат"
|
||||
onClick={() => onDelete(cert)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface IpsecServerGridProps {
|
||||
canManage?: (serverId: string) => boolean
|
||||
servers: IpsecServerSummaryDto[]
|
||||
onInit?: (row: IpsecServerSummaryDto) => void
|
||||
onRemove?: (row: IpsecServerSummaryDto) => void
|
||||
onEditPeer?: (serverId: string, peer: IpsecPeerDto) => void
|
||||
onDeletePeer?: (serverId: string, peer: IpsecPeerDto) => void
|
||||
onDeleteCert?: (serverId: string, cert: IpsecCertInfoDto) => void
|
||||
onExportCert?: (serverId: string, cert: IpsecCertInfoDto) => void
|
||||
}
|
||||
|
||||
function IpsecServerGrid({
|
||||
servers,
|
||||
canManage,
|
||||
onInit,
|
||||
onRemove,
|
||||
onEditPeer,
|
||||
onDeletePeer,
|
||||
onDeleteCert,
|
||||
onExportCert,
|
||||
}: IpsecServerGridProps) {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{servers.map((s) => {
|
||||
const certs = s.certs ?? []
|
||||
return (
|
||||
<div key={s.serverId} className="rounded-lg border border-border bg-card p-4 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{s.serverName}</p>
|
||||
<p className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
{s.serverEndpoint ?? "endpoint не определён"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
{s.ike2Ready ? (
|
||||
<Badge className="gap-1">
|
||||
<ShieldCheckIcon className="size-3" />
|
||||
IKEv2 готов
|
||||
</Badge>
|
||||
) : null}
|
||||
{s.peers.length > 0 ? (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
{s.peers.length} peer
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">нет peer</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Peer / серверы
|
||||
</p>
|
||||
{s.peers.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{s.peers.map((p) => (
|
||||
<PeerRow
|
||||
key={p.rosId}
|
||||
peer={p}
|
||||
onEdit={onEditPeer && (!canManage || canManage(s.serverId)) ? (peer) => onEditPeer(s.serverId, peer) : undefined}
|
||||
onDelete={onDeletePeer && (!canManage || canManage(s.serverId)) ? (peer) => onDeletePeer(s.serverId, peer) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Peers не найдены</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Сертификаты
|
||||
</p>
|
||||
{certs.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{certs.map((c) => (
|
||||
<CertRow
|
||||
key={c.name}
|
||||
cert={c}
|
||||
onDelete={onDeleteCert && (!canManage || canManage(s.serverId)) ? (cert) => onDeleteCert(s.serverId, cert) : undefined}
|
||||
onExport={onExportCert && (!canManage || canManage(s.serverId)) ? (cert) => onExportCert(s.serverId, cert) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Сертификаты не найдены</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col divide-y divide-border/60">
|
||||
<Row label="Клиенты" value={s.clientsSessionAmbiguous ? `${s.clientsOnline} подтверждено онлайн / ${s.clientsTotal}; статус ${s.clientsSessionAmbiguous} не определён` : `${s.clientsOnline} онлайн / ${s.clientsTotal}`} />
|
||||
<Row label="Пул адресов" value={s.pool ? `${s.pool.name}: ${s.pool.ranges}` : "—"} />
|
||||
<Row label="NAT (интернет)" value={s.natRuleManaged ? "managed masquerade" : "нет правила"} mono={false} />
|
||||
<Row label="Managed IKEv2" value={s.initialized ? "инициализирован" : "не инициализирован"} mono={false} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!onInit || (canManage && !canManage(s.serverId))}
|
||||
onClick={() => onInit?.(s)}
|
||||
title={s.initialized ? "Обновить managed-конфиг сервера" : "Инициализировать IKEv2-сервер"}
|
||||
>
|
||||
<GlobeIcon className="size-3.5" />
|
||||
{s.initialized ? "Переинициализировать" : "Инициализировать"}
|
||||
</Button>
|
||||
{s.initialized && onRemove ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8 text-destructive"
|
||||
aria-label="Удалить IKEv2-сервер"
|
||||
title="Удалить managed-объекты IKEv2 (identity/mode-config/peer/pool/NAT + сертификаты)"
|
||||
disabled={canManage && !canManage(s.serverId)}
|
||||
onClick={() => onRemove(s)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{servers.length === 0 ? (
|
||||
<div className="col-span-full flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||
<NetworkIcon className="size-4" />
|
||||
Серверы не найдены
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpsecServerGrid }
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { IPSEC_MIN_PASSPHRASE, type IpsecClientDto } from "@mmapp/contracts/ipsec"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
export type IpsecAuthKind = "certificate" | "pre-shared-key"
|
||||
|
||||
export type IpsecUserFormState = {
|
||||
serverId: string
|
||||
name: string
|
||||
peerName: string
|
||||
authMethod: IpsecAuthKind
|
||||
psk: string
|
||||
remoteId: string
|
||||
useStaticIp: boolean
|
||||
staticIp: string
|
||||
passphrase: string
|
||||
}
|
||||
|
||||
export const defaultIpsecUserForm = (): IpsecUserFormState => ({
|
||||
serverId: "",
|
||||
name: "",
|
||||
peerName: "",
|
||||
authMethod: "certificate",
|
||||
psk: "",
|
||||
remoteId: "",
|
||||
useStaticIp: false,
|
||||
staticIp: "",
|
||||
passphrase: "",
|
||||
})
|
||||
|
||||
type ServerOption = { id: string; name: string; host: string }
|
||||
export type PeerOption = { serverId: string; name: string; managed: boolean }
|
||||
|
||||
function IpsecUserSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
servers,
|
||||
peers,
|
||||
busy,
|
||||
defaultServerId,
|
||||
editing,
|
||||
freeIpHint,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
servers: ServerOption[]
|
||||
/** Доступные peers (серверы) для привязки identity при создании. */
|
||||
peers?: PeerOption[]
|
||||
busy?: boolean
|
||||
defaultServerId?: string
|
||||
/** Режим редактирования: сервер и метод аутентификации не меняются. */
|
||||
editing?: IpsecClientDto | null
|
||||
freeIpHint?: string
|
||||
onSubmit: (form: IpsecUserFormState) => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<IpsecUserFormState>(defaultIpsecUserForm)
|
||||
const lock = useRef(false)
|
||||
const opened = useRef(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const pending = busy || submitting
|
||||
const set = <K extends keyof IpsecUserFormState>(k: K, v: IpsecUserFormState[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) { opened.current = false; return }
|
||||
if (opened.current) return
|
||||
opened.current = true
|
||||
queueMicrotask(() => setError(null))
|
||||
if (editing) {
|
||||
const edit = editing
|
||||
queueMicrotask(() => setForm({
|
||||
serverId: edit.serverId,
|
||||
name: edit.name,
|
||||
peerName: edit.peerName ?? "",
|
||||
authMethod: edit.authMethod,
|
||||
psk: "",
|
||||
remoteId: edit.remoteId ?? "",
|
||||
useStaticIp: Boolean(edit.staticIp),
|
||||
staticIp: edit.staticIp ?? "",
|
||||
passphrase: "",
|
||||
}))
|
||||
return
|
||||
}
|
||||
queueMicrotask(() => setForm({ ...defaultIpsecUserForm(), serverId: defaultServerId ?? "" }))
|
||||
}, [open, defaultServerId, editing])
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!editing && !form.serverId) return false
|
||||
if (!form.name.trim()) return false
|
||||
if (form.authMethod === "pre-shared-key" && (!editing || form.psk.trim()) && form.psk.trim().length < 8) return false
|
||||
if (form.useStaticIp && form.staticIp.trim() && !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(form.staticIp.trim())) return false
|
||||
// RouterOS отклоняет export-passphrase короче 8 символов
|
||||
const passphrase = form.passphrase.trim()
|
||||
if (!editing && passphrase.length > 0 && passphrase.length < IPSEC_MIN_PASSPHRASE) return false
|
||||
return true
|
||||
}, [form, editing])
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || busy || lock.current) return
|
||||
lock.current = true
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try { await onSubmit(form) }
|
||||
catch (error) { setError(error instanceof Error ? error.message : "Не удалось сохранить клиента") }
|
||||
finally { lock.current = false; setSubmitting(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={value => { if (!lock.current && !busy) onOpenChange(value) }}>
|
||||
<SheetContent showCloseButton={!pending} side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>{editing ? `Клиент «${editing.name}»` : "Новый клиент IKEv2"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{editing
|
||||
? "Имя, статический IP и secret (для PSK)"
|
||||
: "Identity на роутере; для сертификата — выпуск .p12 для авторизации"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<fieldset disabled={pending} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
{!editing ? (
|
||||
<FormField label="Сервер" required>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||
aria-label="Сервер"
|
||||
value={form.serverId}
|
||||
onChange={(e) => setForm(f => ({ ...f, serverId: e.target.value, peerName: "" }))}
|
||||
>
|
||||
<option value="">Выберите сервер…</option>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
) : null}
|
||||
{!editing && (peers ?? []).some((p) => p.serverId === form.serverId) ? (
|
||||
<FormField label="Peer (сервер)" hint="К какому peer привязать identity">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||
aria-label="Peer (сервер)"
|
||||
value={form.peerName}
|
||||
onChange={(e) => set("peerName", e.target.value)}
|
||||
>
|
||||
<option value="">Автоматически (managed/первый)</option>
|
||||
{(peers ?? [])
|
||||
.filter((p) => p.serverId === form.serverId)
|
||||
.map((p) => (
|
||||
<option key={`${p.serverId}:${p.name}`} value={p.name}>
|
||||
{p.name}{p.managed ? "" : " (RouterOS)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField label="Имя клиента" required hint={editing ? undefined : "CN сертификата и отображаемое имя"}>
|
||||
<Input
|
||||
placeholder="alice"
|
||||
aria-label="Имя клиента"
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
{!editing ? (
|
||||
<FormField
|
||||
label="Аутентификация"
|
||||
required
|
||||
hint={form.authMethod === "certificate"
|
||||
? "По умолчанию клиент подключается через общий IKEv2-peer — выпускается только отдельный клиентский сертификат (CA и серверный серт берутся из уже настроенного IKEv2, напр. MyCA + vpn-server)"
|
||||
: undefined}
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={form.authMethod === "certificate" ? "secondary" : "ghost"}
|
||||
onClick={() => set("authMethod", "certificate")}
|
||||
>
|
||||
Сертификат
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={form.authMethod === "pre-shared-key" ? "secondary" : "ghost"}
|
||||
onClick={() => set("authMethod", "pre-shared-key")}
|
||||
>
|
||||
PSK
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
) : null}
|
||||
{form.authMethod === "pre-shared-key" ? (
|
||||
<>
|
||||
<FormField label={editing ? "Новый secret (PSK)" : "Secret (PSK)"} hint={editing ? "Пусто — не менять" : "Минимум 8 символов"} required={!editing}>
|
||||
<Input
|
||||
className="font-mono"
|
||||
type="password"
|
||||
aria-label="Secret (PSK)"
|
||||
value={form.psk}
|
||||
onChange={(e) => set("psk", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
{!editing ? (
|
||||
<FormField label="Remote ID" hint="По умолчанию — имя клиента">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="alice"
|
||||
aria-label="Remote ID"
|
||||
value={form.remoteId}
|
||||
onChange={(e) => set("remoteId", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
!editing ? (
|
||||
<FormField
|
||||
label="Пароль архива .p12"
|
||||
hint={
|
||||
form.passphrase.trim().length > 0 && form.passphrase.trim().length < IPSEC_MIN_PASSPHRASE
|
||||
? `Минимум ${IPSEC_MIN_PASSPHRASE} символов — требование RouterOS`
|
||||
: `Пусто — сгенерируем автоматически (минимум ${IPSEC_MIN_PASSPHRASE} символов)`
|
||||
}
|
||||
>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="например MySecret123"
|
||||
aria-label="Пароль архива .p12"
|
||||
value={form.passphrase}
|
||||
onChange={(e) => set("passphrase", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>IP-адрес</SectionTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Статический IP</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{freeIpHint ? `Свободный из пула: ${freeIpHint}` : "Иначе — выдача из пула"}
|
||||
{" "}Статический IP создаёт персональную identity на peer.
|
||||
</p>
|
||||
</div>
|
||||
<Switch aria-label="Статический IP" disabled={pending} checked={form.useStaticIp} onCheckedChange={(v) => set("useStaticIp", v)} />
|
||||
</div>
|
||||
{form.useStaticIp && (
|
||||
<FormField label="IP клиента" hint="Например 10.77.0.10">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder={freeIpHint ?? "10.77.0.10"}
|
||||
aria-label="IP клиента"
|
||||
value={form.staticIp}
|
||||
onChange={(e) => set("staticIp", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
{pending && <p role="status" className="text-sm text-muted-foreground">Ожидание результата сохранения…</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={pending} />}>
|
||||
Отмена
|
||||
</SheetClose>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!canSubmit || pending}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{pending ? (editing ? "Сохранение…" : "Создание…") : editing ? "Сохранить" : "Создать клиента"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpsecUserSheet }
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { IpsecClientDto } from "@mmapp/contracts/ipsec"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
BadgeCheckIcon,
|
||||
KeyRoundIcon,
|
||||
Trash2Icon,
|
||||
UsersIcon,
|
||||
WifiIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface IpsecUsersGridProps {
|
||||
canManage?: (serverId: string) => boolean
|
||||
clients: IpsecClientDto[]
|
||||
compactServer?: boolean
|
||||
emptyAction?: ReactNode
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
onDownloadCert?: (row: IpsecClientDto) => void
|
||||
onEdit?: (row: IpsecClientDto) => void
|
||||
onDelete?: (row: IpsecClientDto) => void
|
||||
}
|
||||
|
||||
function IpsecUsersGrid({
|
||||
clients,
|
||||
canManage,
|
||||
compactServer = false,
|
||||
emptyAction,
|
||||
emptyTitle = "Нет клиентов IKEv2",
|
||||
emptyDescription = "Создайте клиента — сертификат и .p12 выпустятся автоматически",
|
||||
onDownloadCert,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: IpsecUsersGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, IpsecClientDto>[]>(() => {
|
||||
const cols: ColumnDef<DataGridFeatures, IpsecClientDto>[] = [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Клиент" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<UsersIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={!onEdit || (canManage && !canManage(c.serverId))}
|
||||
className="truncate text-left font-medium hover:underline"
|
||||
title={c.name}
|
||||
onClick={() => onEdit?.(c)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
{c.disabled ? (
|
||||
<Badge variant="outline" className="ml-1 text-[10px]">выкл</Badge>
|
||||
) : null}
|
||||
{!c.managed ? (
|
||||
<Badge variant="outline" className="ml-1 text-[10px]">RouterOS</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Клиент",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "auth",
|
||||
accessorKey: "authMethod",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Аутентификация" />,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
const cert = c.kind === "cert"
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{cert ? (
|
||||
<BadgeCheckIcon className="size-3.5 shrink-0 text-info" />
|
||||
) : (
|
||||
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{cert ? "Сертификат" : "PSK"}
|
||||
</div>
|
||||
{cert && c.signedBy ? (
|
||||
<span
|
||||
className="truncate font-mono text-[10px] text-muted-foreground"
|
||||
title={`Подписан: ${c.signedBy}`}
|
||||
>
|
||||
подписан: {c.signedBy}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Аутентификация",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (!compactServer) {
|
||||
cols.push({
|
||||
id: "server",
|
||||
accessorKey: "serverName",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-[11px] text-muted-foreground">
|
||||
{row.original.serverName}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
id: "ip",
|
||||
accessorFn: (row) => row.staticIp ?? "",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="IP" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs",
|
||||
row.original.staticIp ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{row.original.staticIp ?? "из пула"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "IP",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "online",
|
||||
accessorKey: "online",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex size-1.5 rounded-full",
|
||||
row.original.online ? "bg-success" : "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
<span className={cn("font-mono text-[11px]", row.original.online ? "text-success" : "text-muted-foreground")}>
|
||||
{row.original.sessionAmbiguous ? "Не определено" : row.original.online ? row.original.activeAddress ?? "онлайн" : "офлайн"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
size: 96,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-0.5">
|
||||
{c.kind === "cert" && onDownloadCert ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label="Скачать сертификат"
|
||||
title="Скачать .p12"
|
||||
disabled={canManage && !canManage(c.serverId)}
|
||||
onClick={() => onDownloadCert(c)}
|
||||
>
|
||||
<WifiIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-destructive"
|
||||
aria-label="Удалить клиента"
|
||||
disabled={canManage && !canManage(c.serverId)}
|
||||
onClick={() => onDelete(c)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return cols
|
||||
}, [compactServer, onDownloadCert, onEdit, onDelete, canManage])
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
data: clients,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (clients.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<UsersIcon className="size-4" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={emptyAction}
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={clients.length}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpsecUsersGrid }
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
// Adapted from ReUI PRO sheet-14: event header, facts, tabs and stepping context.
|
||||
// https://reui.io/preview/base/sheet-14
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { z } from "zod"
|
||||
import type { LogEntry } from "@mmapp/contracts"
|
||||
import { toast } from "sonner"
|
||||
import { CopyIcon, ExternalLinkIcon } from "lucide-react"
|
||||
import { Sheet, SheetContent, SheetTitle, SheetDescription, SheetHeader } from "@/components/ui/sheet"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||
import { getLogContext } from "@/shared/api/logs"
|
||||
import { LevelBadge, formatLogTime } from "./log-presentation"
|
||||
|
||||
export function LogDetailSheet({ entry, base, onClose, onSelect }: { entry: LogEntry | null; base: string; onClose: () => void; onSelect: (entry: LogEntry) => void }) {
|
||||
return <Sheet open={!!entry} onOpenChange={open => { if (!open) onClose() }}><SheetContent className="data-[side=right]:w-full data-[side=right]:sm:max-w-2xl overflow-y-auto">
|
||||
{entry && <LogDetail key={entry.id} entry={entry} base={base} onSelect={onSelect} />}
|
||||
</SheetContent></Sheet>
|
||||
}
|
||||
function LogDetail({ entry, base, onSelect }: { entry: LogEntry; base: string; onSelect: (entry: LogEntry) => void }) {
|
||||
const [context, setContext] = useState<Awaited<ReturnType<typeof getLogContext>> | null>(null), [error, setError] = useState<string | null>(null), [retry, setRetry] = useState(0)
|
||||
useEffect(() => {
|
||||
const ac = new AbortController()
|
||||
void getLogContext(base, entry.id, ac.signal).then(data => { if (!ac.signal.aborted) { setContext(data); setError(null) } }).catch(e => { if (!ac.signal.aborted) setError(e instanceof z.ZodError ? "Некорректный ответ контекста" : e.message) })
|
||||
return () => ac.abort()
|
||||
}, [base, entry.id, retry])
|
||||
const copy = () => { void navigator.clipboard.writeText(JSON.stringify(entry, null, 2)).then(() => toast.success("Событие скопировано"), () => toast.error("Не удалось скопировать событие")) }
|
||||
return <>
|
||||
<SheetHeader className="gap-3 border-b">
|
||||
<div className="flex flex-wrap items-center gap-2"><LevelBadge level={entry.level} /><span className="text-xs text-muted-foreground font-mono">{entry.routerId}</span></div>
|
||||
<SheetTitle>Событие RouterOS</SheetTitle><SheetDescription>{entry.serverName} · {entry.host} · {formatLogTime(entry.effectiveAt)}</SheetDescription>
|
||||
<div className="flex flex-wrap gap-2"><Button size="sm" variant="outline" onClick={copy}><CopyIcon />Копировать</Button><Button size="sm" variant="outline" nativeButton={false} render={<Link href={`/servers?server=${entry.serverId}`} />}><ExternalLinkIcon />Устройство</Button></div>
|
||||
</SheetHeader>
|
||||
<Tabs defaultValue="details" className="min-w-0 px-4 pb-4">
|
||||
<TabsList variant="line"><TabsTrigger value="details">Событие</TabsTrigger><TabsTrigger value="raw">Исходные поля</TabsTrigger><TabsTrigger value="context">Контекст</TabsTrigger></TabsList>
|
||||
<TabsContent value="details" className="flex flex-col gap-4">
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">{entry.message}</p>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-[auto_1fr] gap-x-6 gap-y-3 text-sm">
|
||||
{[["Topics", entry.topics.join(", ")], ["Время RouterOS", entry.rawTime], ["Получено", formatLogTime(entry.receivedAt)], ["Источник времени", entry.timeKind === "receipt" ? "Время получения: дата или часовой пояс RouterOS неоднозначны" : "Время RouterOS"], ["Поколение буфера", String(entry.generation)]].map(([label, value]) => <FragmentPair key={label} label={label} value={value} />)}
|
||||
</dl>
|
||||
</TabsContent>
|
||||
<TabsContent value="raw"><pre className="overflow-auto whitespace-pre-wrap break-all text-xs font-mono">{JSON.stringify({ ".id": entry.routerId, time: entry.rawTime, topics: entry.topics.join(","), message: entry.message }, null, 2)}</pre></TabsContent>
|
||||
<TabsContent value="context" className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">До 10 событий до и после записи на этом MikroTik. Фильтры журнала здесь не применяются.</p>
|
||||
{error ? <Alert variant="destructive"><AlertTitle>Контекст не получен</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)}>Повторить</Button></AlertDescription></Alert> : !context ? <Skeleton className="h-32 w-full" /> : [context.before, [context.entry], context.after].flat().map(row => <Button key={row.id} variant="ghost" className="h-auto justify-start whitespace-normal text-left" onClick={() => onSelect(row)} aria-current={row.id === entry.id ? "true" : undefined}><span className="flex min-w-0 flex-col gap-1"><span className="text-xs text-muted-foreground tabular-nums">{formatLogTime(row.effectiveAt)} · {row.topics.join(", ")}</span><span className="break-all font-mono text-xs">{row.message}</span></span></Button>)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
}
|
||||
function FragmentPair({ label, value }: { label: string; value: string }) { return <><dt className="text-muted-foreground">{label}</dt><dd className="min-w-0 break-words">{value}</dd></> }
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client"
|
||||
import { Fragment } from "react"
|
||||
import { AlertTriangleIcon, CircleAlertIcon, CircleHelpIcon, InfoIcon, BugIcon } from "lucide-react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import type { LogLevel } from "@mmapp/contracts"
|
||||
|
||||
export const LEVEL_LABELS: Record<LogLevel, string> = { critical: "Критическая", error: "Ошибка", warning: "Предупреждение", info: "Информация", debug: "Отладка", unknown: "Не определён" }
|
||||
export const LEVEL_COLORS: Record<LogLevel, string> = { critical: "var(--destructive)", error: "var(--destructive)", warning: "var(--warning)", info: "var(--info)", debug: "var(--muted-foreground)", unknown: "var(--border)" }
|
||||
export function LevelBadge({ level }: { level: LogLevel }) {
|
||||
const Icon = level === "critical" || level === "error" ? CircleAlertIcon : level === "warning" ? AlertTriangleIcon : level === "debug" ? BugIcon : level === "unknown" ? CircleHelpIcon : InfoIcon
|
||||
return <Badge variant={level === "critical" || level === "error" ? "destructive-light" : level === "warning" ? "warning-light" : level === "info" ? "info-light" : "secondary"} size="sm"><Icon aria-hidden="true" className="size-3" />{LEVEL_LABELS[level]}</Badge>
|
||||
}
|
||||
export function HighlightMessage({ text, search }: { text: string; search: string }) {
|
||||
if (!search) return <>{text}</>
|
||||
const lower = text.toLocaleLowerCase(), needle = search.toLocaleLowerCase(), parts = []
|
||||
let start = 0, found = lower.indexOf(needle)
|
||||
while (found >= 0) {
|
||||
parts.push(<Fragment key={start}>{text.slice(start, found)}<mark className="bg-primary/15 text-foreground">{text.slice(found, found + search.length)}</mark></Fragment>)
|
||||
start = found + search.length; found = lower.indexOf(needle, start)
|
||||
}
|
||||
return <>{parts}{text.slice(start)}</>
|
||||
}
|
||||
export function formatLogTime(at: string | null) { return at ? new Date(at).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "—" }
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { type LogSettings, type LogSummary } from "@mmapp/contracts"
|
||||
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { getLogSettings, getLogSummary, putLogSettings } from "@/shared/api/logs"
|
||||
import { can, ensureAuthConfig } from "@/lib/auth"
|
||||
import { formatLogTime } from "./log-presentation"
|
||||
|
||||
export function LogsCollectorSettings({ base, onSaved }: { base: string; onSaved?: (settings: LogSettings) => void }) {
|
||||
const [settings, setSettings] = useState<LogSettings | null>(null), [summary, setSummary] = useState<LogSummary | null>(null)
|
||||
const [interval, setIntervalDraft] = useState("10"), [retention, setRetention] = useState("14"), [enabled, setEnabled] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null), [saving, setSaving] = useState(false), [revision, setRevision] = useState(0), [notice, setNotice] = useState("")
|
||||
const lifetime = useRef<AbortController | null>(null), busy = useRef(false)
|
||||
useEffect(() => {
|
||||
const ac = new AbortController(); lifetime.current = ac
|
||||
void ensureAuthConfig().then(async () => {
|
||||
if (!can("mm:settings:admin")) throw new Error("Настройки логов доступны с mm:settings:admin")
|
||||
const data = await getLogSettings(base, ac.signal)
|
||||
if (ac.signal.aborted) return
|
||||
setSettings(data); setEnabled(data.enabled); setIntervalDraft(String(data.intervalSec)); setRetention(String(data.retentionDays)); setError(null)
|
||||
const stats = await getLogSummary(base, new URLSearchParams({ from: new Date(Date.now() - 900000).toISOString() }), ac.signal)
|
||||
if (!ac.signal.aborted) setSummary(stats)
|
||||
}).catch(e => { if (!ac.signal.aborted) setError(e instanceof Error ? e.message : String(e)) })
|
||||
return () => ac.abort()
|
||||
}, [base, revision])
|
||||
const save = async () => {
|
||||
if (busy.current || !settings || !can("mm:settings:admin")) return
|
||||
if (!/^\d+$/.test(interval) || !/^\d+$/.test(retention)) { setError("Укажите целые значения интервала и срока хранения"); return }
|
||||
if (Number(interval) < 5 || Number(interval) > 3600 || Number(retention) < 1 || Number(retention) > 365) { setError("Интервал должен быть от 5 до 3600 секунд, срок хранения — от 1 до 365 дней"); return }
|
||||
busy.current = true; setSaving(true); setNotice("")
|
||||
const signal = lifetime.current?.signal
|
||||
try {
|
||||
const confirmed = await putLogSettings(base, { enabled, intervalSec: Number(interval), retentionDays: Number(retention) }, signal)
|
||||
if (signal?.aborted) return
|
||||
setSettings(confirmed); setError(null); setNotice("Настройки сборщика логов сохранены"); onSaved?.(confirmed)
|
||||
} catch (e) { if (!signal?.aborted) setError(e instanceof Error ? e.message : String(e)) }
|
||||
finally { busy.current = false; if (!signal?.aborted) setSaving(false) }
|
||||
}
|
||||
return <Frame id="router-logs" dense><FrameHeader><FrameTitle>Логи MikroTik</FrameTitle><FrameDescription>REST-сбор буфера memory, сохранение в PostgreSQL. При открытом live опрос выбранного устройства ускоряется до 2 секунд.</FrameDescription></FrameHeader><FramePanel className="flex flex-col gap-4">
|
||||
{error && <Alert variant="destructive"><AlertTitle>Данные сборщика не подтверждены</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" disabled={saving} onClick={() => setRevision(n => n + 1)}>Повторить загрузку</Button></AlertDescription></Alert>}
|
||||
{!settings && !error && <Skeleton className="h-24 w-full" />}
|
||||
<div className="flex flex-wrap items-end gap-4"><label className="flex items-center gap-2 text-sm"><Switch checked={enabled} onCheckedChange={setEnabled} disabled={!settings || saving} aria-label="Включить сбор логов" />Собирать логи</label><label className="flex flex-col gap-1 text-xs">Фоновый интервал, секунд<Input aria-label="Интервал сбора логов" className="w-40" inputMode="numeric" value={interval} onChange={e => setIntervalDraft(e.target.value)} disabled={!settings || saving} /></label><label className="flex flex-col gap-1 text-xs">Хранить, дней<Input aria-label="Срок хранения логов" className="w-40" inputMode="numeric" value={retention} onChange={e => setRetention(e.target.value)} disabled={!settings || saving} /></label><Button size="sm" disabled={!settings || saving} onClick={() => void save()}>{saving ? "Сохранение…" : "Сохранить настройки логов"}</Button></div>
|
||||
{notice && <p role="status" className="text-sm">{notice}</p>}
|
||||
<p className="text-xs text-muted-foreground">Интервал: 5–3600 секунд, хранение: 1–365 дней. Буфер MikroTik может перезаписаться между опросами. Новые правила логирования на устройствах не создаются.</p>
|
||||
{summary?.states.length ? <div className="flex flex-col gap-2">{summary.states.map(state => <div key={state.serverId} className="flex flex-wrap items-center gap-2 border-t py-2 text-xs"><Link href={`/logs?server=${state.serverId}`} className="font-medium underline">{state.serverName}</Link><Badge size="sm" variant={state.lastError ? "warning-light" : state.lastCollectedAt ? "success-light" : "secondary"}>{state.lastError ? "Ошибка" : state.lastCollectedAt ? "Получено" : "Ожидание"}</Badge><span className="text-muted-foreground">{formatLogTime(state.lastCollectedAt)}</span>{state.gapAt && <span>Возможный разрыв: {formatLogTime(state.gapAt)}</span>}{state.lastError && <span role="status" className="break-all">{state.lastError}</span>}</div>)}</div> : <p className="text-xs text-muted-foreground">Результатов сбора по устройствам пока нет.</p>}
|
||||
</FramePanel></Frame>
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
// ReUI PRO solution-dev-ops-2: Frame-led grid, toolbar and display settings.
|
||||
// https://reui.io/preview/base/solution-dev-ops-2
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import { useTable, type ColumnDef } from "@tanstack/react-table"
|
||||
import { elementScroll, observeElementRect, observeElementOffset } from "@tanstack/react-virtual"
|
||||
import type { LogEntry } from "@mmapp/contracts"
|
||||
import { CopyIcon, Columns3Icon, WrapTextIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { DataGrid, DataGridContainer, DataGridTableVirtual, DataGridColumnVisibility, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DATA_GRID_LABELS_RU } from "@/lib/data-grid-labels-ru"
|
||||
import { HighlightMessage, LevelBadge, formatLogTime } from "./log-presentation"
|
||||
|
||||
export function LogsGrid({ entries, loading, total, search, toolbar, onSelect, onPause, hasMore, fetchingMore, loadMore, reset }: {
|
||||
entries: LogEntry[]; loading: boolean; total: number | null; search: string; toolbar: ReactNode;
|
||||
onSelect: (entry: LogEntry) => void; onPause: () => void; hasMore: boolean; fetchingMore: boolean; loadMore: () => void; reset: () => void
|
||||
}) {
|
||||
const [wrap, setWrap] = useState(false)
|
||||
const [narrow, setNarrow] = useState(false)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 767px)")
|
||||
const update = () => setNarrow(mq.matches)
|
||||
queueMicrotask(update); mq.addEventListener("change", update)
|
||||
return () => mq.removeEventListener("change", update)
|
||||
}, [])
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, LogEntry>[]>(() => [
|
||||
{ accessorKey: "effectiveAt", header: "Время", size: 165, cell: ({ row }) => <span className="font-mono text-xs tabular-nums" title={row.original.timeKind === "receipt" ? `Время получения; RouterOS: ${row.original.rawTime}` : row.original.rawTime}>{formatLogTime(row.original.effectiveAt)}{row.original.timeKind === "receipt" && <span aria-label="Время получения"> *</span>}</span> },
|
||||
{ accessorKey: "serverName", header: "Устройство", size: 150, cell: ({ row }) => <span className="truncate" title={row.original.host}>{row.original.serverName}</span> },
|
||||
{ accessorKey: "level", header: "Уровень", size: 165, cell: ({ row }) => <LevelBadge level={row.original.level} /> },
|
||||
{ accessorKey: "topics", header: "Topics", size: 180, cell: ({ row }) => <span className="truncate text-xs text-muted-foreground font-mono" title={row.original.topics.join(", ")}>{row.original.topics.join(", ")}</span> },
|
||||
{ accessorKey: "message", header: narrow ? "Событие" : "Сообщение", size: narrow ? 300 : 600, enableHiding: false, cell: ({ row }) => <Button variant="ghost" className="h-auto min-w-0 w-full justify-start whitespace-normal text-left" onClick={event => { event.stopPropagation(); onSelect(row.original) }} aria-label={`Открыть событие ${row.original.id}`}><span className="flex min-w-0 flex-col gap-1">{narrow && <span className="flex flex-wrap items-center gap-1"><LevelBadge level={row.original.level} /><span className="text-xs text-muted-foreground tabular-nums">{formatLogTime(row.original.effectiveAt)}</span></span>}<span className={`min-w-0 font-mono text-xs ${wrap || narrow ? "whitespace-pre-wrap break-words" : "truncate"}`}><HighlightMessage text={row.original.message} search={search} /></span></span></Button> },
|
||||
{ id: "copy", header: "", size: 46, enableHiding: false, cell: ({ row }) => <Button size="icon-sm" variant="ghost" aria-label="Копировать сообщение" onClick={event => { event.stopPropagation(); void navigator.clipboard.writeText(row.original.message).then(() => toast.success("Сообщение скопировано"), () => toast.error("Не удалось скопировать сообщение")) }}><CopyIcon /></Button> },
|
||||
], [wrap, narrow, search, onSelect])
|
||||
const table = useTable({ features: dataGridFeatures, data: entries, columns, getRowId: row => row.id, manualPagination: true, rowCount: entries.length })
|
||||
useEffect(() => { table.setColumnVisibility({ effectiveAt: !narrow, serverName: !narrow, level: !narrow, topics: !narrow, copy: !narrow }) }, [narrow, table])
|
||||
return <DataGrid table={table} recordCount={entries.length} isLoading={loading} loadingMode="skeleton" i18n={{ labels: DATA_GRID_LABELS_RU }} onRowClick={onSelect}
|
||||
tableLayout={{ dense: true, headerSticky: true, columnsVisibility: true, width: "fixed" }}
|
||||
emptyMessage={<div className="flex flex-col items-center gap-2 p-6"><p>Событий за выбранный период нет</p><Button size="sm" variant="outline" onClick={reset}>Сбросить фильтры</Button></div>}
|
||||
tableClassNames={{ edgeCell: "first:ps-(--frame-panel-header-px) last:pe-(--frame-panel-header-px)" }}>
|
||||
<Frame dense className="min-w-0 w-full">
|
||||
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3"><div className="flex min-w-0 flex-col gap-0.5"><FrameTitle>Журнал событий</FrameTitle><FrameDescription className="text-xs tabular-nums">Загружено {entries.length.toLocaleString("ru-RU")}{total !== null ? ` · Найдено ${total.toLocaleString("ru-RU")}` : ""}</FrameDescription></div><div className="flex items-center gap-2"><Button size="sm" variant="outline" aria-pressed={wrap} onClick={() => setWrap(v => !v)}><WrapTextIcon /><span className="max-sm:sr-only">Перенос</span></Button><DataGridColumnVisibility table={table} trigger={<Button variant="outline" size="sm"><Columns3Icon />Колонки</Button>} /></div></FrameHeader>
|
||||
<FramePanel className="p-0"><div className="flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">{toolbar}</div>
|
||||
<div onScrollCapture={event => { if ((event.target as HTMLElement).scrollTop > 16) onPause() }}>
|
||||
<DataGridContainer border={false}><DataGridTableVirtual height={420} estimateSize={48} overscan={8} hasMore={hasMore} isFetchingMore={fetchingMore} onFetchMore={loadMore} virtualizerOptions={{ scrollToFn: elementScroll, observeElementRect, observeElementOffset, measureElement: element => element.getBoundingClientRect().height }} /></DataGridContainer>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t p-3 text-xs text-muted-foreground"><span>* Использовано время получения. Часовой пояс отображения: браузер.</span>{hasMore && <Button size="sm" variant="outline" disabled={fetchingMore} onClick={loadMore}>{fetchingMore ? "Загрузка…" : "Загрузить ещё"}</Button>}</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { z } from "zod"
|
||||
import { logFiltersSchema, logLevels, type LogEntry } from "@mmapp/contracts"
|
||||
import { Bar, BarChart, XAxis, YAxis } from "recharts"
|
||||
import { DownloadIcon, PauseIcon, PlayIcon, RefreshCwIcon, SearchIcon, BookmarkPlusIcon, ActivityIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||
import { Filters } from "@/components/reui/filters/filters"
|
||||
import { createFilterQuery, createFilterRule, flattenFilterConditions } from "@/components/reui/filters/filters-query"
|
||||
import type { FilterField, FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { can, ensureAuthConfig, getClaims } from "@/lib/auth"
|
||||
import { firewallServersSchema } from "@/lib/firewall-response"
|
||||
import { FILTER_LABELS_RU } from "@/lib/data-filters/filter-labels-ru"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { exportLogs } from "@/shared/api/logs"
|
||||
import { useRouterLogs } from "@/hooks/use-router-logs"
|
||||
import { LogsGrid } from "./logs-grid"
|
||||
import { LogDetailSheet } from "./log-detail-sheet"
|
||||
import { LEVEL_COLORS, LEVEL_LABELS, formatLogTime } from "./log-presentation"
|
||||
|
||||
const RANGES = [{ value: "15m", label: "15 минут" }, { value: "1h", label: "1 час" }, { value: "24h", label: "24 часа" }, { value: "7d", label: "7 дней" }, { value: "14d", label: "14 дней" }, { value: "custom", label: "Свой период" }]
|
||||
const RANGE_MS: Record<string, number> = { "15m": 900000, "1h": 3600000, "24h": 86400000, "7d": 604800000, "14d": 1209600000 }
|
||||
const PRESETS = [{ value: "", label: "Все события" }, { value: "errors", label: "Ошибки и critical" }, { value: "warnings", label: "Предупреждения" }, { value: "login-failure", label: "Ошибки входа · шаблон" }, { value: "interfaces", label: "Интерфейсы" }, { value: "routing", label: "Маршрутизация" }, { value: "firewall", label: "Firewall" }]
|
||||
const viewSchema = z.array(z.object({ name: z.string().max(60), query: z.string().max(4000) })).max(20)
|
||||
function navigate(values: Record<string, string | null>, replace = false) {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
for (const [key, value] of Object.entries(values)) { if (value) params.set(key, value); else params.delete(key) }
|
||||
window.history[replace ? "replaceState" : "pushState"](null, "", "/logs?" + params)
|
||||
}
|
||||
export function LogsWorkspace() { return <Suspense fallback={<Skeleton className="h-64 w-full" />}><LogsRoute /></Suspense> }
|
||||
function LogsRoute() {
|
||||
const { backendUrl, mode, prefsHydrated } = useDataSource()
|
||||
return prefsHydrated ? <LogsCatalog key={JSON.stringify([backendUrl, mode])} base={backendUrl} active={mode === "live"} /> : <p role="status" className="p-4">Загружаем источник…</p>
|
||||
}
|
||||
function LogsCatalog({ base, active }: { base: string; active: boolean }) {
|
||||
const params = useSearchParams(), selectedId = params.get("server") ?? ""
|
||||
const [servers, setServers] = useState<z.infer<typeof firewallServersSchema>>([]), [error, setError] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false), [loading, setLoading] = useState(active), [retry, setRetry] = useState(0)
|
||||
useEffect(() => {
|
||||
const ac = new AbortController()
|
||||
void ensureAuthConfig().then(async () => {
|
||||
if (!active || ac.signal.aborted) return
|
||||
if (!can("mm:servers:read")) throw new Error("Для чтения логов требуется mm:servers:read")
|
||||
const rows = firewallServersSchema.parse(await requestJson(base, "/api/servers", { signal: ac.signal }))
|
||||
if (ac.signal.aborted) return
|
||||
setServers(rows); setReady(true); setError(null)
|
||||
if (!new URLSearchParams(window.location.search).has("server")) navigate({ server: String(rows.find(s => s.enabled)?.id ?? ALL_SERVERS_ID) }, true)
|
||||
}).catch(e => { if (!ac.signal.aborted) setError(e instanceof z.ZodError ? "Некорректный каталог устройств" : e.message) }).finally(() => { if (!ac.signal.aborted) setLoading(false) })
|
||||
return () => ac.abort()
|
||||
}, [base, active, retry])
|
||||
const items: ServerTileItem[] = servers.map(s => ({ id: String(s.id), name: s.name, host: s.host, country: s.country, type: s.type, status: s.status ?? "unknown", enabled: s.enabled, selectable: true }))
|
||||
// Keep an explicit historical ID in the rail after deletion; history remains addressable.
|
||||
if (selectedId && selectedId !== ALL_SERVERS_ID && !items.some(s => s.id === selectedId) && /^\d+$/.test(selectedId)) items.push({ id: selectedId, name: `История устройства #${selectedId}`, host: "Удалено или отсутствует в каталоге", enabled: false, selectable: true })
|
||||
const selected = servers.find(s => String(s.id) === selectedId)
|
||||
return <ServerRailLayout items={items} selectedId={selectedId} preserveSelection showAll allCount={servers.length} showCount={false} loading={loading} onSelect={id => navigate({ server: id, live: id === ALL_SERVERS_ID ? "0" : null })} contentClassName="min-w-0 min-h-0 overflow-y-auto p-2 md:p-3"
|
||||
header={<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Логи" }]} actions={<><ServerRailMobileButton /><Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)} disabled={!active || loading}><RefreshCwIcon />Обновить устройства</Button></>} />}>
|
||||
{!active ? <Alert variant="info"><AlertTitle>Демонстрационный источник</AlertTitle><AlertDescription>Для просмотра сохранённых логов и live-потока выберите «Живые» в настройках источника данных.</AlertDescription></Alert> : error ? <Alert variant="destructive"><AlertTitle>Каталог устройств не подтверждён</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)}>Повторить</Button></AlertDescription></Alert> : !ready || !selectedId ? <Skeleton className="h-80 w-full" /> : <LogExplorer key={JSON.stringify([base, params.toString()])} base={base} raw={params.toString()} selectedName={selected?.name ?? "История"} liveAvailable={!!selected?.enabled} />}
|
||||
</ServerRailLayout>
|
||||
}
|
||||
function LogExplorer({ base, raw, selectedName, liveAvailable }: { base: string; raw: string; selectedName: string; liveAvailable: boolean }) {
|
||||
const params = useMemo(() => new URLSearchParams(raw), [raw])
|
||||
const [anchor] = useState(() => Date.now())
|
||||
const range = params.get("range") ?? "15m", server = params.get("server") ?? ALL_SERVERS_ID
|
||||
const live = server !== ALL_SERVERS_ID && liveAvailable && params.get("live") !== "0" && range !== "custom"
|
||||
const parsed = useMemo(() => {
|
||||
const from = range === "custom" ? params.get("from") : new Date(anchor - (RANGE_MS[range] ?? 900000)).toISOString()
|
||||
return logFiltersSchema.safeParse({ serverId: server === ALL_SERVERS_ID ? undefined : server, from: from || undefined, to: range === "custom" ? params.get("to") || undefined : live ? undefined : new Date(anchor).toISOString(), levels: params.get("levels") ?? "", topics: params.get("topics") ?? "", q: params.get("q") ?? "", preset: params.get("preset") ?? "" })
|
||||
}, [params, server, range, anchor, live])
|
||||
const query = useMemo(() => { const query = new URLSearchParams(); if (parsed.success) for (const [key, value] of Object.entries(parsed.data)) if (value !== undefined && value !== "") query.set(key, String(value)); return query.toString() }, [parsed])
|
||||
const [paused, setPaused] = useState(false), [selected, setSelected] = useState<LogEntry | null>(null)
|
||||
const valid = parsed.success && RANGES.some(r => r.value === range) && (range !== "custom" || !!params.get("from") && !!params.get("to"))
|
||||
const result = useRouterLogs(base, query, valid, live, paused || !!selected, live ? RANGE_MS[range] ?? 900000 : 0)
|
||||
const [search, setSearch] = useState(params.get("q") ?? "")
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() => createFilterQuery(["levels", "topics"].flatMap(field => params.get(field) ? [createFilterRule({ id: field, path: [field], operator: "is_any_of", value: params.get(field)!.split(",") })] : [])))
|
||||
const [viewName, setViewName] = useState(""), [views, setViews] = useState<z.infer<typeof viewSchema>>([]), [exporting, setExporting] = useState(false)
|
||||
const exportController = useRef<AbortController | null>(null)
|
||||
const storageKey = `mm:logs:views:${base}:${getClaims()?.sub ?? "local"}`
|
||||
useEffect(() => { queueMicrotask(() => { try { setViews(viewSchema.parse(JSON.parse(localStorage.getItem(storageKey) ?? "[]"))) } catch { setViews([]) } }); return () => exportController.current?.abort() }, [storageKey])
|
||||
const fields: FilterField[] = useMemo(() => [
|
||||
{ id: "levels", label: "Уровень", type: "multiselect", operators: [{ value: "is_any_of", label: "любой из", arity: "many" }], defaultOperator: "is_any_of", options: logLevels.map(value => ({ value, label: LEVEL_LABELS[value] })) },
|
||||
{ id: "topics", label: "Topics", type: "multiselect", operators: [{ value: "is_any_of", label: "любой из", arity: "many" }], defaultOperator: "is_any_of", options: [...new Set(["system", "account", "interface", "firewall", "route", "routing", "bgp", "ospf", "bfd", "dhcp", "dns", "wireguard", "ipsec", "script", ...result.entries.flatMap(row => row.topics), ...(params.get("topics")?.split(",") ?? [])])].sort().map(value => ({ value, label: value })) },
|
||||
], [result.entries, params])
|
||||
const applyFilters = (next: FilterQuery) => {
|
||||
setFilterQuery(next)
|
||||
const conditions = flattenFilterConditions(next), values: Record<string, string | null> = { levels: null, topics: null }
|
||||
for (const condition of conditions) if (condition.values.length) values[condition.field] = condition.values.join(",")
|
||||
navigate(values)
|
||||
}
|
||||
const reset = () => navigate({ q: null, levels: null, topics: null, preset: null, range: "15m", from: null, to: null })
|
||||
const openEntry = useCallback((entry: LogEntry) => setSelected(entry), [])
|
||||
const fetchMore = result.loadMore
|
||||
const loadMore = useCallback(() => { setPaused(true); void fetchMore() }, [fetchMore])
|
||||
const pause = useCallback(() => setPaused(true), [])
|
||||
const state = result.stream?.state ?? result.summary?.states.find(s => String(s.serverId) === server)
|
||||
const collectorEnabled = result.stream?.enabled ?? result.summary?.settings.enabled
|
||||
const saveView = () => {
|
||||
if (!viewName.trim()) return
|
||||
const savedQuery = new URLSearchParams(raw); savedQuery.delete("server")
|
||||
const next = [...views.filter(v => v.name !== viewName.trim()), { name: viewName.trim().slice(0, 60), query: savedQuery.toString() }].slice(-20)
|
||||
try { localStorage.setItem(storageKey, JSON.stringify(next)); setViews(next); setViewName(""); toast.success("Представление сохранено") } catch { toast.error("Не удалось сохранить представление") }
|
||||
}
|
||||
const doExport = async (format: "csv" | "json") => {
|
||||
const ac = new AbortController(); exportController.current = ac; setExporting(true)
|
||||
const exportQuery = new URLSearchParams(query)
|
||||
if (live) exportQuery.set("from", new Date(Date.now() - (RANGE_MS[range] ?? 900000)).toISOString())
|
||||
try { await exportLogs(base, exportQuery, format, ac.signal) } catch (e) { if (!ac.signal.aborted) toast.error(e instanceof Error ? e.message : "Экспорт не завершён") } finally { if (!ac.signal.aborted) setExporting(false) }
|
||||
}
|
||||
if (!parsed.success || !RANGES.some(r => r.value === range) || range === "custom" && (!params.get("from") || !params.get("to"))) return <Alert variant="destructive"><AlertTitle>Некорректные фильтры в ссылке</AlertTitle><AlertDescription>Проверьте устройство, уровни и период.<Button size="sm" variant="outline" onClick={() => navigate({ server: ALL_SERVERS_ID, q: null, levels: null, topics: null, preset: null, range: null, from: null, to: null })}>Сбросить ссылку</Button></AlertDescription></Alert>
|
||||
return <div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3"><div className="flex min-w-0 flex-col gap-0.5"><h1 className="text-lg font-semibold tracking-tight">Логи MikroTik</h1><p className="text-sm text-muted-foreground">{server === ALL_SERVERS_ID ? "Все серверы · сохранённая история" : selectedName}</p></div><div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={range} items={RANGES} onValueChange={value => { if (!value) return; navigate(value === "custom" ? { range: value, from: new Date(anchor - 900000).toISOString(), to: new Date(anchor).toISOString(), live: "0" } : { range: value, from: null, to: null }) }}><SelectTrigger size="sm" aria-label="Период логов"><SelectValue /></SelectTrigger><SelectContent>{RANGES.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}</SelectContent></Select>
|
||||
<Button size="sm" variant={live ? "default" : "outline"} disabled={!liveAvailable || range === "custom"} onClick={() => navigate({ live: live ? "0" : null })}><ActivityIcon />{live ? "Live включён" : "История"}</Button><Button size="sm" variant="outline" onClick={result.reload}><RefreshCwIcon />Обновить</Button>
|
||||
<Button size="sm" variant="outline" disabled={exporting} onClick={() => void doExport("csv")}><DownloadIcon />CSV</Button><Button size="sm" variant="outline" disabled={exporting} onClick={() => void doExport("json")}>JSON</Button>
|
||||
</div></div>
|
||||
{range === "custom" && <div className="flex flex-wrap gap-3">{["from", "to"].map(key => <label key={key} className="flex min-w-0 flex-col gap-1 text-xs">{key === "from" ? "Начало (UTC)" : "Конец (UTC)"}<Input type="datetime-local" aria-label={key === "from" ? "Начало периода UTC" : "Конец периода UTC"} value={params.get(key)?.slice(0, 16) ?? ""} onChange={event => { if (event.target.value) navigate({ [key]: new Date(event.target.value + "Z").toISOString() }) }} /></label>)}</div>}
|
||||
<div role="status" className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"><Badge variant={!collectorEnabled ? "secondary" : result.streamError || state?.lastError ? "warning-light" : live && result.stream ? "success-light" : "info-light"}>{collectorEnabled === undefined ? "Проверяем сборщик" : !collectorEnabled ? "Сборщик выключен" : result.streamError ? "Переподключение" : state?.lastError ? "Ошибка сбора" : live && result.stream ? "Live подключён" : "История"}</Badge><span>Последний сбор: {formatLogTime(state?.lastCollectedAt ?? result.summary?.states.map(s => s.lastCollectedAt).filter((s): s is string => !!s).sort().at(-1) ?? null)}</span>{live && <Button size="sm" variant="outline" onClick={() => { if (paused) setPaused(false); else setPaused(true) }}>{paused ? <PlayIcon /> : <PauseIcon />}{paused ? `Новых событий: ${result.pendingCount}` : "Пауза"}</Button>}</div>
|
||||
{collectorEnabled === false && <Alert variant="info"><AlertTitle>История доступна, сбор новых событий выключен</AlertTitle><AlertDescription>Администратор может включить коллектор в разделе <Link className="underline" href="/data-collection#router-logs">«Сбор данных»</Link>.</AlertDescription></Alert>}
|
||||
{(result.streamError || state?.lastError) && <Alert variant="warning"><AlertTitle>Live требует внимания</AlertTitle><AlertDescription>{state?.lastError ?? result.streamError}</AlertDescription></Alert>}
|
||||
{state?.gapAt && <Alert variant="warning"><AlertTitle>Возможен разрыв истории</AlertTitle><AlertDescription>Буфер изменился без пересечения с предыдущим снимком: {formatLogTime(state.gapAt)}. Количество недоступных событий неизвестно.</AlertDescription></Alert>}
|
||||
<Frame dense><FrameHeader className="flex-row flex-wrap items-center justify-between gap-3"><div className="flex flex-col gap-0.5"><FrameTitle>Поток событий</FrameTitle><FrameDescription>Выберите столбец, чтобы открыть его период</FrameDescription></div><div className="flex flex-wrap gap-2">{[["Событий", result.summary?.total], ["Ошибок", result.summary ? result.summary.levels.error + result.summary.levels.critical : undefined], ["Предупреждений", result.summary?.levels.warning]].map(([label, count]) => <span key={label} className="text-xs text-muted-foreground">{label}: <strong className="text-foreground tabular-nums">{count?.toLocaleString("ru-RU") ?? "—"}</strong></span>)}</div></FrameHeader><FramePanel>
|
||||
{result.summaryError ? <Alert variant="warning"><AlertDescription>{result.summaryError}<Button size="sm" variant="outline" onClick={result.reload}>Повторить</Button></AlertDescription></Alert> : !result.summary ? <Skeleton className="h-32 w-full" /> : result.summary.buckets.length === 0 ? <div className="flex h-32 items-center justify-center text-sm text-muted-foreground">За выбранный период событий нет</div> : <ChartContainer className="h-36 w-full" config={Object.fromEntries(logLevels.map(level => [level, { label: LEVEL_LABELS[level], color: LEVEL_COLORS[level] }]))}>
|
||||
<BarChart accessibilityLayer data={result.summary.buckets} onClick={value => { const at = value.activeLabel; if (!at || !result.summary) return; const buckets = result.summary.buckets, i = buckets.findIndex(b => b.at === at); if (i < 0) return; const width = Math.max(1000, Math.ceil(((parsed.data.to ? Date.parse(parsed.data.to) : anchor) - Date.parse(parsed.data.from ?? new Date(anchor - 900000).toISOString())) / 60000) * 1000); navigate({ range: "custom", from: buckets[i].at, to: new Date(Date.parse(buckets[i].at) + width - 1).toISOString(), live: "0" }) }}><XAxis dataKey="at" tickFormatter={value => new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })} minTickGap={40} tickLine={false} axisLine={false} /><YAxis allowDecimals={false} width={32} tickLine={false} axisLine={false} /><ChartTooltip content={<ChartTooltipContent labelFormatter={value => formatLogTime(String(value))} />} />{logLevels.map(level => <Bar key={level} dataKey={level} stackId="events" fill={`var(--color-${level})`} maxBarSize={28} isAnimationActive={false} />)}</BarChart>
|
||||
</ChartContainer>}
|
||||
</FramePanel></Frame>
|
||||
{result.error && <Alert variant="destructive"><AlertTitle>Журнал не загружен</AlertTitle><AlertDescription>{result.error}<Button size="sm" variant="outline" onClick={result.reload}>Повторить</Button></AlertDescription></Alert>}
|
||||
<LogsGrid entries={result.entries} loading={result.loading} total={result.summary?.total ?? null} search={parsed.data.q} onSelect={openEntry} onPause={pause} hasMore={result.hasMore} fetchingMore={result.fetchingMore} loadMore={loadMore} reset={reset} toolbar={<>
|
||||
<div className="flex flex-wrap items-center gap-2"><form className="flex min-w-0 flex-1 basis-full gap-2 sm:basis-auto sm:max-w-md" onSubmit={event => { event.preventDefault(); navigate({ q: search || null }) }}><Input maxLength={500} aria-label="Поиск по сообщениям" placeholder="Сообщение, IP, интерфейс…" value={search} onChange={event => setSearch(event.target.value)} /><Button size="sm" variant="outline" aria-label="Найти события" type="submit"><SearchIcon /></Button></form><Select value={params.get("preset") || (params.get("levels") === "critical,error" ? "errors" : params.get("levels") === "warning" ? "warnings" : "")} items={PRESETS} onValueChange={value => { navigate(value === "errors" ? { levels: "critical,error", preset: null } : value === "warnings" ? { levels: "warning", preset: null } : { preset: value || null, levels: null }) }}><SelectTrigger size="sm" aria-label="Быстрый срез"><SelectValue placeholder="Все события" /></SelectTrigger><SelectContent>{PRESETS.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}</SelectContent></Select><Button size="sm" variant="ghost" onClick={reset}>Сбросить</Button></div>
|
||||
<Filters size="sm" fields={fields} query={filterQuery} labels={FILTER_LABELS_RU} onBeforeQueryChange={(next, details) => details.reason !== "negate" && next.rules.every(rule => rule.type === "rule") && new Set(next.rules.map(rule => rule.type === "rule" ? rule.path[0] : "")).size === next.rules.length} onQueryChange={applyFilters} />
|
||||
<div className="flex flex-wrap items-center gap-2"><Input aria-label="Название представления" placeholder="Название представления" className="w-full sm:w-48" maxLength={60} value={viewName} onChange={event => setViewName(event.target.value)} /><Button size="sm" variant="outline" disabled={!viewName.trim()} onClick={saveView}><BookmarkPlusIcon />Сохранить</Button>{views.map(view => <Button key={view.name} size="sm" variant="ghost" onClick={() => { const saved = new URLSearchParams(view.query); saved.set("server", server); window.history.pushState(null, "", "/logs?" + saved) }}>{view.name}</Button>)}</div>
|
||||
</>} />
|
||||
{result.entries.length >= 5000 && <p role="status" className="text-xs text-muted-foreground">Показаны последние 5 000 загруженных событий. Для более ранних записей задайте период или используйте экспорт.</p>}
|
||||
<p className="text-xs text-muted-foreground">REST читает буфер memory. Переполнение, очистка и перезагрузка MikroTik могут привести к пропускам. Сохранённая история остаётся в БД.</p>
|
||||
<LogDetailSheet entry={selected} base={base} onClose={() => setSelected(null)} onSelect={openEntry} />
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { humanArtifactError, humanArtifactReceipt } from "@/shared/api/mcp-artifact"
|
||||
import type { McpApi } from "@/shared/api/mcp"
|
||||
|
||||
function ArtifactDownload({ api, receipt }: { api: McpApi; receipt: unknown }) {
|
||||
const [busy, setBusy] = useState(false), [error, setError] = useState(""), [saved, setSaved] = useState(false)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
useEffect(() => () => controller.current?.abort(), [])
|
||||
const download = async () => {
|
||||
if (controller.current) return
|
||||
const request = new AbortController()
|
||||
controller.current = request
|
||||
setBusy(true); setError(""); setSaved(false)
|
||||
try { await api.downloadArtifact(receipt, request.signal); if (!request.signal.aborted) setSaved(true) }
|
||||
catch (failure) { if (!request.signal.aborted) setError(humanArtifactError(failure)) }
|
||||
finally { if (!request.signal.aborted) setBusy(false); controller.current = null }
|
||||
}
|
||||
return <div className="flex flex-col items-start gap-2">
|
||||
<Button variant="outline" disabled={busy} aria-busy={busy} onClick={() => { void download() }}>{busy ? "Скачивание…" : "Скачать файл"}</Button>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">{busy ? "Получаем файл с проверкой доступа…" : saved ? "Файл передан браузеру для сохранения." : "Файл доступен владельцу через авторизацию портала. Срок хранения ограничен."}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function ArtifactDownloads({ api, operation }: { api: McpApi; operation: Record<string, unknown> }) {
|
||||
if (operation.state !== "applied" || !Array.isArray(operation.artifactReceipts)) return null
|
||||
const receipts = [...new Map(operation.artifactReceipts.flatMap(value => {
|
||||
const receipt = humanArtifactReceipt(value)
|
||||
return receipt ? [[receipt.artifactId, value] as const] : []
|
||||
})).entries()]
|
||||
if (!receipts.length) return null
|
||||
return <div className="flex flex-col gap-3 rounded-md border p-3"><h3 className="text-sm font-medium">Файлы операции</h3>{receipts.map(([id, receipt]) => <ArtifactDownload key={id} api={api} receipt={receipt} />)}</div>
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { McpApi } from "@/shared/api/mcp"
|
||||
|
||||
export function DatabaseRestore({ api }: { api: McpApi }) {
|
||||
const [connections, setConnections] = useState<{ id: string; name: string }[]>([])
|
||||
const [connectionId, setConnectionId] = useState("")
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [archive, setArchive] = useState<Awaited<ReturnType<McpApi["uploadRestoreArchive"]>> | null>(null)
|
||||
const [operationId, setOperationId] = useState("")
|
||||
const [result, setResult] = useState<unknown>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
useEffect(() => {
|
||||
const abort = new AbortController()
|
||||
void api.connections(abort.signal).then(data => setConnections(data.connections.filter(item => item.policy.scope.allowGlobal && item.policy.scope.operationIds.includes("database.restore"))), failure => { if (!abort.signal.aborted) setError(String(failure)) })
|
||||
return () => abort.abort()
|
||||
}, [api])
|
||||
const run = async (work: () => Promise<unknown>) => {
|
||||
setBusy(true); setError("")
|
||||
try { setResult(await work()) } catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)) } finally { setBusy(false) }
|
||||
}
|
||||
return <section className="flex flex-col gap-3 rounded-xl border p-4" aria-labelledby="mcp-database-restore-title">
|
||||
<h2 id="mcp-database-restore-title" className="font-semibold">Восстановление БД через MCP</h2>
|
||||
<p className="text-sm text-muted-foreground">Архив PostgreSQL загружает администратор. Модель получает ссылку и SHA-256. Выполнение требует отдельного подтверждения в планах. После восстановления прежний MCP-доступ отзывается, а результат хранится во внешней квитанции.</p>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<label className="flex flex-col gap-1 text-sm">Подключение для восстановления
|
||||
<select className="h-9 rounded-md border bg-background px-3" value={connectionId} onChange={event => setConnectionId(event.target.value)}><option value="">Выберите свой глобальный допуск</option>{connections.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm">Архив PostgreSQL, до 64 МиБ
|
||||
<Input type="file" accept=".dump,.backup" onChange={event => { setFile(event.target.files?.[0] ?? null); setArchive(null); setResult(null) }} />
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" disabled={busy || !file || file.size > 64 * 1024 * 1024} onClick={() => { if (file) void run(async () => { const data = await api.uploadRestoreArchive(file); setArchive(data); return data }) }}>Загрузить архив</Button>
|
||||
<Button disabled={busy || !archive || !connectionId} onClick={() => { if (archive) void run(() => api.restorePlan(connectionId, archive.archiveRef, archive.sha256)) }}>Подготовить план восстановления БД</Button>
|
||||
</div>
|
||||
{archive && <p className="break-all text-xs text-muted-foreground">SHA-256: {archive.sha256} · Допуск до {new Date(archive.expiresAt).toLocaleString()}</p>}
|
||||
<label className="flex flex-col gap-1 text-sm">ID операции восстановления БД<Input value={operationId} onChange={event => setOperationId(event.target.value)} /></label>
|
||||
<Button className="self-start" variant="outline" disabled={busy || !operationId} onClick={() => { void run(() => api.restoreReceipt(operationId)) }}>Прочитать внешнюю квитанцию</Button>
|
||||
{result !== null && <pre role="status" className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{JSON.stringify(result, null, 2)}</pre>}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useTable, type ColumnDef } from "@tanstack/react-table"
|
||||
import { BotIcon, CopyIcon, PlusIcon, RefreshCwIcon, ShieldIcon, StopCircleIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { mcpConnectionCreateSchema, type McpConnection, type McpOperationDescriptor } from "@mmapp/contracts/mcp"
|
||||
import { routeAiProfileSchema, type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { can, getClaims, hasPermission } from "@/lib/auth"
|
||||
import { DATA_GRID_LABELS_RU } from "@/lib/data-grid-labels-ru"
|
||||
import { mcpApi, type McpApi } from "@/shared/api/mcp"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DatabaseRestore } from "./database-restore"
|
||||
import { PlanReview } from "./plan-review"
|
||||
import { ArtifactDownloads } from "./artifact-downloads"
|
||||
import { PollStorageCapacity } from "./poll-storage-capacity"
|
||||
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
|
||||
import { DataGrid, DataGridContainer, DataGridTable, DataGridScrollArea, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
|
||||
const errorText = (error: unknown) => error instanceof Error ? error.message : "Не удалось выполнить запрос"
|
||||
const modeLabels = { observer: "Наблюдатель", approval: "С подтверждением", autonomous: "Автономный" }
|
||||
const tabs = { overview: "Обзор", connections: "Подключения", permissions: "Разрешения", devices: "Интерфейсы", requests: "Обращения", plans: "Планы и операции", routing: "Route AI", settings: "Настройки" }
|
||||
type Tab = keyof typeof tabs
|
||||
type RecordRow = Record<string, unknown>
|
||||
function pretty(value: unknown) { return JSON.stringify(value, null, 2) }
|
||||
function date(value: unknown) { return value ? new Date(String(value)).toLocaleString("ru-RU") : "—" }
|
||||
function useQuery<T>(load: (signal: AbortSignal) => Promise<T>) {
|
||||
const [data, setData] = useState<T | null>(null), [error, setError] = useState("")
|
||||
const [updatedAt, setUpdatedAt] = useState<string | null>(null)
|
||||
const refresh = useCallback(() => { window.dispatchEvent(new Event("mcp-refresh")) }, [])
|
||||
useEffect(() => {
|
||||
let alive = true, revision = 0, controller: AbortController | undefined
|
||||
const run = async () => {
|
||||
if (document.hidden) return
|
||||
controller?.abort(); controller = new AbortController(); const requestRevision = ++revision
|
||||
try { const result = await load(controller.signal); if (alive && requestRevision === revision) { setData(result); setError(""); setUpdatedAt(new Date().toISOString()) } }
|
||||
catch (failure) { if (alive && requestRevision === revision && !controller.signal.aborted) setError(errorText(failure)) }
|
||||
}
|
||||
queueMicrotask(() => { void run() })
|
||||
const tick = () => { void run() }, timer = window.setInterval(tick, 5000)
|
||||
window.addEventListener("mcp-refresh", tick); document.addEventListener("visibilitychange", tick)
|
||||
return () => { alive = false; revision++; controller?.abort(); window.clearInterval(timer); window.removeEventListener("mcp-refresh", tick); document.removeEventListener("visibilitychange", tick) }
|
||||
}, [load])
|
||||
return { data, error, refresh, updatedAt }
|
||||
}
|
||||
function Panel({ title, description, children, actions }: { title: string; description?: string; children: ReactNode; actions?: ReactNode }) {
|
||||
return <Frame dense><FrameHeader className="flex-row flex-wrap items-center justify-between gap-3"><div><FrameTitle>{title}</FrameTitle>{description && <FrameDescription>{description}</FrameDescription>}</div>{actions}</FrameHeader><FramePanel className="flex min-w-0 flex-col gap-4 p-4">{children}</FramePanel></Frame>
|
||||
}
|
||||
function ErrorMessage({ message }: { message: string }) { return message ? <p role="alert" className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{message}</p> : null }
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) { return <label className="flex min-w-0 flex-col gap-1.5 text-sm"><span>{label}</span>{children}</label> }
|
||||
function Choice({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) { return <label className="flex items-center gap-2 text-sm"><Checkbox checked={checked} onCheckedChange={checked => onChange(!!checked)} />{label}</label> }
|
||||
function NativeSelect({ label, value, onChange, children }: { label: string; value: string; onChange: (value: string) => void; children: ReactNode }) {
|
||||
return <Field label={label}><select aria-label={label} className="h-9 min-w-0 rounded-md border border-input bg-background px-2 text-sm" value={value} onChange={event => onChange(event.target.value)}>{children}</select></Field>
|
||||
}
|
||||
function Grid({ items, columns, loading, total, onSelect }: { items: RecordRow[]; columns: ColumnDef<DataGridFeatures, RecordRow>[]; loading?: boolean; total?: number; onSelect?: (row: RecordRow) => void }) {
|
||||
const table = useTable({ features: dataGridFeatures, data: items, columns, getRowId: (row, index) => String(row.id ?? index), manualPagination: true, rowCount: total ?? items.length })
|
||||
return <DataGrid table={table} recordCount={total ?? items.length} isLoading={loading} onRowClick={onSelect} i18n={{ labels: DATA_GRID_LABELS_RU }} emptyMessage="Записей пока нет" tableLayout={{ dense: true, width: "fixed" }}><DataGridContainer><DataGridScrollArea className="max-h-[32rem]"><DataGridTable /></DataGridScrollArea></DataGridContainer></DataGrid>
|
||||
}
|
||||
type DeviceResource = Awaited<ReturnType<McpApi["deviceCatalog"]>>["resources"][number]
|
||||
function DeviceManagement({ api }: { api: McpApi }) {
|
||||
const connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||||
const [connectionId, setConnectionId] = useState("")
|
||||
return <Panel title="Интерфейсы и адреса" description="Чтение и подготовка изменений от имени выбранного подключения. Подтверждение и применение — на вкладке «Планы и операции»."><ErrorMessage message={connections.error} /><NativeSelect label="Подключение для управления интерфейсами" value={connectionId} onChange={setConnectionId}><option value="">Выберите подключение</option>{connections.data?.connections.filter(connection => connection.ownerId === getClaims()?.sub && connection.policy.status === "active").map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}</NativeSelect>{connectionId && <DeviceCatalog key={connectionId} api={api} connectionId={connectionId} />}</Panel>
|
||||
}
|
||||
function DeviceCatalog({ api, connectionId }: { api: McpApi; connectionId: string }) {
|
||||
const catalog = useQuery(useCallback(signal => api.deviceCatalog(connectionId, signal), [api, connectionId]))
|
||||
const [serverId, setServerId] = useState(""), [resourceId, setResourceId] = useState("")
|
||||
const resource = catalog.data?.resources.find(resource => resource.id === resourceId)
|
||||
return <><ErrorMessage message={catalog.error} /><div className="grid gap-3 sm:grid-cols-2"><NativeSelect label="Сервер интерфейсов" value={serverId} onChange={setServerId}><option value="">Выберите сервер</option>{catalog.data?.servers.map(server => <option key={server.id} value={String(server.id)} disabled={!server.enabled}>{server.name} · {server.host}</option>)}</NativeSelect><NativeSelect label="Тип объектов устройства" value={resourceId} onChange={setResourceId}><option value="">Выберите тип</option>{catalog.data?.resources.filter(resource => resource.read).map(resource => <option key={resource.id} value={resource.id}>{resource.title}</option>)}</NativeSelect></div>{catalog.data && !catalog.data.resources.some(resource => resource.read) && <p className="text-sm text-muted-foreground">Подключению не выданы инструменты чтения интерфейсов.</p>}{resource && serverId && catalog.data?.servers.some(server => String(server.id) === serverId && server.enabled) && <DeviceEditor key={`${serverId}:${resourceId}`} api={api} connectionId={connectionId} serverId={serverId} resource={resource} />}</>
|
||||
}
|
||||
function DeviceEditor({ api, connectionId, serverId, resource }: { api: McpApi; connectionId: string; serverId: string; resource: DeviceResource }) {
|
||||
const [offset, setOffset] = useState(0), [editing, setEditing] = useState<RecordRow | "new" | null>(null), [draft, setDraft] = useState<Record<string, unknown>>({})
|
||||
const [busy, setBusy] = useState(false), [error, setError] = useState(""), [receipt, setReceipt] = useState("")
|
||||
const rows = useQuery(useCallback(signal => api.deviceRows(connectionId, serverId, resource.id, offset, signal), [api, connectionId, serverId, resource.id, offset]))
|
||||
const open = (row: RecordRow | "new") => { setEditing(row); setDraft({}); setError(""); setReceipt("") }
|
||||
const prepare = async (action: "create" | "update" | "delete") => {
|
||||
setBusy(true); setError(""); setReceipt("")
|
||||
try {
|
||||
const result = await api.devicePlan(connectionId, serverId, resource.id, action, editing && editing !== "new" ? String(editing[".id"]) : undefined, action === "delete" ? {} : draft)
|
||||
setReceipt(`Создан план ${result.planId}. Проверьте изменения, подтвердите и примените его на вкладке «Планы и операции».`); setEditing(null); rows.refresh()
|
||||
} catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||||
}
|
||||
const current = editing && editing !== "new" ? editing : null
|
||||
const protectedRow = current && ["dynamic", "builtin", "default"].some(key => current[key] === "true" || current[key] === true)
|
||||
const change = (key: string, value: unknown) => setDraft(fields => { const next = { ...fields }; if (value === undefined) delete next[key]; else next[key] = value; return next })
|
||||
return <><ErrorMessage message={rows.error || error} />{receipt && <p role="status" className="text-sm">{receipt}</p>}<div className="flex flex-wrap items-center justify-between gap-2"><p className="text-sm text-muted-foreground">Объектов: {rows.data?.total ?? "…"}. Выберите строку для просмотра и подготовки изменения.</p>{resource.create && <Button variant="outline" disabled={busy} onClick={() => open("new")}><PlusIcon />Создать объект</Button>}</div><Grid items={rows.data?.items ?? []} loading={!rows.data && !rows.error} total={rows.data?.total} columns={[{ id: "nativeId", header: "ID", cell: ({ row }) => String(row.original[".id"] ?? "—") }, { id: "name", header: "Объект", cell: ({ row }) => String(row.original.name ?? row.original.address ?? row.original.interface ?? "—") }, { id: "state", header: "Состояние", cell: ({ row }) => String(row.original.disabled === "true" || row.original.disabled === true ? "Выключен" : row.original.running ?? "—") }]} onSelect={row => { if (row[".id"] && !busy) open(row) }} /><div className="flex gap-2"><Button variant="outline" disabled={busy || offset === 0} onClick={() => { setOffset(Math.max(0, offset - 100)); setEditing(null) }}>Предыдущие объекты</Button><Button variant="outline" disabled={busy || rows.data?.nextOffset == null} onClick={() => { if (rows.data?.nextOffset != null) setOffset(rows.data.nextOffset); setEditing(null) }}>Следующие объекты</Button></div>{editing && <div className="flex flex-col gap-4 rounded-md border p-4"><p className="font-medium">{editing === "new" ? "Новый объект" : `Объект ${String(current?.[".id"])}`}</p>{current && <details><summary className="cursor-pointer text-sm">Текущие свойства</summary><pre className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(current)}</pre></details>}{protectedRow ? <p className="text-sm">Системный или динамический объект доступен для чтения.</p> : <><p className="text-xs text-muted-foreground">В план попадут только заполненные изменения. Пустой выбор оставляет свойство без изменения; при создании действует настройка устройства.</p><div className="grid gap-3 sm:grid-cols-2">{resource.fields.filter(field => editing === "new" || field.key in (current ?? {})).map(field => <Field key={field.key} label={`${field.key}${editing === "new" && field.requiredOnCreate ? " *" : ""}`}>{field.type === "boolean" || field.options ? <select aria-label={field.key} className="h-9 min-w-0 rounded-md border border-input bg-background px-2 text-sm" value={String(draft[field.key] ?? "")} disabled={busy || editing !== "new" && !resource.update} onChange={event => change(field.key, event.target.value === "" ? undefined : field.type === "boolean" ? event.target.value === "true" : event.target.value)}><option value="">{current ? `Сейчас: ${String(current[field.key])}` : "По умолчанию устройства"}</option>{(field.options ?? ["true", "false"]).map(value => <option key={value} value={value}>{value}</option>)}</select> : <Input aria-label={field.key} type={field.type === "number" ? "number" : "text"} min={field.min} max={field.max} maxLength={field.maxLength} disabled={busy || editing !== "new" && !resource.update} placeholder={current ? String(current[field.key] ?? "") : ""} value={String(draft[field.key] ?? "")} onChange={event => change(field.key, field.type === "number" ? event.target.value === "" ? undefined : Number(event.target.value) : event.target.value)} />}</Field>)}</div></>}<div className="flex flex-wrap gap-2">{!protectedRow && (editing === "new" ? resource.create : resource.update) && <Button disabled={busy || !Object.keys(draft).length} onClick={() => { void prepare(editing === "new" ? "create" : "update") }}>Подготовить план изменения</Button>}{current && !protectedRow && resource.delete && <Button variant="destructive" disabled={busy} onClick={() => { void prepare("delete") }}>Подготовить план удаления</Button>}<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>Закрыть объект</Button></div></div>}</>
|
||||
}
|
||||
function TokenDialog({ token, close }: { token: string; close: () => void }) {
|
||||
return <Dialog open={!!token} onOpenChange={open => { if (!open) close() }}><DialogContent className="sm:max-w-xl"><DialogHeader><DialogTitle>Доступ создан</DialogTitle><DialogDescription>Сохраните токен в настройках AI-клиента. Повторно посмотреть его нельзя.</DialogDescription></DialogHeader><Input aria-label="Одноразовый токен MCP" readOnly type="password" value={token} /><Button onClick={() => { void navigator.clipboard.writeText(token).then(() => toast.success("Токен скопирован"), () => toast.error("Не удалось скопировать токен")) }}><CopyIcon />Копировать</Button><Button variant="outline" onClick={close}>Закрыть и скрыть токен</Button></DialogContent></Dialog>
|
||||
}
|
||||
function ConnectionForm({ api, operations, editing, close, created }: { api: McpApi; operations: McpOperationDescriptor[]; editing?: McpConnection; close: () => void; created: (token?: string) => void }) {
|
||||
const [step, setStep] = useState(1), [busy, setBusy] = useState(false), [error, setError] = useState("")
|
||||
const [name, setName] = useState(editing?.name ?? ""), [mode, setMode] = useState<McpConnection["policy"]["mode"]>(editing?.policy.mode ?? "observer")
|
||||
const [serverText, setServerText] = useState(editing?.policy.scope.serverIds.join(", ") ?? ""), [selected, setSelected] = useState<string[]>(editing?.policy.scope.operationIds ?? [])
|
||||
const [global, setGlobal] = useState(editing?.policy.scope.allowGlobal ?? false), [danger, setDanger] = useState(editing?.policy.dangerousPolicy ?? "approval")
|
||||
const [dangerIds, setDangerIds] = useState(editing?.policy.autonomousDangerousOperationIds ?? [])
|
||||
const [expires, setExpires] = useState(() => editing?.policy.expiresAt.slice(0, 16) ?? new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 16))
|
||||
const [limits, setLimits] = useState(editing?.policy.limits ?? { callsPerMinute: 60, concurrentReads: 4, mutationsPerHour: 10, serversPerPlan: 5, objectsPerPlan: 100 })
|
||||
const permissions = getClaims()?.permissions ?? []
|
||||
const eligible = operations.filter(operation => operation.implementation === "available" && operation.requiredPermissions.every(permission => hasPermission(permissions, permission)))
|
||||
const toggle = (id: string) => setSelected(ids => ids.includes(id) ? ids.filter(value => value !== id) : [...ids, id])
|
||||
const save = async () => {
|
||||
setBusy(true); setError("")
|
||||
try {
|
||||
const input = mcpConnectionCreateSchema.parse({ name, mode, expiresAt: new Date(expires + "Z").toISOString(), permissions: [...new Set(eligible.filter(operation => selected.includes(operation.id)).flatMap(operation => operation.requiredPermissions))], scope: { serverIds: serverText.split(/[,\s]+/).filter(Boolean).map(Number), operationIds: selected, allowGlobal: global }, dangerousPolicy: danger, autonomousDangerousOperationIds: dangerIds.filter(id => selected.includes(id)), limits })
|
||||
if (editing) { await api.update(editing.id, input); created() }
|
||||
else { const result = await api.create(input); created(result.token) }
|
||||
} catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||||
}
|
||||
return <Dialog open onOpenChange={open => { if (!open && !busy) close() }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"><DialogHeader><DialogTitle>{editing ? "Изменить разрешения" : "Новое подключение"}</DialogTitle><DialogDescription>Шаг {step} из 4 · доступ ограничивается правами текущего владельца.</DialogDescription></DialogHeader><ErrorMessage message={error} />
|
||||
{step === 1 && <><Field label="Название"><Input value={name} onChange={event => setName(event.target.value)} placeholder="Агент диагностики" /></Field><Field label="Срок действия (UTC)"><Input type="datetime-local" value={expires} onChange={event => setExpires(event.target.value)} /></Field><NativeSelect label="Режим управления" value={mode} onChange={value => setMode(value as typeof mode)}>{Object.entries(modeLabels).map(([value, title]) => <option key={value} value={value}>{title}</option>)}</NativeSelect></>}
|
||||
{step === 2 && <><Field label="ID разрешённых серверов через запятую"><Input value={serverText} onChange={event => setServerText(event.target.value)} placeholder="1, 2" /></Field><Choice label="Разрешить выбранные глобальные операции" checked={global} onChange={setGlobal} /><div className="grid max-h-80 gap-2 overflow-y-auto sm:grid-cols-2">{eligible.map(operation => <Choice key={operation.id} label={operation.title} checked={selected.includes(operation.id)} onChange={() => toggle(operation.id)} />)}</div><p className="text-xs text-muted-foreground">Новые инструменты не добавляются в доступ автоматически. Для применения планов выберите также инструменты просмотра и применения.</p></>}
|
||||
{step === 3 && <><div className="grid gap-3 sm:grid-cols-2">{Object.entries(limits).map(([key, value]) => <Field key={key} label={({ callsPerMinute: "Вызовов в минуту", concurrentReads: "Одновременных чтений", mutationsPerHour: "Изменений в час", serversPerPlan: "Серверов на план", objectsPerPlan: "Объектов на план" } as Record<string, string>)[key]}><Input type="number" min={1} value={value} onChange={event => setLimits(current => ({ ...current, [key]: Number(event.target.value) }))} /></Field>)}</div><NativeSelect label="Опасные действия" value={danger} onChange={value => setDanger(value as typeof danger)}><option value="approval">Всегда подтверждать</option><option value="explicit-allow">Автономно для явно выбранных действий</option></NativeSelect>{danger === "explicit-allow" && eligible.filter(operation => operation.risk === "dangerous" && selected.includes(operation.id)).map(operation => <Choice key={operation.id} label={operation.title} checked={dangerIds.includes(operation.id)} onChange={checked => setDangerIds(ids => checked ? [...ids, operation.id] : ids.filter(id => id !== operation.id))} />)}</>}
|
||||
{step === 4 && <div className="space-y-3 text-sm"><p><strong>{name || "Без названия"}</strong> · {modeLabels[mode]}</p><p>Серверы: {serverText || "не выбраны"}. Операций: {selected.length}. Глобальный доступ: {global ? "да" : "нет"}.</p><p>Защита объектов продолжает действовать независимо от автономных разрешений.</p><p>После сохранения проверьте конкретное действие в симуляторе на вкладке «Разрешения».</p></div>}
|
||||
<div className="flex flex-wrap justify-between gap-2"><Button variant="outline" disabled={busy} onClick={() => step > 1 ? setStep(step - 1) : close()}>{step > 1 ? "Назад" : "Отмена"}</Button>{step < 4 ? <Button onClick={() => setStep(step + 1)}>Далее</Button> : <Button disabled={busy} onClick={() => { void save() }}>{busy ? "Сохранение…" : "Сохранить доступ"}</Button>}</div>
|
||||
</DialogContent></Dialog>
|
||||
}
|
||||
function Connections({ api, operations }: { api: McpApi; operations: McpOperationDescriptor[] }) {
|
||||
const query = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||||
const [editing, setEditing] = useState<McpConnection | "new" | null>(null), [token, setToken] = useState("")
|
||||
const admin = can("mm:mcp:admin")
|
||||
const refresh = query.refresh
|
||||
const act = useCallback(async (work: () => Promise<unknown>) => { try { await work(); refresh() } catch (error) { toast.error(errorText(error)) } }, [refresh])
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, RecordRow>[]>(() => [
|
||||
{ accessorKey: "name", header: "Подключение", size: 200 },
|
||||
{ id: "mode", header: "Режим", cell: ({ row }) => modeLabels[(row.original.policy as McpConnection["policy"]).mode] },
|
||||
{ id: "status", header: "Состояние", cell: ({ row }) => <Badge variant={(row.original.policy as McpConnection["policy"]).status === "active" && row.original.generationValid ? "success-light" : "warning-light"}>{row.original.generationValid ? (row.original.policy as McpConnection["policy"]).status : "доступ аннулирован"}</Badge> },
|
||||
{ accessorKey: "lastUsedAt", header: "Последний вызов", cell: ({ row }) => date(row.original.lastUsedAt) },
|
||||
{ id: "ownerValidation", header: "Права владельца", size: 190, cell: ({ row }) => { const connection = row.original as unknown as McpConnection; return <div className="flex flex-col gap-1"><span className="text-xs">До {date(connection.ownerValidUntil)}</span>{connection.ownerId === getClaims()?.sub && <Button size="sm" variant="outline" disabled={!connection.generationValid || connection.policy.status === "revoked"} onClick={() => { void act(() => api.revalidate(connection.id)) }}>Обновить мои права</Button>}</div> } },
|
||||
{ id: "actions", header: "Действия", size: 350, cell: ({ row }) => { const connection = row.original as unknown as McpConnection; return admin && <div className="flex flex-wrap gap-1"><Button size="sm" variant="outline" onClick={() => setEditing(connection)}>Разрешения</Button><Button size="sm" variant="outline" disabled={connection.policy.status === "revoked"} onClick={() => { void act(() => api.status(connection.id, connection.policy.status === "active" ? "paused" : "active")) }}>{connection.policy.status === "active" ? "Пауза" : "Включить"}</Button><Button size="sm" variant="outline" disabled={connection.policy.status === "revoked"} onClick={() => { if (window.confirm("Отозвать доступ подключения и все его токены?")) void act(() => api.status(connection.id, "revoked")) }}>Отозвать</Button><Button size="sm" variant="ghost" disabled={connection.policy.status === "revoked"} onClick={() => { if (window.confirm("Заменить токен? Старый токен перестанет работать.")) void act(async () => setToken((await api.rotate(connection.id)).token)) }}>Новый токен</Button></div> } },
|
||||
], [api, admin, act])
|
||||
return <><Panel title="Подключения" description="Токены, сроки и разрешения AI-клиентов" actions={admin && <Button onClick={() => setEditing("new")}><PlusIcon />Выдать доступ</Button>}><ErrorMessage message={query.error} /><Grid items={(query.data?.connections ?? []) as unknown as RecordRow[]} columns={columns} loading={!query.data && !query.error} /><p className="text-sm text-muted-foreground">HTTP endpoint: адрес backend + /mcp. Для stdio используйте MCP_URL и MCP_TOKEN с командой npm --prefix backend run mcp:stdio.</p></Panel>{editing && <ConnectionForm api={api} operations={operations} editing={editing === "new" ? undefined : editing} close={() => setEditing(null)} created={value => { setEditing(null); if (value) setToken(value); query.refresh() }} />}<TokenDialog token={token} close={() => setToken("")} /></>
|
||||
}
|
||||
function AccessTools({ api }: { api: McpApi }) {
|
||||
const connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||||
const [selected, setSelected] = useState(""), [result, setResult] = useState<unknown>(null), [token, setToken] = useState("")
|
||||
const [operations, setOperations] = useState<string[]>([]), [serverText, setServerText] = useState(""), [minutes, setMinutes] = useState(60), [global, setGlobal] = useState(false)
|
||||
const grantLoad = useCallback(async (signal: AbortSignal) => selected ? api.grants(selected, signal) : { grants: [] }, [api, selected])
|
||||
const grants = useQuery(grantLoad), connection = connections.data?.connections.find(value => value.id === selected)
|
||||
const create = async () => {
|
||||
if (!connection) return
|
||||
try {
|
||||
const now = new Date(), expires = new Date(Math.min(now.getTime() + minutes * 60000, Date.parse(connection.policy.expiresAt)))
|
||||
const issued = await api.grant(connection.id, { id: crypto.randomUUID(), startsAt: now.toISOString(), expiresAt: expires.toISOString(), scope: { serverIds: serverText.split(",").map(value => value.trim()).filter(Boolean).map(Number), operationIds: operations, allowGlobal: global } })
|
||||
setToken(issued.token); grants.refresh()
|
||||
} catch (error) { toast.error(errorText(error)) }
|
||||
}
|
||||
return <Panel title="Проверка подключения и временные допуски" description="Допуск с отдельным токеном может только сузить основной доступ и действует не более 24 часов"><ErrorMessage message={connections.error || grants.error} /><NativeSelect label="Подключение для проверки" value={selected} onChange={value => { setSelected(value); setOperations([]); setServerText(""); setGlobal(false); setResult(null) }}><option value="">Выберите подключение</option>{connections.data?.connections.map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}</NativeSelect>{connection && <><p className="text-sm text-muted-foreground">Права владельца подтверждены до {date(connection.ownerValidUntil)}. Для продления войдите с актуальными правами портала; старые планы и допуски контроллера потребуется подготовить заново.</p><Button variant="outline" className="self-start" disabled={connection.ownerId !== getClaims()?.sub} onClick={() => { void api.revalidate(connection.id).then(() => { connections.refresh(); toast.success("Права владельца обновлены") }, error => toast.error(errorText(error))) }}>Подтвердить текущие права владельца</Button><Button variant="outline" className="self-start" onClick={() => { void api.check(connection.id).then(setResult, error => toast.error(errorText(error))) }}>Проверить готовность доступа</Button>{result !== null && <pre className="overflow-auto rounded bg-muted p-3 text-xs">{pretty(result)}</pre>}<div className="grid gap-3 sm:grid-cols-2"><Field label={`Серверы допуска (доступны: ${connection.policy.scope.serverIds.join(", ")})`}><Input value={serverText} onChange={event => setServerText(event.target.value)} placeholder="ID через запятую" /></Field><Field label="Срок допуска, минут"><Input type="number" min={1} max={1440} value={minutes} onChange={event => setMinutes(Number(event.target.value))} /></Field></div>{connection.policy.scope.allowGlobal && <Choice label="Разрешить глобальные действия в допуске" checked={global} onChange={setGlobal} />}<div className="grid max-h-60 gap-2 overflow-y-auto sm:grid-cols-2">{connection.policy.scope.operationIds.map(id => <Choice key={id} label={id} checked={operations.includes(id)} onChange={checked => setOperations(values => checked ? [...values, id] : values.filter(value => value !== id))} />)}</div><Button className="self-start" disabled={!operations.length || connection.policy.status !== "active"} onClick={() => { void create() }}>Выдать временный допуск</Button>{grants.data?.grants.map(grant => <div key={grant.id} className="flex flex-wrap items-center justify-between gap-2 rounded border p-3 text-sm"><span>До {date(grant.grant.expiresAt)} · {grant.revoked_at ? "отозван" : "выдан"}</span><Button size="sm" variant="outline" disabled={!!grant.revoked_at} onClick={() => { void api.revokeGrant(grant.id).then(grants.refresh, error => toast.error(errorText(error))) }}>Отозвать допуск</Button></div>)}</>}<TokenDialog token={token} close={() => setToken("")} /></Panel>
|
||||
}
|
||||
function RoutingProfileForm({ api, editing, close }: { api: McpApi; editing?: RecordRow; close: () => void }) {
|
||||
const connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||||
const original = editing ? routeAiProfileSchema.parse(editing.config) : null
|
||||
const [connectionId, setConnectionId] = useState(String(editing?.connection_id ?? "")), [name, setName] = useState(original?.name ?? ""), [serverId, setServerId] = useState(String(original?.serverId ?? ""))
|
||||
const [community, setCommunity] = useState(original?.community ?? ""), [family, setFamily] = useState(original?.family ?? "ipv4"), [table, setTable] = useState(original?.table ?? "main")
|
||||
const [probeVrf, setProbeVrf] = useState(original?.probeVrf ?? "main")
|
||||
const [bindBgp, setBindBgp] = useState(!!original?.bgpContext)
|
||||
const [bgp, setBgp] = useState(original?.bgpContext ?? { connectionId: "", instance: "", inputChain: "", vrf: "main" })
|
||||
const executionReason = table.trim() !== "main" || probeVrf.trim() !== "main"
|
||||
? "Автоматическое переключение для отдельной таблицы маршрутов или VRF проб пока недоступно."
|
||||
: bindBgp && (bgp.inputChain.trim() !== "bgp-in" || bgp.vrf.trim() !== "main")
|
||||
? "Автоматическое переключение для этой входящей цепочки BGP или транспортной VRF пока недоступно."
|
||||
: ""
|
||||
const [enabled, setEnabled] = useState(editing?.enabled === true), [expires, setExpires] = useState(() => new Date(Date.now() + 1800000).toISOString().slice(0, 16))
|
||||
const [autoRecovery, setAutoRecovery] = useState(original?.autoRecovery ?? false)
|
||||
const controllerReason = autoRecovery ? "Автовосстановление Community пока недоступно. Отключите его для активации контроллера." : executionReason
|
||||
const [weights, setWeights] = useState(original?.weights ?? { ping: 60, speed: 30, loss: 10 }), [threshold, setThreshold] = useState(original?.switchThreshold ?? 15), [cooldown, setCooldown] = useState(original?.cooldownSec ?? 600)
|
||||
const [pin, setPin] = useState(original?.pinnedCandidateId ?? ""), [excluded, setExcluded] = useState<string[]>(original?.excludedCandidateIds ?? [])
|
||||
const [sla, setSla] = useState({ ping: original?.maxPingMs?.toString() ?? "", loss: original?.maxLossPct?.toString() ?? "", speed: original?.minBandwidthMbps?.toString() ?? "" })
|
||||
const [candidates, setCandidates] = useState<CandidateForm[]>(() => original?.candidates.map(candidate => ({ ...candidate, jumpHostId: String(candidate.jumpHostId), exitNodeId: String(candidate.exitNodeId) })) ?? [{ id: "path-1", wanInterface: "", jumpHostId: "", exitNodeId: "", downstreamInterface: "", gateway: "", gatewayRef: { resource: "gre" as "gre" | "ipv4_route" | "ipv6_route", id: "" }, wanRemoteInterface: "", downstreamRemoteInterface: "" }])
|
||||
const [error, setError] = useState(""), [busy, setBusy] = useState(false), [plan, setPlan] = useState("")
|
||||
const save = async () => {
|
||||
setBusy(true); setError("")
|
||||
try {
|
||||
const profile = routeAiProfileSchema.parse({ ...original, name, serverId: Number(serverId), community, family, table, probeVrf, bgpContext: bindBgp ? bgp : undefined, weights, switchThreshold: threshold, cooldownSec: cooldown, autoRecovery, pinnedCandidateId: pin || null, excludedCandidateIds: excluded,
|
||||
maxPingMs: sla.ping ? Number(sla.ping) : null, maxLossPct: sla.loss ? Number(sla.loss) : null, minBandwidthMbps: sla.speed ? Number(sla.speed) : null,
|
||||
candidates: candidates.map(candidate => ({ ...candidate, jumpHostId: Number(candidate.jumpHostId), exitNodeId: Number(candidate.exitNodeId), wanRemoteInterface: candidate.wanRemoteInterface || undefined, downstreamRemoteInterface: candidate.downstreamRemoteInterface || undefined })) })
|
||||
if (enabled && controllerReason) throw new Error(controllerReason + " Отключите контроллер для сохранения профиля.")
|
||||
const result = await api.profilePlan({ connectionId, ...(editing ? { profileId: editing.id } : {}), profile, enabled, expiresAt: new Date(expires + "Z").toISOString() })
|
||||
setPlan(result.planId)
|
||||
} catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||||
}
|
||||
return <Dialog open onOpenChange={open => { if (!open && !busy) close() }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-4xl"><DialogHeader><DialogTitle>{editing ? "Изменить профиль Route AI" : "Новый профиль Route AI"}</DialogTitle><DialogDescription>Проверьте пути и допуск. Сохранение создаёт план для отдельного подтверждения и применения.</DialogDescription></DialogHeader><ErrorMessage message={error || connections.error} />{plan ? <><p>Создан план {plan}. Откройте «Планы и операции», проверьте изменения, подтвердите и примените план.</p><Button onClick={close}>Закрыть</Button></> : <><div className="grid gap-3 sm:grid-cols-2"><NativeSelect label="Подключение контроллера" value={connectionId} onChange={setConnectionId}><option value="">Выберите подключение владельца</option>{connections.data?.connections.filter(connection => connection.ownerId === getClaims()?.sub && connection.policy.status === "active" && connection.policy.mode !== "observer" && connection.policy.scope.operationIds.includes("routing.profiles.save")).map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}</NativeSelect><Field label="Название профиля"><Input value={name} onChange={event => setName(event.target.value)} /></Field><Field label="ID домашнего сервера"><Input type="number" min={1} value={serverId} onChange={event => setServerId(event.target.value)} /></Field><Field label="Community"><Input value={community} onChange={event => setCommunity(event.target.value)} placeholder="65000:10" /></Field><NativeSelect label="Семейство адресов" value={family} onChange={value => setFamily(value as typeof family)}><option value="ipv4">IPv4</option><option value="ipv6">IPv6</option></NativeSelect><Field label="Таблица маршрутизации"><Input value={table} onChange={event => setTable(event.target.value)} /></Field><Field label="VRF диагностических проб"><Input value={probeVrf} onChange={event => setProbeVrf(event.target.value)} /></Field></div>
|
||||
<p className="text-sm text-muted-foreground">Таблица определяет установку маршрутов. VRF диагностических проб определяет контекст измерений и задаётся отдельно от транспортной VRF BGP.</p>
|
||||
<Choice label="Привязать профиль к конкретному BGP-подключению RouterOS" checked={bindBgp} onChange={setBindBgp} />
|
||||
{bindBgp ? <fieldset className="rounded-lg border p-3"><legend className="px-1 text-sm font-medium">Контекст BGP</legend><div className="grid gap-3 sm:grid-cols-2">{([{ key: "connectionId", title: "ID BGP-подключения RouterOS" }, { key: "instance", title: "Экземпляр BGP" }, { key: "inputChain", title: "Входящая цепочка BGP" }, { key: "vrf", title: "Транспортная VRF BGP" }] as const).map(field => <Field key={field.key} label={field.title}><Input value={bgp[field.key]} placeholder={field.key === "connectionId" ? "*A" : undefined} onChange={event => setBgp(values => ({ ...values, [field.key]: event.target.value }))} /></Field>)}</div><p className="mt-2 text-sm text-muted-foreground">Заполните все поля. Снятие привязки явно удалит контекст BGP из плана профиля.</p></fieldset> : <p className="text-sm text-muted-foreground">Подключение BGP будет определено по наблюдаемой конфигурации; неоднозначная привязка блокирует переключение.</p>}
|
||||
{controllerReason && <p role="status" className="text-sm text-muted-foreground">{controllerReason} Профиль можно сохранить с выключенным контроллером для анализа и подготовки предложений.</p>}
|
||||
{candidates.map((candidate, index) => <fieldset key={candidate.id} className="rounded-lg border p-3"><legend className="px-1 text-sm font-medium">Путь {index + 1} · {candidate.id}</legend><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">{([{ key: "wanInterface", title: "WAN-интерфейс дома" }, { key: "jumpHostId", title: "ID JumpHost" }, { key: "exitNodeId", title: "ID ExitNode" }, { key: "downstreamInterface", title: "Интерфейс JumpHost к ExitNode" }, { key: "gateway", title: "Адрес gateway" }, { key: "wanRemoteInterface", title: "Входящий интерфейс JumpHost" }, { key: "downstreamRemoteInterface", title: "Входящий интерфейс ExitNode" }] as const).map(field => <Field key={field.key} label={field.title}><Input value={candidate[field.key] ?? ""} onChange={event => setCandidates(values => values.map(value => value.id === candidate.id ? { ...value, [field.key]: event.target.value } : value))} /></Field>)}<NativeSelect label="Тип шлюза" value={candidate.gatewayRef.resource} onChange={resource => setCandidates(values => values.map(value => value.id === candidate.id ? { ...value, gatewayRef: { ...value.gatewayRef, resource: resource as typeof value.gatewayRef.resource } } : value))}><option value="gre">GRE</option><option value="ipv4_route">Рекурсивный IPv4</option><option value="ipv6_route">Рекурсивный IPv6</option></NativeSelect><Field label="ID шлюза на устройстве"><Input value={candidate.gatewayRef.id} onChange={event => setCandidates(values => values.map(value => value.id === candidate.id ? { ...value, gatewayRef: { ...value.gatewayRef, id: event.target.value } } : value))} /></Field></div><div className="mt-3 flex flex-wrap items-center gap-3"><Choice label="Исключить путь" checked={excluded.includes(candidate.id)} onChange={checked => setExcluded(values => checked ? [...values, candidate.id] : values.filter(value => value !== candidate.id))} /><Button size="sm" variant="ghost" disabled={candidates.length === 1} onClick={() => { setCandidates(values => values.filter(value => value.id !== candidate.id)); setExcluded(values => values.filter(value => value !== candidate.id)); if (pin === candidate.id) setPin("") }}>Удалить путь</Button></div></fieldset>)}
|
||||
<CandidateProbeEditor candidates={candidates} update={(id, controlProbe) => setCandidates(values => values.map(candidate => candidate.id === id ? { ...candidate, controlProbe } : candidate))} /><Button variant="outline" className="self-start" disabled={candidates.length >= 100} onClick={() => setCandidates(values => [...values, { ...values[0], id: crypto.randomUUID(), gateway: "", gatewayRef: { ...values[0].gatewayRef, id: "" } }])}>Добавить путь</Button><div className="grid gap-3 sm:grid-cols-3">{Object.entries(weights).map(([key, value]) => <Field key={key} label={`Вес ${key}, %`}><Input type="number" min={0} max={100} value={value} onChange={event => setWeights(values => ({ ...values, [key]: Number(event.target.value) }))} /></Field>)}{([{ key: "ping", title: "Максимум RTT, мс" }, { key: "loss", title: "Максимум потерь, %" }, { key: "speed", title: "Минимум скорости, Mbps" }] as const).map(field => <Field key={field.key} label={field.title}><Input type="number" min={0} value={sla[field.key]} placeholder="Без ограничения" onChange={event => setSla(values => ({ ...values, [field.key]: event.target.value }))} /></Field>)}<Field label="Порог улучшения, баллы"><Input type="number" min={0} max={100} value={threshold} onChange={event => setThreshold(Number(event.target.value))} /></Field><Field label="Пауза между переключениями, сек"><Input type="number" min={60} value={cooldown} onChange={event => setCooldown(Number(event.target.value))} /></Field><NativeSelect label="Закреплённый путь" value={pin} onChange={setPin}><option value="">Автоматический выбор</option>{candidates.map((candidate, index) => <option key={candidate.id} value={candidate.id}>Путь {index + 1}</option>)}</NativeSelect></div><Choice label="Разрешить заранее зарезервированное восстановление" checked={autoRecovery} onChange={setAutoRecovery} /><Choice label="Разрешить работу контроллера после применения плана" checked={enabled} onChange={setEnabled} /><Field label="Допуск действует до (UTC, максимум 24 часа)"><Input type="datetime-local" value={expires} onChange={event => setExpires(event.target.value)} /></Field><Button disabled={busy || !connectionId || (!!controllerReason && enabled)} onClick={() => { void save() }}>{busy ? "Подготовка…" : "Подготовить план профиля"}</Button></>}</DialogContent></Dialog>
|
||||
}
|
||||
type CandidateForm = Omit<RouteAiProfile["candidates"][number], "jumpHostId" | "exitNodeId"> & { jumpHostId: string; exitNodeId: string }
|
||||
function CandidateProbeEditor({ candidates, update }: { candidates: CandidateForm[]; update: (id: string, probe: CandidateForm["controlProbe"]) => void }) {
|
||||
return <div className="grid gap-3">{candidates.map((candidate, index) => <fieldset key={candidate.id} className="flex flex-col gap-3 rounded-md border p-3"><legend className="px-1 text-sm">Контрольная проверка пути {index + 1}</legend><Choice label={`Включить контроль пути ${index + 1}`} checked={!!candidate.controlProbe} onChange={checked => update(candidate.id, checked ? { targetInterface: candidate.downstreamRemoteInterface ?? "", expectedHops: [{ serverId: Number(candidate.jumpHostId), interface: candidate.wanRemoteInterface ?? "" }, { serverId: Number(candidate.exitNodeId), interface: candidate.downstreamRemoteInterface ?? "" }] } : undefined)} />{candidate.controlProbe && <><Field label={`Контрольный интерфейс ExitNode пути ${index + 1}`}><Input value={candidate.controlProbe.targetInterface} onChange={event => update(candidate.id, { ...candidate.controlProbe!, targetInterface: event.target.value })} /></Field><Field label={`Исходный IP контрольной пробы пути ${index + 1}`}><Input value={candidate.controlProbe.sourceAddress ?? ""} placeholder="Автоматический выбор RouterOS" onChange={event => update(candidate.id, { ...candidate.controlProbe!, sourceAddress: event.target.value || undefined })} /></Field><Field label={`Конкретный IP ExitNode пути ${index + 1}`}><Input value={candidate.controlProbe.targetAddress ?? ""} placeholder="Для интерфейса с несколькими адресами" onChange={event => update(candidate.id, { ...candidate.controlProbe!, targetAddress: event.target.value || undefined })} /></Field>{candidate.controlProbe.expectedHops.map((hop, hopIndex) => <div key={hopIndex} className="grid gap-3 sm:grid-cols-2"><Field label={`Сервер hop ${hopIndex + 1} пути ${index + 1}`}><Input type="number" min={1} value={hop.serverId || ""} onChange={event => update(candidate.id, { ...candidate.controlProbe!, expectedHops: candidate.controlProbe!.expectedHops.map((item, current) => current === hopIndex ? { ...item, serverId: Number(event.target.value) } : item) })} /></Field><Field label={`IP hop ${hopIndex + 1} пути ${index + 1}`}><Input value={hop.address ?? ""} placeholder="Для интерфейса с несколькими адресами" onChange={event => update(candidate.id, { ...candidate.controlProbe!, expectedHops: candidate.controlProbe!.expectedHops.map((item, current) => current === hopIndex ? { ...item, address: event.target.value || undefined } : item) })} /></Field><Field label={`Интерфейс hop ${hopIndex + 1} пути ${index + 1}`}><Input value={hop.interface} onChange={event => update(candidate.id, { ...candidate.controlProbe!, expectedHops: candidate.controlProbe!.expectedHops.map((item, current) => current === hopIndex ? { ...item, interface: event.target.value } : item) })} /></Field></div>)}<p className="text-xs text-muted-foreground">Проверяются зарегистрированные адреса, порядок hops и достижение ExitNode. Это наблюдение контрольных пакетов; оно не доказывает путь всего пользовательского трафика.</p></>}</fieldset>)}</div>
|
||||
}
|
||||
function RoutePathVerification({ api }: { api: McpApi }) {
|
||||
const profiles = useQuery(useCallback(signal => api.profiles(signal), [api]))
|
||||
const [profileId, setProfileId] = useState(""), [candidateId, setCandidateId] = useState(""), [busy, setBusy] = useState(false), [error, setError] = useState(""), [result, setResult] = useState<unknown>(null)
|
||||
const [proofPurpose, setProofPurpose] = useState<"community" | "control-path">("community")
|
||||
const profile = profiles.data?.profiles.find(profile => profile.id === profileId), config = routeAiProfileSchema.safeParse(profile?.config)
|
||||
const check = async () => { setBusy(true); setError(""); setResult(null); try { setResult(await api.checkRoutePath(profileId, candidateId, proofPurpose)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) } }
|
||||
return <Panel title="Контрольная проверка пути" description="Ограниченные ping и traceroute; неизвестный результат не считается отказом пути.">
|
||||
<ErrorMessage message={profiles.error || error} />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<NativeSelect label="Профиль для проверки пути" value={profileId} onChange={value => { setProfileId(value); setCandidateId(""); setResult(null) }}><option value="">Выберите профиль</option>{profiles.data?.profiles.map(profile => <option key={String(profile.id)} value={String(profile.id)}>{String((profile.config as RecordRow)?.name ?? profile.id)}</option>)}</NativeSelect>
|
||||
<NativeSelect label="Проверяемый путь" value={candidateId} onChange={value => { setCandidateId(value); setResult(null) }}><option value="">Выберите путь</option>{config.success && config.data.candidates.filter(candidate => candidate.controlProbe).map(candidate => <option key={candidate.id} value={candidate.id}>{candidate.id}</option>)}</NativeSelect>
|
||||
<NativeSelect label="Назначение проверки" value={proofPurpose} onChange={value => { setProofPurpose(value as typeof proofPurpose); setResult(null) }}><option value="community">Путь Community</option><option value="control-path">Самостоятельный контрольный путь</option></NativeSelect>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{proofPurpose === "control-path" ? "Проверяется только контрольный путь. Результат не разрешает переключение Community и не подтверждает обратный путь." : "Проверка учитывает Community, таблицу маршрутизации и BGP. Применение изменений требует отдельного плана; обратный путь проверяется отдельно."}</p>
|
||||
<Button disabled={busy || !profileId || !candidateId} onClick={() => { void check() }}>Проверить контрольный путь</Button>
|
||||
{result !== null && <pre role="status" className="max-h-72 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(result)}</pre>}
|
||||
</Panel>
|
||||
}
|
||||
function RoutingProfiles({ api }: { api: McpApi }) {
|
||||
const query = useQuery(useCallback(signal => api.profiles(signal), [api])), [selected, setSelected] = useState<RecordRow | null>(null)
|
||||
const [editing, setEditing] = useState<RecordRow | "new" | null>(null)
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, RecordRow>[]>(() => [
|
||||
{ id: "name", header: "Профиль", size: 220, cell: ({ row }) => String((row.original.config as RecordRow)?.name ?? row.original.id) },
|
||||
{ id: "community", header: "Community / семейство", cell: ({ row }) => { const config = row.original.config as RecordRow; return `${config?.community} · ${config?.family} · ${config?.table}` } },
|
||||
{ id: "state", header: "Контроллер", cell: ({ row }) => <Badge variant={row.original.enabled ? "success-light" : "warning-light"}>{row.original.enabled ? "включён" : "пауза"}</Badge> },
|
||||
{ id: "reason", header: "Решение", size: 300, cell: ({ row }) => String((row.original.state as RecordRow)?.pausedReason ?? (row.original.state as RecordRow)?.reason ?? "Ожидает проверки") },
|
||||
{ accessorKey: "grant_expires_at", header: "Допуск до", cell: ({ row }) => date(row.original.grant_expires_at) },
|
||||
{ id: "actions", header: "", cell: ({ row }) => can("mm:mcp:admin") && <Button size="sm" variant="outline" disabled={!row.original.enabled} onClick={() => { void api.pauseProfile(String(row.original.id)).then(query.refresh, error => toast.error(errorText(error))) }}>Остановить</Button> },
|
||||
], [api, query.refresh])
|
||||
return <><Panel title="Постоянный контроллер Route AI" description="Профили, временные допуски и решения маршрутизации" actions={can("mm:mcp:admin") && can("mm:network:write") && <Button onClick={() => setEditing("new")}>Создать профиль</Button>}><ErrorMessage message={query.error} /><Grid items={query.data?.profiles ?? []} columns={columns} loading={!query.data && !query.error} onSelect={setSelected} /><p className="text-sm text-muted-foreground">Контроллер учитывает свежесть измерений, закрепления, SLA, гистерезис, cooldown и бюджет переключений. Неизвестный фактический путь не считается подтверждённым.</p><Dialog open={!!selected} onOpenChange={open => { if (!open) setSelected(null) }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"><DialogHeader><DialogTitle>Профиль Route AI</DialogTitle><DialogDescription>Сохранённые параметры и последнее решение контроллера</DialogDescription></DialogHeader><pre className="overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(selected)}</pre>{can("mm:mcp:admin") && can("mm:network:write") && <Button onClick={() => { setEditing(selected); setSelected(null) }}>Изменить профиль</Button>}</DialogContent></Dialog></Panel>{editing && <RoutingProfileForm api={api} editing={editing === "new" ? undefined : editing} close={() => { setEditing(null); query.refresh() }} />}</>
|
||||
}
|
||||
function Overview({ api }: { api: McpApi }) {
|
||||
const query = useQuery(useCallback(signal => api.overview(signal), [api]))
|
||||
return <Panel title="Управление AI-доступом" description={query.data ? "Последнее обновление: " + date(query.data.observedAt) : "Загрузка состояния"} actions={can("mm:mcp:pause") && <Button variant="outline" onClick={() => { if (window.confirm("Остановить новые обращения MCP и все профили Route AI? Начатые записи будут завершены или сверены.")) void api.stopAutomation().then(query.refresh, error => toast.error(errorText(error))) }}><StopCircleIcon />Остановить автоматические изменения</Button>}><ErrorMessage message={query.error} /><div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">{Object.entries({ connections: "Подключения", calls: "Вызовы за 24 часа", errors: "Ошибки и отказы", approvals: "Ожидают подтверждения", running: "Выполняются" }).map(([key, title]) => <div key={key} className="rounded-lg border p-4"><p className="text-sm text-muted-foreground">{title}</p><p className="mt-2 text-2xl font-semibold tabular-nums">{query.data ? query.data[key as "calls"] : "—"}</p></div>)}</div><Badge variant={query.data?.enabled ? "success-light" : "warning-light"}>{query.data?.enabled ? "MCP включён" : "MCP выключен"}</Badge><p className="text-sm text-muted-foreground">Выключатель прекращает новые вызовы. Выключатель также останавливает новые решения Route AI. Отдельный профиль можно остановить на вкладке Route AI.</p><PollStorageCapacity capacity={query.error ? {status:"unavailable"} : query.data?.pollStorageCapacity} loading={!query.data && !query.error} refresh={query.refresh} /></Panel>
|
||||
}
|
||||
function Records({ api, kind }: { api: McpApi; kind: "requests" | "plans" | "operations" | "audit" | "drift" }) {
|
||||
const urlParams = useSearchParams(), [selected, setSelected] = useState<RecordRow | null>(null)
|
||||
const search = urlParams.get(kind + ".tool") ?? "", status = urlParams.get(kind + ".status") ?? "", cursor = urlParams.get(kind + ".cursor") ?? ""
|
||||
const setFilter = (key: string, value: string) => { const url = new URL(window.location.href); if (value) url.searchParams.set(kind + "." + key, value); else url.searchParams.delete(kind + "." + key); if (key !== "cursor") url.searchParams.delete(kind + ".cursor"); window.history.replaceState(null, "", url.pathname + url.search) }
|
||||
const setSearch = (value: string) => setFilter("tool", value), setStatus = (value: string) => setFilter("status", value), setCursor = (value: string) => setFilter("cursor", value)
|
||||
const queryFields = new URLSearchParams({ limit: "50" })
|
||||
for (const field of ["tool", "status", "cursor", "connectionId", "serverId", "userId", "from", "to"]) { const value = urlParams.get(kind + "." + field); if (value) queryFields.set(field, value) }
|
||||
const queryKey = queryFields.toString()
|
||||
const load = useCallback((signal: AbortSignal) => api.records(kind, new URLSearchParams(queryKey), signal), [api, kind, queryKey])
|
||||
const query = useQuery(load)
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, RecordRow>[]>(() => [
|
||||
{ accessorKey: "created_at", header: "Время", size: 170, cell: ({ row }) => date(row.original.created_at ?? row.original.createdAt ?? row.original.detected_at) },
|
||||
{ id: "action", header: "Действие", size: 260, cell: ({ row }) => String(row.original.tool ?? row.original.operationId ?? row.original.action ?? row.original.id) },
|
||||
{ id: "state", header: "Результат", cell: ({ row }) => <Badge variant={["completed", "applied"].includes(String(row.original.status ?? row.original.state)) ? "success-light" : "info-light"}>{String(row.original.status ?? row.original.state ?? "событие")}</Badge> },
|
||||
{ accessorKey: "connection_id", header: "Подключение", size: 250, cell: ({ row }) => <span className="font-mono text-xs">{String(row.original.connection_id ?? row.original.connectionId ?? "—")}</span> },
|
||||
{ accessorKey: "error_code", header: "Причина", cell: ({ row }) => String(row.original.error_code ?? row.original.verification ?? "—") },
|
||||
{ id: "details", header: "", cell: ({ row }) => <Button size="sm" variant="ghost" onClick={() => setSelected(row.original)}>Открыть</Button> },
|
||||
], [])
|
||||
const decision = async (approve: boolean) => { if (!selected) return; try { await api.decision(String(selected.id), approve); setSelected(null); query.refresh() } catch (error) { toast.error(errorText(error)) } }
|
||||
return <Panel title={kind === "requests" ? "Обращения" : kind === "plans" ? "Планы изменений" : kind === "operations" ? "Выполнение операций" : kind === "drift" ? "Расхождения конфигурации" : "Административный аудит"} description="Чтение, отказы и изменения доступны для расследования"><ErrorMessage message={query.error} /><p className="text-xs text-muted-foreground">Последнее успешное обновление: {date(query.updatedAt)}{query.error ? " · показаны ранее полученные данные" : ""}</p><RecordFilters kind={kind} /><div className="flex flex-wrap gap-2">{kind === "requests" && <Input className="max-w-sm" aria-label="Имя инструмента" placeholder="Имя инструмента" value={search} onChange={event => { setSearch(event.target.value); setCursor("") }} />}<Input className="max-w-xs" aria-label="Фильтр состояния" placeholder="Состояние" value={status} onChange={event => { setStatus(event.target.value); setCursor("") }} /><Button variant="outline" onClick={query.refresh}><RefreshCwIcon />Обновить</Button></div><Grid items={query.data?.items ?? []} columns={columns} total={query.data?.total} loading={!query.data && !query.error} onSelect={setSelected} /><div className="flex items-center justify-between"><span className="text-sm text-muted-foreground">Найдено: {query.data?.total ?? "—"}</span><div className="flex gap-2"><Button variant="outline" disabled={!cursor} onClick={() => setCursor("")}>В начало</Button><Button variant="outline" disabled={!query.data?.nextCursor} onClick={() => setCursor(query.data?.nextCursor ?? "")}>Далее</Button></div></div><Dialog open={!!selected} onOpenChange={open => { if (!open) setSelected(null) }}><DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"><DialogHeader><DialogTitle>Детали {kind === "plans" ? "плана" : kind === "operations" ? "операции" : "обращения"}</DialogTitle><DialogDescription>Секретные поля скрыты. Подтверждение относится к этому содержимому.</DialogDescription></DialogHeader>{kind === "plans" && selected ? <PlanReview plan={selected} /> : <pre className="max-h-[55dvh] overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs">{pretty(selected)}</pre>}{kind === "operations" && selected && <ArtifactDownloads key={String(selected.id)} api={api} operation={selected} />}{kind === "plans" && ["approved", "draft"].includes(String(selected?.state)) && can("mm:mcp:admin") && <Button onClick={() => { if (selected) void api.apply(String(selected.id)).then(() => { setSelected(null); query.refresh(); toast.success("Операция принята; результат появится в списке операций") }, error => toast.error(errorText(error))) }}>Применить план</Button>}{kind === "plans" && selected?.state === "awaiting-approval" && can("mm:mcp:approve") && <div className="flex gap-2"><Button onClick={() => { void decision(true) }}>Подтвердить план</Button><Button variant="outline" onClick={() => { void decision(false) }}>Отклонить</Button></div>}</DialogContent></Dialog></Panel>
|
||||
}
|
||||
function RecordFilters({ kind }: { kind: string }) {
|
||||
const params = useSearchParams()
|
||||
const update = (field: string, value: string) => { const url = new URL(window.location.href); if (value) url.searchParams.set(kind + "." + field, value); else url.searchParams.delete(kind + "." + field); url.searchParams.delete(kind + ".cursor"); window.history.replaceState(null, "", url.pathname + url.search) }
|
||||
return <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"><Field label="Фильтр подключения MCP"><Input value={params.get(kind + ".connectionId") ?? ""} placeholder="UUID подключения" onChange={event => update("connectionId", event.target.value)} /></Field>{kind === "requests" && <><Field label="Фильтр ID сервера"><Input type="number" min={1} value={params.get(kind + ".serverId") ?? ""} onChange={event => update("serverId", event.target.value)} /></Field><Field label="Фильтр пользователя"><Input value={params.get(kind + ".userId") ?? ""} onChange={event => update("userId", event.target.value)} /></Field></>}{["from", "to"].map(field => <Field key={field} label={field === "from" ? "Обращения с (UTC)" : "Обращения до (UTC)"}><Input type="datetime-local" value={(params.get(kind + "." + field) ?? "").slice(0, 16)} onChange={event => update(field, event.target.value ? new Date(event.target.value + "Z").toISOString() : "")} /></Field>)}</div>
|
||||
}
|
||||
function Permissions({ api }: { api: McpApi }) {
|
||||
const connections = useQuery(useCallback(signal => api.connections(signal), [api])), protections = useQuery(useCallback(signal => api.protections(signal), [api]))
|
||||
const [connectionId, setConnectionId] = useState(""), [operationId, setOperationId] = useState(""), [serverText, setServerText] = useState(""), [result, setResult] = useState<unknown>(null)
|
||||
const [kind, setKind] = useState("server"), [objectId, setObjectId] = useState(""), [protection, setProtection] = useState("human-approval-required"), [reason, setReason] = useState("")
|
||||
const selected = connections.data?.connections.find(connection => connection.id === connectionId)
|
||||
const preview = async () => { if (!selected) return; try { const serverIds = serverText.split(/[,\s]+/).filter(Boolean).map(Number); const res = await api.preview({ operationId, policy: selected.policy, ownerPermissions: getClaims()?.permissions ?? [], target: serverIds.length ? { scope: "servers", serverIds } : { scope: "global" }, objects: [], protections: protections.data?.protections.map(item => item.object_ref) ?? [] }); setResult(res.result) } catch (error) { toast.error(errorText(error)) } }
|
||||
return <div className="flex flex-col gap-4"><Panel title="Проверка разрешений" description="Гипотетическое решение без выполнения действий и резервирования бюджета"><ErrorMessage message={connections.error || protections.error} /><div className="grid gap-3 md:grid-cols-3"><NativeSelect label="Подключение" value={connectionId} onChange={setConnectionId}><option value="">Выберите подключение</option>{connections.data?.connections.map(connection => <option key={connection.id} value={connection.id}>{connection.name}</option>)}</NativeSelect><NativeSelect label="Операция" value={operationId} onChange={setOperationId}><option value="">Выберите действие</option>{selected?.policy.scope.operationIds.map(id => <option key={id}>{id}</option>)}</NativeSelect><Field label="ID серверов; пусто — глобальное действие"><Input value={serverText} onChange={event => setServerText(event.target.value)} /></Field></div><Button className="self-start" disabled={!selected || !operationId || !can("mm:mcp:admin")} onClick={() => { void preview() }}><ShieldIcon />Проверить</Button>{result !== null && <pre role="status" className="overflow-auto whitespace-pre-wrap rounded bg-muted p-3 text-xs">{pretty(result)}</pre>}</Panel><Panel title="Защищённые объекты" description="Защита сохраняется и при автономном доступе"><div className="flex flex-col gap-2">{protections.data?.protections.map(item => <div key={item.id} className="flex flex-wrap items-center justify-between gap-2 rounded border p-3 text-sm"><span>{item.object_ref.kind} · {item.object_ref.id} · {item.object_ref.protection}<span className="ml-2 text-muted-foreground">{item.reason}</span></span>{can("mm:mcp:admin") && <Button variant="outline" size="sm" onClick={() => { if (window.confirm("Удалить защиту объекта?")) void api.removeProtection(item.id).then(protections.refresh, error => toast.error(errorText(error))) }}>Удалить защиту</Button>}</div>)}</div>{can("mm:mcp:admin") && <><div className="grid gap-3 md:grid-cols-4"><Field label="Тип объекта"><Input value={kind} onChange={event => setKind(event.target.value)} /></Field><Field label="Стабильный ID"><Input value={objectId} onChange={event => setObjectId(event.target.value)} /></Field><NativeSelect label="Защита" value={protection} onChange={setProtection}><option value="human-approval-required">Подтверждение человеком</option><option value="deny">Запрет изменений</option></NativeSelect><Field label="Причина"><Input value={reason} onChange={event => setReason(event.target.value)} /></Field></div><Button className="self-start" disabled={!objectId} onClick={() => { void api.protect({ kind, id: objectId, protection, ...(kind === "server" ? {} : { serverId: Number(serverText) }) }, reason).then(protections.refresh, error => toast.error(errorText(error))) }}>Добавить защиту</Button></>}</Panel></div>
|
||||
}
|
||||
function OperationRecovery({ api }: { api: McpApi }) {
|
||||
const [id, setId] = useState(""), [result, setResult] = useState<unknown>(null), [error, setError] = useState(""), [busy, setBusy] = useState(false)
|
||||
const run = async (recover: boolean) => {
|
||||
setBusy(true); setError(""); setResult(null)
|
||||
try { setResult(recover ? await api.recoverOperation(id) : await api.reconcileOperation(id)) } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||||
}
|
||||
return <Panel title="Сверка и восстановление" description="Сверка читает состояние устройства. Восстановление создаёт отдельный план и проверяет отсутствие внешних изменений."><ErrorMessage message={error} /><Field label="ID исходной операции"><Input value={id} onChange={event => { setId(event.target.value); setResult(null) }} /></Field><div className="flex flex-wrap gap-2"><Button variant="outline" disabled={busy || !id} onClick={() => { void run(false) }}>Сверить состояние операции</Button>{can("mm:mcp:admin") && <Button disabled={busy || !id} onClick={() => { void run(true) }}>Подготовить обратный план</Button>}</div>{result !== null && <pre role="status" className="max-h-60 overflow-auto whitespace-pre-wrap break-all text-xs">{pretty(result)}</pre>}</Panel>
|
||||
}
|
||||
function ManagementChannels({ api }: { api: McpApi }) {
|
||||
const channels = useQuery(useCallback(signal => api.managementChannels(signal), [api]))
|
||||
const [serverId, setServerId] = useState(""), [host, setHost] = useState(""), [port, setPort] = useState(443), [ssl, setSsl] = useState(true), [username, setUsername] = useState(""), [password, setPassword] = useState(""), [attestation, setAttestation] = useState("")
|
||||
const [expiry, setExpiry] = useState(() => new Date(Date.now() + 1800000).toISOString().slice(0, 16)), [busy, setBusy] = useState(false), [error, setError] = useState("")
|
||||
const register = async () => {
|
||||
setBusy(true); setError("")
|
||||
try { await api.registerManagement({ serverId: Number(serverId), host, port, useSsl: ssl, verifySsl: true, username, password, attestation, expiresAt: new Date(expiry + "Z").toISOString() }); setPassword(""); channels.refresh(); toast.success("Канал проверен по идентичности RouterOS и зарегистрирован") } catch (failure) { setError(errorText(failure)) } finally { setBusy(false) }
|
||||
}
|
||||
return <Panel title="Независимый канал управления" description="Другой IP того же RouterOS. Backend проверяет стабильный ID и доступность; независимость сетевого пути подтверждает администратор."><ErrorMessage message={channels.error || error} /><Grid items={channels.data?.channels ?? []} columns={[{ accessorKey: "server_id", header: "Сервер" }, { accessorKey: "verified_at", header: "Проверен", cell: ({ row }) => date(row.original.verified_at) }, { accessorKey: "expires_at", header: "Допуск до", cell: ({ row }) => date(row.original.expires_at) }, { id: "action", header: "Доступ", cell: ({ row }) => row.original.revoked_at ? "Отозван" : <Button size="sm" variant="outline" onClick={() => { void api.revokeManagement(String(row.original.id)).then(channels.refresh, failure => toast.error(errorText(failure))) }}>Отозвать канал</Button> }]} />{can("mm:network:admin") && <><div className="grid gap-3 sm:grid-cols-2"><Field label="ID сервера независимого канала"><Input type="number" min={1} value={serverId} onChange={event => setServerId(event.target.value)} /></Field><Field label="IP независимого управления"><Input value={host} onChange={event => setHost(event.target.value)} /></Field><Field label="Порт независимого управления"><Input type="number" min={1} max={65535} value={port} onChange={event => setPort(Number(event.target.value))} /></Field><Choice label="HTTPS с проверкой сертификата" checked={ssl} onChange={setSsl} /><Field label="Пользователь независимого канала"><Input autoComplete="off" value={username} onChange={event => setUsername(event.target.value)} /></Field><Field label="Пароль независимого канала"><Input type="password" autoComplete="new-password" value={password} onChange={event => setPassword(event.target.value)} /></Field><Field label="Допуск независимого канала до (UTC)"><Input type="datetime-local" value={expiry} onChange={event => setExpiry(event.target.value)} /></Field><Field label="Подтверждение независимости пути"><Textarea value={attestation} onChange={event => setAttestation(event.target.value)} placeholder="Опишите отдельный путь, интерфейс/VRF и почему изменение основного пути его не затронет" /></Field></div><Button disabled={busy || !serverId || !host || !username || !password || attestation.trim().length < 30} onClick={() => { void register() }}>Проверить и зарегистрировать канал</Button></>}</Panel>
|
||||
}
|
||||
function Settings({ api }: { api: McpApi }) {
|
||||
const query = useQuery(useCallback(signal => api.settings(signal), [api])), clients = useQuery(useCallback(signal => api.clients(signal), [api]))
|
||||
const [name, setName] = useState(""), [redirect, setRedirect] = useState("")
|
||||
const save = async (value: unknown) => { try { await api.saveSettings(value); query.refresh() } catch (error) { toast.error(errorText(error)) } }
|
||||
return <div className="flex flex-col gap-4"><Panel title="Настройки MCP" description="По умолчанию внешний доступ выключен"><ErrorMessage message={query.error} />{query.data && <><Choice label="Разрешить обращения MCP" checked={query.data.enabled} onChange={enabled => { void save({ enabled }) }} /><div className="grid gap-3 sm:grid-cols-2"><Field label="Хранение обращений, дней"><Input type="number" min={1} max={365} defaultValue={query.data.requestsRetentionDays} onBlur={event => { const value = Number(event.target.value); if (value !== query.data?.requestsRetentionDays && window.confirm("Изменить срок хранения обращений? Старые записи будут удаляться по новому сроку.")) void save({ requestsRetentionDays: value }) }} /></Field><Field label="Хранение аудита, дней"><Input type="number" min={30} max={3650} defaultValue={query.data.auditRetentionDays} onBlur={event => { const value = Number(event.target.value); if (value !== query.data?.auditRetentionDays && window.confirm("Изменить срок хранения аудита?")) void save({ auditRetentionDays: value }) }} /></Field></div></>}</Panel><Panel title="OAuth-клиенты" description="Зарегистрируйте точный redirect URI внешнего AI-клиента"><ErrorMessage message={clients.error} />{clients.data?.clients.map(client => <div key={client.id} className="rounded border p-3 text-sm"><p className="font-medium">{client.name} · {client.enabled ? "разрешён" : "отключён"}</p><p className="break-all font-mono text-xs">client_id: {client.id}</p><p className="break-all text-muted-foreground">{client.redirect_uris.join(", ")}</p>{client.enabled && <Button size="sm" variant="outline" onClick={() => { if (window.confirm("Отключить OAuth-клиент и отозвать его токены?")) void api.disableClient(client.id).then(clients.refresh, error => toast.error(errorText(error))) }}>Отключить и отозвать токены</Button>}</div>)}<Field label="Название клиента"><Input value={name} onChange={event => setName(event.target.value)} /></Field><Field label="Redirect URI, по одному на строку"><Textarea value={redirect} onChange={event => setRedirect(event.target.value)} /></Field><Button className="self-start" disabled={!name || !redirect} onClick={() => { void api.createClient(name, redirect.split("\n").map(value => value.trim()).filter(Boolean)).then(() => { setName(""); setRedirect(""); clients.refresh() }, error => toast.error(errorText(error))) }}>Зарегистрировать клиент</Button></Panel><Records api={api} kind="audit" /></div>
|
||||
}
|
||||
function RestCoverage({ api }: { api: McpApi }) {
|
||||
const query = useQuery(useCallback(signal => api.coverage(signal), [api])), routes = query.data?.routes ?? []
|
||||
return <Panel title="Покрытие REST через MCP" description="Сервисные сценарии проверяются отдельно от количества инструментов. Новые endpoints отслеживает автоматическая проверка реестра."><ErrorMessage message={query.error} /><div className="flex flex-wrap gap-2"><Badge variant="success-light">Сценариев с MCP: {routes.filter(route => route.status === "mediated").length}</Badge><Badge variant="info-light">Транспорт и управление доступом: {routes.filter(route => route.status === "excluded").length}</Badge><Badge variant="warning-light">Ожидают реализации: {routes.filter(route => route.status === "pending").length}</Badge></div><Grid items={routes.filter(route => route.status === "pending")} columns={[{ accessorKey: "method", header: "Метод", size: 90 }, { accessorKey: "path", header: "Незавершённый сценарий", size: 310 }, { accessorKey: "reason", header: "Что осталось", size: 400 }]} loading={!query.data && !query.error} /></Panel>
|
||||
}
|
||||
function Consent({ api, id }: { api: McpApi; id: string }) {
|
||||
const request = useQuery(useCallback(signal => api.consentRequest(id, signal), [api, id])), connections = useQuery(useCallback(signal => api.connections(signal), [api]))
|
||||
const [selected, setSelected] = useState("")
|
||||
const confirm = async (approve: boolean) => { try { const result = await api.consent(id, selected, approve); window.location.assign(result.redirectUrl) } catch (error) { toast.error(errorText(error)) } }
|
||||
return <Panel title="Разрешение OAuth-доступа" description="Выберите подключение с нужными ограничениями; клиент получит только его доступ"><ErrorMessage message={request.error || connections.error} /><p>{request.data?.name} запрашивает доступ к MikrotikManager.</p><p className="break-all text-sm text-muted-foreground">Возврат: {request.data?.redirect_uri}</p><NativeSelect label="Подключение" value={selected} onChange={setSelected}><option value="">Выберите ограниченный доступ</option>{connections.data?.connections.filter(connection => connection.ownerId === getClaims()?.sub && connection.policy.status === "active").map(connection => <option key={connection.id} value={connection.id}>{connection.name} · {modeLabels[connection.policy.mode]}</option>)}</NativeSelect><div className="flex gap-2"><Button disabled={!selected} onClick={() => { void confirm(true) }}>Разрешить</Button><Button variant="outline" disabled={!selected} onClick={() => { void confirm(false) }}>Отклонить</Button></div></Panel>
|
||||
}
|
||||
export function McpWorkspace() {
|
||||
const { backendUrl, mode } = useDataSource()
|
||||
return <McpWorkspaceContent key={JSON.stringify([backendUrl, mode])} backendUrl={backendUrl} live={mode === "live"} />
|
||||
}
|
||||
function McpWorkspaceContent({ backendUrl, live }: { backendUrl: string; live: boolean }) {
|
||||
const params = useSearchParams(), initial = params.get("tab") as Tab | null
|
||||
const [tab, setTab] = useState<Tab>(initial && initial in tabs ? initial : "overview")
|
||||
const api = useMemo(() => mcpApi(backendUrl), [backendUrl])
|
||||
const [operations, setOperations] = useState<McpOperationDescriptor[]>([]), [error, setError] = useState("")
|
||||
useEffect(() => { if (!live) return; const controller = new AbortController(); void api.catalog(controller.signal).then(catalog => setOperations(catalog.operations), failure => { if (!controller.signal.aborted) setError(errorText(failure)) }); return () => controller.abort() }, [api, live])
|
||||
const changeTab = (value: unknown) => { const next = String(value) as Tab; setTab(next); const url = new URL(window.location.href); url.searchParams.set("tab", next); window.history.replaceState(null, "", url.pathname + url.search) }
|
||||
return <><PageHeader crumbs={[{ label: "MCP" }]} /><main className="flex min-w-0 flex-1 flex-col gap-5 p-4 md:p-6"><div className="flex items-center gap-3"><BotIcon className="size-7 text-muted-foreground" /><div><h1 className="text-xl font-semibold">MCP</h1><p className="text-sm text-muted-foreground">Доступ нейросетей, разрешения и контроль изменений</p></div></div>{!live ? <Panel title="Подключите backend"><p>Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.</p></Panel> : <><ErrorMessage message={error} />{params.get("oauthRequest") && <Consent api={api} id={params.get("oauthRequest")!} />}<Tabs value={tab} onValueChange={changeTab}><div className="overflow-x-auto pb-1"><TabsList variant="line">{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => <TabsTrigger key={key} value={key}>{title}</TabsTrigger>)}</TabsList></div><TabsContent value="overview"><Overview api={api} /></TabsContent><TabsContent value="connections"><div className="flex flex-col gap-4"><Connections api={api} operations={operations} />{can("mm:mcp:admin") && <AccessTools api={api} />}</div></TabsContent><TabsContent value="permissions"><Permissions api={api} /></TabsContent><TabsContent value="devices"><DeviceManagement api={api} /></TabsContent><TabsContent value="routing"><div className="flex flex-col gap-4"><RoutingProfiles api={api} /><RoutePathVerification api={api} /></div></TabsContent><TabsContent value="requests"><Records api={api} kind="requests" /></TabsContent><TabsContent value="plans"><div className="flex flex-col gap-4"><Records api={api} kind="plans" /><Records api={api} kind="operations" /><OperationRecovery api={api} /><Records api={api} kind="drift" /></div></TabsContent>{can("mm:mcp:admin") && <TabsContent value="settings"><div className="flex flex-col gap-4"><Settings api={api} /><RestCoverage api={api} /><ManagementChannels api={api} />{can("mm:settings:admin") && <DatabaseRestore api={api} />}</div></TabsContent>}</Tabs></>}</main></>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
const object = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
const list = (value: unknown): unknown[] => Array.isArray(value) ? value : []
|
||||
const json = (value: unknown) => JSON.stringify(value, null, 2)
|
||||
export function PlanReview({ plan }: { plan: Record<string, unknown> }) {
|
||||
const changes = object(plan.changes), target = object(changes.target)
|
||||
const objects = list(changes.objects), steps = list(changes.steps), recovery = object(changes.recovery)
|
||||
return <div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-2"><Badge variant="info-light">{String(plan.operationId ?? "Операция")}</Badge><Badge variant="warning-light">{String(plan.state ?? "Неизвестно")}</Badge><Badge variant="invert-light">Шагов: {steps.length}</Badge></div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-2"><div><dt className="text-muted-foreground">Область</dt><dd>{target.scope === "servers" ? "Серверы: " + list(target.serverIds).join(", ") : target.scope === "global" ? "Всё приложение" : "Подключение"}</dd></div><div><dt className="text-muted-foreground">Срок плана</dt><dd>{plan.expiresAt ? new Date(String(plan.expiresAt)).toLocaleString("ru-RU") : "Не указан"}</dd></div></dl>
|
||||
{!!list(changes.warnings).length && <div className="rounded-md border border-warning/30 p-3 text-sm"><p className="mb-2 font-medium">Условия и последствия</p><ul className="list-disc space-y-1 pl-5">{list(changes.warnings).map((warning, index) => <li key={index}>{String(warning)}</li>)}</ul></div>}
|
||||
{!!objects.length && <div><h3 className="mb-2 text-sm font-medium">Объекты и защищённые зависимости</h3><div className="flex flex-wrap gap-2">{objects.map((value, index) => { const item = object(value); return <Badge key={index} variant="invert-light">{item.serverId ? `Сервер ${item.serverId} · ` : ""}{String(item.kind)} · {String(item.id)}</Badge> })}</div></div>}
|
||||
<div className="grid min-w-0 gap-3 md:grid-cols-2"><div className="min-w-0"><h3 className="mb-2 text-sm font-medium">Исходное состояние</h3><pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs">{json(changes.before ?? changes.snapshots ?? null)}</pre></div><div className="min-w-0"><h3 className="mb-2 text-sm font-medium">Запланированные шаги</h3><ol className="flex flex-col gap-2">{steps.map((value, index) => { const step = object(value); return <li key={index} className="min-w-0 rounded-md border p-3"><p className="mb-2 text-sm font-medium">{index + 1}. {step.kind === "ros" ? String(object(step.step).action) + " · " + String(object(step.step).resource) : String(step.service)}</p><pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all text-xs">{json(step.kind === "ros" ? step.step : step.input)}</pre></li> })}</ol></div></div>
|
||||
{!!Object.keys(recovery).length && <p className="rounded-md border p-3 text-sm">Зарезервировано восстановление до {new Date(String(recovery.validUntil)).toLocaleString("ru-RU")}. Оно выполняется только при подтверждённой исходной записи, неуспешной проверке и действующем допуске; неопределённую запись исполнитель не повторяет.</p>}
|
||||
<details><summary className="cursor-pointer text-sm text-muted-foreground">Полные данные плана</summary><pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-all text-xs">{json(plan)}</pre></details>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {McpPollStorageCapacity} from "@mmapp/contracts/mcp"
|
||||
import {Badge} from "@/components/reui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
|
||||
export function PollStorageCapacity({capacity,loading,refresh}:{capacity?:McpPollStorageCapacity;loading:boolean;refresh:()=>void}){
|
||||
const observed=capacity?.status==="observed"?capacity:undefined
|
||||
const ownerFull=observed?.owner.remaining===0,globalFull=observed?.global.exhausted===true
|
||||
const full=ownerFull||globalFull
|
||||
return <section aria-label="Журнал полных опросов" className="flex flex-col gap-3 rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-medium">Журнал полных опросов</h3>
|
||||
<div className="flex items-center gap-2"><Badge variant={!observed?"info-light":full?"warning-light":"success-light"}>{loading?"Загрузка":!observed?"Нет данных":full?"Квота заполнена":"Квота не заполнена"}</Badge><Button size="sm" variant="outline" onClick={refresh}>Обновить статус</Button></div>
|
||||
</div>
|
||||
{observed?<>
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div><dt className="text-muted-foreground">Ваши полные журналы</dt><dd className="font-medium tabular-nums">{observed.owner.used} / {observed.owner.limit}<span className="ml-2 font-normal text-muted-foreground">Осталось: {observed.owner.remaining}</span></dd></div>
|
||||
<div><dt className="text-muted-foreground">Полные журналы приложения</dt><dd className="font-medium tabular-nums">{observed.global.usage?<>{observed.global.usage.used} / {observed.global.usage.limit}<span className="ml-2 font-normal text-muted-foreground">Осталось: {observed.global.usage.remaining}</span></>:globalFull?"Заполнена":"Не заполнена"}</dd></div>
|
||||
</dl>
|
||||
{full&&<p role="alert" className="text-sm">{ownerFull&&globalFull?"Личная и общая квоты журнала заполнены.":ownerFull?"Личная квота журнала заполнена.":"Общая квота журнала заполнена."} Новый полный опрос не получит место. После безопасного архивирования или очистки по сроку хранения место может освободиться. Неизвестный результат сохраняет резерв; повторные попытки его не освобождают.</p>}
|
||||
<p className="text-sm text-muted-foreground">Счётчик учитывает резерв под выполнение и журналы, ожидающие архивирования. Архив сохраняет все исходные записи и продолжает занимать место. Поэтому остаток зависит и от объёма сохранённой истории.</p>
|
||||
<p className="text-sm text-muted-foreground">Срок хранения новых записей: {observed.retentionDays} дн. Существующие записи могут храниться дольше. Архивирование сохраняет исходный срок; очистка учитывает завершение операции, истечение допуска и защитный интервал.</p>
|
||||
<p className="text-xs text-muted-foreground">Состояние показано на момент обновления. Окончательный допуск проверяется при запуске.</p>
|
||||
</>:<p role="status" className="text-sm text-muted-foreground">{loading?"Проверяем заполнение журнала…":"Заполнение журнала сейчас недоступно. Свободное место не подтверждено; обновите статус позже."}</p>}
|
||||
</section>
|
||||
}
|
||||
Loaded 100 of 3835 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user