Go backend (miekg/dns) + Nuxt 3 frontend (Tailwind CSS v4). 8 check categories, 52 checks total: - Overview: @ record, WWW, MX with ASN/provider lookup - Domain Registration: expiry, registrar (RDAP + whois fallback) - Parent Delegation: NS records, glue, consistency - Nameservers: 17 checks (reachability, auth, recursion, TCP/UDP, AXFR, etc.) - SOA: serial consistency, timing values - Mail (MX): 11 checks (CNAME, PTR, public IPs, consistency) - Mail Auth: SPF, DKIM, DMARC - WWW: A record, CNAME Features: - SSE streaming (results appear as each category completes) - SQLite history (modernc.org/sqlite) - Rate limiting, CORS, request logging - Dark mode, responsive design
174 lines
7.0 KiB
Vue
174 lines
7.0 KiB
Vue
<template>
|
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
|
<button
|
|
@click="expanded = !expanded"
|
|
class="flex w-full items-center justify-between px-5 py-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ category.title }}</h3>
|
|
<div class="flex items-center gap-1.5">
|
|
<span v-if="passCount > 0" class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
|
{{ passCount }} passed
|
|
</span>
|
|
<span v-if="warnCount > 0" class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400">
|
|
{{ warnCount }} {{ warnCount === 1 ? 'warning' : 'warnings' }}
|
|
</span>
|
|
<span v-if="failCount > 0" class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
|
{{ failCount }} {{ failCount === 1 ? 'problem' : 'problems' }}
|
|
</span>
|
|
<span v-if="infoCount > 0" class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
|
{{ infoCount }} info
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-5 w-5 text-gray-400 transition-transform dark:text-gray-500"
|
|
:class="{ 'rotate-180': expanded }"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
<div v-if="expanded" class="border-t border-gray-200 dark:border-gray-800">
|
|
<!-- When filtering by status, show only those checks directly -->
|
|
<template v-if="props.statusFilter">
|
|
<CheckResult
|
|
v-for="check in filteredByStatus"
|
|
:key="check.id"
|
|
:check="check"
|
|
:default-expanded="true"
|
|
/>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<!-- Problems (fail) — always shown expanded with details -->
|
|
<CheckResult
|
|
v-for="check in failChecks"
|
|
:key="check.id"
|
|
:check="check"
|
|
:default-expanded="true"
|
|
/>
|
|
|
|
<!-- Warnings — shown expanded with details -->
|
|
<CheckResult
|
|
v-for="check in warnChecks"
|
|
:key="check.id"
|
|
:check="check"
|
|
:default-expanded="true"
|
|
/>
|
|
|
|
<!-- Passed checks — condensed into a compact list -->
|
|
<div v-if="passChecks.length > 0" class="border-b border-gray-100 last:border-b-0 dark:border-gray-800">
|
|
<div
|
|
class="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
|
@click="passExpanded = !passExpanded"
|
|
>
|
|
<div class="mt-0.5 shrink-0">
|
|
<StatusBadge status="pass" />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ passChecks.length }} {{ passChecks.length === 1 ? 'check' : 'checks' }} passed
|
|
</h4>
|
|
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
|
{{ passChecks.map(c => c.title).join(' · ') }}
|
|
</p>
|
|
</div>
|
|
<div class="mt-1 shrink-0 text-gray-400 dark:text-gray-500">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-4 w-4 transition-transform"
|
|
:class="{ 'rotate-180': passExpanded }"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div v-if="passExpanded" class="border-t border-gray-100 bg-gray-50/50 dark:border-gray-800 dark:bg-gray-800/20">
|
|
<CheckResult
|
|
v-for="check in passChecks"
|
|
:key="check.id"
|
|
:check="check"
|
|
compact
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info checks — condensed similarly -->
|
|
<div v-if="infoChecks.length > 0" class="border-b border-gray-100 last:border-b-0 dark:border-gray-800">
|
|
<div
|
|
class="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
|
@click="infoExpanded = !infoExpanded"
|
|
>
|
|
<div class="mt-0.5 shrink-0">
|
|
<StatusBadge status="info" />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ infoChecks.length }} informational {{ infoChecks.length === 1 ? 'note' : 'notes' }}
|
|
</h4>
|
|
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
|
{{ infoChecks.map(c => c.title).join(' · ') }}
|
|
</p>
|
|
</div>
|
|
<div class="mt-1 shrink-0 text-gray-400 dark:text-gray-500">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-4 w-4 transition-transform"
|
|
:class="{ 'rotate-180': infoExpanded }"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div v-if="infoExpanded" class="border-t border-gray-100 bg-gray-50/50 dark:border-gray-800 dark:bg-gray-800/20">
|
|
<CheckResult
|
|
v-for="check in infoChecks"
|
|
:key="check.id"
|
|
:check="check"
|
|
compact
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Category, CheckStatus } from '~/types/dns'
|
|
|
|
const props = defineProps<{
|
|
category: Category
|
|
statusFilter?: CheckStatus | null
|
|
}>()
|
|
|
|
const expanded = ref(true)
|
|
const passExpanded = ref(false)
|
|
const infoExpanded = ref(false)
|
|
|
|
const passChecks = computed(() => props.category.checks.filter(c => c.status === 'pass'))
|
|
const warnChecks = computed(() => props.category.checks.filter(c => c.status === 'warn'))
|
|
const failChecks = computed(() => props.category.checks.filter(c => c.status === 'fail'))
|
|
const infoChecks = computed(() => props.category.checks.filter(c => c.status === 'info'))
|
|
const filteredByStatus = computed(() => props.statusFilter ? props.category.checks.filter(c => c.status === props.statusFilter) : [])
|
|
|
|
const passCount = computed(() => passChecks.value.length)
|
|
const warnCount = computed(() => warnChecks.value.length)
|
|
const failCount = computed(() => failChecks.value.length)
|
|
const infoCount = computed(() => infoChecks.value.length)
|
|
</script>
|