checkers: show worst check status badge on domain list

Add DomainWithCheckStatus model and GetWorstDomainStatuses usecase to
compute the most critical checker status per domain. The GET /domains
endpoint now returns status alongside each domain. The frontend domain
store, list components, and table row display dynamic status badges
with color and icon instead of a hardcoded "OK".

ZoneList is made generic (T extends HappydnsDomain) so the badges
snippet preserves the caller's concrete type without unsafe casts.
This commit is contained in:
nemunaire 2026-04-05 09:55:10 +07:00
commit 9350b71b48
14 changed files with 141 additions and 53 deletions

View file

@ -74,7 +74,7 @@ func NewDomainController(
func (dc *DomainController) ListDomains(c *gin.Context) {
user := middleware.MyUser(c)
if user != nil {
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter)
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter, nil)
apidc.GetDomains(c)
return
}

View file

@ -30,6 +30,7 @@ import (
"github.com/miekg/dns"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
@ -37,13 +38,15 @@ type DomainController struct {
domainService happydns.DomainUsecase
remoteZoneImporter happydns.RemoteZoneImporterUsecase
zoneImporter happydns.ZoneImporterUsecase
checkStatusUC *checkerUC.CheckStatusUsecase
}
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase) *DomainController {
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, checkStatusUC *checkerUC.CheckStatusUsecase) *DomainController {
return &DomainController{
domainService: domainService,
remoteZoneImporter: remoteZoneImporter,
zoneImporter: zoneImporter,
checkStatusUC: checkStatusUC,
}
}
@ -56,7 +59,7 @@ func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporte
// @Accept json
// @Produce json
// @Security securitydefinitions.basic
// @Success 200 {array} happydns.Domain
// @Success 200 {array} happydns.DomainWithCheckStatus
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Unable to retrieve user's domains"
// @Router /domains [get]
@ -73,7 +76,25 @@ func (dc *DomainController) GetDomains(c *gin.Context) {
return
}
c.JSON(http.StatusOK, domains)
var statusByDomain map[string]*happydns.Status
if dc.checkStatusUC != nil {
var err error
statusByDomain, err = dc.checkStatusUC.GetWorstDomainStatuses(user.Id)
if err != nil {
log.Printf("GetWorstDomainStatuses: %s", err.Error())
}
}
result := make([]*happydns.DomainWithCheckStatus, 0, len(domains))
for _, d := range domains {
entry := &happydns.DomainWithCheckStatus{Domain: d}
if statusByDomain != nil {
entry.LastCheckStatus = statusByDomain[d.Id.String()]
}
result = append(result, entry)
}
c.JSON(http.StatusOK, result)
}
// AddDomain appends a new domain to those managed.

View file

@ -26,6 +26,7 @@ import (
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
"git.happydns.org/happyDomain/model"
)
@ -40,11 +41,13 @@ func DeclareDomainRoutes(
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
cc *controller.CheckerController,
checkStatusUC *checkerUC.CheckStatusUsecase,
) {
dc := controller.NewDomainController(
domainUC,
remoteZoneImporter,
zoneImporter,
checkStatusUC,
)
router.GET("/domains", dc.GetDomains)

View file

@ -136,6 +136,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
dep.ZoneService,
dep.Service,
cc,
dep.CheckStatusUC,
)
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)

View file

@ -174,6 +174,44 @@ func (u *CheckStatusUsecase) DeleteExecutionsByChecker(checkerID string, target
return u.execStore.DeleteExecutionsByChecker(checkerID, target)
}
// GetWorstDomainStatuses fetches all executions for a user and returns the worst
// (most critical) status per domain. It keeps only the latest execution per
// (domain, checker) pair and reports the worst status among them.
func (u *CheckStatusUsecase) GetWorstDomainStatuses(userId happydns.Identifier) (map[string]*happydns.Status, error) {
execs, err := u.execStore.ListExecutionsByUser(userId, 0)
if err != nil {
return nil, err
}
type key struct {
domainId string
checker string
}
latest := map[key]*happydns.Execution{}
for _, exec := range execs {
if exec.Target.DomainId == nil || exec.Status != happydns.ExecutionDone {
continue
}
k := key{domainId: exec.Target.DomainId.String(), checker: exec.CheckerID}
if prev, ok := latest[k]; !ok || exec.StartedAt.After(prev.StartedAt) {
latest[k] = exec
}
}
worst := map[string]*happydns.Status{}
for k, exec := range latest {
s := exec.Result.Status
if s == happydns.StatusUnknown {
continue
}
if prev, ok := worst[k.domainId]; !ok || s > *prev {
worst[k.domainId] = &s
}
}
return worst, nil
}
// GetWorstServiceStatuses returns the worst check status for each service in the zone.
// It iterates all services and all registered checkers, fetching the latest execution
// for each (service, checker) pair, and returns the worst status per service.

View file

@ -104,6 +104,13 @@ type DomainWithZoneMetadata struct {
ZoneMeta map[string]*ZoneMeta `json:"zone_meta"`
}
type DomainWithCheckStatus struct {
*Domain
// LastCheckStatus is the worst status across the most recent result of each
// checker that has run on this domain. Nil if no results exist yet.
LastCheckStatus *Status `json:"last_check_status,omitempty"`
}
type Subdomain string
type Origin string

View file

@ -27,21 +27,25 @@ import {
deleteDomainsByDomainId,
getDomainsByDomainIdLogs,
} from "$lib/api-base/sdk.gen";
import type { HappydnsDomainUpdateInput } from "$lib/api-base/types.gen";
import type {
HappydnsDomainUpdateInput,
HappydnsDomainWithCheckStatus,
HappydnsDomainWithZoneMetadata,
} from "$lib/api-base/types.gen";
import type { Domain, DomainLog } from "$lib/model/domain";
import type { Provider } from "$lib/model/provider";
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
export async function listDomains(): Promise<Array<Domain>> {
return unwrapSdkResponse(await getDomains()) as Array<Domain>;
export async function listDomains(): Promise<Array<HappydnsDomainWithCheckStatus>> {
return unwrapSdkResponse(await getDomains()) as Array<HappydnsDomainWithCheckStatus>;
}
export async function getDomain(id: string): Promise<Domain> {
export async function getDomain(id: string): Promise<HappydnsDomainWithZoneMetadata> {
return unwrapSdkResponse(
await getDomainsByDomainId({
path: { domainId: id },
}),
) as Domain;
) as HappydnsDomainWithZoneMetadata;
}
export async function addDomain(domain: string, provider: Provider | undefined): Promise<Domain> {

View file

@ -25,13 +25,14 @@
import { Badge, Button, ButtonGroup, Icon } from "@sveltestrap/sveltestrap";
import ProviderLink from "$lib/components/providers/ProviderLink.svelte";
import type { Domain } from "$lib/model/domain";
import type { HappydnsDomainWithCheckStatus } from "$lib/api-base/types.gen";
import { navigate } from "$lib/stores/config";
import { t } from "$lib/translations";
import { getStatusColor, getStatusIcon } from "$lib/utils/checkers";
interface Props {
domain: Domain;
ondelete: (event: Event, domain: Domain) => void;
domain: HappydnsDomainWithCheckStatus;
ondelete: (event: Event, domain: HappydnsDomainWithCheckStatus) => void;
}
let { domain, ondelete }: Props = $props();
@ -47,7 +48,15 @@
<ProviderLink id_provider={domain.id_provider} onclick={(e) => e.stopPropagation()} />
</td>
<td>
<Badge color="success">OK</Badge>
<a
href="/domains/{encodeURIComponent(domain.domain)}/checks"
class="text-decoration-none"
onclick={(e) => e.stopPropagation()}
>
<Badge color={getStatusColor(domain.last_check_status)}>
<Icon name={getStatusIcon(domain.last_check_status)} />
</Badge>
</a>
</td>
<td class="text-end">
<ButtonGroup size="sm">

View file

@ -28,14 +28,14 @@
import { deleteDomain } from "$lib/api/domains";
import DomainTableRow from "$lib/components/domains/DomainTableRow.svelte";
import type { Domain } from "$lib/model/domain";
import type { HappydnsDomainWithCheckStatus } from "$lib/api-base/types.gen";
import { refreshDomains } from "$lib/stores/domains";
import { providersSpecs, refreshProvidersSpecs } from "$lib/stores/providers";
import { t } from "$lib/translations";
interface Props {
class?: ClassValue;
items: Array<Domain>;
items: Array<HappydnsDomainWithCheckStatus>;
[key: string]: unknown;
}
@ -43,7 +43,7 @@
if (!$providersSpecs) refreshProvidersSpecs();
async function delDomain(event: Event, item: Domain) {
async function delDomain(event: Event, item: HappydnsDomainWithCheckStatus) {
event.stopPropagation();
if (!confirm($t("domains.alert.remove", { domain: item.domain }))) return;

View file

@ -120,7 +120,7 @@
<Input
type="select"
value={domain.group}
on:change={(event) => changeGroup(event, domain.id, domain)}
onchange={(event) => changeGroup(event, domain.id, domain)}
>
<option value="">{$t("domaingroups.no-group")}</option>
{#each mygroups as group}

View file

@ -22,21 +22,24 @@
-->
<script lang="ts">
import { Icon } from "@sveltestrap/sveltestrap";
import FilterDomainInput from "$lib/components/pages/home/FilterDomainInput.svelte";
import CardImportableDomains from "$lib/components/providers/CardImportableDomains.svelte";
import ZoneList from "$lib/components/zones/ZoneList.svelte";
import type { HappydnsDomainWithCheckStatus } from "$lib/api-base/types.gen";
import { fqdnCompare } from "$lib/dns";
import type { Domain } from "$lib/model/domain";
import { domains } from "$lib/stores/domains";
import { filteredGroup, filteredName, filteredProvider } from "$lib/stores/home";
import { t } from "$lib/translations";
import { getStatusColor, getStatusIcon } from "$lib/utils/checkers";
let noDomainsList = $state(false);
let filteredDomains: Array<Domain> = $derived(refreshFilteredDomains());
let filteredDomains: Array<HappydnsDomainWithCheckStatus> = $derived(refreshFilteredDomains());
function refreshFilteredDomains(): Array<Domain> {
let myDomains: Array<Domain> = [];
function refreshFilteredDomains(): Array<HappydnsDomainWithCheckStatus> {
let myDomains: Array<HappydnsDomainWithCheckStatus> = [];
if ($domains) {
myDomains = $domains.filter(
@ -58,7 +61,16 @@
<FilterDomainInput class="mb-3" />
{#if filteredDomains.length}
<ZoneList button display_by_groups domains={filteredDomains} links show_empty_groups />
<ZoneList button display_by_groups domains={filteredDomains} links show_empty_groups>
{#snippet badges({ domain })}
<a
href="/domains/{encodeURIComponent(domain.domain)}/checks"
class={"text-" + getStatusColor(domain.last_check_status)}
>
<Icon name={getStatusIcon(domain.last_check_status)} />
</a>
{/snippet}
</ZoneList>
{:else}
<div class="my-4 text-center text-muted">
{$t("domains.filtered-no-result")}

View file

@ -21,34 +21,27 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
<script lang="ts" generics="T extends HappydnsDomain = HappydnsDomain">
import { createEventDispatcher, type Snippet } from "svelte";
import { Badge } from "@sveltestrap/sveltestrap";
import { ListGroup } from "@sveltestrap/sveltestrap";
import DomainWithProvider from "$lib/components/domains/DomainWithProvider.svelte";
import { updateDomain } from "$lib/api/domains";
import type { HappydnsDomain } from "$lib/api-base/types.gen";
import { domains_idx, newlyGroups, refreshDomains } from "$lib/stores/domains";
import { t } from "$lib/translations";
const dispatch = createEventDispatcher();
interface ZoneListDomain {
id: string;
domain: string;
id_provider: string;
group?: string;
href?: string;
}
interface Props {
flush?: boolean;
links?: boolean;
display_by_groups?: boolean;
show_empty_groups?: boolean;
domains?: Array<ZoneListDomain>;
domains?: Array<T>;
no_domain?: Snippet;
badges?: Snippet<[{ domain: ZoneListDomain }]>;
badges?: Snippet<[{ domain: T }]>;
[key: string]: unknown;
}
@ -64,7 +57,7 @@
}: Props = $props();
function genGroups(
domains: Array<ZoneListDomain>,
domains: Array<T>,
display_by_groups: boolean,
show_empty_groups: boolean,
extraGroups: Array<string>,
@ -73,7 +66,7 @@
return { "": domains };
}
const groups: Record<string, Array<ZoneListDomain>> = {};
const groups: Record<string, Array<T>> = {};
for (const domain of domains) {
const group = domain.group ?? "";
@ -91,13 +84,13 @@
return groups;
}
let localDomains: Array<ZoneListDomain> = $derived([...domains]);
let localDomains: Array<T> = $derived([...domains]);
let groups: Record<string, Array<ZoneListDomain>> = $derived(
let groups: Record<string, Array<T>> = $derived(
genGroups(localDomains, display_by_groups, show_empty_groups, $newlyGroups),
);
let draggedDomain: ZoneListDomain | null = $state(null);
let draggedDomain: T | null = $state(null);
let dragOverGroup: string | null = $state(null);
async function handleDrop(targetGroup: string) {
@ -129,19 +122,19 @@
}
}
function getDomainHref(domain: ZoneListDomain): string | undefined {
if (links && !domain.href) {
function getDomainHref(domain: T): string | undefined {
if (links) {
if ($domains_idx[domain.domain]) {
return "/domains/" + encodeURIComponent(domain.domain);
} else {
return "/domains/" + encodeURIComponent(domain.id);
}
}
return domain.href;
return undefined;
}
</script>
{#snippet domainRow(domain: ZoneListDomain)}
{#snippet domainRow(domain: T)}
<DomainWithProvider {domain} />
{#if badges}{@render badges({ domain })}{:else}
<Badge color="success">OK</Badge>

View file

@ -21,9 +21,9 @@
import { get, derived, writable, type Writable } from "svelte/store";
import { listDomains } from "$lib/api/domains";
import type { Domain } from "$lib/model/domain";
import type { HappydnsDomainWithCheckStatus } from "$lib/api-base/types.gen";
export const domains: Writable<Array<Domain> | undefined> = writable(undefined);
export const domains: Writable<Array<HappydnsDomainWithCheckStatus> | undefined> = writable(undefined);
export const newlyGroups: Writable<Array<string>> = writable([]);
export async function refreshDomains() {
@ -35,7 +35,7 @@ export async function refreshDomains() {
return data;
}
export const groups = derived(domains, ($domains: Array<Domain> | undefined) => {
export const groups = derived(domains, ($domains: Array<HappydnsDomainWithCheckStatus> | undefined) => {
if (!$domains) return [];
const groups = new Set<string>();
@ -51,8 +51,8 @@ export const groups = derived(domains, ($domains: Array<Domain> | undefined) =>
});
});
export const domains_idx = derived(domains, ($domains: Array<Domain> | undefined) => {
const idx: Record<string, Domain> = {};
export const domains_idx = derived(domains, ($domains: Array<HappydnsDomainWithCheckStatus> | undefined) => {
const idx: Record<string, HappydnsDomainWithCheckStatus> = {};
if (!$domains) return idx;
@ -75,8 +75,8 @@ export const domains_idx = derived(domains, ($domains: Array<Domain> | undefined
return idx;
});
export const domains_by_name = derived(domains, ($domains: Array<Domain> | undefined) => {
const idx: Record<string, Array<Domain>> = {};
export const domains_by_name = derived(domains, ($domains: Array<HappydnsDomainWithCheckStatus> | undefined) => {
const idx: Record<string, Array<HappydnsDomainWithCheckStatus>> = {};
if (!$domains) return idx;
@ -91,8 +91,8 @@ export const domains_by_name = derived(domains, ($domains: Array<Domain> | undef
return idx;
});
export const domains_by_groups = derived(domains, ($domains: Array<Domain> | undefined) => {
const groups: Record<string, Array<Domain>> = {};
export const domains_by_groups = derived(domains, ($domains: Array<HappydnsDomainWithCheckStatus> | undefined) => {
const groups: Record<string, Array<HappydnsDomainWithCheckStatus>> = {};
if ($domains === undefined) {
return groups;

View file

@ -1,7 +1,7 @@
import { error, type Load } from "@sveltejs/kit";
import { get } from "svelte/store";
import type { Domain } from "$lib/model/domain";
import type { HappydnsDomainWithZoneMetadata } from "$lib/api-base/types.gen";
import { domains, domains_idx, refreshDomains } from "$lib/stores/domains";
export const load: Load = async ({ parent, params }) => {
@ -15,7 +15,7 @@ export const load: Load = async ({ parent, params }) => {
});
}
const domain: Domain | null = get(domains_idx)[params.dn];
const domain: HappydnsDomainWithZoneMetadata | null = get(domains_idx)[params.dn];
if (!domain) {
error(404, {