Zwei unabhängige Bugs hinter "Live-Log zeigt Einträge, aber nichts Neues":
1) ulogd2 stirbt beim nächtlichen Logrotate. Der postinst nahm an, ulogd
laufe als root — die Debian-Unit startet aber `ulogd --daemon --uid ulog`.
Beim Start öffnet ulogd die JSONL noch als root und schreibt danach über
den offenen fd weiter, egal wem sie gehört. Nachts schickt das Distro-
Profil /etc/logrotate.d/ulogd2 ein SIGHUP; das Reopen läuft dann als
`ulog` und scheitert an root:edgeguard 0640 ("can't open JSON log file:
Permission denied"). ulogd wertet das als fatal und beendet sich mit
Exit-Code 0 — Restart=on-failure hätte also nicht gegriffen, und ohne
Restart= blieb der Dienst tot (auf utm-1 5 Tage unbemerkt). Die API
servierte derweil weiter ihren In-Memory-Ring von vor der Rotation,
deshalb sah die UI Einträge, aber nie neue.
Fix: Owner ulog (Schreiber) : edgeguard (Leser), logrotate `create`
passend, plus Drop-in Restart=always als Selbstheilung.
2) Seitengröße liess sich nicht umstellen. Die Tabellen übergaben ein
literales `pagination={{ pageSize: N }}`. antd merged via
extendsObject(innerPagination, paginationObj) — der Prop überschreibt
bei jedem Render den State, den der Size-Changer gerade gesetzt hat.
Bei einem Live-Log rendert das im Sekundentakt, der Klick auf 20/100
war also sofort wieder weg. Fix: defaultPageSize (unkontrolliert).
Betraf ausser dem Live-Log auch Logs, Backups-History, Routes,
Alerts und CrowdSec.
Ausserdem: `t` aus den WS-Effect-Deps genommen. i18n wechselt dessen
Identität bei Store-Updates, was den Effect neu laufen liess — inklusive
setEntries([]), d.h. der Live-Puffer leerte sich ohne erkennbaren Grund.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
import { useState } from 'react'
|
|
import {
|
|
Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Space, Switch, Table, Tag, Tooltip, Typography, message,
|
|
} from 'antd'
|
|
import type { ColumnsType } from 'antd/es/table'
|
|
import {
|
|
EnvironmentOutlined, PlusOutlined, ReloadOutlined,
|
|
} from '@ant-design/icons'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
import apiClient, { isEnvelope } from '../../api/client'
|
|
import { useAuthStore } from '../../stores/auth'
|
|
import EmptyState from '../../components/EmptyState'
|
|
|
|
const { Text } = Typography
|
|
|
|
interface ManagedRoute {
|
|
id: number
|
|
destination: string
|
|
gateway?: string | null
|
|
dev?: string | null
|
|
metric: number
|
|
table_name: string
|
|
active: boolean
|
|
comment?: string | null
|
|
}
|
|
|
|
interface LiveRoute {
|
|
destination: string
|
|
gateway?: string
|
|
dev?: string
|
|
protocol?: string
|
|
scope?: string
|
|
src?: string
|
|
metric?: number
|
|
table?: string
|
|
flags?: string[]
|
|
}
|
|
|
|
interface RouteFormValues {
|
|
destination: string
|
|
gateway?: string
|
|
dev?: string
|
|
metric: number
|
|
table_name: string
|
|
active: boolean
|
|
comment?: string
|
|
}
|
|
|
|
export default function RoutesTab() {
|
|
const { t } = useTranslation()
|
|
const qc = useQueryClient()
|
|
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
|
|
|
const managed = useQuery({
|
|
queryKey: ['routes', 'managed'],
|
|
queryFn: async () => {
|
|
const r = await apiClient.get('/routes')
|
|
return isEnvelope(r.data) ? (r.data.data as { routes: ManagedRoute[] }).routes : []
|
|
},
|
|
})
|
|
|
|
const live = useQuery({
|
|
queryKey: ['routes', 'live'],
|
|
queryFn: async () => {
|
|
const r = await apiClient.get('/routes/live')
|
|
return isEnvelope(r.data) ? (r.data.data as { routes: LiveRoute[] }).routes : []
|
|
},
|
|
refetchInterval: 30_000,
|
|
})
|
|
|
|
const [edit, setEdit] = useState<ManagedRoute | null>(null)
|
|
const [creating, setCreating] = useState(false)
|
|
const [form] = Form.useForm<RouteFormValues>()
|
|
|
|
const create = useMutation({
|
|
mutationFn: async (v: RouteFormValues) => { await apiClient.post('/routes', v) },
|
|
onSuccess: () => {
|
|
message.success(t('common.save'))
|
|
setCreating(false); form.resetFields()
|
|
qc.invalidateQueries({ queryKey: ['routes'] })
|
|
},
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
|
|
const update = useMutation({
|
|
mutationFn: async ({ id, v }: { id: number; v: RouteFormValues }) => {
|
|
await apiClient.put(`/routes/${id}`, v)
|
|
},
|
|
onSuccess: () => {
|
|
message.success(t('common.save'))
|
|
setEdit(null); form.resetFields()
|
|
qc.invalidateQueries({ queryKey: ['routes'] })
|
|
},
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
|
|
const del = useMutation({
|
|
mutationFn: async (id: number) => { await apiClient.delete(`/routes/${id}`) },
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['routes'] }) },
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
const quickToggle = useMutation({
|
|
mutationFn: async ({ id, row, checked }: { id: number; row: ManagedRoute; checked: boolean }) => {
|
|
const { id: _id, ...body } = row
|
|
await apiClient.put(`/routes/${id}`, { ...body, active: checked })
|
|
},
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['routes'] }) },
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
|
|
const managedColumns: ColumnsType<ManagedRoute> = [
|
|
{
|
|
title: t('routes.col.destination'), dataIndex: 'destination',
|
|
render: (v: string) => <Text style={{ fontFamily: 'monospace' }}>{v}</Text>,
|
|
},
|
|
{
|
|
title: t('routes.col.gateway'), dataIndex: 'gateway',
|
|
render: (v?: string) => v
|
|
? <Text style={{ fontFamily: 'monospace' }}>{v}</Text>
|
|
: <Text type="secondary">on-link</Text>,
|
|
},
|
|
{ title: t('routes.col.dev'), dataIndex: 'dev', width: 110,
|
|
render: (v?: string) => v ? <Tag>{v}</Tag> : '—' },
|
|
{ title: t('routes.col.metric'), dataIndex: 'metric', width: 80 },
|
|
{ title: t('routes.col.table'), dataIndex: 'table_name', width: 90 },
|
|
{
|
|
title: t('routes.col.active'), dataIndex: 'active', width: 80,
|
|
render: (v: boolean, row: ManagedRoute) => (
|
|
<Switch
|
|
size="small"
|
|
checked={v}
|
|
disabled={isViewer}
|
|
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
|
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
|
/>
|
|
),
|
|
},
|
|
{ title: t('routes.col.comment'), dataIndex: 'comment',
|
|
render: (v?: string) => v || <Text type="secondary">—</Text> },
|
|
{
|
|
title: t('common.actions'), key: 'a', width: 160,
|
|
render: (_, r) => (
|
|
<Space size={4}>
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button size="small" disabled={isViewer} onClick={() => {
|
|
setEdit(r)
|
|
form.setFieldsValue({
|
|
destination: r.destination,
|
|
gateway: r.gateway ?? undefined,
|
|
dev: r.dev ?? undefined,
|
|
metric: r.metric,
|
|
table_name: r.table_name,
|
|
active: r.active,
|
|
comment: r.comment ?? undefined,
|
|
})
|
|
}}>{t('common.edit')}</Button>
|
|
</Tooltip>
|
|
{isViewer
|
|
? <Tooltip title={t('auth.viewerBadge')}><Button size="small" danger disabled>{t('common.delete')}</Button></Tooltip>
|
|
: <Popconfirm title={t('routes.confirmDelete', { dest: r.destination })} onConfirm={() => del.mutate(r.id)}>
|
|
<Button size="small" danger>{t('common.delete')}</Button>
|
|
</Popconfirm>}
|
|
</Space>
|
|
),
|
|
},
|
|
]
|
|
|
|
const liveColumns: ColumnsType<LiveRoute> = [
|
|
{ title: t('routes.col.destination'), dataIndex: 'destination',
|
|
render: (v?: string) => (
|
|
<Text style={{ fontFamily: 'monospace' }}>{v || 'default'}</Text>
|
|
) },
|
|
{ title: t('routes.col.gateway'), dataIndex: 'gateway',
|
|
render: (v?: string) => v
|
|
? <Text style={{ fontFamily: 'monospace' }}>{v}</Text>
|
|
: <Text type="secondary">—</Text> },
|
|
{ title: t('routes.col.dev'), dataIndex: 'dev', width: 110,
|
|
render: (v?: string) => v ? <Tag>{v}</Tag> : '—' },
|
|
{ title: t('routes.col.proto'), dataIndex: 'protocol', width: 110,
|
|
render: (v?: string) => v
|
|
? <Tag color={v === 'edgeguard' ? 'cyan' : 'default'}>{v}</Tag>
|
|
: '—' },
|
|
{ title: t('routes.col.scope'), dataIndex: 'scope', width: 90,
|
|
render: (v?: string) => v ? <Tag>{v}</Tag> : '—' },
|
|
{ title: t('routes.col.src'), dataIndex: 'src', width: 130,
|
|
render: (v?: string) => v
|
|
? <Text style={{ fontFamily: 'monospace', fontSize: 11 }}>{v}</Text>
|
|
: <Text type="secondary">—</Text> },
|
|
{ title: t('routes.col.metric'), dataIndex: 'metric', width: 80,
|
|
render: (v?: number) => v ?? '—' },
|
|
]
|
|
|
|
const openCreate = () => {
|
|
setCreating(true); form.resetFields()
|
|
form.setFieldsValue({
|
|
metric: 100, table_name: 'main', active: true,
|
|
destination: '', gateway: '', dev: '',
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<Card
|
|
size="small"
|
|
title={
|
|
<Space>
|
|
<EnvironmentOutlined />
|
|
{t('routes.liveTitle')}
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
({(live.data ?? []).length})
|
|
</Text>
|
|
</Space>
|
|
}
|
|
extra={
|
|
<Tooltip title={t('routes.refreshTooltip')}>
|
|
<Button size="small" icon={<ReloadOutlined />}
|
|
loading={live.isFetching}
|
|
onClick={() => live.refetch()}>{t('common.refresh')}</Button>
|
|
</Tooltip>
|
|
}
|
|
className="mb-16"
|
|
>
|
|
<Text type="secondary">{t('routes.liveIntro')}</Text>
|
|
<Table
|
|
rowKey={(r, idx) => `${r.destination}-${r.dev}-${r.metric}-${idx}`}
|
|
size="small"
|
|
dataSource={live.data ?? []}
|
|
columns={liveColumns}
|
|
pagination={{ defaultPageSize: 25, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
|
|
locale={{ emptyText: t('routes.liveEmpty') }}
|
|
style={{ marginTop: 12 }}
|
|
/>
|
|
</Card>
|
|
|
|
<Card
|
|
size="small"
|
|
title={t('routes.managedTitle')}
|
|
extra={
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" size="small" icon={<PlusOutlined />}
|
|
disabled={isViewer} onClick={openCreate}>
|
|
{t('routes.add')}
|
|
</Button>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
<Text type="secondary">{t('routes.managedIntro')}</Text>
|
|
<Table
|
|
rowKey="id"
|
|
size="small"
|
|
loading={managed.isFetching}
|
|
dataSource={managed.data ?? []}
|
|
columns={managedColumns}
|
|
pagination={false}
|
|
locale={{ emptyText: (
|
|
<EmptyState
|
|
icon={<EnvironmentOutlined />}
|
|
title={t('routes.emptyTitle')}
|
|
description={t('routes.emptyDesc')}
|
|
action={
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
|
|
{t('routes.add')}
|
|
</Button>
|
|
</Tooltip>
|
|
}
|
|
/>
|
|
) }}
|
|
style={{ marginTop: 12 }}
|
|
/>
|
|
</Card>
|
|
|
|
<Modal
|
|
title={edit ? t('routes.editTitle') : t('routes.addTitle')}
|
|
open={edit !== null || creating}
|
|
onCancel={() => { setEdit(null); setCreating(false); form.resetFields() }}
|
|
onOk={() => form.submit()}
|
|
confirmLoading={create.isPending || update.isPending}
|
|
width={560}
|
|
>
|
|
<Form form={form} layout="vertical"
|
|
onFinish={(v) => edit ? update.mutate({ id: edit.id, v }) : create.mutate(v)}>
|
|
<Form.Item label={t('routes.col.destination')} name="destination"
|
|
rules={[{ required: true }]} extra={t('routes.destExtra')}>
|
|
<Input placeholder="10.0.5.0/24" />
|
|
</Form.Item>
|
|
<Form.Item label={t('routes.col.gateway')} name="gateway"
|
|
extra={t('routes.gatewayExtra')}>
|
|
<Input placeholder="192.168.1.1" />
|
|
</Form.Item>
|
|
<Form.Item label={t('routes.col.dev')} name="dev"
|
|
extra={t('routes.devExtra')}>
|
|
<Input placeholder="ens18" />
|
|
</Form.Item>
|
|
<Form.Item label={t('routes.col.metric')} name="metric"
|
|
extra={t('routes.metricExtra')}>
|
|
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item label={t('routes.col.table')} name="table_name"
|
|
extra={t('routes.tableExtra')}>
|
|
<Input placeholder="main" />
|
|
</Form.Item>
|
|
<Form.Item label={t('routes.col.comment')} name="comment">
|
|
<Input.TextArea rows={2} />
|
|
</Form.Item>
|
|
<Form.Item label={t('routes.col.active')} name="active" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|