feat: NTP-Server (Chrony) — vollständig
Stub raus, vollständige Implementierung analog Unbound/Squid:
* Migration 0015: ntp_settings (single-row mit listen_addresses,
allow_acl, serve_clients, makestep, rtcsync) + ntp_pools (kind
pool|server, address, iburst/prefer, minpoll/maxpoll). Default
4 deutsche pool.ntp.org-Server seeded.
* Models DNSSettings/NTPPool, services/ntp Repo, handlers/ntp.go
REST /api/v1/ntp/{settings,pools} mit Auto-Restart nach Mutation.
* internal/chrony/chrony.cfg.tpl + chrony.go: Renderer schreibt
/etc/chrony/conf.d/edgeguard.conf direkt (analog unbound — distro
chrony.conf included conf.d automatisch). Listen-bind nur wenn
serve_clients=true; sonst port 0 (= Client-only).
* main.go: ntpRepo + chronyReloader injiziert.
* render.go: chrony als sechste generator.
* postinst:
- chrony als hard Depends im control file.
- Conf-Datei /etc/chrony/conf.d/edgeguard.conf wird als
edgeguard:edgeguard 0644 angelegt.
- Sudoers für systemctl reload + restart chrony.
* Auto-FW-Rule-Generator: udp/123 wenn serve_clients=true und
listen_addresses non-loopback enthält.
* Frontend /ntp: PageHeader + Quellen-Tab + Settings-Tab. Listen-
Addresses als Multi-Select aus Kernel-IPs (analog DNS).
* Sidebar-Eintrag unter Network.
* i18n DE/EN für ntp.* Block.
chrony.service hat kein 'reload' — Renderer ruft RestartService auf.
Verified: 4 default-pool-server connected (chronyc sources zeigt
sie nach erstem render).
Version 1.0.40.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "edgeguard-management-ui",
|
||||
"private": true,
|
||||
"version": "1.0.38",
|
||||
"version": "1.0.40",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -23,6 +23,7 @@ const FirewallPage = lazy(() => import('./pages/Firewall'))
|
||||
const WireguardPage = lazy(() => import('./pages/Wireguard'))
|
||||
const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
|
||||
const DNSPage = lazy(() => import('./pages/DNS'))
|
||||
const NTPPage = lazy(() => import('./pages/NTP'))
|
||||
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||
|
||||
@@ -105,6 +106,7 @@ export default function App() {
|
||||
<Route path="/vpn/wireguard" element={<WireguardPage />} />
|
||||
<Route path="/forward-proxy" element={<ForwardProxyPage />} />
|
||||
<Route path="/dns" element={<DNSPage />} />
|
||||
<Route path="/ntp" element={<NTPPage />} />
|
||||
<Route path="/cluster" element={<ClusterPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NavLink } from 'react-router-dom'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
DashboardOutlined,
|
||||
@@ -54,6 +55,7 @@ const NAV: NavSection[] = [
|
||||
{ path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: <NodeIndexOutlined /> },
|
||||
{ path: '/ssl', labelKey: 'nav.ssl', icon: <SafetyCertificateOutlined /> },
|
||||
{ path: '/dns', labelKey: 'nav.dns', icon: <GlobalOutlined /> },
|
||||
{ path: '/ntp', labelKey: 'nav.ntp', icon: <ClockCircleOutlined /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -73,7 +75,7 @@ const NAV: NavSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.38'
|
||||
const VERSION = '1.0.40'
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"wireguard": "WireGuard",
|
||||
"forwardProxy": "Forward-Proxy",
|
||||
"dns": "DNS",
|
||||
"ntp": "Zeit (NTP)",
|
||||
"firewall": "Firewall",
|
||||
"cluster": "Cluster",
|
||||
"settings": "Einstellungen",
|
||||
@@ -400,6 +401,44 @@
|
||||
"wg": "WireGuard"
|
||||
}
|
||||
},
|
||||
"ntp": {
|
||||
"title": "Zeitserver (Chrony)",
|
||||
"intro": "Chrony als Time-Sync-Daemon (NTP). Quellen oben, Listen-/Serve-Konfig im Settings-Tab. Wenn 'serve_clients' aktiv und LAN-IPs gebound sind, wird die Box selbst zum NTP-Server für das LAN.",
|
||||
"tabs": { "pools": "Quellen", "settings": "Settings" },
|
||||
"pool": {
|
||||
"kind": "Typ",
|
||||
"kindPool": "pool — DNS-Round-Robin (mehrere Server aus A-Records)",
|
||||
"kindServer": "server — einzelner Host",
|
||||
"address": "Adresse / Host",
|
||||
"addressExtra": "FQDN (für pool: 0.de.pool.ntp.org) oder IP.",
|
||||
"iburst": "iburst",
|
||||
"prefer": "prefer",
|
||||
"minpoll": "min-poll",
|
||||
"maxpoll": "max-poll",
|
||||
"options": "Optionen",
|
||||
"description": "Beschreibung",
|
||||
"add": "Quelle hinzufügen",
|
||||
"edit": "Quelle bearbeiten",
|
||||
"deleteConfirm": "NTP-Quelle {{addr}} wirklich löschen?"
|
||||
},
|
||||
"settings": {
|
||||
"intro": "Globale Chrony-Settings. Save reloaded chrony automatisch.",
|
||||
"serveClients": "Als NTP-Server für Clients arbeiten",
|
||||
"serveClientsExtra": "Wenn aus: chrony agiert nur als Client (port 0). Wenn an + Listen-IP: bindet UDP/123.",
|
||||
"listenAddresses": "Listen-Adressen",
|
||||
"listenAddressesPlaceholder": "IPs wählen (oder eintippen)",
|
||||
"listenAddressesExtra": "Auf welchen IPs chrony :123/UDP bindet. 127.0.0.1+::1 = nur lokal; LAN-IPs öffnen für LAN-Clients (FW-Rule wird automatisch generiert).",
|
||||
"allowACL": "Allow-ACL (CIDRs)",
|
||||
"allowACLExtra": "Wer darf NTP-Time anfragen.",
|
||||
"makestepSecs": "makestep secs",
|
||||
"makestepSecsExtra": "Erlaube step (statt slew) wenn offset > N sec.",
|
||||
"makestepLimit": "makestep limit",
|
||||
"rtcsync": "RTC mit System-Time syncen",
|
||||
"rtcsyncExtra": "Hardware-Clock alle 11 min synchron halten — nach Reboot ist die Zeit grob korrekt.",
|
||||
"leapsectz": "Leap-Sec TZ",
|
||||
"leapsectzExtra": "Optional, z.B. 'right/UTC' für leap-sec über tzdata."
|
||||
}
|
||||
},
|
||||
"dns": {
|
||||
"title": "DNS (Unbound)",
|
||||
"intro": "Unbound-Resolver auf :53. Lokale Zonen (authoritativ aus DNS-Records) und Forward-Zonen (per stub-zone weiter zu fremden Resolvern). Default-Forwarder für alles andere.",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"wireguard": "WireGuard",
|
||||
"forwardProxy": "Forward proxy",
|
||||
"dns": "DNS",
|
||||
"ntp": "Time (NTP)",
|
||||
"firewall": "Firewall",
|
||||
"cluster": "Cluster",
|
||||
"settings": "Settings",
|
||||
@@ -400,6 +401,44 @@
|
||||
"wg": "WireGuard"
|
||||
}
|
||||
},
|
||||
"ntp": {
|
||||
"title": "Time server (Chrony)",
|
||||
"intro": "Chrony as time-sync daemon (NTP). Sources on top, listen/serve config on the settings tab. With 'serve_clients' on and LAN-IPs bound, the box itself becomes an NTP server for the LAN.",
|
||||
"tabs": { "pools": "Sources", "settings": "Settings" },
|
||||
"pool": {
|
||||
"kind": "Type",
|
||||
"kindPool": "pool — DNS round-robin (multiple servers from A records)",
|
||||
"kindServer": "server — single host",
|
||||
"address": "Address / host",
|
||||
"addressExtra": "FQDN (for pool: 0.de.pool.ntp.org) or IP.",
|
||||
"iburst": "iburst",
|
||||
"prefer": "prefer",
|
||||
"minpoll": "min-poll",
|
||||
"maxpoll": "max-poll",
|
||||
"options": "Options",
|
||||
"description": "Description",
|
||||
"add": "Add source",
|
||||
"edit": "Edit source",
|
||||
"deleteConfirm": "Really delete NTP source {{addr}}?"
|
||||
},
|
||||
"settings": {
|
||||
"intro": "Global chrony settings. Saves reload chrony automatically.",
|
||||
"serveClients": "Act as NTP server for clients",
|
||||
"serveClientsExtra": "If off: chrony acts as client only (port 0). If on + listen IP: binds UDP/123.",
|
||||
"listenAddresses": "Listen addresses",
|
||||
"listenAddressesPlaceholder": "Pick IPs (or type)",
|
||||
"listenAddressesExtra": "Which IPs chrony binds :123/UDP on. 127.0.0.1+::1 = local only; LAN IPs open for LAN clients (FW rule auto-generated).",
|
||||
"allowACL": "Allow ACL (CIDRs)",
|
||||
"allowACLExtra": "Who is allowed to ask for NTP time.",
|
||||
"makestepSecs": "makestep secs",
|
||||
"makestepSecsExtra": "Allow step (vs. slew) when offset > N seconds.",
|
||||
"makestepLimit": "makestep limit",
|
||||
"rtcsync": "Sync RTC with system time",
|
||||
"rtcsyncExtra": "Keep hardware clock in sync every 11 min — after reboot time is roughly correct.",
|
||||
"leapsectz": "Leap-sec TZ",
|
||||
"leapsectzExtra": "Optional, e.g. 'right/UTC' for leap-sec via tzdata."
|
||||
}
|
||||
},
|
||||
"dns": {
|
||||
"title": "DNS (Unbound)",
|
||||
"intro": "Unbound resolver on :53. Local zones (authoritative from DNS records) and forward zones (stub-zone to remote resolvers). Default forwarders catch everything else.",
|
||||
|
||||
297
management-ui/src/pages/NTP/index.tsx
Normal file
297
management-ui/src/pages/NTP/index.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tabs, Tag, Typography, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ClockCircleOutlined, DatabaseOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface Pool {
|
||||
id: number
|
||||
kind: 'pool' | 'server'
|
||||
address: string
|
||||
iburst: boolean
|
||||
prefer: boolean
|
||||
minpoll?: number | null
|
||||
maxpoll?: number | null
|
||||
active: boolean
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
listen_addresses: string
|
||||
allow_acl: string
|
||||
serve_clients: boolean
|
||||
makestep_secs: number
|
||||
makestep_limit: number
|
||||
rtcsync: boolean
|
||||
leapsectz?: string | null
|
||||
}
|
||||
|
||||
interface SettingsForm extends Omit<Settings, 'listen_addresses'> {
|
||||
listen_addresses: string[]
|
||||
}
|
||||
|
||||
interface SystemIface {
|
||||
ifname: string
|
||||
addr_info?: Array<{ family: 'inet' | 'inet6'; local: string; prefixlen: number }>
|
||||
}
|
||||
|
||||
async function listPools(): Promise<Pool[]> {
|
||||
const r = await apiClient.get('/ntp/pools')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { pools?: Pool[] }).pools ?? []
|
||||
}
|
||||
async function getSettings(): Promise<Settings | null> {
|
||||
const r = await apiClient.get('/ntp/settings')
|
||||
return isEnvelope(r.data) ? (r.data.data as Settings) : null
|
||||
}
|
||||
async function listSystemInterfaces(): Promise<SystemIface[]> {
|
||||
const r = await apiClient.get('/system/interfaces')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { interfaces?: SystemIface[] }).interfaces ?? []
|
||||
}
|
||||
|
||||
export default function NTPPage() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
icon={<ClockCircleOutlined />}
|
||||
title={t('ntp.title')}
|
||||
subtitle={t('ntp.intro')}
|
||||
/>
|
||||
<Tabs
|
||||
defaultActiveKey="pools"
|
||||
items={[
|
||||
{ key: 'pools', label: <span><DatabaseOutlined /> {t('ntp.tabs.pools')}</span>, children: <PoolsTab /> },
|
||||
{ key: 'settings', label: <span><SettingOutlined /> {t('ntp.tabs.settings')}</span>, children: <SettingsTab /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PoolsTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const { data, isLoading } = useQuery({ queryKey: ['ntp', 'pools'], queryFn: listPools })
|
||||
|
||||
const [editing, setEditing] = useState<Pool | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [form] = Form.useForm<Pool>()
|
||||
|
||||
const upsert = useMutation({
|
||||
mutationFn: async (v: Pool) => {
|
||||
if (editing) return (await apiClient.put(`/ntp/pools/${editing.id}`, v)).data
|
||||
return (await apiClient.post('/ntp/pools', v)).data
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(t('common.save'))
|
||||
setEditing(null); setCreating(false); form.resetFields()
|
||||
void qc.invalidateQueries({ queryKey: ['ntp', 'pools'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) => { await apiClient.delete(`/ntp/pools/${id}`) },
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['ntp', 'pools'] }) },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const cols: ColumnsType<Pool> = [
|
||||
{ title: t('ntp.pool.kind'), dataIndex: 'kind', key: 'kind',
|
||||
render: (s: string) => <Tag color={s === 'pool' ? 'blue' : 'purple'}>{s}</Tag> },
|
||||
{ title: t('ntp.pool.address'), dataIndex: 'address', key: 'address',
|
||||
render: (s: string) => <code>{s}</code> },
|
||||
{ title: t('ntp.pool.options'), key: 'options',
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
{row.iburst && <Tag>iburst</Tag>}
|
||||
{row.prefer && <Tag color="gold">prefer</Tag>}
|
||||
{row.minpoll != null && <Text type="secondary">minpoll {row.minpoll}</Text>}
|
||||
{row.maxpoll != null && <Text type="secondary">maxpoll {row.maxpoll}</Text>}
|
||||
</Space>
|
||||
) },
|
||||
{ title: t('ntp.pool.description'), dataIndex: 'description', key: 'description',
|
||||
render: (v?: string | null) => v ?? '—' },
|
||||
{ title: t('common.active'), dataIndex: 'active', key: 'active',
|
||||
render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
<ActionButtons
|
||||
onEdit={() => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue(row)
|
||||
}}
|
||||
onDelete={() => del.mutate(row.id)}
|
||||
deleteConfirm={t('ntp.pool.deleteConfirm', { addr: row.address })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={data ?? []}
|
||||
columns={cols}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ kind: 'pool', iburst: true, prefer: false, active: true } as Pool)
|
||||
}}>
|
||||
{t('ntp.pool.add')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? t('ntp.pool.edit') : t('ntp.pool.add')}
|
||||
open={editing !== null || creating}
|
||||
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
|
||||
onOk={() => { void form.submit() }}
|
||||
confirmLoading={upsert.isPending}
|
||||
width={580}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
|
||||
<Form.Item label={t('ntp.pool.kind')} name="kind" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ value: 'pool', label: t('ntp.pool.kindPool') },
|
||||
{ value: 'server', label: t('ntp.pool.kindServer') },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('ntp.pool.address')} name="address" rules={[{ required: true }]}
|
||||
extra={t('ntp.pool.addressExtra')}>
|
||||
<Input placeholder="0.de.pool.ntp.org oder 10.0.0.10" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Form.Item label={t('ntp.pool.iburst')} name="iburst" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('ntp.pool.prefer')} name="prefer" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space>
|
||||
<Form.Item label={t('ntp.pool.minpoll')} name="minpoll">
|
||||
<InputNumber min={0} max={17} style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('ntp.pool.maxpoll')} name="maxpoll">
|
||||
<InputNumber min={0} max={17} style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item label={t('ntp.pool.description')} name="description">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('common.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const { data, isLoading } = useQuery({ queryKey: ['ntp', 'settings'], queryFn: getSettings })
|
||||
const { data: sys } = useQuery({ queryKey: ['system', 'interfaces'], queryFn: listSystemInterfaces })
|
||||
const [form] = Form.useForm<SettingsForm>()
|
||||
|
||||
const ipOptions: { value: string; label: string }[] = [
|
||||
{ value: '0.0.0.0', label: '0.0.0.0 — alle IPv4-Interfaces' },
|
||||
{ value: '::', label: ':: — alle IPv6-Interfaces' },
|
||||
{ value: '127.0.0.1', label: '127.0.0.1 — Loopback IPv4' },
|
||||
{ value: '::1', label: '::1 — Loopback IPv6' },
|
||||
]
|
||||
for (const i of sys ?? []) {
|
||||
if (i.ifname === 'lo') continue
|
||||
for (const a of i.addr_info ?? []) {
|
||||
ipOptions.push({
|
||||
value: a.local,
|
||||
label: `${a.local} — ${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const initial: SettingsForm | undefined = data ? {
|
||||
...data,
|
||||
listen_addresses: data.listen_addresses.split(',').map(s => s.trim()).filter(Boolean),
|
||||
} : undefined
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: SettingsForm) => {
|
||||
const body: Settings = { ...v, listen_addresses: v.listen_addresses.join(', ') }
|
||||
return (await apiClient.put('/ntp/settings', body)).data
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(t('common.save'))
|
||||
void qc.invalidateQueries({ queryKey: ['ntp', 'settings'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
if (isLoading) return null
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={initial}
|
||||
onFinish={(v) => save.mutate(v)}
|
||||
style={{ maxWidth: 720 }}
|
||||
>
|
||||
<Alert type="info" showIcon className="mb-12" message={t('ntp.settings.intro')} />
|
||||
<Form.Item label={t('ntp.settings.serveClients')} name="serve_clients" valuePropName="checked"
|
||||
extra={t('ntp.settings.serveClientsExtra')}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('ntp.settings.listenAddresses')} name="listen_addresses"
|
||||
rules={[{ required: true, type: 'array', min: 1 }]}
|
||||
extra={t('ntp.settings.listenAddressesExtra')}>
|
||||
<Select mode="tags" options={ipOptions} showSearch optionFilterProp="value"
|
||||
placeholder={t('ntp.settings.listenAddressesPlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('ntp.settings.allowACL')} name="allow_acl" rules={[{ required: true }]}
|
||||
extra={t('ntp.settings.allowACLExtra')}>
|
||||
<Input placeholder="127.0.0.0/8, 10.0.0.0/8" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Form.Item label={t('ntp.settings.makestepSecs')} name="makestep_secs"
|
||||
extra={t('ntp.settings.makestepSecsExtra')}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('ntp.settings.makestepLimit')} name="makestep_limit">
|
||||
<InputNumber min={-1} max={100} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item label={t('ntp.settings.rtcsync')} name="rtcsync" valuePropName="checked"
|
||||
extra={t('ntp.settings.rtcsyncExtra')}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('ntp.settings.leapsectz')} name="leapsectz"
|
||||
extra={t('ntp.settings.leapsectzExtra')}>
|
||||
<Input placeholder="right/UTC" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user