Squashed commit of the following:

commit a394259f0a
Author: Sense T <me@sense-t.eu.org>
Date:   Fri Apr 19 15:10:08 2024 +0800

    done

commit af9d966376
Author: Sense T <me@sense-t.eu.org>
Date:   Fri Apr 19 13:52:33 2024 +0800

    debug done.

commit 47335ca5e9
Author: Sense T <me@sense-t.eu.org>
Date:   Fri Apr 19 12:47:00 2024 +0800

    swagger done

commit 34fb2a478b
Author: Sense T <me@sense-t.eu.org>
Date:   Fri Apr 19 10:05:19 2024 +0800

    stage 2, not completed

commit 88b2255f8b
Author: Sense T <me@sense-t.eu.org>
Date:   Fri Apr 19 09:44:37 2024 +0800

    test stage 1

commit b583720223
Author: Sense T <me@sense-t.eu.org>
Date:   Mon Apr 15 21:53:09 2024 +0800

    Squashed commit of the following:

    commit 1e92328a0fc570fe9419ad5dbaaef77f7dc9ad2e
    Author: Sense T <me@sense-t.eu.org>
    Date:   Mon Apr 15 21:52:44 2024 +0800

        yes, react it!

    commit 09fffff6139b4cecb81cb1444139f225e95e8917
    Author: Sense T <me@sense-t.eu.org>
    Date:   Mon Apr 15 17:33:26 2024 +0800

        actions to be done

    commit 1611b0b338cfd965d15f43fb10308bc56015895f
    Author: Sense T <me@sense-t.eu.org>
    Date:   Mon Apr 15 15:22:08 2024 +0800

        modal needed.

    commit 88453e7382618fb6774ff1cc4c0f7045d4dfcf46
    Author: Sense T <me@sense-t.eu.org>
    Date:   Mon Apr 15 10:52:13 2024 +0800

        Domain View done

    commit 8cedca27c79ca2ba69c8777dfcb6019799875e31
    Author: Sense T <me@sense-t.eu.org>
    Date:   Sun Apr 14 21:24:14 2024 +0800

        domain delete modal done

    commit 60cd00c0cad0774bae5b57bcfc4723a29d28d221
    Author: Sense T <me@sense-t.eu.org>
    Date:   Sun Apr 14 07:55:11 2024 +0800

        1

    commit 285853e988db6e6a6371135869da0129fd73afd7
    Author: Sense T <me@sense-t.eu.org>
    Date:   Sat Apr 13 17:29:43 2024 +0800

        eslint

    commit 8f0ffbf744fd85a612daacd7bd6cbc45d58907d3
    Author: Sense T <me@sense-t.eu.org>
    Date:   Sat Apr 13 17:20:50 2024 +0800

        f

    commit 9762b632225f185d83388e58d93ed49f62fe6b3f
    Author: Sense T <me@sense-t.eu.org>
    Date:   Sat Apr 13 17:08:37 2024 +0800

        views, components to be done

    commit 321e5255f2b1e705844179dd910d5f5a1ae58298
    Author: Sense T <me@sense-t.eu.org>
    Date:   Sat Apr 13 14:29:04 2024 +0800

        prepare for react

commit 3305d8d618
Author: Sense T <me@sense-t.eu.org>
Date:   Sat Apr 13 10:30:02 2024 +0800

    swagger to be done

commit 2c754e7eec
Author: Sense T <me@sense-t.eu.org>
Date:   Sat Apr 13 10:14:45 2024 +0800

    validate 'em !

commit 7b529ad8f6
Author: Sense T <me@sense-t.eu.org>
Date:   Sat Apr 13 09:22:27 2024 +0800

    try to avoid nil point panic

commit 0012a697cb
Author: Sense T <me@sense-t.eu.org>
Date:   Fri Apr 12 22:26:35 2024 +0800

    fix some bug

commit a098d3056c
Author: Sense T <me@sense-t.eu.org>
Date:   Fri Apr 12 20:03:34 2024 +0800

    web debug done

commit 01765c4e7f
Author: Sense T <me@sense-t.eu.org>
Date:   Fri Apr 12 15:16:52 2024 +0800

    all tsx used, no vue SFC

commit 731504ae82
Author: Sense T <me@sense-t.eu.org>
Date:   Thu Apr 11 22:05:58 2024 +0800

    tsx used - stage 2

commit b669a3e68e
Author: Sense T <me@sense-t.eu.org>
Date:   Thu Apr 11 16:18:11 2024 +0800

    use tsx for compoents stage 1

commit 2ab1b0bf1b
Author: Sense T <me@sense-t.eu.org>
Date:   Thu Apr 11 12:10:57 2024 +0800

    rr validation

commit 58c66fc3a8
Author: Sense T <me@sense-t.eu.org>
Date:   Thu Apr 11 11:41:33 2024 +0800

    stage 1

commit 7a5fcf1972
Author: Sense T <me@sense-t.eu.org>
Date:   Thu Apr 11 10:51:50 2024 +0800

    long options supported

commit c3b80093d2
Author: Sense T <me@sense-t.eu.org>
Date:   Thu Apr 11 10:51:33 2024 +0800

    for develop use

commit 7f52707323
Author: Sense T <me@sense-t.eu.org>
Date:   Thu Apr 11 10:51:24 2024 +0800

    fix typo

commit 9cc2696bbe
Author: Sense T <me@sense-t.eu.org>
Date:   Wed Apr 10 16:53:03 2024 +0800

    record data validate done

commit 5e2ae637a0
Author: Sense T <me@sense-t.eu.org>
Date:   Wed Apr 10 14:56:15 2024 +0800

    end with dot.

commit ed4fee935d
Author: Sense T <me@sense-t.eu.org>
Date:   Wed Apr 10 13:41:32 2024 +0800

    content safety

commit 29f75938bb
Author: Sense T <me@sense-t.eu.org>
Date:   Wed Apr 10 13:24:01 2024 +0800

    cmd is ok

commit 9465bb885d
Author: Sense T <me@sense-t.eu.org>
Date:   Wed Apr 10 11:00:47 2024 +0800

    web done

commit 65bf461d44
Author: Sense T <me@sense-t.eu.org>
Date:   Wed Apr 10 11:00:38 2024 +0800

    use tokei for stat

commit 61395ab61b
Author: Sense T <me@sense-t.eu.org>
Date:   Tue Apr 9 21:53:12 2024 +0800

    errors handler

commit 9752e7d9ae
Author: Sense T <me@sense-t.eu.org>
Date:   Tue Apr 9 21:16:19 2024 +0800

    model with generics done

commit 7dd3af3707
Author: Sense T <me@sense-t.eu.org>
Date:   Tue Apr 9 16:28:18 2024 +0800

    use DAO

commit 2369734230
Author: Sense T <me@sense-t.eu.org>
Date:   Tue Apr 9 13:06:31 2024 +0800

    dao for future

commit e18781ba25
Author: Sense T <me@sense-t.eu.org>
Date:   Tue Apr 9 11:36:34 2024 +0800

    DotEnd

commit 613ef7fdd9
Author: Sense T <me@sense-t.eu.org>
Date:   Tue Apr 9 10:16:06 2024 +0800

    record should endwith .

commit c93e8107dc
Author: Sense T <me@sense-t.eu.org>
Date:   Tue Apr 9 10:06:47 2024 +0800

    update regexp

commit 84e9961f4b
Author: Sense T <me@sense-t.eu.org>
Date:   Tue Apr 9 08:30:32 2024 +0800

    error log

commit db77b0fdb2
Author: Sense T <me@sense-t.eu.org>
Date:   Tue Apr 9 08:25:25 2024 +0800

    no console log

commit 0c197820a0
Author: Sense T <me@sense-t.eu.org>
Date:   Tue Apr 9 08:25:01 2024 +0800

    use flags for validate

commit 33c9050653
Author: Sense T <me@sense-t.eu.org>
Date:   Tue Apr 9 07:58:27 2024 +0800

    SOA Email Format

commit fb9c78efed
Author: Sense T <me@sense-t.eu.org>
Date:   Tue Apr 9 00:19:03 2024 +0800

    no debug

commit 1a7bf83cb9
Author: Sense T <me@sense-t.eu.org>
Date:   Tue Apr 9 00:18:18 2024 +0800

    1

commit e72de14797
Author: Sense T <me@sense-t.eu.org>
Date:   Mon Apr 8 17:30:25 2024 +0800

    last modal

commit e884840b7d
Author: Sense T <me@sense-t.eu.org>
Date:   Mon Apr 8 15:56:03 2024 +0800

    add

commit 36b0384319
Author: Sense T <me@sense-t.eu.org>
Date:   Mon Apr 8 15:02:55 2024 +0800

    delete domain modal done

commit 753e950fae
Author: Sense T <me@sense-t.eu.org>
Date:   Mon Apr 8 13:49:11 2024 +0800

    add domainRemovemodal

commit 69613f9b6e
Author: Sense T <me@sense-t.eu.org>
Date:   Mon Apr 8 13:32:01 2024 +0800

    modal needed for edit

commit 8c0b79066f
Author: Sense T <me@sense-t.eu.org>
Date:   Mon Apr 8 09:37:32 2024 +0800

    base UI

commit a67b2d7724
Author: Sense T <me@sense-t.eu.org>
Date:   Sun Apr 7 21:07:20 2024 +0800

    route update

commit 5a266e9e6c
Author: Sense T <me@sense-t.eu.org>
Date:   Sun Apr 7 14:36:55 2024 +0800

    ui base data struct

commit 3449df913c
Author: Sense T <me@sense-t.eu.org>
Date:   Sun Apr 7 13:08:45 2024 +0800

    web store for dev

commit 156bf651dd
Author: Sense T <me@sense-t.eu.org>
Date:   Sun Apr 7 13:08:30 2024 +0800

    store friendly

commit d90e949472
Author: Sense T <me@sense-t.eu.org>
Date:   Sun Apr 7 10:08:02 2024 +0800

    base code update

commit 0a20b5a670
Author: Sense T <me@sense-t.eu.org>
Date:   Sun Apr 7 10:07:26 2024 +0800

    metrics

commit bdd4866c10
Author: Sense T <me@sense-t.eu.org>
Date:   Wed Apr 3 22:37:15 2024 +0800

    all api done

commit 8a8ea59b71
Author: Sense T <me@sense-t.eu.org>
Date:   Wed Apr 3 17:05:12 2024 +0800

    1
This commit is contained in:
Sense T
2024-04-19 15:16:24 +08:00
parent 94a126086e
commit 021ec9c8f6
72 changed files with 11469 additions and 89 deletions

12
web/.eslintrc Normal file
View File

@@ -0,0 +1,12 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
]
}

30
web/README.md Normal file
View File

@@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3750
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
web/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.3.6",
"antd": "^5.16.1",
"axios": "^1.6.8",
"i18next": "^23.11.1",
"i18next-browser-languagedetector": "^7.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^14.1.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.3"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.6.0",
"@typescript-eslint/parser": "^7.6.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"typescript": "^5.4.5",
"vite": "^5.2.0"
}
}

42
web/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

34
web/src/App.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { RouterProvider } from 'react-router-dom'
import router from './router'
import { App, ConfigProvider, Spin, theme } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import enUS from 'antd/locale/en_US'
import './App.css'
import isBrowserDarkTheme from './isBrowserDarkTheme'
import i18n from './locale'
function detectLanguage() {
switch (i18n.language) {
case 'zh-CN':
case 'zh':
return zhCN
default:
return enUS
}
}
function ReactApp() {
document.title = 'reCoreD-UI'
const themeUsed = isBrowserDarkTheme() ? theme.darkAlgorithm : theme.defaultAlgorithm
return (
<ConfigProvider theme={{ algorithm: themeUsed }} locale={detectLanguage()}>
<App>
<RouterProvider router={router} fallbackElement={<Spin size='large' />} />
</App>
</ConfigProvider>
)
}
export default ReactApp

92
web/src/api/index.ts Normal file
View File

@@ -0,0 +1,92 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios"
import { type Record } from '../stores/records'
import { type Domain } from "../stores/domains"
import i18n from "../locale"
type Result<T> = {
success: boolean
message: string
data: T
}
const t = i18n.t
// 5 second.
const notificationDuration = 5000
const messages = new Map<number, {
message: string, description: string, duration: number
}>(
[
[400, {
message: t("api.error400.title"),
description: t("api.error400.content"),
duration: notificationDuration
}],
[401, {
message: t("api.error401.title"),
description: t("api.error401.content"),
duration: notificationDuration
}],
[403, {
message: t("api.error403.title"),
description: t("api.error403.content"),
duration: notificationDuration
}],
[404, {
message: t("api.error404.title"),
description: t("api.error404.content"),
duration: notificationDuration
}],
[500, {
message: t("api.error500.title"),
description: t("api.error500.content"),
duration: notificationDuration
}]
]
)
export interface ResponseError {
response: {
status: number
}
}
export function getErrorInfo(err: ResponseError) {
const msg = messages.get(err.response.status)
return msg ? msg : {
message: t("api.errorUnknown.title"),
description: t("api.errorUnknown.content"),
duration: notificationDuration
}
}
export class Request {
private instance: AxiosInstance;
private baseConfig: AxiosRequestConfig = { baseURL: "api/v1" }
constructor(config: AxiosRequestConfig) {
this.instance = axios.create(Object.assign(this.baseConfig, config))
}
public request(config: AxiosRequestConfig): Promise<AxiosResponse> {
return this.instance.request(config)
}
public get<T = Record[] | Domain[]>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<Result<T>>> {
return this.instance.get(url, config)
}
public post<T = Record | Domain>(url: string, data?: T, config?: AxiosRequestConfig): Promise<AxiosResponse<Result<T>>> {
return this.instance.post(url, data, config)
}
public put<T = Record | Domain>(url: string, data?: T, config?: AxiosRequestConfig): Promise<AxiosResponse<Result<null>>> {
return this.instance.put(url, data, config)
}
public delete(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<Result<null>>> {
return this.instance.delete(url, config)
}
}
export default new Request({})

View File

@@ -0,0 +1,32 @@
import { Card, Tooltip } from "antd"
import { Domain } from "../../stores/domains"
import { BookOutlined, DeleteOutlined, EditOutlined } from "@ant-design/icons"
import DomainInfo from "./DomainInfo"
import i18n from '../../locale'
const { t } = i18n
type DomainCardProps = {
domain: Domain
onRecordClick(): void
onEditClick(): void
onDeleteClick(): void
}
export default function DomainCard({ domain, onRecordClick, onEditClick, onDeleteClick }: DomainCardProps) {
return (
<Card className='domain-info' title={domain.domain_name} actions={[
<Tooltip title={t('domains.dnsRecord')}>
<BookOutlined key='records' className='icon' onClick={onRecordClick} />
</Tooltip>,
<Tooltip title={t('common.edit')} >
<EditOutlined key='edit' className='icon' onClick={onEditClick} />
</Tooltip>,
<Tooltip title={t('common.delete')}>
<DeleteOutlined key='delete' className='icon' onClick={onDeleteClick} />
</Tooltip>
]} key={domain.id}>
<DomainInfo domain={domain} />
</Card>
)
}

View File

@@ -0,0 +1,59 @@
import { App, Input, Modal } from "antd"
import { Domain } from "../../stores/domains"
import { useState } from "react"
import i18n from '../../locale'
import { CloseOutlined, DeleteOutlined } from "@ant-design/icons"
import { ResponseError, getErrorInfo } from "../../api"
const { t } = i18n
type Props = {
open: boolean
domain: Domain
removeDomain(domain: Domain): Promise<void>
onOk(): void
onCancel(): void
}
export default function DomainDeleteModal({ open, domain, removeDomain, onOk, onCancel }: Props) {
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const { notification } = App.useApp()
function confirm() {
setLoading(true)
removeDomain(domain).then(onOk).finally(() => setLoading(false)).catch(e => {
const msg = getErrorInfo(e as ResponseError)
notification.error(msg)
console.error(e)
})
}
return (
<Modal onOk={confirm} onCancel={onCancel}
title={`${t('domains.delete')} - ${domain.domain_name}`}
confirmLoading={loading}
okButtonProps={{
disabled: input !== domain.domain_name,
icon: <DeleteOutlined />,
danger: true
}}
cancelButtonProps={{
icon: <CloseOutlined />
}}
open={open}
closeIcon={false}
maskClosable={false}
centered
destroyOnClose
>
<p>{t('common.deleteConfirm')}</p>
<p>{t('domains.deleteHint')}</p>
<p>{t('domains.confirm1')} <b id="boldit">{domain.domain_name}</b> {t('domains.confirm2')}</p>
<p />
<p>
<Input placeholder={domain.domain_name} onChange={e => setInput(e.target.value)} />
</p>
</Modal>
)
}

View File

@@ -0,0 +1,132 @@
import { App, Form, FormInstance, Input, InputNumber, Modal, Space } from "antd"
import { Domain } from "../../stores/domains"
import i18n from '../../locale'
import { useEffect, useState } from "react"
import { CheckOutlined, CloseOutlined } from "@ant-design/icons"
import { ResponseError, getErrorInfo } from "../../api"
const { t } = i18n
type Props = {
open: boolean
domain: Domain
editDomain(domain: Domain): Promise<void>
createDomain(domain: Domain): Promise<void>
onCancel(): void
onOk(): void
}
export default function DomainEditModal({ open, domain, editDomain, createDomain, onCancel, onOk }: Props) {
const [loading, setLoading] = useState(false)
const [form] = Form.useForm<Domain>()
const { notification } = App.useApp()
useEffect(() => {
form.setFieldsValue(domain)
}, [open])
async function confirm() {
const commitFunction = (!domain.id || domain.id < 1) ? createDomain : editDomain
setLoading(true)
try {
domain = await form.validateFields()
await commitFunction(domain)
onOk()
} catch (error) {
const msg = getErrorInfo(error as ResponseError)
notification.error(msg)
console.error(error)
} finally {
setLoading(false)
}
}
function easyInput(form: FormInstance<Domain>, domain_name: string) {
form.setFieldValue('admin_email', `admin@${domain_name}`)
form.setFieldValue('main_dns', `ns1.${domain_name}`)
}
return (
<Modal
onCancel={onCancel} onOk={confirm}
title={
<span>
{
(!domain || !domain.id || domain.id < 1) ? t('common.new') : t('common.edit')
}
{
t('domains._')
}
</span>
}
confirmLoading={loading}
cancelButtonProps={{
icon: <CloseOutlined />,
}}
okButtonProps={{
icon: <CheckOutlined />,
htmlType: 'submit'
}}
open={open}
closeIcon={false}
maskClosable={false}
centered
destroyOnClose
forceRender
>
<Form<Domain> name="domain" form={form}
scrollToFirstError
autoComplete="off"
>
<Form.Item<Domain> hidden name='id' />
<Form.Item<Domain>
label={t('domains._')}
name='domain_name'
rules={[
{ required: true, message: t('common.mandatory') },
{ pattern: /^([\w-]+\.)+[\w-]+$/, message: t('domains.errors.domainName') },
]}
>
<Input placeholder='example.com' onChange={v => easyInput(form, v.target.value)} />
</Form.Item>
<Form.Item<Domain>
label={t('domains.form.mainDNS')}
name='main_dns'
rules={[
{ required: true, message: t('common.mandatory') },
{ pattern: /^([\w-]+\.)+[\w-]+$/, message: t('domains.errors.domainName') },
]}
>
<Input placeholder="ns1.example.com" />
</Form.Item>
<Form.Item<Domain>
label={t('domains.form.adminMail')}
name='admin_email'
rules={[
{ required: true, message: t('common.mandatory') },
{ pattern: /^[\w-.]+@([\w-]+\.)+[\w-]+$/, message: t('domains.errors.mail') }
]}
>
<Input placeholder="admin@example.com" />
</Form.Item>
<Space>
<Form.Item<Domain> name='refresh_interval' label={t('records.refresh')}>
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
</Form.Item>
<Form.Item<Domain> name='retry_interval' label={t('records.retry')}>
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
</Form.Item>
</Space>
<Space>
<Form.Item<Domain> name='expiry_period' label={t('records.expire')}>
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
</Form.Item>
<Form.Item<Domain> name='negative_ttl' label={t('records.ttl')}>
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
</Form.Item>
</Space>
</Form>
</Modal>
)
}

View File

@@ -0,0 +1,7 @@
.icon-info {
transform: translateY(1px);
}
span.info {
padding-left: 0.5em;
}

View File

@@ -0,0 +1,22 @@
import { GlobalOutlined, MailOutlined } from "@ant-design/icons"
import { Domain } from "../../stores/domains"
import './DomainInfo.css'
type Props = {
domain: Domain
}
export default function DomainInfo({ domain }: Props) {
return (
<>
<p>
<MailOutlined className="icon-info" />
<span className="info">{domain.admin_email}</span>
</p>
<p>
<GlobalOutlined className="icon-info" />
<span className="info">{domain.main_dns}</span>
</p>
</>
)
}

View File

@@ -0,0 +1,261 @@
import { App, Form, Input, InputNumber, Modal, Select } from 'antd'
import i18n from '../../locale'
import { AAAARecord, ARecord, CAARecord, CNAMERecord, MXRecord, NSRecord, Record, RecordTypes, SRVRecord, TXTRecord } from '../../stores/records'
import { useEffect, useState } from 'react'
import { CheckOutlined, CloseOutlined } from '@ant-design/icons'
import { ResponseError, getErrorInfo } from '../../api'
import { FormInstance } from 'antd/lib/form/Form'
const { t } = i18n
type Props = {
open: boolean
record: Record
//domain: string
onCancel(): void
onOk(): void
editRecord(record: Record): Promise<void>
createRecord(record: Record): Promise<void>
}
const recordTypeOptions = Object.entries(RecordTypes).filter(e => e[1] !== RecordTypes.RecordTypeSOA).map(e => {
return {
value: e[1],
label: e[1]
}
})
export default function RecordEditModal({ open, record, onOk, onCancel, editRecord, createRecord }: Props) {
const [loading, setLoading] = useState(false)
const [form] = Form.useForm<Record>()
const { notification } = App.useApp()
useEffect(() => { form.setFieldsValue(record) }, [open])
async function confirm() {
const commitFunction = (!record.id || record.id < 1) ? createRecord : editRecord
setLoading(true)
try {
record = await form.validateFields()
await commitFunction(record)
onOk()
} catch (error) {
const msg = getErrorInfo(error as ResponseError)
notification.error(msg)
console.error(error)
} finally {
setLoading(false)
}
}
const controls = new Map<RecordTypes, (f: FormInstance<Record>) => JSX.Element>([
[
RecordTypes.RecordTypeA, (
({ getFieldValue }) =>
<Form.Item<Record<ARecord>> label='IP' name={['content', 'ip']} required rules={[{
validator() {
const result = ARecord.validate(getFieldValue('content') as ARecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
)
],
[
RecordTypes.RecordTypeAAAA, (
({ getFieldValue }) =>
<Form.Item<Record<AAAARecord>> label='IP' name={['content', 'ip']} required rules={[{
validator() {
const result = AAAARecord.validate(getFieldValue('content') as AAAARecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
)
],
[
RecordTypes.RecordTypeCNAME, (
({ getFieldValue }) =>
<Form.Item<Record<CNAMERecord>> label={t('records.form.host')} required name={['content', 'host']}
rules={[{
validator() {
const result = CNAMERecord.validate(getFieldValue('content') as CNAMERecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
)
],
[
RecordTypes.RecordTypeNS, (
({ getFieldValue }) =>
<Form.Item<Record<NSRecord>> label={t('records.form.host')} name={['content', 'host']} required
rules={[{
validator() {
const result = NSRecord.validate(getFieldValue('content') as NSRecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
)
],
[
RecordTypes.RecordTypeTXT, (
({ getFieldValue }) =>
<Form.Item<Record<TXTRecord>> label={t('records.form.text')} name={['content', 'text']} required
rules={[{
validator() {
const result = TXTRecord.validate(getFieldValue('content') as TXTRecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
)
],
[
RecordTypes.RecordTypeMX, (
({ getFieldValue }) =>
<>
<Form.Item<Record<MXRecord>> label={t('records.form.host')} name={['content', 'host']} required
rules={[{
validator() {
const result = MXRecord.validate(getFieldValue('content') as MXRecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
<Form.Item<Record<MXRecord>> label={t('records.form.preference')} name={['content', 'preference']} required>
<InputNumber controls={false} />
</Form.Item>
</>
)
],
[
RecordTypes.RecordTypeCAA, (
({ getFieldValue }) =>
<>
<Form.Item<Record<CAARecord>> label={t('records.form.flag')} name={['content', 'flag']} required>
<InputNumber controls={false} />
</Form.Item>
<Form.Item<Record<CAARecord>> label={t('records.form.tag')} name={['content', 'tag']} required rules={[{
validator() {
const result = CAARecord.validate(getFieldValue('content') as CAARecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
<Form.Item<Record<CAARecord>> label={t('records.form.value')} name={['content', 'value']} required rules={[
{ required: true, message: t('common.mandatory') }
]} >
<Input />
</Form.Item>
</>
)
],
[
RecordTypes.RecordTypeSRV, (
({ getFieldValue }) =>
<>
<Form.Item<Record<SRVRecord>> label={t('records.form.priority')} name={['content', 'priority']} required>
<InputNumber controls={false} />
</Form.Item>
<Form.Item<Record<SRVRecord>> label={t('records.form.weight')} name={['content', 'weight']} required>
<InputNumber controls={false} />
</Form.Item>
<Form.Item<Record<SRVRecord>> label={t('records.form.port')} name={['content', 'port']} required>
<InputNumber controls={false} />
</Form.Item>
<Form.Item<Record<SRVRecord>> label={t('records.form.target')} name={['content', 'target']} required rules={[{
validator() {
const result = SRVRecord.validate(getFieldValue('content') as SRVRecord)
return (result === true) ? Promise.resolve() : Promise.reject(result)
}
}]}>
<Input />
</Form.Item>
</>
)
]
])
return (
<Modal
onCancel={onCancel} onOk={confirm}
title={
<span>
{
(!record || !record.id || record.id < 1) ? t('common.new') : t('common.edit')
}
{
t('records._')
}
</span>
}
confirmLoading={loading}
cancelButtonProps={{
icon: <CloseOutlined />,
}}
okButtonProps={{
icon: <CheckOutlined />,
htmlType: 'submit'
}}
open={open}
closeIcon={false}
maskClosable={false}
centered
destroyOnClose
forceRender
>
<Form<Record> name='record' form={form}
scrollToFirstError
autoComplete='off'
labelCol={{ span: 4 }}
wrapperCol={{ span: 20 }}
validateTrigger='onBlur'
>
<Form.Item<Record> hidden name='id' />
<Form.Item<Record> hidden name='zone' />
<Form.Item<Record> label={t('records.recordType')} required name='record_type'>
<Select allowClear={false} options={recordTypeOptions} />
</Form.Item>
<Form.Item<Record> label={t('records.name')} required name='name' rules={[
{
validator(_rule, value) {
const result = Record.validateName(value)
return (result === true) ? Promise.resolve() : Promise.reject(result)
},
}
]}>
<Input />
</Form.Item>
<Form.Item<Record> label='TTL' required name='ttl'>
<InputNumber controls={false} addonAfter={t('common.unitForSecond')} />
</Form.Item>
<Form.Item<Record> noStyle shouldUpdate={(p, c) => p.record_type !== c.record_type}>
{
({ getFieldValue }: FormInstance<Record>) => {
const e = controls.get(getFieldValue('record_type'))
if (!e) {
return <></>
}
return e({ getFieldValue } as FormInstance<Record>)
}
}
</Form.Item>
</Form>
</Modal>
)
}

View File

@@ -0,0 +1,26 @@
import { Button, Flex, Popconfirm, Tooltip } from "antd"
import { DeleteOutlined, EditFilled } from "@ant-design/icons"
import i18n from '../../locale'
const { t } = i18n
type Props = {
onEdit(): void
onDelete(): void
}
export default function RecordOps({ onEdit, onDelete }: Props) {
return (
<Flex justify="end" gap='small'>
<Tooltip title={t("common.edit")}>
<Button icon={<EditFilled />} size="small" onClick={onEdit}/>
</Tooltip>
<Popconfirm onConfirm={onDelete} title={t("common.deleteConfirm")}>
<Tooltip title={t("common.delete")}>
<Button danger type="primary" icon={<DeleteOutlined />} size="small" />
</Tooltip>
</Popconfirm>
</Flex>
)
}

78
web/src/index.css Normal file
View File

@@ -0,0 +1,78 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
--records-layout-header-bgcolor: #eee;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
.records-layout-header{
background-color: #eee;
}
}
@media (prefers-color-scheme: dark) {
:root {
--records-layout-header-bgcolor: #333;
}
}

View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
const isBrowserDarkTheme = () => {
const [isDarkTheme, setIsDarkTheme] = useState(false);
useEffect(() => {
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: { matches: boolean | ((prevState: boolean) => boolean); }) => setIsDarkTheme(e.matches);
mediaQueryList.addEventListener('change', handleChange);
setIsDarkTheme(mediaQueryList.matches);
return () => mediaQueryList.removeEventListener('change', handleChange);
}, []);
return isDarkTheme
};
export default isBrowserDarkTheme;

98
web/src/locale/en.ts Normal file
View File

@@ -0,0 +1,98 @@
export default {
common: {
delete: 'Remove',
remove: 'Remove',
deleteConfirm: 'Are you sure?',
removeConfirm: 'Are you sure?',
edit: 'Edit',
add: 'New',
new: 'New',
cancel: 'Cancel',
confirm: 'OK',
mandatory: 'This field is mandatory',
unitForSecond: 'Second(s)'
},
api: {
error400: {
title: 'Bad Request (400)',
content: 'Bad Parameters'
},
error401: {
title: 'Unauthorized (401)',
content: 'Refresh page and relogin'
},
error403: {
title: 'Forbbiden (403)',
content: 'Permission denied'
},
error404: {
title: 'Not Found (404)',
content: 'No such content'
},
error500: {
title: "Internal Server Error (500)",
content: "Check server log, please"
},
errorUnknown: {
title: "Unknown Error",
content: "Open console for details",
}
},
domains: {
'_': 'Domain',
dnsRecord: 'DNS Record',
delete: 'Remove Domain',
deleteHint: 'All records of this domain will be WIPED!',
confirm1: 'Please input',
confirm2: 'for comfirmation',
form: {
adminMail: 'Admin Email',
mainDNS: 'Main DNS',
},
errors: {
domainName: 'Invalid domain name',
mail: 'Invalid email',
}
},
records: {
'_': 'Record',
name: 'Resource Record',
recordType: 'Type',
content: 'Record',
search: 'Search...',
refresh: 'Refresh Interval',
retry: 'Retry Interval',
expire: 'Expiry Period',
ttl: 'Negative TTL',
form: {
text: 'Text',
host: 'Host',
preference: 'Preference',
priority: 'Priority',
weight: 'Weight',
port: 'Port',
target: 'Target',
flag: 'Flag',
tag: 'Tag',
value: 'Value'
},
errors: {
endWithDot: 'should end with a dot',
hasSpace: 'shoule have no space',
badIPv4: 'invalid IPv4 address',
badIPv6: 'invalid IPv6 address',
badEmail: 'no @ for this email address',
badName: {
dotAndMinus: 'should not start or end with "." "-"',
doubleDots: 'should have no contianus "."',
logerThan63: 'should not longer than 63 characters splited by "."'
},
tooLong: 'too long'
}
}
}

24
web/src/locale/index.ts Normal file
View File

@@ -0,0 +1,24 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import zh from './zh'
import en from './en'
i18n.use(LanguageDetector).use(initReactI18next)
.init({
debug: import.meta.env.DEV,
fallbackLng: 'zh',
interpolation: {
escapeValue: false,
},
resources: {
en: {
translation: en
},
zh: {
translation: zh
}
}
})
export default i18n

98
web/src/locale/zh.ts Normal file
View File

@@ -0,0 +1,98 @@
export default {
common: {
delete: '删除',
remove: '删除',
deleteConfirm: '确定要删除吗?',
removeConfirm: '确定要删除吗?',
edit: '修改',
add: '新增',
new: '新增',
cancel: '取消',
confirm: '确定',
mandatory: '此项必填',
unitForSecond: '秒'
},
api: {
error400: {
title: '请求错误 (400)',
content: '参数提交错误'
},
error401: {
title: '未授权 (401)',
content: '请刷新页面重新登录'
},
error403: {
title: '拒绝访问 (403)',
content: '你没有权限!'
},
error404: {
title: '查无此项 (404)',
content: '没有该项内容'
},
error500: {
title: "服务器错误 (500)",
content: "请检查系统日志"
},
errorUnknown: {
title: "未知错误",
content: "请打开控制台了解详情",
}
},
domains: {
'_': '域名',
dnsRecord: 'DNS 记录',
delete: '删除域名',
deleteHint: '该域名所有记录将被删除!',
confirm1: '请输入',
confirm2: '以确认要删除的域名',
form: {
adminMail: '管理员邮箱',
mainDNS: '主 DNS 服务器',
},
errors: {
domainName: '这不是一个有效的域名',
mail: '这不是一个有效的邮箱',
}
},
records: {
'_': '记录',
name: '资源记录',
recordType: '类型',
content: '记录值',
search: '搜索...',
refresh: '刷新时间',
retry: '重试间隔',
expire: '超期时间',
ttl: '缓存时间',
form: {
text: '文本',
host: '主机',
preference: '优先级',
priority: '优先级',
weight: '权重',
port: '端口',
target: '目标',
flag: '标志',
tag: '标签',
value: '值'
},
errors: {
endWithDot: '应当以 . 结尾',
hasSpace: '不能有空格',
badIPv4: '不是有效的 IPv4 地址',
badIPv6: '不是有效的 IPv6 地址',
badEmail: '这里的邮箱不能有 @ 符号',
badName: {
dotAndMinus: '资源记录不能以 "."、"-" 开头或结尾',
doubleDots: '资源记录不能有连续的 "."',
logerThan63: '资源记录以 "." 分割的每个字符串长度不能超过63字符'
},
tooLong: '记录值过长'
}
}
}

10
web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

28
web/src/router/index.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { createHashRouter, redirect } from 'react-router-dom'
import { Suspense, lazy } from 'react'
import { Spin } from 'antd'
const DomainsView = lazy(() => import('../views/DomainsView'))
const RecordsView = lazy(() => import('../views/RecordsView'))
const router = createHashRouter([
{
path: '/',
loader: async () => {
return redirect('/domains')
},
},
{
path: '/domains',
id: 'domains',
element: <Suspense fallback={<Spin size='large' />}><DomainsView /></Suspense>
},
{
path: '/records/:domain',
id: 'records',
loader: args => args.params,
element: <Suspense fallback={<Spin size='large' />}><RecordsView /></Suspense>
}
])
export default router

76
web/src/stores/domains.ts Normal file
View File

@@ -0,0 +1,76 @@
import { useState } from 'react'
import api from '../api'
export class Domain {
id?: number
domain_name?: string
main_dns?: string
admin_email?: string
serial_number?: number
refresh_interval?: number
retry_interval?: number
expiry_period?: number
negative_ttl?: number
}
// example data for development
const domainDevData: Domain[] = [
{
id: 1,
domain_name: "example.com",
main_dns: "ns1.example.com",
admin_email: "admin@example.com",
serial_number: 114514,
refresh_interval: 86400,
retry_interval: 7200,
expiry_period: 3600000,
negative_ttl: 86400
},
{
id: 2,
domain_name: "example.org",
main_dns: "ns1.example.org",
admin_email: "admin@example.org",
serial_number: 1919810,
refresh_interval: 86400,
retry_interval: 7200,
expiry_period: 3600000,
negative_ttl: 86400
},
]
export const useDomainStore = () => {
const [domains, setDomains] = useState<Domain[]>([])
async function loadDomains() {
setDomains(import.meta.env.DEV ? domainDevData : (await api.get<Domain[]>('/domains')).data.data)
}
async function addDomain(domain: Domain) {
if (!import.meta.env.DEV) {
domain = (await api.post("/domains", domain)).data.data
} else if (!domain.id) {
domain.id = Math.floor(1000 + Math.random() * 9000)
}
setDomains(domains.concat(domain))
}
async function updateDomain(domain: Domain) {
console.log(domain)
if (!import.meta.env.DEV) {
await api.put("/domains", domain)
}
setDomains(domains.map((e: Domain) => (e.id === domain.id || e.domain_name === domain.domain_name) ? domain : e))
}
async function removeDomain(domain: Domain) {
if (!import.meta.env.DEV) {
await api.delete(`/domains/${domain.id}`)
}
setDomains(domains.filter(e => e.id !== domain.id))
}
return { domains, loadDomains, addDomain, updateDomain, removeDomain }
}

358
web/src/stores/records.ts Normal file
View File

@@ -0,0 +1,358 @@
import { useState } from 'react'
import i18n from '../locale'
import api from '../api';
const { t } = i18n
export class ARecord {
ip?: string
static validate(v: ARecord): true | Error {
if (!v.ip || v.ip === '') return new Error(t('common.mandatory'))
if (!/^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])$/.test(v.ip)) return new Error(t('records.errors.badIPv4'))
return true
}
toString(): string | undefined {
return this.ip
}
}
export class AAAARecord {
ip?: string
static validate(v: AAAARecord): true | Error {
if (!v.ip || v.ip === '') return new Error(t('common.mandatory'))
if (!/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/.test(v.ip)) return new Error(t('records.errors.badIPv4'))
return true
}
toString(): string | undefined {
return this.ip
}
}
export class TXTRecord {
text?: string
static validate(v: TXTRecord): true | Error {
if (!v.text || v.text === '') return new Error(t('common.mandatory'))
if (v.text.length > 512) return new Error('records.errors.tooLong')
return true
}
toString(): string | undefined {
return this.text
}
}
export class CNAMERecord {
host?: string
static validate(v: CNAMERecord): true | Error {
if (!v.host || v.host === '') return new Error(t('common.mandatory'))
if (v.host.includes(' ')) return new Error(t('records.errors.hasSpace'))
if (!v.host.endsWith('.')) return new Error(t('records.errors.endWithDot'))
return true
}
toString(): string | undefined {
return this.host
}
}
export class NSRecord {
host?: string
static validate(v: NSRecord): true | Error {
if (!v.host || v.host === '') return new Error(t('common.mandatory'))
if (v.host.includes(' ')) return new Error(t('records.errors.hasSpace'))
if (!v.host.endsWith('.')) return new Error(t('records.errors.endWithDot'))
return true
}
toString(): string | undefined {
return this.host
}
}
export class MXRecord {
host?: string
preference?: number
static validate(v: MXRecord): true | Error {
if (!v.host || v.host === '' || !v.preference) return new Error(t('common.mandatory'))
if (v.host.includes(' ')) return new Error(t('records.errors.hasSpace'))
if (!v.host.endsWith('.')) return new Error(t('records.errors.endWithDot'))
return true
}
toString(): string | undefined {
return Object.entries(this).map(i => i[1]).join(" ")
}
}
export class SRVRecord {
priority?: number;
weight?: number;
port?: number;
target?: string;
static validate(v: SRVRecord): true | Error {
if (!v.port || !v.priority || !v.weight || !v.target || v.target === '') return new Error(t('common.mandatory'))
if (v.target?.includes(' ')) return new Error(t('records.errors.hasSpace'))
if (!v.target?.endsWith('.')) return new Error(t('records.errors.endWithDot'))
return true
}
toString(): string | undefined {
return Object.entries(this).map(i => i[1]).join(" ")
}
}
export class SOARecord {
ns?: string;
MBox?: string;
refresh?: number;
retry?: number;
expire?: number;
minttl?: number;
static validate(v: SOARecord): true | Error {
if (!v.refresh || !v.retry || !v.expire! || !v.minttl || !v.MBox || v.MBox === '' || !v.ns || v.ns === '') return new Error(t('common.mandatory'))
if (v.ns?.includes(' ') || v.MBox?.includes(' ')) return new Error(t('records.errors.hasSpace'))
if (!v.ns?.endsWith('.') || !v.MBox?.endsWith('.')) return new Error(t('records.errors.endWithDot'))
if (v.MBox?.includes('@')) return new Error(t('records.errors.badEmail'))
return true
}
toString(): string | undefined {
return Object.entries(this).map(i => i[1]).join(" ")
}
}
export class CAARecord {
flag?: number
tag?: string
value?: string
static validate(v: CAARecord): true | Error {
if (!v.flag || !v.tag || v.tag === '' || !v.value || v.value === '') return new Error(t('common.mandatory'))
if (v.tag?.includes(' ')) return new Error(t('records.errors.hasSpace'))
return true
}
toString(): string | undefined {
return Object.entries(this).map(i => i[1]).join(" ")
}
}
export enum RecordTypes {
RecordTypeA = "A",
RecordTypeAAAA = "AAAA",
RecordTypeCNAME = "CNAME",
RecordTypeSOA = "SOA",
RecordTypeTXT = "TXT",
RecordTypeNS = "NS",
RecordTypeMX = "MX",
RecordTypeCAA = "CAA",
RecordTypeSRV = "SRV"
}
export type RecordT = ARecord | AAAARecord | TXTRecord | CNAMERecord | NSRecord | MXRecord | SRVRecord | SOARecord | CAARecord
export class Record<T = RecordT> {
id?: number
zone?: string
name?: string
ttl?: number
content?: T
record_type?: RecordTypes
validate(): true | Error {
const zone = Record.validateZone(this.zone!)
if (zone !== true) return zone
const name = Record.validateName(this.name!)
if (name !== true) return name
return true
}
static validateZone(zone: string): true | Error {
if (zone === '') return new Error(t('common.mandatory'))
if (zone.includes(' ')) new Error(t('records.errors.hasSpace'))
if (zone.endsWith('.')) return new Error(t('records.errors.endWithDot'))
return true
}
static validateName(name: string): true | Error {
if (name === '') return new Error(t('common.mandatory'))
// RR should not contain space, and should not start or end with '-' or '.'
if (name.includes(' ')) return new Error(t('records.errors.hasSpace'))
if (name.startsWith('.') || name.endsWith('.')) return new Error(t('records.errors.badName.dotAndMinus'))
if (name.startsWith('-') || name.endsWith('.')) return new Error(t('records.errors.badName.dotAndMinus'))
// RR should not have continuous dots
if (name.includes('..')) new Error(t('records.errors.badName.doubleDots'))
// RR should not longer than 63 characters for every section seprated by '.'
if (name.split('.').filter(e => e.length > 63).length > 0) return new Error(t('records.errors.badName.longerThan63'))
return true
}
}
// example data for development
const recordDevData = new Map<string, Record[]>([
['example.com', [
{
id: 1,
zone: "example.com",
name: "@",
ttl: 3600,
record_type: RecordTypes.RecordTypeSOA,
content: {
ns: "ns1.example.com.",
MBox: "admin@example.com.",
refresh: 86400,
retry: 7200,
expire: 3600000,
minttl: 86400,
}
},
{
id: 2,
zone: "example.com",
name: "@",
ttl: 3600,
record_type: RecordTypes.RecordTypeNS,
content: {
host: "ns1.example.com."
}
},
{
id: 3,
zone: "example.com",
name: "@",
ttl: 3600,
record_type: RecordTypes.RecordTypeNS,
content: {
host: "ns2.example.com."
}
},
{
id: 4,
zone: "example.com",
name: "www",
ttl: 3600,
record_type: RecordTypes.RecordTypeA,
content: {
ip: "233.233.233.233"
}
},
{
id: 5,
zone: "example.com",
name: "cname",
ttl: 3600,
record_type: RecordTypes.RecordTypeCNAME,
content: {
host: "www.example.com."
}
}
] as Record[]],
['example.org', [
{
id: 1,
zone: "example.org",
name: "@",
ttl: 3600,
record_type: RecordTypes.RecordTypeSOA,
content: {
ns: "ns1.example.org.",
MBox: "admin@example.org.",
refresh: 86400,
retry: 7200,
expire: 3600000,
minttl: 86400,
}
},
{
id: 2,
zone: "example.org",
name: "@",
ttl: 3600,
record_type: RecordTypes.RecordTypeNS,
content: {
host: "ns1.example.org."
}
},
{
id: 3,
zone: "example.org",
name: "@",
ttl: 3600,
record_type: RecordTypes.RecordTypeNS,
content: {
host: "ns2.example.org."
}
},
{
id: 4,
zone: "example.org",
name: "www",
ttl: 3600,
record_type: RecordTypes.RecordTypeA,
content: {
ip: "233.233.233.233"
}
},
{
id: 5,
zone: "example.org",
name: "cname",
ttl: 3600,
record_type: RecordTypes.RecordTypeCNAME,
content: {
host: "www.example.org."
}
}
] as Record[]]
])
export const useRecordStore = () => {
const [records, setRecords] = useState<Record[]>([])
async function loadRecords(domain: string) {
// TODO: load from api
setRecords(import.meta.env.DEV ?
recordDevData.get(domain)! :
(await api.get<Record[]>(`/records/${domain}`)).data.data)
}
async function addRecord(domain: string, record: Record) {
// TODO: load from api
if (!import.meta.env.DEV) {
record = (await api.post(`/records/${domain}`, record)).data.data
}
setRecords(records.concat(record))
}
async function updateRecord(domain: string, record: Record) {
// TODO: load from api
if (!import.meta.env.DEV) {
await api.put(`/records/${domain}`, record)
}
setRecords(records.map(e => e.id === record.id ? record : e))
}
async function removeRecord(domain: string, record: Record) {
// TODO: load from api
if (!import.meta.env.DEV) {
await api.delete(`/records/${domain}/${record.id}`)
}
setRecords(records.filter(e => e.id !== record.id))
}
return { records, loadRecords, addRecord, updateRecord, removeRecord }
}

View File

@@ -0,0 +1,4 @@
.domain-info {
width: 32vw;
text-align: left;
}

View File

@@ -0,0 +1,102 @@
import './DomainsView.css'
import { Domain, useDomainStore } from '../stores/domains'
import { useEffect, useState } from 'react'
import { App, Button, Card, Space, Spin } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import DomainDeleteModal from '../components/domains/DomainDeleteModal'
import DomainCard from '../components/domains/DomainCard'
import DomainEditModal from '../components/domains/DomainEditModal'
import { ResponseError, getErrorInfo } from '../api'
const emptyDomain: Domain = { domain_name: '' }
export default function DomainsView() {
const [loading, setLoading] = useState(true)
const [deleteModalShow, setDeleteModalShow] = useState(false)
const [editModalShow, setEditModalShow] = useState(false)
const [currentDomain, setCurrentDomain] = useState(emptyDomain)
const { notification } = App.useApp()
const domainStore = useDomainStore()
const go = useNavigate()
function openDeleteModal(domain: Domain) {
setCurrentDomain(domain)
setDeleteModalShow(true)
}
function closeDeleteModdal() {
setDeleteModalShow(false)
}
function openEditModal(domain: Domain) {
setCurrentDomain(domain)
setEditModalShow(true)
}
function closeEditModal() {
setEditModalShow(false)
}
function newDomain() {
openEditModal({
domain_name: '',
admin_email: '',
main_dns: '',
refresh_interval: 86400,
retry_interval: 7200,
expiry_period: 3600000,
negative_ttl: 86400,
serial_number: 1,
})
}
// called once only.
useEffect(() => {
domainStore.loadDomains().then(() => setLoading(false)).catch(e => {
const msg = getErrorInfo(e as ResponseError)
notification.error(msg)
console.error(e)
})
}, [])
return (
<>
{
loading ? <Spin size='large' /> :
<>
<Space direction="vertical" >
{
domainStore.domains.map(domain => (
<DomainCard domain={domain}
onDeleteClick={() => openDeleteModal(domain)}
onRecordClick={() => go(`/records/${domain.domain_name}`)}
onEditClick={() => openEditModal(domain)}
key={domain.id}
/>
))
}
<Card>
<Button icon={<PlusOutlined className='icon' />}
block type="text" onClick={newDomain} />
</Card>
</Space>
<DomainDeleteModal open={deleteModalShow}
onCancel={closeDeleteModdal}
onOk={closeDeleteModdal}
domain={currentDomain}
removeDomain={domainStore.removeDomain}
/>
<DomainEditModal open={editModalShow}
onCancel={closeEditModal}
onOk={closeEditModal}
domain={currentDomain}
editDomain={domainStore.updateDomain}
createDomain={domainStore.addDomain}
/>
</>
}
</>
)
}

View File

@@ -0,0 +1,38 @@
.records-layout {
position: fixed;
display: block;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
text-align: left;
}
.toolbar {
padding-top: 1em;
}
.right {
position:absolute ;
right: 64px;
}
.records-layout-header {
background-color: var(--records-layout-header-bgcolor);
align-items: center;
}
.display-as-inline {
display:inline-block;
margin: 0 !important;
}
h1.title {
font-weight:normal;
font-size: 14pt;
}
h2.subtitle {
font-weight:normal;
font-size: 12pt;
}

View File

@@ -0,0 +1,119 @@
import { LeftOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons"
import { App, Button, Flex, Input, Layout, Spin, Table, Typography, theme } from "antd"
import { Params, useLoaderData, useNavigate } from "react-router-dom"
import './RecordsView.css'
import i18n from '../locale'
import { useEffect, useState } from "react"
import { type Record, RecordT, useRecordStore, RecordTypes } from "../stores/records"
import { ResponseError, getErrorInfo } from "../api"
import RecordOps from "../components/records/RecordOps"
import RecordEditModal from "../components/records/RecordEditModal"
const { t } = i18n
const emptyRecord: Record<RecordT> = {} as Record
export default function RecordsView() {
const { domain } = useLoaderData() as Params<string>
const [loading, setLoading] = useState(true)
const [searchText, setSearchText] = useState<string>('')
const [editModalShow, setEditModalShow] = useState(false)
const [currentRecord, setCurrentRecord] = useState<Record>(emptyRecord)
const { notification } = App.useApp()
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
const go = useNavigate()
const recordStore = useRecordStore()
useEffect(() => {
if (domain)
recordStore.loadRecords(domain).then(() => setLoading(false)).catch(e => {
const msg = getErrorInfo(e as ResponseError)
notification.error(msg)
console.error(e)
})
}, [domain])
function closeEditModal() {
setCurrentRecord(emptyRecord)
setEditModalShow(false)
}
function openEditModal(record: Record) {
setCurrentRecord(record)
setEditModalShow(true)
}
function newRecord() {
openEditModal({
zone: `${domain}.`,
name: '',
record_type: RecordTypes.RecordTypeA,
ttl: 600
} as Record)
}
return (
<>
{
loading ? <Spin size='large' /> :
<>
<Layout className="records-layout">
<Layout.Header className="records-layout-header">
<Flex align='center' className="toolbar">
<Flex align="center" gap='small'>
<Button onClick={() => go('/domains')} type="text" icon={<LeftOutlined />} />
<Typography.Title level={1} className="display-as-inline title">{t('domains.dnsRecord')}</Typography.Title>
<Typography.Title level={2} type='secondary' className="display-as-inline subtitle">{domain}</Typography.Title>
</Flex>
<Flex align="center" gap='small' className="right">
<Button type="primary" icon={<PlusOutlined />} onClick={newRecord}>
{t('common.add')}
</Button>
<Input prefix={<SearchOutlined />} placeholder={t('records.search')} onChange={v => setSearchText(v.target.value)} />
</Flex>
</Flex>
</Layout.Header>
<Layout.Content style={{
margin: 24,
borderRadius: borderRadiusLG,
minHeight: 480,
background: colorBgContainer,
}}>
<Table<Record<RecordT>>
dataSource={recordStore.records
.filter(i => i.record_type !== RecordTypes.RecordTypeSOA)
.filter(i => i.name?.includes(searchText) || Object.entries(i.content as RecordT).map(i => i[1]).join(" ").includes(searchText))
}
pagination={{ defaultPageSize: 20 }}
rowKey={e => `${e.id}`}
>
<Table.Column<Record<RecordT>> title='#' render={(_v, _r, index) => index + 1} />
<Table.Column<Record<RecordT>> title={t("records.name")} dataIndex='name' key='name' />
<Table.Column<Record<RecordT>> title={t('records.recordType')} dataIndex='record_type' key='record_type' />
<Table.Column<Record<RecordT>> title={t('records.content')} render={(v) => Object.entries(v.content).map(i => i[1]).join(" ")} />
<Table.Column<Record<RecordT>> title='TTL' key='ttl' dataIndex='ttl' />
<Table.Column<Record<RecordT>> key='op' render={(v: Record) =>
<RecordOps
onDelete={() => {
if (domain)
recordStore.removeRecord(domain, v).catch(e => {
const msg = getErrorInfo(e as ResponseError)
notification.error(msg)
console.error(e)
})
}} onEdit={() => openEditModal(v)}
/>
} />
</Table>
</Layout.Content>
</Layout>
<RecordEditModal open={editModalShow} onCancel={closeEditModal}
onOk={closeEditModal} record={currentRecord}
editRecord={record => recordStore.updateRecord(domain!, record)}
createRecord={record => recordStore.addRecord(domain!, record)} />
</>
}
</>
)
}

1
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

25
web/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

7
web/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})