Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions core/ui/public/i18n/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,7 @@
},
"settings_tls_certificates": {
"title": "TLS certificates",
"certificates": "Certificates",
"show_all": "Show all",
"request_certificate": "Request certificate",
"filter_certificates": "Filter certificates",
Expand Down Expand Up @@ -889,6 +890,7 @@
"uploaded": "Uploaded",
"automatic": "Automatic",
"obsolete": "Obsolete",
"obsolete_tooltip": "No longer referenced by any application, HTTP route, or user request. It may still be reachable by clients until renewed or deleted.",
"valid": "Valid",
"expiring": "Expiring",
"expired": "Expired",
Expand Down Expand Up @@ -931,16 +933,20 @@
"certificates_not_displayed": "Certificates configured on {instanceId} are not displayed"
},
"settings_acme_servers": {
"title": "ACME servers",
"search_acme_server": "Search ACME server",
"no_acme_server": "No ACME server",
"no_acme_server_description": "There is no ACME server configured",
"url": "URL",
"acme_settings": "ACME settings",
"search_acme_settings": "Search",
"no_acme_settings": "No ACME settings",
"no_acme_settings_description": "No cluster node has ACME settings configured",
"acme_directory_url": "ACME directory URL",
"acme_directory_url_helper": "URL of the ACME provider used to issue certificates.",
"node": "Node",
"edit_acme_server": "Edit ACME server",
"edit_acme_server_for_node": "Edit ACME server for {node}",
"reload_page": "Page reload needed",
"edit_acme_server_leader_node_warning": "You are editing the ACME server for the leader node. After you click 'Edit ACME server' you'll need to reload the page"
"challenge": "Challenge type",
"challenge_http_01": "HTTP-01",
"challenge_tls_alpn_01": "TLS-ALPN-01",
"edit_acme_settings": "Edit ACME settings",
"acme_settings_for_node": "ACME settings for {node}",
"acme_restart_message": "HTTP clients on {node} will be briefly disconnected. The connection should resume automatically — otherwise, reload the page manually.",
"save_result_unknown": "Traefik restarted before the result was known. Check the ACME settings of the node and try again if needed."
},
"apps_lets_encrypt": {
"lets_encrypt_tips": "Requests a Let's Encrypt TLS certificate automatically. Disabling this option deletes the existing certificate (if any). You can also view or delete it in Cluster settings > TLS certificates.",
Expand Down
304 changes: 304 additions & 0 deletions core/ui/src/components/settings/AcmeSettingsPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
<!--
Copyright (C) 2026 Nethesis S.r.l.
SPDX-License-Identifier: GPL-3.0-or-later
-->
<template>
<div>
<cv-tile light>
<!-- no nested cv-grid: bx--grid caps at 99rem and centers, leaving empty
gutters above 1584px -->
<!-- NsDataTable would replace the rows: report partial failures here -->
<NsInlineNotification
v-if="tableError && servers.length"
kind="error"
:title="tableErrorTitle"
:description="tableErrorDescription"
:showCloseButton="false"
/>
<NsDataTable
:allRows="servers"
:columns="i18nTableColumns"
:rawColumns="tableColumns"
:sortable="true"
:pageSizes="[10, 25, 50, 100]"
:overflow-menu="true"
isSearchable
:searchPlaceholder="$t('settings_acme_servers.search_acme_settings')"
:searchClearLabel="$t('common.clear_search')"
:noSearchResultsLabel="$t('common.no_search_results')"
:noSearchResultsDescription="$t('common.no_search_results_description')"
:isLoading="loadingServers"
:skeletonRows="5"
:isErrorShown="!!tableError && !servers.length"
:errorTitle="tableErrorTitle"
:errorDescription="tableErrorDescription"
:itemsPerPageLabel="$t('pagination.items_per_page')"
:rangeOfTotalItemsLabel="$t('pagination.range_of_total_items')"
:ofTotalPagesLabel="$t('pagination.of_total_pages')"
:backwardText="$t('pagination.previous_page')"
:forwardText="$t('pagination.next_page')"
:pageNumberLabel="$t('pagination.page_number')"
@updatePage="tablePage = $event"
>
<template slot="empty-state">
<NsEmptyState :title="$t('settings_acme_servers.no_acme_settings')">
<template #description>
<div>
{{ $t("settings_acme_servers.no_acme_settings_description") }}
</div>
</template>
</NsEmptyState>
</template>
<template slot="data">
<cv-data-table-row
v-for="(row, rowIndex) in tablePage"
:key="`${rowIndex}`"
:value="`${rowIndex}`"
>
<cv-data-table-cell>
<span>{{ row.node }}</span>
</cv-data-table-cell>
<cv-data-table-cell>
<span>
{{ row.url }}
</span>
</cv-data-table-cell>
<cv-data-table-cell>
<NsTag
v-if="row.challenge"
:kind="getChallengeTagKind(row.challenge)"
size="sm"
:label="getChallengeLabel(row.challenge)"
/>
</cv-data-table-cell>
<cv-data-table-cell>
<div class="flex justify-end">
<NsButton
kind="ghost"
:icon="Edit20"
size="small"
@click="showEditServerModal(row)"
:data-test-id="row.nodeId + '-edit'"
>{{ $t("common.edit") }}
</NsButton>
</div>
</cv-data-table-cell>
</cv-data-table-row>
</template>
</NsDataTable>
</cv-tile>
<EditAcmeServerModal
:isShown="isShownEditServerModal"
:server="currentServer"
@hide="hideEditServerModal"
@reloadServers="onReloadServers"
/>
</div>
</template>

<script>
import to from "await-to-js";
import { UtilService, TaskService, IconService } from "@nethserver/ns8-ui-lib";
import EditAcmeServerModal, {
ACME_CHALLENGE_TYPES,
} from "@/components/settings/EditAcmeServerModal.vue";

export default {
name: "AcmeSettingsPanel",
components: { EditAcmeServerModal },
mixins: [TaskService, UtilService, IconService],
props: {
// the parent page already lists them: do not run the task twice
traefikInstances: {
type: Array,
default: () => [],
},
isLoadingInstances: Boolean,
instancesError: {
type: String,
default: "",
},
},
data() {
return {
tablePage: [],
tableColumns: ["node", "url", "challenge"],
tableColumnLabelKeys: [
"settings_acme_servers.node",
"settings_acme_servers.acme_directory_url",
"settings_acme_servers.challenge",
],
servers: [],
isShownEditServerModal: false,
currentErrorAction: "",
currentErrorDescription: "",
currentServer: null,
taskListeners: [],
loading: {
getAcmeServerNum: 0,
},
error: {
getAcmeServer: "",
},
};
},
computed: {
i18nTableColumns() {
return this.tableColumnLabelKeys.map((key) => this.$t(key));
},
loadingServers() {
return this.isLoadingInstances || this.loading.getAcmeServerNum > 0;
},
tableError() {
return this.instancesError || this.error.getAcmeServer;
},
tableErrorTitle() {
return this.instancesError
? this.$t("action.list-installed-modules")
: this.currentErrorAction;
},
tableErrorDescription() {
return this.instancesError || this.currentErrorDescription;
},
},
watch: {
// a Traefik restart kills the websocket: the parent republishes, so this
// reload is also how the table recovers
traefikInstances: function () {
this.getAcmeServer();
},
},
created() {
if (this.traefikInstances.length) {
this.getAcmeServer();
}
},
beforeDestroy() {
// a task completing after destroy would re-fire the whole task chain
this.clearTaskListeners();
},
methods: {
registerTaskListener(eventName, handler) {
this.$root.$once(eventName, handler);
this.taskListeners.push([eventName, handler]);
},
clearTaskListeners() {
this.taskListeners.forEach(([eventName, handler]) => {
this.$root.$off(eventName, handler);
});
this.taskListeners = [];
},
getChallengeType(challenge) {
return ACME_CHALLENGE_TYPES.find((type) => type.value === challenge);
},
getChallengeTagKind(challenge) {
const challengeType = this.getChallengeType(challenge);
// cv-tag validates the kind against a fixed list
return challengeType ? challengeType.tagKind : "gray";
},
getChallengeLabel(challenge) {
const challengeType = this.getChallengeType(challenge);
return challengeType ? this.$t(challengeType.labelKey) : challenge;
},
showEditServerModal(server) {
this.currentServer = server;
this.isShownEditServerModal = true;
},
hideEditServerModal() {
this.isShownEditServerModal = false;
},
clearTableError() {
this.error.getAcmeServer = "";
this.currentErrorAction = "";
this.currentErrorDescription = "";
},
async getAcmeServer() {
// a handler of a previous round would decrement the counter of this one
this.clearTaskListeners();
this.servers = [];
// otherwise a transient error sticks after the nodes answer again
this.clearTableError();
// count upfront: the counter must not hit zero between two iterations
this.loading.getAcmeServerNum = this.traefikInstances.length;

for (const traefikInstance of this.traefikInstances) {
const taskAction = "get-acme-server";
const eventId = this.getUuid();

// register to task events

this.registerTaskListener(
`${taskAction}-aborted-${eventId}`,
this.getAcmeServerAborted
);

this.registerTaskListener(
`${taskAction}-completed-${eventId}`,
this.getAcmeServerCompleted
);

const res = await to(
this.createModuleTaskForApp(traefikInstance.id, {
action: taskAction,
data: {
expand_list: true,
},
extra: {
title: this.$t("action." + taskAction),
isNotificationHidden: true,
traefikInstance: traefikInstance,
eventId,
},
})
);
const err = res[0];

if (err) {
console.error(`error creating task ${taskAction}`, err);
const errMessage = this.getErrorMessage(err);
this.error.getAcmeServer = errMessage;
this.currentErrorAction = this.$t("action." + taskAction);
this.currentErrorDescription = errMessage;
this.loading.getAcmeServerNum--;
}
}
},
getAcmeServerAborted(taskResult, taskContext) {
console.error(`${taskContext.action} aborted`, taskResult);
this.error.getAcmeServer = this.$t("error.generic_error");
this.currentErrorAction = this.$t("action." + taskContext.action);
this.currentErrorDescription = this.$t("error.generic_error");
this.loading.getAcmeServerNum--;
},
getAcmeServerCompleted(taskContext, taskResult) {
const server = taskResult.output;
const traefikId = taskContext.extra.traefikInstance.id;
const nodeId = taskContext.extra.traefikInstance.node;
const nodeUiName = taskContext.extra.traefikInstance.node_ui_name;
const node = { id: nodeId, ui_name: nodeUiName };
const nodeLabel = this.getShortNodeLabel(node) + ` (${traefikId})`;
server.node = nodeLabel;
server.nodeId = nodeId;
server.longNodeLabel = this.getNodeLabel(node);
server.traefikInstance = traefikId;

// replace instead of append: an event delivered after a reconnection
// reset would otherwise duplicate the row
const index = this.servers.findIndex(
(existing) => existing.traefikInstance === traefikId
);

if (index > -1) {
this.servers.splice(index, 1, server);
} else {
this.servers.push(server);
}
this.servers.sort(this.sortByProperty("node"));
this.loading.getAcmeServerNum--;
},
onReloadServers() {
this.getAcmeServer();
},
},
};
</script>
Loading
Loading