diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..0d2c710 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,32 @@ +import { + NNotificationProvider, + NConfigProvider, + NGlobalStyle, + useOsTheme, + darkTheme, + lightTheme, +} from "naive-ui"; + +import { zhCN, dateZhCN, enUS, dateEnUS } from 'naive-ui' +import { RouterView } from "vue-router"; + +const osThemeRef = useOsTheme() +const theme = osThemeRef.value === 'dark' ? darkTheme : lightTheme +const locale = navigator.language === "zh-CN" ? zhCN : enUS +const dateLocale = navigator.language === "zh-CN" ? dateZhCN : dateEnUS + +function App() { + document.title = 'reCoreD-UI' + return ( + + + + + + + ) +} + +App.displayName = 'App' + +export default App \ No newline at end of file diff --git a/web/src/App.vue b/web/src/App.vue deleted file mode 100644 index e19767f..0000000 --- a/web/src/App.vue +++ /dev/null @@ -1,32 +0,0 @@ - - - diff --git a/web/src/components/domains/DomainOps.tsx b/web/src/components/domains/DomainOps.tsx index ccdff4e..5434cca 100644 --- a/web/src/components/domains/DomainOps.tsx +++ b/web/src/components/domains/DomainOps.tsx @@ -76,4 +76,15 @@ function DomainOps({ domain }: Props, { emit }: SetupContext) { ) } +DomainOps.props = { + domain: { + required: true + } +} + +DomainOps.emits = { + removeDomain: (d:Domain) => d, + editDomain: (d:Domain) => d +} as Events + export default DomainOps \ No newline at end of file diff --git a/web/src/components/records/RecordEditModal.tsx b/web/src/components/records/RecordEditModal.tsx new file mode 100644 index 0000000..a1dbbf6 --- /dev/null +++ b/web/src/components/records/RecordEditModal.tsx @@ -0,0 +1,459 @@ +import { + NModal, + NCard, + NForm, + NFormItem, + NFlex, + NButton, + NInput, + NInputNumber, + NInputGroup, + NSelect, + NIcon, + type FormRules, + type SelectOption, + createDiscreteApi, + type FormItemRule, +} from 'naive-ui' +import { getErrorInfo } from '@/apis/api'; +import { + useRecordStore, + RecordTypes, + type Record, + type ARecord, + type AAAARecord, + type CAARecord, + type CNAMERecord, + type NSRecord, + type SRVRecord, + type TXTRecord, + type MXRecord, + type RecordT, +} from '@/stores/records'; +import { Check, Times } from '@vicons/fa'; +import { ref, type SetupContext } from 'vue'; +import i18n from '@/locale/i18n'; + +const { t } = i18n.global + +const enum validationFlags { + name = 1, + content = name << 1 +} + +type Props = { + record: Record + domain: string + show: boolean +} + +type Events = { + reloadRecords(): void + 'update:show': (v: boolean) => void +} + +const invalidData = ref(validationFlags.content) +const loading = ref(false) +const recordStore = useRecordStore() +const { notification } = createDiscreteApi(['notification']) +const recordTypeOptions = Object.entries(RecordTypes).filter( + e => e[1] !== RecordTypes.RecordTypeSOA +).map(e => { + return { + label: e[1], + value: e[1] + } as SelectOption +}) + +function validateName(_rule: FormItemRule, value: string): boolean | Error { + invalidData.value |= validationFlags.name + if (!value || value === '') { + invalidData.value &= ~validationFlags.name + return new Error(t('common.mandatory')) + } + + if (value.includes(' ')) { + invalidData.value &= ~validationFlags.name + return new Error(t('records.errors.hasSpace')) + } + + if (value.startsWith('.') || value.endsWith('.')) { + invalidData.value &= ~validationFlags.name + return new Error(t('records.errors.badName.dotAndMinus')) + } + + if (value.startsWith('-') || value.endsWith('-')) { + invalidData.value &= ~validationFlags.name + return new Error(t('records.errors.badName.dotAndMinus')) + } + + if (value.includes('..')) { + invalidData.value &= ~validationFlags.name + return new Error(t('records.errors.badName.doubleDots')) + } + + if (value.split('.').filter(e => e.length > 63).length > 0) { + invalidData.value &= ~validationFlags.name + return new Error(t('records.errors.badName.longerThan63')) + } + return true +} + +function validateTXTRecord(record: Record) { + return () => { + invalidData.value |= validationFlags.content + if (record.record_type !== RecordTypes.RecordTypeTXT) return true + + const r = (record.content as TXTRecord) + if (!r || !r.text || r.text === '') { + invalidData.value &= ~validationFlags.content + return new Error(t('common.mandatory')) + } + + return true + } +} + +function validateHostRecord(record: Record) { + return () => { + invalidData.value |= validationFlags.content + if ([RecordTypes.RecordTypeCNAME, RecordTypes.RecordTypeNS].indexOf(record.record_type) === -1) return true + + const r = (record.content as CNAMERecord | NSRecord) + if (!r || !r.host || r.host === '') { + invalidData.value &= ~validationFlags.content + return new Error(t('common.mandatory')) + } + + if (r.host.includes(' ')) { + invalidData.value &= ~validationFlags.content + return new Error(t('records.errors.hasSpace')) + } + + if (!r.host.endsWith('.')) { + invalidData.value &= ~validationFlags.content + return new Error(t('records.errors.endWithDot')) + } + + return true + } +} + +function validateIPRecord(record: Record) { + return () => { + invalidData.value |= validationFlags.content + if ([RecordTypes.RecordTypeA, RecordTypes.RecordTypeAAAA].indexOf(record.record_type) === -1) return true + const r = (record.content as AAAARecord | ARecord) + if (!r || !r.ip || r.ip === '') { + invalidData.value &= ~validationFlags.content + return new Error(t('common.mandatory')) + } + + switch (record.record_type) { + case RecordTypes.RecordTypeA: + 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(r.ip)) { + invalidData.value &= ~validationFlags.content + return new Error(t('records.errors.badIPv4')) + } + + break + case RecordTypes.RecordTypeAAAA: + 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(r.ip)) { + invalidData.value &= ~validationFlags.content + return new Error(t('records.errors.badIPv6')) + } + + break + } + + return true + } +} + +function validateMXRecord(record: Record) { + return () => { + invalidData.value |= validationFlags.content + if (record.record_type !== RecordTypes.RecordTypeMX) return true + const r = (record.content as MXRecord) + + if (!r || !r.host || !r.preference || r.host === '') { + invalidData.value &= ~validationFlags.content + return new Error(t('common.mandatory')) + } + + if (r.host.includes(' ')) { + invalidData.value &= ~validationFlags.content + return new Error(t('records.errors.hasSpace')) + } + + if (!r.host.endsWith('.')) { + invalidData.value &= ~validationFlags.content + return new Error(t('records.errors.endWithDot')) + } + + return true + } +} + +function validateSRVRecord(record: Record) { + return () => { + invalidData.value |= validationFlags.content + if (record.record_type !== RecordTypes.RecordTypeSRV) return true + const r = (record.content as SRVRecord) + + if (!r || !r.port || !r.priority || !r.weight || !r.target || r.target === '') { + invalidData.value &= ~validationFlags.content + return new Error(t('common.mandatory')) + } + + if (r.target.includes(' ')) { + invalidData.value &= ~validationFlags.content + return new Error(t('records.errors.hasSpace')) + } + + if (!r.target.endsWith('.')) { + invalidData.value &= ~validationFlags.content + return new Error(t('records.errors.endWithDot')) + } + + return true + } +} + +function validateCAARecord(record: Record) { + return () => { + invalidData.value |= validationFlags.content + if (record.record_type !== RecordTypes.RecordTypeCAA) return true + const r = (record.content as CAARecord) + + if (!r || !r.flag || !r.tag || r.tag === '' || !r.value || r.value === '') { + invalidData.value &= ~validationFlags.content + return new Error(t('common.mandatory')) + } + + if (r.tag.includes(' ')) { + invalidData.value &= ~validationFlags.content + return new Error(t('records.errors.hasSpace')) + } + + return true + } +} + +function buildRules(record: Record): FormRules { + return { + name: { + trigger: 'blur', + validator: validateName + }, + txt: { + trigger: 'blur', + validator: validateTXTRecord(record) + }, + host: { + trigger: 'blur', + validator: validateHostRecord(record) + }, + ip: { + trigger: 'blur', + validator: validateIPRecord(record) + }, + mx: { + trigger: 'blur', + validator: validateMXRecord(record) + }, + srv: { + trigger: 'blur', + validator: validateSRVRecord(record) + }, + caa: { + trigger: 'blur', + validator: validateCAARecord(record) + } + } +} + +async function confirm({ record, domain }: Props) { + loading.value = true; + try { + if (!record.id || record.id < 1) { + await recordStore.addRecord(domain, record) + } else { + await recordStore.updateRecord(domain, record) + } + } catch (e) { + const msg = getErrorInfo(e) + notification.error(msg) + console.error(e) + } + loading.value = false; +} + +function modalHeader({ record }: Props) { + return ( + <> + {(!record || !record.id || record.id < 1) ? {t('common.new')} : t('common.edit')} + {t('records._')} + + ) +} + +function modalActions({ record, domain }: Props, { emit }: SetupContext) { + return ( + + emit('update:show', false)}> + {{ + icon: () => , + default: () => t('common.cancel') + }} + + + confirm({ record, domain, show: false }).then(() => { emit('reloadRecords'); emit('update:show', false) })}> + {{ + icon: () => , + default: () => t('common.confirm') + }} + + + ) +} + +function modalBody({ record }: Props) { + const rules = buildRules(record) + return ( + <> + + + { record.record_type = v; record.content = {} as RecordT }} + options={recordTypeOptions} style={{ width: '8vw' }} /> + + + record.name = v} /> + + + v ? record.ttl = v : null} showButton={false} > + {{ + suffix: () => t('common.unitForSecond') + }} + + + + + + + + ) +} + +const IPRecordE = ({ record }: Props) => ( + + (record.content as ARecord | AAAARecord).ip = v} placeholder='IP' /> + +) + +const HostRecordE = ({ record }: Props) => ( + + (record.content as CNAMERecord | NSRecord).host = v} placeholder={t('records.form.host')} /> + +) + +const TXTRecordE = ({ record }: Props) => ( + + (record.content as TXTRecord).text = v} placeholder={t('records.form.text')} /> + +) + +const MXRecordE = ({ record }: Props) => ( + + + (record.content as MXRecord).host = v} + style={{ width: '75%' }} /> + v ? (record.content as MXRecord).preference = v : null} + style={{ width: '25%' }} show-button={false} /> + + +) + +const CAARecordE = ({ record }: Props) => ( + + + v ? (record.content as CAARecord).flag = v : null} + show-button={false} /> + v ? (record.content as CAARecord).tag = v : null} + style={{ width: '40%' }} /> + v ? (record.content as CAARecord).value = v : null} + style={{ width: '40%' }} /> + + +) + +const SRVRecordE = ({ record }: Props) => ( + + + v ? (record.content as SRVRecord).priority = v : null} + show-button={false} /> + v ? (record.content as SRVRecord).weight = v : null} + show-button={false} /> + v ? (record.content as SRVRecord).port = v : null} + show-button={false} /> + (record.content as SRVRecord).target = v} + /> + + +) + +const modalBodyContent = ({ type, record }: { type: RecordTypes, record: Record }) => { + const e = { + 'A': IPRecordE, + 'AAAA': IPRecordE, + 'CNAME': HostRecordE, + 'NS': HostRecordE, + 'TXT': TXTRecordE, + 'MX': MXRecordE, + 'SRV': SRVRecordE, + 'CAA': CAARecordE, + 'SOA': ({ }: Props) => <> + }[type] + return +} + +function RecordEditModal( + { domain, show, record }: Props, + { emit }: SetupContext) { + return ( + + + {{ + header: () => , + default: () => , + action: () => emit('update:show', v)} + onReloadRecords={() => emit('reloadRecords')} /> + }} + + + ) +} + +export default RecordEditModal \ No newline at end of file diff --git a/web/src/components/records/RecordEditModal.vue b/web/src/components/records/RecordEditModal.vue deleted file mode 100644 index 1f84b71..0000000 --- a/web/src/components/records/RecordEditModal.vue +++ /dev/null @@ -1,380 +0,0 @@ - - - \ No newline at end of file diff --git a/web/src/main.ts b/web/src/main.ts index b0e3c1e..f6e8074 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -3,7 +3,7 @@ import './assets/main.css' import { createApp } from 'vue' import { createPinia } from 'pinia' -import App from './App.vue' +import App from './App' import router from './router' import i18n from './locale/i18n' diff --git a/web/src/router/index.ts b/web/src/router/index.ts index bbab616..27c9f46 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -1,23 +1,27 @@ import { createRouter, createWebHashHistory } from 'vue-router' -import HomeView from '../views/HomeView.vue' const router = createRouter({ history: createWebHashHistory(), routes: [ { path: '/', - name: 'home', - component: HomeView + redirect: '/domains' }, { path: '/domains', name: 'domains', - component: () => import('../views/DomainsView.vue') + meta: { + type: 'domains' + }, + component: () => import('@/views/DomainsView') }, { path: '/records/:domain', name: 'records', - component: () => import('../views/RecordsView.vue'), + meta: { + type: 'records' + }, + component: () => import('@/views/RecordsView'), props: true } ] diff --git a/web/src/views/DomainsView.css b/web/src/views/DomainsView.css new file mode 100644 index 0000000..20aa6a3 --- /dev/null +++ b/web/src/views/DomainsView.css @@ -0,0 +1,3 @@ +.n-card { + width: 32vw; +} \ No newline at end of file diff --git a/web/src/views/DomainsView.tsx b/web/src/views/DomainsView.tsx new file mode 100644 index 0000000..a642701 --- /dev/null +++ b/web/src/views/DomainsView.tsx @@ -0,0 +1,87 @@ +import './DomainsView.css' + +import { NSpin, NFlex, NCard, NButton, NIcon, NModalProvider, createDiscreteApi } from 'naive-ui' +import { PlusSquare } from "@vicons/fa" +import { type Domain, useDomainStore } from '@/stores/domains' +import { getErrorInfo } from '@/apis/api' +import DomainInfo from '@/components/domains/DomainInfo' +import DomainOps from '@/components/domains/DomainOps' +import DomainRemoveModal from '@/components/domains/DomainRemoveModal' +import DomainEditModal from '@/components/domains/DomainEditModal' +import { ref } from 'vue' + +const domainStore = useDomainStore() +const { notification } = createDiscreteApi(['notification']) + +const loading = ref(true); +const removeModalShow = ref(false); +const editModalShow = ref(false); +const operationDomain = ref({} as Domain) + +function showRemoveModal(domain: Domain) { + operationDomain.value = domain + removeModalShow.value = true +} + +function showEditModal(domain: Domain) { + operationDomain.value = domain + editModalShow.value = true +} + +function addDomain() { + const domain = { + refresh_interval: 86400, + retry_interval: 7200, + expiry_period: 3600000, + negative_ttl: 86400, + serial_number: 1, + } as Domain + showEditModal(domain) +} + +function DomainsView() { + try { + domainStore.loadDomains() + loading.value = false + } catch (e) { + const msg = getErrorInfo(e) + notification.error(msg) + console.error(e) + } + return ( + <> + { + loading.value ? : + + + { + domainStore.domains.map((domain: Domain) => ( + + {{ + default: () => , + action: () => + }} + + + )) + } + + + + {{ + icon: () => + }} + + + + removeModalShow.value = v} /> + editModalShow.value = v} /> + + } + + ) +} + +DomainsView.displayName = 'DomainsView' + +export default DomainsView \ No newline at end of file diff --git a/web/src/views/DomainsView.vue b/web/src/views/DomainsView.vue deleted file mode 100644 index de75802..0000000 --- a/web/src/views/DomainsView.vue +++ /dev/null @@ -1,83 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue deleted file mode 100644 index ce5bed3..0000000 --- a/web/src/views/HomeView.vue +++ /dev/null @@ -1,6 +0,0 @@ - - - \ No newline at end of file diff --git a/web/src/views/RecordsView.css b/web/src/views/RecordsView.css new file mode 100644 index 0000000..726782a --- /dev/null +++ b/web/src/views/RecordsView.css @@ -0,0 +1,7 @@ +div#records { + position: absolute; + top: 0; + left: 0; + width: 100vw; + padding: 1.5rem; +} \ No newline at end of file diff --git a/web/src/views/RecordsView.tsx b/web/src/views/RecordsView.tsx new file mode 100644 index 0000000..7de7b8a --- /dev/null +++ b/web/src/views/RecordsView.tsx @@ -0,0 +1,250 @@ +import './RecordsView.css' + +import { + NSpin, NPageHeader, + NFlex, NButton, NIcon, NGrid, NGi, + NStatistic, NDataTable, NInput, + NModalProvider, + createDiscreteApi +} from 'naive-ui' +import type { DataTableColumns } from 'naive-ui' +import { useRecordStore, type Record, type SOARecord, RecordTypes, type ARecord } from '@/stores/records' +import { getErrorInfo } from '@/apis/api' +import { PlusSquare, RedoAlt, CheckCircle, Clock, Cogs, Search } from '@vicons/fa' +import router from '@/router'; +import RecordOps from '@/components/records/RecordOps' +import RecordEditModal from '@/components/records/RecordEditModal' +import i18n from '@/locale/i18n' +import { ref } from 'vue' + +const { t } = i18n.global + +type Props = { + domain: string +} + +const recordStore = useRecordStore() +const { notification } = createDiscreteApi(['notification']) +const editModalShow = ref(false) +const editingRecord = ref({} as Record) +const loading = ref(true); +const records = ref([] as Record[]); +const soa = ref({} as SOARecord) +const reloadRecords = () => records.value = recordStore.records?.filter(e => e.record_type !== RecordTypes.RecordTypeSOA) + +async function refreshRecords(domain: string) { + try { + await recordStore.loadRecords(domain) + reloadRecords() + soa.value = recordStore.records?.find(e => e.record_type === RecordTypes.RecordTypeSOA)?.content as SOARecord + } catch (err) { + const msg = getErrorInfo(err) + notification.error(msg) + console.error(err) + } finally { + loading.value = false; + } +} + +function goBack() { + router.push('/domains') +} + +function searchRecord(value: string) { + if (value.length > 0) { + records.value = recordStore.records?. + filter(e => e.record_type !== RecordTypes.RecordTypeSOA). + filter(e => !!~e.name.indexOf(value)) + } else { + records.value = recordStore.records?. + filter(e => e.record_type !== RecordTypes.RecordTypeSOA) + } +} + +async function deleteRecord(domain: string, record: Record) { + try { + await recordStore.removeRecord(domain, record) + reloadRecords() + } catch (err) { + const msg = getErrorInfo(err) + notification.error(msg) + console.error(err) + } +} + +function showEditing(domain: string, record: Record) { + editModalShow.value = true + editingRecord.value = record +} + +function newRecord(domain: string) { + showEditing(domain, { + zone: `${domain}.`, + ttl: 500, + record_type: RecordTypes.RecordTypeA, + content: { + ip: '' + } as ARecord + } as Record) +} + +const generateColumns = (domain: string) => [ + { + key: 'no', + title: '#', + render(_, index) { + return index + 1 + } + }, + { + key: 'name', + title: t("records.name"), + }, + { + key: 'record_type', + title: t('records.recordType') + }, + { + key: 'content', + title: t('records.content'), + render(row: Record) { + return Object.entries(row.content).map(i => i[1]).join(" ") + } + }, + { + key: 'ttl', + title: 'TTL (s)' + }, + { + key: '', + render(row: Record) { + return + } + } +] as DataTableColumns + +const statRefresh = () => ( + + + {{ + suffix: () => 's', + label: () => ( + <> + + {t('records.refresh')} + + ) + }} + + +) + +const statRetry = () => ( + + + {{ + suffix: () => 's', + label: () => ( + <> + + {t('records.retry')} + + ) + }} + + +) + +const statExpire = () => ( + + + {{ + suffix: () => 's', + label: () => ( + <> + + {t('records.expire')} + + ) + }} + + +) + +const statTTL = () => ( + + + {{ + suffix: () => 's', + label: () => ( + <> + + {t('records.ttl')} + + ) + }} + + +) + +function recordsViewBodyHeaderExtra() { + return ( + + + + + + + ) +} + +function recordsViewBody({ domain }: Props) { + const columns = generateColumns(domain) + return ( + + + {{ + extra: () => ( + + newRecord(domain)}> + {{ + icon: () => , + default: () => t('common.add') + }} + + + {{ + prefix: () => + }} + + + ), + default: () => + }} + +
+ +
+ ) +} + +function RecordsView({ domain }: Props) { + try { + refreshRecords(domain) + } catch (err) { + const msg = getErrorInfo(err) + notification.error(msg) + console.error(err) + } + return ( +
+ + { + loading.value ? : + } +
+ ) +} + +RecordsView.displayName = 'RecordsView' +export default RecordsView \ No newline at end of file diff --git a/web/src/views/RecordsView.vue b/web/src/views/RecordsView.vue deleted file mode 100644 index a69cec5..0000000 --- a/web/src/views/RecordsView.vue +++ /dev/null @@ -1,231 +0,0 @@ - - - - - diff --git a/web/tsconfig.json b/web/tsconfig.json index 66b5e57..219a648 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -7,5 +7,10 @@ { "path": "./tsconfig.app.json" } - ] + ], + "compilerOptions": { + "jsx": "preserve", + "jsxFactory": "h", + "jsxFragmentFactory": "Fragment", + } }