Refactor SubdomainItem: split Header

This commit is contained in:
nemunaire 2025-06-10 13:42:11 +02:00
commit d30baef2bd
8 changed files with 295 additions and 302 deletions

View file

@ -56,6 +56,7 @@
"resolver": "Resolver",
"run": "Run the request!",
"survey": "A remark? A comment to share? Don't hesitate to write to us!",
"spinning": "Spinning",
"update": "Update",
"welcome": {
"start": "Welcome to ",
@ -89,7 +90,7 @@
"add-a-subdomain": "Add a subdomain",
"add-a-service": "Add a service",
"add-alias": "Add alias",
"add-an-alias": "Add an alias",
"add-an-alias": "Add an alias to {{domain}}",
"add-now": "Add it now!",
"create-on-provider": "Create on {{provider}}",
"added-success": "Great! {{domain}} has been added. You can manage it right now.",

View file

@ -49,6 +49,7 @@
"rename": "Renommer",
"resolver": "Résolveur",
"run": "Lancer l'opération!",
"spinning": "Chargement",
"update": "Mettre à jour",
"help": "Besoin d'aide?",
"records": "{{n:eq; 0:aucun enregistrement {{type}}; 1:enregistrement {{type}}; default:enregistrements {{type}}}}",
@ -85,7 +86,7 @@
"add-a-subdomain": "Ajouter un sous-domaine",
"add-a-service": "Ajouter un service",
"add-alias": "Ajout d'alias",
"add-an-alias": "Ajouter un alias",
"add-an-alias": "Ajouter un alias vers {{domain}}",
"add-now": "Ajouter maintenant!",
"added-success": "Bravo! {{domain}} a été ajouté. Vous pouvez désormais le gérer.",
"apply": {

View file

@ -29,6 +29,33 @@ import { refreshDomains } from "$lib/stores/domains";
export const thisZone: Writable<null | Zone> = writable(null);
// thisAliases returns all aliases in the domain
export const thisAliases = derived(thisZone, (zone: null | Zone) => {
const aliases: Record<string, Array<string>> = {};
if (!zone || !zone.services) {
return aliases;
}
for (const dn in zone.services) {
if (!zone.services[dn]) continue;
zone.services[dn].forEach(function (svc) {
if (svc._svctype === "svcs.CNAME") {
if (!aliases[svc.Service.Target]) {
aliases[svc.Service.Target] = [];
}
aliases[svc.Service.Target].push(dn);
}
});
}
if (aliases["@"])
aliases[""] = aliases["@"];
return aliases;
});
// sortedDomains returns all subdomains, sorted
export const sortedDomains = derived(thisZone, ($thisZone: null | Zone) => {
if (!$thisZone) {

View file

@ -24,6 +24,7 @@
<script lang="ts">
import { Button, Col, Icon, Row, Spinner } from "@sveltestrap/sveltestrap";
import AliasModal from "./AliasModal.svelte";
import SubdomainList from "./SubdomainList.svelte";
import type { Domain } from "$lib/model/domain";
import type { Zone } from "$lib/model/zone";
@ -57,19 +58,17 @@
{#if !$sortedDomainsWithIntermediate || $sortedDomains.length == 0}
<SubdomainList
origin={data.domain}
sortedDomains={['']}
sortedDomainsWithIntermediate={['']}
zone={$thisZone}
on:update-zone-services={(event) => thisZone.set(event.detail)}
/>
{:else}
<SubdomainList
origin={data.domain}
sortedDomains={$sortedDomains}
sortedDomainsWithIntermediate={$sortedDomainsWithIntermediate}
zone={$thisZone}
on:update-zone-services={(event) => thisZone.set(event.detail)}
/>
{/if}
</div>
{/if}
<AliasModal
origin={data.domain}
/>

View file

@ -50,6 +50,7 @@
import { fqdn, validateDomain } from "$lib/dns";
import type { Domain } from "$lib/model/domain";
import type { Zone } from "$lib/model/zone";
import { thisZone } from "$lib/stores/thiszone";
import { t } from "$lib/translations";
const dispatch = createEventDispatcher();
@ -64,7 +65,7 @@
export let dn: string = "";
export let origin: Domain;
export let value: string = "";
export let zone: Zone;
let zone = $thisZone;
let newDomainState: boolean | undefined = undefined;
$: newDomainState = value ? validateNewSubdomain(value) : undefined;
@ -120,7 +121,7 @@
Service: { Target: dn ? dn : "@" },
}).then(
(z) => {
dispatch("update-zone-services", z);
thisZone.set(z);
addAliasInProgress = false;
toggle();
},
@ -142,8 +143,7 @@
<Modal {isOpen} {toggle}>
<ModalHeader {toggle}>
{$t("domains.add-an-alias")}
{origin.domain}
{$t("domains.add-an-alias", {domain: origin.domain})}
</ModalHeader>
<ModalBody>
<form id="addAliasForm" on:submit|preventDefault={submitAliasForm}>

View file

@ -24,267 +24,72 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { Badge, Button, Icon, Popover, Spinner } from "@sveltestrap/sveltestrap";
import { deleteZoneService } from "$lib/api/zone";
import { controls as ctrlNewService } from "$lib/components/services/NewServicePath.svelte";
import { controls as ctrlService } from "$lib/components/services/ServiceModal.svelte";
import Service from "./Service.svelte";
import { fqdn, isReverseZone, unreverseDomain } from "$lib/dns";
import SubdomainItemHeader from "./SubdomainItemHeader.svelte";
import { isReverseZone } from "$lib/dns";
import type { Domain } from "$lib/model/domain";
import type { ServiceCombined } from "$lib/model/service";
import { ZoneViewGrid } from "$lib/model/usersettings";
import { servicesSpecs } from "$lib/stores/services";
import { thisZone } from "$lib/stores/thiszone";
import { userSession } from "$lib/stores/usersession";
import { t } from "$lib/translations";
const dispatch = createEventDispatcher();
export let aliases: Array<string> = [];
export let dn: string;
export let origin: Domain;
export let services: Array<ServiceCombined>;
export let zoneId: string;
let reverseZone = false;
$: reverseZone = isReverseZone(origin.domain);
let showResources = true;
let showResources = true && (services.length > 1 || (services.length === 1 && services[0]._svctype !== "svcs.CNAME" && services[0]._svctype !== "svcs.PTR"));
function isCNAME(services: Array<ServiceCombined>) {
return services.length === 1 && services[0]._svctype === "svcs.CNAME";
}
function isPTR(services: Array<ServiceCombined>) {
return services.length === 1 && services[0]._svctype === "svcs.PTR";
}
let deleteServiceInProgress = false;
function deleteCNAME() {
deleteServiceInProgress = true;
deleteZoneService(origin, zoneId, services[0]).then(
(z) => {
dispatch("update-zone-services", z);
deleteServiceInProgress = false;
},
(err) => {
deleteServiceInProgress = false;
throw err;
},
);
}
function showServiceModal(service: ServiceCombined) {
dispatch("show-service", service);
function showServiceModal(event: CustomEvent<ServiceCombined>) {
ctrlService.Open(event.detail);
}
</script>
{#if services.length === 0 && dn != ""}
<div id={dn}>
{#if !reverseZone}
<h2 class="sticky-top bg-light d-flex align-items-center" style="z-index: 1">
<span class="text-truncate text-muted">
<Icon name="plus-square-dotted" title="Intermediate domain with no service" />
<span class="font-monospace" title={fqdn(dn, origin.domain)}>
{#if reverseZone}
{unreverseDomain(fqdn(dn, origin.domain))}
{:else}
{fqdn(dn, origin.domain)}
{/if}
</span>
</span>
<div class="flex-fill"></div>
<Button
type="button"
color="primary"
size="sm"
class="ms-2"
title={$t("service.add")}
on:click={() => dispatch("new-service")}
>
<Icon name="plus" />
</Button>
</h2>
{/if}
</div>
{:else if isCNAME(services) || isPTR(services)}
<div id={dn}>
<h2 class="sticky-top bg-light d-flex align-items-center" style="z-index: 1">
<span class="text-truncate">
{#if isPTR(services)}
<Icon name="signpost" title="PTR" />
{:else}
<Icon name="sign-turn-right" title="CNAME" />
{/if}
<span class="font-monospace" title={fqdn(dn, origin.domain)}>
{#if reverseZone}
{unreverseDomain(fqdn(dn, origin.domain))}
{:else}
{fqdn(dn, origin.domain)}
{/if}
</span>
</span>
<span class="text-truncate">
<Icon name="arrow-right" />
<span class="font-monospace" title={services[0].Service.Target}>
{services[0].Service.Target}
</span>
</span>
<div class="flex-fill"></div>
<Button
type="button"
color="info"
outline
size="sm"
class="ms-2"
title={$t("domains.edit-target")}
on:click={() => showServiceModal(services[0])}
>
<Icon name="pencil" />
</Button>
<Button
type="button"
color="danger"
disabled={deleteServiceInProgress}
outline
size="sm"
class="ms-2"
title={isPTR(services) ? $t("domains.drop-pointer") : $t("domains.drop-alias")}
on:click={deleteCNAME}
>
{#if deleteServiceInProgress}
<Spinner size="sm" />
{:else}
<Icon name="x-circle" />
{/if}
</Button>
<Button
type="button"
color="primary"
size="sm"
class="ms-2"
title={$t("service.add")}
on:click={() => dispatch("new-service")}
>
<Icon name="plus" />
</Button>
</h2>
</div>
{:else}
<div id={dn ? dn : "@"}>
<div class="d-flex align-items-center sticky-top mb-2 gap-2 bg-light" style="z-index: 1">
<h2
role="button"
tabindex="0"
style="white-space: nowrap; cursor: pointer;"
class="mb-0 text-truncate"
on:click={() => (showResources = !showResources)}
on:keypress={() => (showResources = !showResources)}
>
{#if showResources}
<Icon name="chevron-down" />
{:else}
<Icon name="chevron-right" />
{/if}
<span class="font-monospace" title={fqdn(dn, origin.domain)}>
{#if reverseZone}
{unreverseDomain(fqdn(dn, origin.domain))}
{:else}
{fqdn(dn, origin.domain)}
{/if}
</span>
</h2>
{#if !showResources && $servicesSpecs}
<Badge id={"popoversvc-" + dn.replace(".", "__")} style="cursor: pointer;">
{$t("domains.n-services", { count: services.length })}
</Badge>
<Popover
dismissible
placement="bottom"
target={"popoversvc-" + dn.replace(".", "__")}
>
{#each services as service}
<strong>{$servicesSpecs[service._svctype].name}:</strong>
<span class="text-muted">{service._comment}</span>
<br />
{/each}
</Popover>
{/if}
{#if aliases.length != 0}
<Badge id={"popoverbadge-" + dn.replace(".", "__")} style="cursor: pointer;">
+ {$t("domains.n-aliases", { count: aliases.length })}
</Badge>
<Popover
dismissible
placement="bottom"
target={"popoverbadge-" + dn.replace(".", "__")}
class="font-monospace"
>
{#each aliases as alias}
<a href={"#" + alias}>
{alias}
</a>
<br />
{/each}
</Popover>
{/if}
<div class="flex-fill"></div>
{#if !showResources || ($userSession && $userSession.settings.zoneview !== ZoneViewGrid)}
<Button
type="button"
color="primary"
size="sm"
title={$t("domains.add-a-service")}
on:click={() => dispatch("new-service")}
>
<Icon name="plus" />
</Button>
{/if}
{#if showResources}
<Button
type="button"
color="primary"
outline
size="sm"
title={$t("domains.add-an-alias")}
on:click={() => dispatch("new-alias")}
>
<Icon name="link" />
</Button>
{/if}
</div>
{#if showResources}
<div
class:d-flex={showResources &&
$userSession &&
$userSession.settings.zoneview === ZoneViewGrid}
class:justify-content-around={showResources &&
$userSession &&
$userSession.settings.zoneview === ZoneViewGrid}
class:flex-wrap={showResources &&
$userSession &&
$userSession.settings.zoneview === ZoneViewGrid}
>
{#each services as service}
{#key service}
<Service
{origin}
{service}
{zoneId}
on:show-service={(event) => showServiceModal(event.detail)}
on:update-zone-services={(event) =>
dispatch("update-zone-services", event.detail)}
/>
{/key}
{/each}
{#if $userSession && $userSession.settings.zoneview === ZoneViewGrid}
{#if $thisZone}
<div id={dn ? dn : "@"}>
<SubdomainItemHeader
{dn}
{origin}
{services}
zoneId={$thisZone.id}
{reverseZone}
bind:showResources={showResources}
/>
{#if showResources}
<div
class:d-flex={showResources &&
$userSession &&
$userSession.settings.zoneview === ZoneViewGrid}
class:justify-content-around={showResources &&
$userSession &&
$userSession.settings.zoneview === ZoneViewGrid}
class:flex-wrap={showResources &&
$userSession &&
$userSession.settings.zoneview === ZoneViewGrid}
>
{#each services as service}
{#key service}
<Service
{origin}
{zoneId}
on:show-service={() => dispatch("new-service")}
on:update-zone-services={(event) =>
dispatch("update-zone-services", event.detail)}
{service}
zoneId={$thisZone.id}
/>
{/if}
</div>
{/if}
</div>
{/key}
{/each}
{#if $userSession && $userSession.settings.zoneview === ZoneViewGrid}
<Service
{origin}
zoneId={$thisZone.id}
/>
{/if}
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,200 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2025 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { Badge, Button, Icon, Popover, Spinner } from "@sveltestrap/sveltestrap";
import { deleteZoneService } from "$lib/api/zone";
import { controls as ctrlNewAlias } from "./AliasModal.svelte";
import { controls as ctrlNewService } from "$lib/components/services/NewServicePath.svelte";
import { controls as ctrlService } from "$lib/components/services/ServiceModal.svelte";
import { fqdn, unreverseDomain } from "$lib/dns";
import type { Domain } from "$lib/model/domain";
import type { ServiceCombined } from "$lib/model/service";
import { ZoneViewGrid } from "$lib/model/usersettings";
import { servicesSpecs } from "$lib/stores/services";
import { thisAliases, thisZone } from "$lib/stores/thiszone";
import { userSession } from "$lib/stores/usersession";
import { t } from "$lib/translations";
export let dn: string;
export let origin: Domain;
export let services: Array<ServiceCombined>;
export let zoneId: string;
export let reverseZone = false;
export let showResources = true;
function isCNAME(services: Array<ServiceCombined>) {
return services.length === 1 && services[0]._svctype === "svcs.CNAME";
}
function isPTR(services: Array<ServiceCombined>) {
return services.length === 1 && services[0]._svctype === "svcs.PTR";
}
let deleteServiceInProgress = false;
function deleteCNAME() {
deleteServiceInProgress = true;
deleteZoneService(origin, zoneId, services[0]).then(
(z) => {
thisZone.set(z);
deleteServiceInProgress = false;
},
(err) => {
deleteServiceInProgress = false;
throw err;
},
);
}
function showServiceModal(service: ServiceCombined) {
ctrlService.Open(service);
}
</script>
<div
class="sticky-top bg-light d-flex align-items-center mb-2 gap-2"
style="z-index: 1"
>
<h2
role="button"
tabindex="0"
class="text-truncate"
class:text-muted={services.length === 0 && dn != ""}
style:cursor={(services.length || dn == "") && !isPTR(services) && !isCNAME(services) ? "pointer": "default"}
on:click={() => (showResources = !showResources)}
on:keypress={() => (showResources = !showResources)}
>
{#if services.length === 0 && dn != ""}
<Icon name="plus-square-dotted" title="Intermediate domain with no service" />
{:else if isPTR(services)}
<Icon name="signpost" title="PTR" />
{:else if isCNAME(services)}
<Icon name="sign-turn-right" title="CNAME" />
{:else if showResources}
<Icon name="chevron-down" />
{:else}
<Icon name="chevron-right" />
{/if}
<span class="font-monospace" title={fqdn(dn, origin.domain)}>
{#if reverseZone}
{unreverseDomain(fqdn(dn, origin.domain))}
{:else}
{fqdn(dn, origin.domain)}
{/if}
</span>
</h2>
{#if isCNAME(services) || isPTR(services)}
<span class="text-truncate text-muted lead">
<Icon name="arrow-right" />
<span class="font-monospace">
{services[0].Service.Target}
</span>
</span>
{:else if !showResources && services.length}
<Badge id={"popoversvc-" + dn.replace(".", "__")} style="cursor: pointer;">
{$t("domains.n-services", { count: services.length })}
</Badge>
<Popover
dismissible
placement="bottom"
target={"popoversvc-" + dn.replace(".", "__")}
>
{#each services as service}
{#if $servicesSpecs && $servicesSpecs[service._svctype]}
<strong>{$servicesSpecs[service._svctype].name}:</strong>
{/if}
<span class="text-muted">{service._comment}</span>
<br />
{/each}
</Popover>
{/if}
{#if $thisAliases[dn] && $thisAliases[dn].length != 0}
<Badge id={"popoverbadge-" + dn.replace(".", "__")} style="cursor: pointer;">
+ {$t("domains.n-aliases", { count: $thisAliases[dn].length })}
</Badge>
<Popover
dismissible
placement="bottom"
target={"popoverbadge-" + dn.replace(".", "__")}
class="font-monospace"
>
{#each $thisAliases[dn] as alias}
<a href={"#" + alias}>
{alias}
</a>
<br />
{/each}
</Popover>
{/if}
<div class="flex-fill"></div>
{#if isCNAME(services) || isPTR(services)}
<Button
type="button"
color="info"
outline
size="sm"
title={$t("domains.edit-target")}
on:click={() => showServiceModal(services[0])}
>
<Icon name="pencil" />
</Button>
<Button
type="button"
color="danger"
disabled={deleteServiceInProgress}
outline
size="sm"
title={isPTR(services) ? $t("domains.drop-pointer") : $t("domains.drop-alias")}
on:click={deleteCNAME}
>
{#if deleteServiceInProgress}
<Spinner size="sm" />
{:else}
<Icon name="x-circle" />
{/if}
</Button>
{:else if showResources && services.length}
<Button
type="button"
color="primary"
outline
size="sm"
title={$t("domains.add-an-alias")}
on:click={() => ctrlNewAlias.Open(dn)}
>
<Icon name="link" />
</Button>
{/if}
{#if !showResources || ($userSession && $userSession.settings.zoneview !== ZoneViewGrid)}
<Button
type="button"
color="primary"
size="sm"
title={$t("domains.add-a-service")}
on:click={() => ctrlNewService.Open(dn)}
>
<Icon name="plus" />
</Button>
{/if}
</div>

View file

@ -24,63 +24,23 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import AliasModal, { controls as ctrlAlias } from "./AliasModal.svelte";
import { controls as ctrlNewService } from "$lib/components/services/NewServicePath.svelte";
import { controls as ctrlService } from "$lib/components/services/ServiceModal.svelte";
import SubdomainItem from "./SubdomainItem.svelte";
import type { Domain } from "$lib/model/domain";
import type { ServiceCombined } from "$lib/model/service";
import type { Zone } from "$lib/model/zone";
import { thisZone } from "$lib/stores/thiszone";
const dispatch = createEventDispatcher();
export let origin: Domain;
export let sortedDomains: Array<string>;
export let sortedDomainsWithIntermediate: Array<string>;
export let zone: Zone;
let aliases: Record<string, Array<string>>;
$: {
const tmp: Record<string, Array<string>> = {};
for (const dn of sortedDomains) {
if (!zone.services[dn]) continue;
zone.services[dn].forEach(function (svc) {
if (svc._svctype === "svcs.CNAME") {
if (!tmp[svc.Service.Target]) {
tmp[svc.Service.Target] = [];
}
tmp[svc.Service.Target].push(dn);
}
});
}
if (tmp["@"]) tmp[""] = tmp["@"];
aliases = tmp;
}
function showServiceModal(event: CustomEvent<ServiceCombined>) {
ctrlService.Open(event.detail);
}
</script>
{#each sortedDomainsWithIntermediate as dn}
<SubdomainItem
aliases={aliases[dn] ? aliases[dn] : []}
{dn}
{origin}
zoneId={zone.id}
services={zone.services[dn] ? zone.services[dn] : []}
on:new-alias={() => ctrlAlias.Open(dn)}
on:new-service={() => ctrlNewService.Open(dn)}
on:show-service={showServiceModal}
on:update-zone-services={(event) => dispatch("update-zone-services", event.detail)}
/>
{/each}
<AliasModal
{origin}
{zone}
on:update-zone-services={(event) => dispatch("update-zone-services", event.detail)}
/>
{#if $thisZone}
{#each sortedDomainsWithIntermediate as dn}
<SubdomainItem
{dn}
{origin}
services={$thisZone.services[dn] ? $thisZone.services[dn] : []}
/>
{/each}
{/if}