feat(redis): optimize TTL display with human-readable format (#1689)

This commit is contained in:
lexmin0412 2026-06-24 12:53:59 +08:00 committed by GitHub
parent c411cc6707
commit 941dfd5f82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 198 additions and 1 deletions

View File

@ -17,6 +17,7 @@ import { useTheme } from "@/composables/useTheme";
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
import { createRedisShikiJsonHighlighter, type RedisJsonHighlighter } from "@/lib/redisJsonHighlighter";
import { copyToClipboard } from "@/lib/clipboard";
import { formatTtl } from "@/lib/ttlFormat";
import { canEditRedisMemberDetail, clampRedisMemberDetailSheetWidth, formatRedisMemberDetail, getRedisMemberSelectionKey } from "@/lib/redisValuePresentation";
const { t } = useI18n();
@ -783,7 +784,7 @@ onBeforeUnmount(() => {
<Badge variant="secondary" class="dbx-editor-font-family text-xs uppercase">{{ data.key_type }}</Badge>
<Badge v-if="metadataSizeLabel" variant="outline" class="text-xs text-muted-foreground"> {{ t("redis.columnSize") }}: {{ metadataSizeLabel }} </Badge>
<template v-if="!editingTtl">
<Badge v-if="data.ttl > 0" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent" @click="startEditTtl">TTL: {{ data.ttl }}s</Badge>
<Badge v-if="data.ttl > 0" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent" @click="startEditTtl">TTL: {{ formatTtl(data.ttl, t) }}</Badge>
<Badge v-else-if="data.ttl === -1" variant="outline" class="text-xs cursor-pointer text-muted-foreground hover:bg-accent" @click="startEditTtl">{{ t("redis.noExpiry") }}</Badge>
</template>
<div v-else class="flex items-center gap-1">

View File

@ -1574,6 +1574,7 @@ export default {
loadedMembers: "{loaded} of {total} members loaded",
entries: "{count} entries",
noExpiry: "no expiry",
ttlDay: "{count}d",
columnType: "Type",
columnKey: "Key",
columnValue: "Value",

View File

@ -1308,6 +1308,7 @@ export default {
loadedMembers: "{loaded} de {total} miembros cargados",
entries: "{count} entradas",
noExpiry: "sin expiración",
ttlDay: "{count}d",
columnType: "Tipo",
columnKey: "Clave",
columnValue: "Valor",

View File

@ -1432,6 +1432,7 @@ export default {
loadedMembers: "{loaded} di {total} membri caricati",
entries: "{count} voci",
noExpiry: "nessuna scadenza",
ttlDay: "{count}g",
columnType: "Tipo",
columnKey: "Chiave",
columnValue: "Valore",

View File

@ -1561,6 +1561,7 @@ export default {
loadedMembers: "{loaded}/{total}メンバー読み込み完了",
entries: "{count}エントリ",
noExpiry: "期限なし",
ttlDay: "{count}日",
columnType: "型",
columnKey: "キー",
columnValue: "値",

View File

@ -1443,6 +1443,7 @@ export default {
loadedMembers: "{loaded} de {total} membros carregados",
entries: "{count} entradas",
noExpiry: "sem expiração",
ttlDay: "{count}d",
columnType: "Tipo",
columnKey: "Chave",
columnValue: "Valor",

View File

@ -1573,6 +1573,7 @@ export default {
loadedMembers: "已加载 {loaded} / 共 {total} 个成员",
entries: "{count} 条记录",
noExpiry: "永不过期",
ttlDay: "{count}天",
columnType: "类型",
columnKey: "键",
columnValue: "值",

View File

@ -1423,6 +1423,7 @@ export default {
loadedMembers: "已載入 {loaded} / 共 {total} 個成員",
entries: "{count} 筆項目",
noExpiry: "永不過期",
ttlDay: "{count}天",
columnType: "類型",
columnKey: "鍵",
columnValue: "值",

View File

@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import { formatTtl } from "@/lib/ttlFormat";
// ---------------------------------------------------------------------------
// Mock `t()` helpers — only the day unit needs localization
// ---------------------------------------------------------------------------
function enT(key: string, options?: Record<string, unknown>): string {
const messages: Record<string, string> = {
"redis.ttlDay": "{count}d",
};
const msg = messages[key] ?? key;
const count = (options as { count?: number })?.count;
if (count != null) return msg.replace("{count}", String(count));
return msg;
}
function zhT(key: string, options?: Record<string, unknown>): string {
const messages: Record<string, string> = {
"redis.ttlDay": "{count}天",
};
const msg = messages[key] ?? key;
const count = (options as { count?: number })?.count;
if (count != null) return msg.replace("{count}", String(count));
return msg;
}
function esT(key: string, options?: Record<string, unknown>): string {
const messages: Record<string, string> = {
"redis.ttlDay": "{count}d",
};
const msg = messages[key] ?? key;
const count = (options as { count?: number })?.count;
if (count != null) return msg.replace("{count}", String(count));
return msg;
}
function itT(key: string, options?: Record<string, unknown>): string {
const messages: Record<string, string> = {
"redis.ttlDay": "{count}g",
};
const msg = messages[key] ?? key;
const count = (options as { count?: number })?.count;
if (count != null) return msg.replace("{count}", String(count));
return msg;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("formatTtl — special values", () => {
it("returns null for -1 (no expiry)", () => {
expect(formatTtl(-1, enT)).toBeNull();
});
it("returns null for 0", () => {
expect(formatTtl(0, enT)).toBeNull();
expect(formatTtl(0, zhT)).toBeNull();
});
it("returns null for negative values other than -1", () => {
expect(formatTtl(-2, enT)).toBeNull();
});
});
describe("formatTtl — sub-day, HH:mm:ss (no i18n)", () => {
it("formats 45s as 00:00:45", () => {
expect(formatTtl(45, enT)).toBe("00:00:45");
});
it("formats 1m as 00:01:00", () => {
expect(formatTtl(60, enT)).toBe("00:01:00");
});
it("formats 1m 30s as 00:01:30", () => {
expect(formatTtl(90, enT)).toBe("00:01:30");
});
it("formats 2m 5s as 00:02:05", () => {
expect(formatTtl(125, enT)).toBe("00:02:05");
});
it("formats 1h as 01:00:00", () => {
expect(formatTtl(3600, enT)).toBe("01:00:00");
});
it("formats 1h 1m 1s as 01:01:01", () => {
expect(formatTtl(3661, enT)).toBe("01:01:01");
});
it("formats 23h 59m 59s as 23:59:59", () => {
expect(formatTtl(86399, enT)).toBe("23:59:59");
});
});
describe("formatTtl — multi-day, {n}d HH:mm:ss (day unit localized)", () => {
it("formats exactly 1 day as 1d 00:00:00", () => {
expect(formatTtl(86400, enT)).toBe("1d 00:00:00");
expect(formatTtl(86400, zhT)).toBe("1天 00:00:00");
});
it("formats 7 days as 7d 00:00:00", () => {
expect(formatTtl(604800, enT)).toBe("7d 00:00:00");
expect(formatTtl(604800, zhT)).toBe("7天 00:00:00");
});
it("formats 365 days as 365d 00:00:00", () => {
expect(formatTtl(31536000, enT)).toBe("365d 00:00:00");
expect(formatTtl(31536000, zhT)).toBe("365天 00:00:00");
});
it("formats 1d 1h 1m 1s", () => {
expect(formatTtl(90061, enT)).toBe("1d 01:01:01");
expect(formatTtl(90061, zhT)).toBe("1天 01:01:01");
});
it("formats 2d 3h 4m 5s", () => {
const ttl = 2 * 86400 + 3 * 3600 + 4 * 60 + 5; // 183845
expect(formatTtl(ttl, enT)).toBe("2d 03:04:05");
expect(formatTtl(ttl, zhT)).toBe("2天 03:04:05");
});
});
describe("formatTtl — Spanish locale", () => {
it("formats sub-day values with no i18n", () => {
expect(formatTtl(45, esT)).toBe("00:00:45");
expect(formatTtl(3661, esT)).toBe("01:01:01");
});
it("formats multi-day with localized d", () => {
expect(formatTtl(86400, esT)).toBe("1d 00:00:00");
expect(formatTtl(604800, esT)).toBe("7d 00:00:00");
});
it("returns null for -1", () => {
expect(formatTtl(-1, esT)).toBeNull();
});
it("returns null for 0", () => {
expect(formatTtl(0, esT)).toBeNull();
});
});
describe("formatTtl — Italian locale", () => {
it("formats sub-day values with no i18n", () => {
expect(formatTtl(45, itT)).toBe("00:00:45");
expect(formatTtl(3661, itT)).toBe("01:01:01");
});
it("formats multi-day with localized g", () => {
expect(formatTtl(86400, itT)).toBe("1g 00:00:00");
expect(formatTtl(604800, itT)).toBe("7g 00:00:00");
});
it("returns null for -1", () => {
expect(formatTtl(-1, itT)).toBeNull();
});
it("returns null for 0", () => {
expect(formatTtl(0, itT)).toBeNull();
});
});

View File

@ -0,0 +1,26 @@
/**
* Formats Redis TTL (seconds) as a compact string:
* < 1 day HH:mm:ss
* >= 1 day {n}d HH:mm:ss (day unit localized)
*
* Returns null for ttl === -1 (no expiry) or ttl <= 0.
*/
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
export function formatTtl(ttl: number, t: TranslateFn): string | null {
if (ttl === -1) return null;
if (ttl <= 0) return null;
const days = Math.floor(ttl / 86_400);
const hours = Math.floor((ttl % 86_400) / 3_600);
const minutes = Math.floor((ttl % 3_600) / 60);
const seconds = ttl % 60;
const time = [String(hours).padStart(2, "0"), String(minutes).padStart(2, "0"), String(seconds).padStart(2, "0")].join(":");
if (days > 0) {
return `${t("redis.ttlDay", { count: days })} ${time}`;
}
return time;
}