Compare commits

...

6 Commits

Author SHA1 Message Date
Dorian Grasset
2d8918a1b8 feat: enhance monitor deletion functionality by adding child deletion… (#6314)
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2025-11-11 03:52:13 +01:00
Mercury233
20c6cfcfad Fix(i18n): refactor secondsToHumanReadableFormat (#6281)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2025-11-11 03:34:07 +01:00
Evgeniy Timokhov
8d48ed7850 feat: Disabled telegram links preview (#6335) 2025-11-10 21:26:36 +01:00
Teodor Moquist
751ffd8e72 feat: Added option to clone a existing maintenance (#6330) 2025-11-10 19:22:14 +01:00
MayMeow
81544c8a39 Fix Group monitors to send notification after reaching maximum retires count (#6286)
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2025-11-09 17:57:20 +01:00
Max
36ac5dd56d feat: Add Drag & drop for groups (#6256)
Co-authored-by: Frank Elsinga <frank@elsinga.de>
2025-11-06 09:13:46 +01:00
14 changed files with 404 additions and 77 deletions

View File

@@ -1727,6 +1727,55 @@ class Monitor extends BeanModel {
]);
}
/**
* Delete a monitor from the system
* @param {number} monitorID ID of the monitor to delete
* @param {number} userID ID of the user who owns the monitor
* @returns {Promise<void>}
*/
static async deleteMonitor(monitorID, userID) {
const server = UptimeKumaServer.getInstance();
// Stop the monitor if it's running
if (monitorID in server.monitorList) {
await server.monitorList[monitorID].stop();
delete server.monitorList[monitorID];
}
// Delete from database
await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [
monitorID,
userID,
]);
}
/**
* Recursively delete a monitor and all its descendants
* @param {number} monitorID ID of the monitor to delete
* @param {number} userID ID of the user who owns the monitor
* @returns {Promise<void>}
*/
static async deleteMonitorRecursively(monitorID, userID) {
// Check if this monitor is a group
const monitor = await R.findOne("monitor", " id = ? AND user_id = ? ", [
monitorID,
userID,
]);
if (monitor && monitor.type === "group") {
// Get all children and delete them recursively
const children = await Monitor.getChildren(monitorID);
if (children && children.length > 0) {
for (const child of children) {
await Monitor.deleteMonitorRecursively(child.id, userID);
}
}
}
// Delete the monitor itself
await Monitor.deleteMonitor(monitorID, userID);
}
/**
* Checks recursive if parent (ancestors) are active
* @param {number} monitorID ID of the monitor to get

View File

@@ -11,35 +11,67 @@ class GroupMonitorType extends MonitorType {
async check(monitor, heartbeat, _server) {
const children = await Monitor.getChildren(monitor.id);
if (children.length > 0) {
heartbeat.status = UP;
heartbeat.msg = "All children up and running";
for (const child of children) {
if (!child.active) {
// Ignore inactive childs
continue;
}
const lastBeat = await Monitor.getPreviousHeartbeat(child.id);
// Only change state if the monitor is in worse conditions then the ones before
// lastBeat.status could be null
if (!lastBeat) {
heartbeat.status = PENDING;
} else if (heartbeat.status === UP && (lastBeat.status === PENDING || lastBeat.status === DOWN)) {
heartbeat.status = lastBeat.status;
} else if (heartbeat.status === PENDING && lastBeat.status === DOWN) {
heartbeat.status = lastBeat.status;
}
}
if (heartbeat.status !== UP) {
heartbeat.msg = "Child inaccessible";
}
} else {
if (children.length === 0) {
// Set status pending if group is empty
heartbeat.status = PENDING;
heartbeat.msg = "Group empty";
return;
}
let worstStatus = UP;
const downChildren = [];
const pendingChildren = [];
for (const child of children) {
if (!child.active) {
// Ignore inactive (=paused) children
continue;
}
const label = child.name || `#${child.id}`;
const lastBeat = await Monitor.getPreviousHeartbeat(child.id);
if (!lastBeat) {
if (worstStatus === UP) {
worstStatus = PENDING;
}
pendingChildren.push(label);
continue;
}
if (lastBeat.status === DOWN) {
worstStatus = DOWN;
downChildren.push(label);
} else if (lastBeat.status === PENDING) {
if (worstStatus !== DOWN) {
worstStatus = PENDING;
}
pendingChildren.push(label);
}
}
if (worstStatus === UP) {
heartbeat.status = UP;
heartbeat.msg = "All children up and running";
return;
}
if (worstStatus === PENDING) {
heartbeat.status = PENDING;
heartbeat.msg = `Pending child monitors: ${pendingChildren.join(", ")}`;
return;
}
heartbeat.status = DOWN;
let message = `Child monitors down: ${downChildren.join(", ")}`;
if (pendingChildren.length > 0) {
message += `; pending: ${pendingChildren.join(", ")}`;
}
// Throw to leverage the generic retry handling and notification flow
throw new Error(message);
}
}

View File

@@ -17,6 +17,7 @@ class Telegram extends NotificationProvider {
text: msg,
disable_notification: notification.telegramSendSilently ?? false,
protect_content: notification.telegramProtectContent ?? false,
link_preview_options: { is_disabled: true },
};
if (notification.telegramMessageThreadID) {
params.message_thread_id = notification.telegramMessageThreadID;
@@ -30,9 +31,9 @@ class Telegram extends NotificationProvider {
}
}
let config = this.getAxiosConfigWithProxy({ params });
let config = this.getAxiosConfigWithProxy();
await axios.get(`${url}/bot${notification.telegramBotToken}/sendMessage`, config);
await axios.post(`${url}/bot${notification.telegramBotToken}/sendMessage`, params, config);
return okMsg;
} catch (error) {

View File

@@ -1047,51 +1047,78 @@ let needSetup = false;
}
});
socket.on("deleteMonitor", async (monitorID, callback) => {
socket.on("deleteMonitor", async (monitorID, deleteChildren, callback) => {
try {
checkLogin(socket);
log.info("manage", `Delete Monitor: ${monitorID} User ID: ${socket.userID}`);
if (monitorID in server.monitorList) {
await server.monitorList[monitorID].stop();
delete server.monitorList[monitorID];
// Backward compatibility: if deleteChildren is omitted, the second parameter is the callback
if (typeof deleteChildren === "function") {
callback = deleteChildren;
deleteChildren = false;
}
checkLogin(socket);
const startTime = Date.now();
// Check if this is a group monitor and unlink children before deletion
// Check if this is a group monitor
const monitor = await R.findOne("monitor", " id = ? AND user_id = ? ", [
monitorID,
socket.userID,
]);
// Log with context about deletion type
if (monitor && monitor.type === "group") {
// Get all children before unlinking them
if (deleteChildren) {
log.info("manage", `Delete Group and Children: ${monitorID} User ID: ${socket.userID}`);
} else {
log.info("manage", `Delete Group (unlink children): ${monitorID} User ID: ${socket.userID}`);
}
} else {
log.info("manage", `Delete Monitor: ${monitorID} User ID: ${socket.userID}`);
}
if (monitor && monitor.type === "group") {
// Get all children before processing
const children = await Monitor.getChildren(monitorID);
// Unlink all children from the group
await Monitor.unlinkAllChildren(monitorID);
if (deleteChildren) {
// Delete all child monitors recursively
if (children && children.length > 0) {
for (const child of children) {
await Monitor.deleteMonitorRecursively(child.id, socket.userID);
await server.sendDeleteMonitorFromList(socket, child.id);
}
}
} else {
// Unlink all children from the group (set parent to null)
await Monitor.unlinkAllChildren(monitorID);
// Notify frontend to update each child monitor's parent to null
if (children && children.length > 0) {
for (const child of children) {
await server.sendUpdateMonitorIntoList(socket, child.id);
// Notify frontend to update each child monitor's parent to null
if (children && children.length > 0) {
for (const child of children) {
await server.sendUpdateMonitorIntoList(socket, child.id);
}
}
}
}
await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [
monitorID,
socket.userID,
]);
// Delete the monitor itself
await Monitor.deleteMonitor(monitorID, socket.userID);
// Fix #2880
apicache.clear();
const endTime = Date.now();
log.info("DB", `Delete Monitor completed in : ${endTime - startTime} ms`);
// Log completion with context about children handling
if (monitor && monitor.type === "group") {
if (deleteChildren) {
log.info("DB", `Delete Monitor completed (group and children deleted) in: ${endTime - startTime} ms`);
} else {
log.info("DB", `Delete Monitor completed (group deleted, children unlinked) in: ${endTime - startTime} ms`);
}
} else {
log.info("DB", `Delete Monitor completed in: ${endTime - startTime} ms`);
}
callback({
ok: true,

View File

@@ -1,6 +1,15 @@
<template>
<div>
<div :style="depthMargin">
<div
class="draggable-item"
:style="depthMargin"
:class="{ 'drag-over': dragOverCount > 0 }"
@dragstart="onDragStart"
@dragenter.prevent="onDragEnter"
@dragleave.prevent="onDragLeave"
@dragover.prevent
@drop.prevent="onDrop"
>
<!-- Checkbox -->
<div v-if="isSelectMode" class="select-input-wrapper">
<input
@@ -116,6 +125,7 @@ export default {
data() {
return {
isCollapsed: true,
dragOverCount: 0,
};
},
computed: {
@@ -187,6 +197,91 @@ export default {
window.localStorage.setItem("monitorCollapsed", JSON.stringify(storageObject));
},
/**
* Initializes the drag operation if the monitor is draggable.
* @param {DragEvent} event - The dragstart event triggered by the browser.
* @returns {void} This method does not return anything.
*/
onDragStart(event) {
try {
event.dataTransfer.setData("text/monitor-id", String(this.monitor.id));
event.dataTransfer.effectAllowed = "move";
} catch (e) {
// ignore
}
},
onDragEnter(event) {
if (this.monitor.type !== "group") {
return;
}
this.dragOverCount++;
},
onDragLeave(event) {
if (this.monitor.type !== "group") {
return;
}
this.dragOverCount = Math.max(0, this.dragOverCount - 1);
},
async onDrop(event) {
this.dragOverCount = 0;
// Only groups accept drops
if (this.monitor.type !== "group") {
return;
}
const draggedId = event.dataTransfer.getData("text/monitor-id");
if (!draggedId) {
return;
}
const draggedMonitorId = parseInt(draggedId);
if (isNaN(draggedMonitorId) || draggedMonitorId === this.monitor.id) {
return;
}
const draggedMonitor = this.$root.monitorList[draggedMonitorId];
if (!draggedMonitor) {
return;
}
// Save original parent so we can revert locally if server returns error
const originalParent = draggedMonitor.parent;
// Prepare a full monitor object (clone) and set new parent
const monitorToSave = JSON.parse(JSON.stringify(draggedMonitor));
monitorToSave.parent = this.monitor.id;
// Optimistically update local state so UI updates immediately
this.$root.monitorList[draggedMonitorId].parent = this.monitor.id;
// Send updated monitor state via socket
try {
this.$root.getSocket().emit("editMonitor", monitorToSave, (res) => {
if (!res || !res.ok) {
// Revert local change on error
if (this.$root.monitorList[draggedMonitorId]) {
this.$root.monitorList[draggedMonitorId].parent = originalParent;
}
if (res && res.msg) {
this.$root.toastError(res.msg);
}
} else {
this.$root.toastRes(res);
}
});
} catch (e) {
// revert on exception
if (this.$root.monitorList[draggedMonitorId]) {
this.$root.monitorList[draggedMonitorId].parent = originalParent;
}
}
},
/**
* Get URL of monitor
* @param {number} id ID of monitor
@@ -253,4 +348,35 @@ export default {
z-index: 15;
}
.drag-over {
border: 4px dashed $primary;
border-radius: 0.5rem;
background-color: $highlight-white;
}
.dark {
.drag-over {
background-color: $dark-bg2;
}
}
/* -4px on all due to border-width */
.monitor-list .drag-over .item {
padding: 9px 11px 6px 11px;
}
.draggable-item {
cursor: grab;
position: relative;
/* We don't want the padding change due to the border animated */
.item {
transition: none !important;
}
&.dragging {
cursor: grabbing;
}
}
</style>

View File

@@ -518,6 +518,12 @@
"Effective Date Range": "Effective Date Range (Optional)",
"Schedule Maintenance": "Schedule Maintenance",
"Edit Maintenance": "Edit Maintenance",
"Clone Maintenance": "Clone Maintenance",
"ariaPauseMaintenance": "Pause this maintenance schedule",
"ariaResumeMaintenance": "Resume this maintenance schedule",
"ariaCloneMaintenance": "Create a copy of this maintenance schedule",
"ariaEditMaintenance": "Edit this maintenance schedule",
"ariaDeleteMaintenance": "Delete this maintenance schedule",
"Date and Time": "Date and Time",
"DateTime Range": "DateTime Range",
"loadingError": "Cannot fetch the data, please try again later.",
@@ -590,6 +596,8 @@
"grpcMethodDescription": "Method name is convert to camelCase format such as sayHello, check, etc.",
"acceptedStatusCodesDescription": "Select status codes which are considered as a successful response.",
"deleteMonitorMsg": "Are you sure want to delete this monitor?",
"deleteGroupMsg": "Are you sure you want to delete this group?",
"deleteChildrenMonitors": "Also delete the direct child monitors and its children if it has any | Also delete all {count} direct child monitors and their children if they have any",
"deleteMaintenanceMsg": "Are you sure want to delete this maintenance?",
"deleteNotificationMsg": "Are you sure want to delete this notification for all monitors?",
"dnsPortDescription": "DNS server port. Defaults to 53. You can change the port at any time.",

View File

@@ -1,5 +1,5 @@
import { currentLocale } from "../i18n";
import { setPageLocale, relativeTimeFormatter } from "../util-frontend";
import { setPageLocale, timeDurationFormatter } from "../util-frontend";
const langModules = import.meta.glob("../lang/*.json");
export default {
@@ -34,7 +34,7 @@ export default {
this.$i18n.locale = lang;
localStorage.locale = lang;
setPageLocale();
relativeTimeFormatter.updateLocale(lang);
timeDurationFormatter.updateLocale(lang);
},
},
};

View File

@@ -602,11 +602,12 @@ export default {
/**
* Delete monitor by ID
* @param {number} monitorID ID of monitor to delete
* @param {boolean} deleteChildren Whether to delete child monitors (for groups)
* @param {socketCB} callback Callback for socket response
* @returns {void}
*/
deleteMonitor(monitorID, callback) {
socket.emit("deleteMonitor", monitorID, callback);
deleteMonitor(monitorID, deleteChildren, callback) {
socket.emit("deleteMonitor", monitorID, deleteChildren, callback);
},
/**

View File

@@ -441,7 +441,23 @@
:no-text="$t('No')"
@yes="deleteMonitor"
>
{{ $t("deleteMonitorMsg") }}
<div v-if="monitor && monitor.type === 'group'">
<div>{{ $t("deleteGroupMsg") }}</div>
<div v-if="hasChildren" class="form-check">
<input
id="delete-children-checkbox"
v-model="deleteChildrenMonitors"
class="form-check-input"
type="checkbox"
>
<label class="form-check-label" for="delete-children-checkbox">
{{ $t("deleteChildrenMonitors", childrenCount, { count: childrenCount }) }}
</label>
</div>
</div>
<div v-else>
{{ $t("deleteMonitorMsg") }}
</div>
</Confirm>
<Confirm
@@ -487,7 +503,7 @@ import { getMonitorRelativeURL } from "../util.ts";
import { URL } from "whatwg-url";
import DOMPurify from "dompurify";
import { marked } from "marked";
import { getResBaseURL, relativeTimeFormatter } from "../util-frontend";
import { getResBaseURL, timeDurationFormatter } from "../util-frontend";
import { highlight, languages } from "prismjs/components/prism-core";
import "prismjs/components/prism-clike";
import "prismjs/components/prism-javascript";
@@ -530,6 +546,7 @@ export default {
currentExample: "javascript-fetch",
code: "",
},
deleteChildrenMonitors: false,
};
},
computed: {
@@ -538,6 +555,26 @@ export default {
return this.$root.monitorList[id];
},
/**
* Get the count of children monitors for this group
* @returns {number} Number of children monitors
*/
childrenCount() {
if (!this.monitor || this.monitor.type !== "group") {
return 0;
}
const children = Object.values(this.$root.monitorList).filter(m => m.parent === this.monitor.id);
return children.length;
},
/**
* Check if the monitor is a group and has children
* @returns {boolean} True if monitor is a group with children
*/
hasChildren() {
return this.childrenCount > 0;
},
lastHeartBeat() {
// Also trigger screenshot refresh here
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
@@ -752,7 +789,7 @@ export default {
* @returns {void}
*/
deleteMonitor() {
this.$root.deleteMonitor(this.monitor.id, (res) => {
this.$root.deleteMonitor(this.monitor.id, this.deleteChildrenMonitors, (res) => {
this.$root.toastRes(res);
if (res.ok) {
this.$router.push("/dashboard");
@@ -928,7 +965,7 @@ export default {
},
secondsToHumanReadableFormat(seconds) {
return relativeTimeFormatter.secondsToHumanReadableFormat(seconds);
return timeDurationFormatter.secondsToHumanReadableFormat(seconds);
},
},
};
@@ -937,6 +974,10 @@ export default {
<style lang="scss" scoped>
@import "../assets/vars.scss";
.form-check {
margin-top: 16px;
}
@media (max-width: 767px) {
.badge {
margin-top: 14px;

View File

@@ -354,7 +354,14 @@ export default {
},
pageName() {
return this.$t((this.isAdd) ? "Schedule Maintenance" : "Edit Maintenance");
let name = "Schedule Maintenance";
if (this.isEdit) {
name = "Edit Maintenance";
} else if (this.isClone) {
name = "Clone Maintenance";
}
return this.$t(name);
},
isAdd() {
@@ -365,6 +372,9 @@ export default {
return this.$route.path.startsWith("/maintenance/edit");
},
isClone() {
return this.$route.path.startsWith("/maintenance/clone");
}
},
watch: {
"$route.fullPath"() {
@@ -443,11 +453,16 @@ export default {
daysOfMonth: [],
timezoneOption: null,
};
} else if (this.isEdit) {
} else if (this.isEdit || this.isClone) {
this.$root.getSocket().emit("getMaintenance", this.$route.params.id, (res) => {
if (res.ok) {
this.maintenance = res.maintenance;
if (this.isClone) {
this.maintenance.id = undefined; // Remove id when cloning as we want a new id
this.maintenance.title = this.$t("cloneOf", [ this.maintenance.title ]);
}
this.$root.getSocket().emit("getMonitorMaintenance", this.$route.params.id, (res) => {
if (res.ok) {
Object.values(res.monitors).map(monitor => {
@@ -491,7 +506,7 @@ export default {
return this.processing = false;
}
if (this.isAdd) {
if (this.isAdd || this.isClone) {
this.$root.addMaintenance(this.maintenance, async (res) => {
if (res.ok) {
await this.addMonitorMaintenance(res.maintenanceID, async () => {

View File

@@ -1174,7 +1174,7 @@ import {
MIN_INTERVAL_SECOND,
sleep,
} from "../util.ts";
import { hostNameRegexPattern, relativeTimeFormatter } from "../util-frontend";
import { hostNameRegexPattern, timeDurationFormatter } from "../util-frontend";
import HiddenInput from "../components/HiddenInput.vue";
import EditMonitorConditions from "../components/EditMonitorConditions.vue";
@@ -1190,7 +1190,7 @@ const monitorDefaults = {
method: "GET",
ipFamily: null,
interval: 60,
humanReadableInterval: relativeTimeFormatter.secondsToHumanReadableFormat(60),
humanReadableInterval: timeDurationFormatter.secondsToHumanReadableFormat(60),
retryInterval: 60,
resendInterval: 0,
maxretries: 0,
@@ -1574,7 +1574,7 @@ message HealthCheckResponse {
this.monitor.retryInterval = value;
}
// Converting monitor.interval to human readable format.
this.monitor.humanReadableInterval = relativeTimeFormatter.secondsToHumanReadableFormat(value);
this.monitor.humanReadableInterval = timeDurationFormatter.secondsToHumanReadableFormat(value);
},
"monitor.timeout"(value, oldValue) {

View File

@@ -41,19 +41,23 @@
<router-link v-if="false" :to="maintenanceURL(item.id)" class="btn btn-light">{{ $t("Details") }}</router-link>
<div class="btn-group" role="group">
<button v-if="item.active" class="btn btn-normal" @click="pauseDialog(item.id)">
<button v-if="item.active" class="btn btn-normal" :aria-label="$t('ariaPauseMaintenance')" @click="pauseDialog(item.id)">
<font-awesome-icon icon="pause" /> {{ $t("Pause") }}
</button>
<button v-if="!item.active" class="btn btn-primary" @click="resumeMaintenance(item.id)">
<button v-if="!item.active" class="btn btn-primary" :aria-label="$t('ariaResumeMaintenance')" @click="resumeMaintenance(item.id)">
<font-awesome-icon icon="play" /> {{ $t("Resume") }}
</button>
<router-link :to="'/maintenance/edit/' + item.id" class="btn btn-normal">
<router-link :to="'/maintenance/clone/' + item.id" class="btn btn-normal" :aria-label="$t('ariaCloneMaintenance')">
<font-awesome-icon icon="clone" /> {{ $t("Clone") }}
</router-link>
<router-link :to="'/maintenance/edit/' + item.id" class="btn btn-normal" :aria-label="$t('ariaEditMaintenance')">
<font-awesome-icon icon="edit" /> {{ $t("Edit") }}
</router-link>
<button class="btn btn-normal text-danger" @click="deleteDialog(item.id)">
<button class="btn btn-normal text-danger" :aria-label="$t('ariaDeleteMaintenance')" @click="deleteDialog(item.id)">
<font-awesome-icon icon="trash" /> {{ $t("Delete") }}
</button>
</div>

View File

@@ -162,6 +162,10 @@ const routes = [
path: "/maintenance/edit/:id",
component: EditMaintenance,
},
{
path: "/maintenance/clone/:id",
component: EditMaintenance,
}
],
},
],

View File

@@ -214,13 +214,18 @@ export function getToastErrorTimeout() {
return errorTimeout;
}
class RelativeTimeFormatter {
class TimeDurationFormatter {
/**
* Default locale and options for Relative Time Formatter
* Default locale and options for Time Duration Formatter (supports both DurationFormat and RelativeTimeFormat)
*/
constructor() {
this.options = { numeric: "always" };
this.instance = new Intl.RelativeTimeFormat(currentLocale(), this.options);
this.durationFormatOptions = { style: "long" };
this.relativeTimeFormatOptions = { numeric: "always" };
if (Intl.DurationFormat !== undefined) {
this.durationFormatInstance = new Intl.DurationFormat(currentLocale(), this.durationFormatOptions);
} else {
this.relativeTimeFormatInstance = new Intl.RelativeTimeFormat(currentLocale(), this.relativeTimeFormatOptions);
}
}
/**
@@ -229,7 +234,11 @@ class RelativeTimeFormatter {
* @returns {void} No return value.
*/
updateLocale(locale) {
this.instance = new Intl.RelativeTimeFormat(locale, this.options);
if (Intl.DurationFormat !== undefined) {
this.durationFormatInstance = new Intl.DurationFormat(locale, this.durationFormatOptions);
} else {
this.relativeTimeFormatInstance = new Intl.RelativeTimeFormat(locale, this.relativeTimeFormatOptions);
}
}
/**
@@ -242,6 +251,17 @@ class RelativeTimeFormatter {
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor(((seconds % 86400) % 3600) / 60);
const secs = ((seconds % 86400) % 3600) % 60;
if (this.durationFormatInstance !== undefined) {
// use Intl.DurationFormat if available
return this.durationFormatInstance.format({
days,
hours,
minutes,
seconds: secs
});
}
const parts = [];
/**
* Build the formatted string from parts
@@ -253,12 +273,11 @@ class RelativeTimeFormatter {
* @returns {void}
*/
const toFormattedPart = (value, unitOfTime) => {
const partsArray = this.instance.formatToParts(value, unitOfTime);
const partsArray = this.relativeTimeFormatInstance.formatToParts(value, unitOfTime);
const filteredParts = partsArray
.filter(
(part, index) =>
(part.type === "literal" || part.type === "integer") &&
index > 0
part.type === "integer" || (part.type === "literal" && index > 0)
)
.map((part) => part.value);
@@ -282,9 +301,9 @@ class RelativeTimeFormatter {
if (parts.length > 0) {
return `${parts.join(" ")}`;
}
return this.instance.format(0, "second"); // Handle case for 0 seconds
return this.relativeTimeFormatInstance.format(0, "second"); // Handle case for 0 seconds
}
}
export const relativeTimeFormatter = new RelativeTimeFormatter();
export const timeDurationFormatter = new TimeDurationFormatter();