Merge 1.23.8 (#4142)

1.23.x merge to master
This commit is contained in:
Louis Lam 2023-12-02 18:47:26 +08:00 committed by GitHub
commit 7772a546db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 100 additions and 49 deletions

51
package-lock.json generated
View file

@ -32,7 +32,7 @@
"express-basic-auth": "~1.2.1", "express-basic-auth": "~1.2.1",
"express-static-gzip": "~2.1.7", "express-static-gzip": "~2.1.7",
"form-data": "~4.0.0", "form-data": "~4.0.0",
"gamedig": "~4.1.0", "gamedig": "^4.2.0",
"html-escaper": "^3.0.3", "html-escaper": "^3.0.3",
"http-cookie-agent": "~5.0.4", "http-cookie-agent": "~5.0.4",
"http-graceful-shutdown": "~3.1.7", "http-graceful-shutdown": "~3.1.7",
@ -66,6 +66,7 @@
"playwright-core": "~1.35.1", "playwright-core": "~1.35.1",
"prom-client": "~13.2.0", "prom-client": "~13.2.0",
"prometheus-api-metrics": "~3.2.1", "prometheus-api-metrics": "~3.2.1",
"promisify-child-process": "~4.1.2",
"protobufjs": "~7.2.4", "protobufjs": "~7.2.4",
"qs": "~6.10.4", "qs": "~6.10.4",
"redbean-node": "~0.3.0", "redbean-node": "~0.3.0",
@ -141,7 +142,7 @@
"vue-router": "~4.0.14", "vue-router": "~4.0.14",
"vue-toastification": "~2.0.0-rc.5", "vue-toastification": "~2.0.0-rc.5",
"vuedraggable": "~4.1.0", "vuedraggable": "~4.1.0",
"wait-on": "^6.0.1", "wait-on": "^7.2.0",
"whatwg-url": "~12.0.1" "whatwg-url": "~12.0.1"
}, },
"engines": { "engines": {
@ -8531,9 +8532,9 @@
} }
}, },
"node_modules/gamedig": { "node_modules/gamedig": {
"version": "4.1.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/gamedig/-/gamedig-4.1.0.tgz", "resolved": "https://registry.npmjs.org/gamedig/-/gamedig-4.2.0.tgz",
"integrity": "sha512-jvLUEakihJgpiw9t9yQRsbcemeALeTNlnaWY1gvYdwI63ZlkxznTaLqX5K/eluRTTCtAWNW3YceT6NVjyAZIwA==", "integrity": "sha512-UwV9gT1PrOTxjwGzj9/i8XJLx9QqmzFtrRJnRLqN7fZWEIRHjbuUpx2b6Y3v/5QyRDFP+vaB3Pm0hw3Xg4RnOQ==",
"dependencies": { "dependencies": {
"cheerio": "^1.0.0-rc.10", "cheerio": "^1.0.0-rc.10",
"gbxremote": "^0.2.1", "gbxremote": "^0.2.1",
@ -12983,6 +12984,14 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/promisify-child-process": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/promisify-child-process/-/promisify-child-process-4.1.2.tgz",
"integrity": "sha512-APnkIgmaHNJpkAn7k+CrJSi9WMuff5ctYFbD0CO2XIPkM8yO7d/ShouU2clywbpHV/DUsyc4bpJCsNgddNtx4g==",
"engines": {
"node": ">=8"
}
},
"node_modules/prompts": { "node_modules/prompts": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@ -16117,31 +16126,33 @@
} }
}, },
"node_modules/wait-on": { "node_modules/wait-on": {
"version": "6.0.1", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-6.0.1.tgz", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
"integrity": "sha512-zht+KASY3usTY5u2LgaNqn/Cd8MukxLGjdcZxT2ns5QzDmTFc4XoWBgC+C/na+sMRZTuVygQoMYwdcVjHnYIVw==", "integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"axios": "^0.25.0", "axios": "^1.6.1",
"joi": "^17.6.0", "joi": "^17.11.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"minimist": "^1.2.5", "minimist": "^1.2.8",
"rxjs": "^7.5.4" "rxjs": "^7.8.1"
}, },
"bin": { "bin": {
"wait-on": "bin/wait-on" "wait-on": "bin/wait-on"
}, },
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/wait-on/node_modules/axios": { "node_modules/wait-on/node_modules/axios": {
"version": "0.25.0", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
"integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"follow-redirects": "^1.14.7" "follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
} }
}, },
"node_modules/wait-on/node_modules/joi": { "node_modules/wait-on/node_modules/joi": {
@ -16157,6 +16168,12 @@
"@sideway/pinpoint": "^2.0.0" "@sideway/pinpoint": "^2.0.0"
} }
}, },
"node_modules/wait-on/node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
"node_modules/walker": { "node_modules/walker": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",

View file

@ -44,7 +44,7 @@
"build-docker-nightly-local": "npm run build && docker build -f docker/dockerfile -t louislam/uptime-kuma:nightly2 --target nightly .", "build-docker-nightly-local": "npm run build && docker build -f docker/dockerfile -t louislam/uptime-kuma:nightly2 --target nightly .",
"build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test2 --target pr-test2 . --push", "build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test2 --target pr-test2 . --push",
"upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain", "upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain",
"setup": "git checkout 1.23.7 && npm ci --production && npm run download-dist", "setup": "git checkout 1.23.8 && npm ci --production && npm run download-dist",
"download-dist": "node extra/download-dist.js", "download-dist": "node extra/download-dist.js",
"mark-as-nightly": "node extra/mark-as-nightly.js", "mark-as-nightly": "node extra/mark-as-nightly.js",
"reset-password": "node extra/reset-password.js", "reset-password": "node extra/reset-password.js",
@ -96,7 +96,7 @@
"express-basic-auth": "~1.2.1", "express-basic-auth": "~1.2.1",
"express-static-gzip": "~2.1.7", "express-static-gzip": "~2.1.7",
"form-data": "~4.0.0", "form-data": "~4.0.0",
"gamedig": "~4.1.0", "gamedig": "^4.2.0",
"http-cookie-agent": "~5.0.4", "http-cookie-agent": "~5.0.4",
"html-escaper": "^3.0.3", "html-escaper": "^3.0.3",
"http-graceful-shutdown": "~3.1.7", "http-graceful-shutdown": "~3.1.7",
@ -130,6 +130,7 @@
"playwright-core": "~1.35.1", "playwright-core": "~1.35.1",
"prom-client": "~13.2.0", "prom-client": "~13.2.0",
"prometheus-api-metrics": "~3.2.1", "prometheus-api-metrics": "~3.2.1",
"promisify-child-process": "~4.1.2",
"protobufjs": "~7.2.4", "protobufjs": "~7.2.4",
"qs": "~6.10.4", "qs": "~6.10.4",
"redbean-node": "~0.3.0", "redbean-node": "~0.3.0",
@ -205,7 +206,7 @@
"vue-router": "~4.0.14", "vue-router": "~4.0.14",
"vue-toastification": "~2.0.0-rc.5", "vue-toastification": "~2.0.0-rc.5",
"vuedraggable": "~4.1.0", "vuedraggable": "~4.1.0",
"wait-on": "^6.0.1", "wait-on": "^7.2.0",
"whatwg-url": "~12.0.1" "whatwg-url": "~12.0.1"
} }
} }

View file

@ -44,6 +44,7 @@ if (process.platform === "win32") {
"/usr/bin/chromium", "/usr/bin/chromium",
"/usr/bin/chromium-browser", "/usr/bin/chromium-browser",
"/usr/bin/google-chrome", "/usr/bin/google-chrome",
"/snap/bin/chromium", // Ubuntu
]; ];
} else if (process.platform === "darwin") { } else if (process.platform === "darwin") {
allowedList = [ allowedList = [

View file

@ -1,6 +1,6 @@
const { MonitorType } = require("./monitor-type"); const { MonitorType } = require("./monitor-type");
const { UP } = require("../../src/util"); const { UP } = require("../../src/util");
const childProcess = require("child_process"); const childProcessAsync = require("promisify-child-process");
/** /**
* A TailscalePing class extends the MonitorType. * A TailscalePing class extends the MonitorType.
@ -37,12 +37,9 @@ class TailscalePing extends MonitorType {
*/ */
async runTailscalePing(hostname, interval) { async runTailscalePing(hostname, interval) {
let timeout = interval * 1000 * 0.8; let timeout = interval * 1000 * 0.8;
let res = childProcess.spawnSync("tailscale", [ "ping", hostname ], { let res = await childProcessAsync.spawn("tailscale", [ "ping", "--c", "1", hostname ], {
timeout: timeout timeout: timeout
}); });
if (res.error) {
throw new Error(`Execution error: ${res.error.message}`);
}
if (res.stderr && res.stderr.toString()) { if (res.stderr && res.stderr.toString()) {
throw new Error(`Error in output: ${res.stderr.toString()}`); throw new Error(`Error in output: ${res.stderr.toString()}`);
} }

View file

@ -1,5 +1,5 @@
const NotificationProvider = require("./notification-provider"); const NotificationProvider = require("./notification-provider");
const childProcess = require("child_process"); const childProcessAsync = require("promisify-child-process");
class Apprise extends NotificationProvider { class Apprise extends NotificationProvider {
@ -14,7 +14,7 @@ class Apprise extends NotificationProvider {
args.push("-t"); args.push("-t");
args.push(notification.title); args.push(notification.title);
} }
const s = childProcess.spawnSync("apprise", args); const s = await childProcessAsync.spawn("apprise", args);
const output = (s.stdout) ? s.stdout.toString() : "ERROR: maybe apprise not found"; const output = (s.stdout) ? s.stdout.toString() : "ERROR: maybe apprise not found";

View file

@ -1337,9 +1337,9 @@ let needSetup = false;
// Update nscd status // Update nscd status
if (previousNSCDStatus !== data.nscd) { if (previousNSCDStatus !== data.nscd) {
if (data.nscd) { if (data.nscd) {
server.startNSCDServices(); await server.startNSCDServices();
} else { } else {
server.stopNSCDServices(); await server.stopNSCDServices();
} }
} }

View file

@ -10,7 +10,7 @@ const util = require("util");
const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent");
const { Settings } = require("./settings"); const { Settings } = require("./settings");
const dayjs = require("dayjs"); const dayjs = require("dayjs");
const childProcess = require("child_process"); const childProcessAsync = require("promisify-child-process");
const path = require("path"); const path = require("path");
const axios = require("axios"); const axios = require("axios");
// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead. // DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead.
@ -372,7 +372,7 @@ class UptimeKumaServer {
let enable = await Settings.get("nscd"); let enable = await Settings.get("nscd");
if (enable || enable === null) { if (enable || enable === null) {
this.startNSCDServices(); await this.startNSCDServices();
} }
} }
@ -384,7 +384,7 @@ class UptimeKumaServer {
let enable = await Settings.get("nscd"); let enable = await Settings.get("nscd");
if (enable || enable === null) { if (enable || enable === null) {
this.stopNSCDServices(); await this.stopNSCDServices();
} }
} }
@ -393,11 +393,11 @@ class UptimeKumaServer {
* For now, only used in Docker * For now, only used in Docker
* @returns {void} * @returns {void}
*/ */
startNSCDServices() { async startNSCDServices() {
if (process.env.UPTIME_KUMA_IS_CONTAINER) { if (process.env.UPTIME_KUMA_IS_CONTAINER) {
try { try {
log.info("services", "Starting nscd"); log.info("services", "Starting nscd");
childProcess.execSync("sudo service nscd start", { stdio: "pipe" }); await childProcessAsync.exec("sudo service nscd start");
} catch (e) { } catch (e) {
log.info("services", "Failed to start nscd"); log.info("services", "Failed to start nscd");
} }
@ -408,11 +408,11 @@ class UptimeKumaServer {
* Stop all system services * Stop all system services
* @returns {void} * @returns {void}
*/ */
stopNSCDServices() { async stopNSCDServices() {
if (process.env.UPTIME_KUMA_IS_CONTAINER) { if (process.env.UPTIME_KUMA_IS_CONTAINER) {
try { try {
log.info("services", "Stopping nscd"); log.info("services", "Stopping nscd");
childProcess.execSync("sudo service nscd stop"); await childProcessAsync.exec("sudo service nscd stop");
} catch (e) { } catch (e) {
log.info("services", "Failed to stop nscd"); log.info("services", "Failed to stop nscd");
} }

View file

@ -8,9 +8,9 @@
:placeholder="placeholder" :placeholder="placeholder"
:disabled="!enabled" :disabled="!enabled"
> >
<a class="btn btn-outline-primary" @click="action()"> <button class="btn btn-outline-primary" @click="action()" :aria-label="actionAriaLabel">
<font-awesome-icon :icon="icon" /> <font-awesome-icon :icon="icon" />
</a> </button>
</div> </div>
</template> </template>
@ -66,6 +66,13 @@ export default {
action: { action: {
type: Function, type: Function,
default: () => {}, default: () => {},
},
/**
* The aria-label of the action button
*/
actionAriaLabel: {
type: String,
required: true,
} }
}, },
emits: [ "update:modelValue" ], emits: [ "update:modelValue" ],

View file

@ -1,11 +1,11 @@
<template> <template>
<div class="input-group mb-3"> <div class="input-group mb-3">
<select ref="select" v-model="model" class="form-select" :disabled="disabled" :required="required"> <select :id="id" ref="select" v-model="model" class="form-select" :disabled="disabled" :required="required">
<option v-for="option in options" :key="option" :value="option.value" :disabled="option.disabled">{{ option.label }}</option> <option v-for="option in options" :key="option" :value="option.value" :disabled="option.disabled">{{ option.label }}</option>
</select> </select>
<a class="btn btn-outline-primary" :class="{ disabled: actionDisabled }" @click="action()"> <button class="btn btn-outline-primary" :class="{ disabled: actionDisabled }" :aria-label="actionAriaLabel" @click="action()">
<font-awesome-icon :icon="icon" /> <font-awesome-icon :icon="icon" aria-hidden="true" />
</a> </button>
</div> </div>
</template> </template>
@ -20,6 +20,13 @@ export default {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
/**
* The id of the form which will be targeted by a <label for=..
*/
id: {
type: String,
required: true,
},
/** /**
* The value of the select field. * The value of the select field.
*/ */
@ -51,6 +58,13 @@ export default {
type: Function, type: Function,
default: () => {}, default: () => {},
}, },
/**
* The aria-label of the action button
*/
actionAriaLabel: {
type: String,
required: true,
},
/** /**
* Whether the action button is disabled. * Whether the action button is disabled.
* @example true * @example true

View file

@ -60,13 +60,13 @@
<div class="mt-1 mb-3 ps-2 cert-exp-days col-12 col-xl-6"> <div class="mt-1 mb-3 ps-2 cert-exp-days col-12 col-xl-6">
<div v-for="day in settings.tlsExpiryNotifyDays" :key="day" class="d-flex align-items-center justify-content-between cert-exp-day-row py-2"> <div v-for="day in settings.tlsExpiryNotifyDays" :key="day" class="d-flex align-items-center justify-content-between cert-exp-day-row py-2">
<span>{{ day }} {{ $tc("day", day) }}</span> <span>{{ day }} {{ $tc("day", day) }}</span>
<button type="button" class="btn-rm-expiry btn btn-outline-danger ms-2 py-1" @click="removeExpiryNotifDay(day)"> <button type="button" class="btn-rm-expiry btn btn-outline-danger ms-2 py-1" @click="removeExpiryNotifDay(day)" :aria-label="$t('Remove the expiry notification')">
<font-awesome-icon class="" icon="times" /> <font-awesome-icon icon="times" />
</button> </button>
</div> </div>
</div> </div>
<div class="col-12 col-xl-6"> <div class="col-12 col-xl-6">
<ActionInput v-model="expiryNotifInput" :type="'number'" :placeholder="$t('day')" :icon="'plus'" :action="() => addExpiryNotifDay(expiryNotifInput)" /> <ActionInput v-model="expiryNotifInput" :type="'number'" :placeholder="$t('day')" :icon="'plus'" :action="() => addExpiryNotifDay(expiryNotifInput)" :action-aria-label="$t('Add a new expiry notification day')" />
</div> </div>
<div> <div>
<button class="btn btn-primary" type="button" @click="saveSettings()"> <button class="btn btn-primary" type="button" @click="saveSettings()">

View file

@ -348,6 +348,8 @@
"Fingerprint:": "Fingerprint:", "Fingerprint:": "Fingerprint:",
"No status pages": "No status pages", "No status pages": "No status pages",
"Domain Name Expiry Notification": "Domain Name Expiry Notification", "Domain Name Expiry Notification": "Domain Name Expiry Notification",
"Add a new expiry notification day": "Add a new expiry notification day",
"Remove the expiry notification": "Remove the expiry notification day",
"Proxy": "Proxy", "Proxy": "Proxy",
"Date Created": "Date Created", "Date Created": "Date Created",
"Footer Text": "Footer Text", "Footer Text": "Footer Text",
@ -683,6 +685,10 @@
"Notify Channel": "Notify Channel", "Notify Channel": "Notify Channel",
"aboutNotifyChannel": "Notify channel will trigger a desktop or mobile notification for all members of the channel, whether their availability is set to active or away.", "aboutNotifyChannel": "Notify channel will trigger a desktop or mobile notification for all members of the channel, whether their availability is set to active or away.",
"Uptime Kuma URL": "Uptime Kuma URL", "Uptime Kuma URL": "Uptime Kuma URL",
"setup a new monitor group": "setup a new monitor group",
"openModalTo": "open modal to {0}",
"Add a domain": "Add a domain",
"Remove domain": "Remove domain '{0}'",
"Icon Emoji": "Icon Emoji", "Icon Emoji": "Icon Emoji",
"signalImportant": "IMPORTANT: You cannot mix groups and numbers in recipients!", "signalImportant": "IMPORTANT: You cannot mix groups and numbers in recipients!",
"aboutWebhooks": "More info about Webhooks on: {0}", "aboutWebhooks": "More info about Webhooks on: {0}",

View file

@ -315,7 +315,9 @@
<div class="mb-3"> <div class="mb-3">
<label for="docker-host" class="form-label">{{ $t("Docker Host") }}</label> <label for="docker-host" class="form-label">{{ $t("Docker Host") }}</label>
<ActionSelect <ActionSelect
id="docker-host"
v-model="monitor.docker_host" v-model="monitor.docker_host"
:action-aria-label="$t('openModalTo', $t('Setup Docker Host'))"
:options="dockerHostOptionsList" :options="dockerHostOptionsList"
:disabled="$root.dockerHostList == null || $root.dockerHostList.length === 0" :disabled="$root.dockerHostList == null || $root.dockerHostList.length === 0"
:icon="'plus'" :icon="'plus'"
@ -525,9 +527,11 @@
<!-- Parent Monitor --> <!-- Parent Monitor -->
<div class="my-3"> <div class="my-3">
<label for="parent" class="form-label">{{ $t("Monitor Group") }}</label> <label for="monitorGroupSelector" class="form-label">{{ $t("Monitor Group") }}</label>
<ActionSelect <ActionSelect
id="monitorGroupSelector"
v-model="monitor.parent" v-model="monitor.parent"
:action-aria-label="$t('openModalTo', 'setup a new monitor group')"
:options="parentMonitorOptionsList" :options="parentMonitorOptionsList"
:disabled="sortedGroupMonitorList.length === 0 && draftGroupName == null" :disabled="sortedGroupMonitorList.length === 0 && draftGroupName == null"
:icon="'plus'" :icon="'plus'"
@ -892,7 +896,7 @@ const monitorDefaults = {
interval: 60, interval: 60,
retryInterval: 60, retryInterval: 60,
resendInterval: 0, resendInterval: 0,
maxretries: 1, maxretries: 0,
timeout: 48, timeout: 48,
notificationIDList: {}, notificationIDList: {},
ignoreTls: false, ignoreTls: false,

View file

@ -69,13 +69,17 @@
<div class="my-3"> <div class="my-3">
<label class="form-label"> <label class="form-label">
{{ $t("Domain Names") }} {{ $t("Domain Names") }}
<font-awesome-icon icon="plus-circle" class="btn-add-domain action text-primary" @click="addDomainField" /> <button class="p-0 bg-transparent border-0" :aria-label="$t('Add a domain')" @click="addDomainField">
<font-awesome-icon icon="plus-circle" class="action text-primary" />
</button>
</label> </label>
<ul class="list-group domain-name-list"> <ul class="list-group domain-name-list">
<li v-for="(domain, index) in config.domainNameList" :key="index" class="list-group-item"> <li v-for="(domain, index) in config.domainNameList" :key="index" class="list-group-item">
<input v-model="config.domainNameList[index]" type="text" class="no-bg domain-input" placeholder="example.com" /> <input v-model="config.domainNameList[index]" type="text" class="no-bg domain-input" placeholder="example.com" />
<font-awesome-icon icon="times" class="action remove ms-2 me-3 text-danger" @click="removeDomain(index)" /> <button class="p-0 bg-transparent border-0" :aria-label="$t('Remove domain', [ domain ])" @click="removeDomain(index)">
<font-awesome-icon icon="times" class="action remove ms-2 me-3 text-danger" />
</button>
</li> </li>
</ul> </ul>
</div> </div>