feat(redis): view and edit Hash field expiry (HEXPIRE/HTTL/HPERSIST)

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-07 02:41:04 +08:00
1 parent f8b2924f42
commit 59187f2e41
12 files changed
+583 -16

No files matched your search

@@ -170,6 +170,12 @@ const stringValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
const redisJsonHighlighter = ref<JsonHighlighter>();
const showHashFieldTtlDialog = ref(false);
const editingHashField = ref<string | null>(null);
const savingHashFieldTtl = ref(false);
const hashFieldTtlMode = ref<RedisExpiryMode>("none");
const hashFieldTtlInput = ref("");
const hashFieldExpireAt = shallowRef<CalendarDateTime | null>(null);
// Decompressed view state. Decompression yields to the event loop so the
// loading state paints before the synchronous bounded inflate runs; the result
@@ -497,11 +503,15 @@ const hasRetainedMemberDraft = computed(() => {
}
return memberEditValue.value !== original;
});
const hasUnsavedRedisDraft = computed(() => hasRetainedStringDraft.value || redisJsonValueChanged.value || hasRetainedMemberDraft.value || editingZsetMemberKey.value !== null);
const hasUnsavedRedisDraft = computed(() => hasRetainedStringDraft.value || redisJsonValueChanged.value || hasRetainedMemberDraft.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value);
const hasMore = computed(() => scanCursor.value != null && scanCursor.value > 0);
const collectionTotal = computed(() => (data.value ? redisValueCollectionTotal(data.value) : null));
const hashFieldTtlSupported = computed(() => {
if (redisKind.value !== "hash") return false;
return (collectionItems.value as RedisHashItem[]).some((item) => item.field_ttl !== undefined);
});
const hashGridStyle = computed(() => ({
gridTemplateColumns: `${hashFieldWidth.value}px minmax(12rem, 1fr) 84px`,
gridTemplateColumns: hashFieldTtlSupported.value ? `${hashFieldWidth.value}px minmax(12rem, 1fr) minmax(7rem, 9rem) 84px` : `${hashFieldWidth.value}px minmax(12rem, 1fr) 84px`,
}));
const zsetGridStyle = computed(() => ({
gridTemplateColumns: `60px ${zsetScoreWidth.value}px minmax(0, 1fr) 104px`,
@@ -643,7 +653,7 @@ let zsetResizeStartWidth = 0;
function shouldPauseAutoValueRefresh(): boolean {
const loadedPageSize = data.value ? redisValueCollectionItems(data.value).length : 0;
const hasExpandedCollectionPage = collectionItems.value.length > loadedPageSize;
return showMemberDetail.value || editingZsetMemberKey.value !== null || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage;
return showMemberDetail.value || editingZsetMemberKey.value !== null || showHashFieldTtlDialog.value || valueSearchOpen.value || Boolean(hashSearchQuery.value.trim()) || Boolean(activeHashSearchQuery.value) || searchLoading.value || loadingMore.value || hasExpandedCollectionPage;
}
type PendingDelete = { kind: "key" } | { kind: "hash"; field: string } | { kind: "list"; index: number } | { kind: "set"; member: string } | { kind: "zset"; member: string };
@@ -1463,13 +1473,20 @@ function generateInsertStatements(): string | null {
break;
}
case "hash": {
const pairs = (collectionItems.value as RedisHashItem[]).map((item) => {
const hashItems = collectionItems.value as RedisHashItem[];
const pairs = hashItems.map((item) => {
const field = blobWriteText(item.field);
const value = blobWriteText(item.value);
return field == null || value == null ? null : `${escapeRedisArg(field)} ${escapeRedisArg(value)}`;
});
if (pairs.some((item) => item == null)) return null;
commands.push(`HSET ${escapeRedisArg(key)} ${(pairs as string[]).join(" ")}`);
for (const item of hashItems) {
const field = blobWriteText(item.field);
if (field != null && item.field_ttl !== undefined && item.field_ttl > 0) {
commands.push(`HEXPIRE ${escapeRedisArg(key)} ${item.field_ttl} FIELDS 1 ${escapeRedisArg(field)}`);
}
}
break;
}
case "stream": {
@@ -1992,6 +2009,90 @@ function cancelEditTtl() {
}
// Hash
function hashFieldItem(field: string | null): RedisHashItem | null {
if (!field || redisKind.value !== "hash") return null;
return (collectionItems.value as RedisHashItem[]).find((item) => redisBlobText(item.field) === field) ?? null;
}
function hashFieldTtlLabel(item: RedisHashItem): string {
if (item.field_ttl === undefined) return "-";
if (item.field_ttl === -1) return t("redis.noExpiry");
return formatTtl(item.field_ttl, t) ?? "-";
}
function currentHashFieldTtl(): number {
return hashFieldItem(editingHashField.value)?.field_ttl ?? -1;
}
function startEditHashFieldTtl(field: string | null) {
if (!field || savingHashFieldTtl.value || hashFieldItem(field)?.field_ttl === undefined) return;
const ttl = hashFieldItem(field)?.field_ttl ?? -1;
editingHashField.value = field;
hashFieldTtlMode.value = redisExpiryModeForTtl(ttl);
hashFieldTtlInput.value = ttl > 0 ? String(ttl) : "";
hashFieldExpireAt.value = null;
showHashFieldTtlDialog.value = true;
}
watch(hashFieldTtlMode, (mode, previousMode) => {
const ttl = currentHashFieldTtl();
if (mode === "at" && previousMode !== "at" && ttl > 0) {
hashFieldExpireAt.value = unixSecondsToCalendarDateTime(Math.ceil(Date.now() / 1_000) + ttl);
}
});
function cancelEditHashFieldTtl(force = false) {
if (savingHashFieldTtl.value && !force) return;
showHashFieldTtlDialog.value = false;
editingHashField.value = null;
hashFieldTtlInput.value = "";
hashFieldExpireAt.value = null;
}
function handleHashFieldTtlOpenChange(open: boolean) {
if (open) {
showHashFieldTtlDialog.value = true;
} else {
cancelEditHashFieldTtl();
}
}
async function reloadHashPreservingSearch() {
const query = activeHashSearchQuery.value || hashSearchQuery.value.trim();
await load({ selectDefaultMember: false });
if (query) {
hashSearchQuery.value = query;
await onHashSearch();
}
}
async function saveHashFieldTtl() {
if (savingHashFieldTtl.value || !editingHashField.value) return;
const validation = validateRedisExpiry(hashFieldTtlMode.value, hashFieldTtlInput.value, hashFieldExpireAt.value);
if (!validation.valid) {
toast(expiryValidationMessage(validation.reason), 3000);
return;
}
savingHashFieldTtl.value = true;
const field = editingHashField.value;
try {
if (validation.policy.mode === "none") {
await api.redisHashFieldSetTtl(props.connectionId, props.db, props.keyRaw, field, -1);
} else if (validation.policy.mode === "ttl") {
await api.redisHashFieldSetTtl(props.connectionId, props.db, props.keyRaw, field, validation.policy.ttl);
} else {
await api.redisHashFieldSetExpireAt(props.connectionId, props.db, props.keyRaw, field, validation.policy.expireAt);
}
cancelEditHashFieldTtl(true);
await reloadHashPreservingSearch();
} catch (error) {
toast(errorMessage(error), 3000);
} finally {
savingHashFieldTtl.value = false;
}
}
async function hashSet() {
if (!newField.value.trim()) {
toast(t("redis.fieldRequired"), 3000);
@@ -2584,6 +2685,7 @@ defineExpose({ focusSearch });
<ArrowDown v-else-if="hashSortBy === 'value' && hashSortDir === 'desc'" class="h-3 w-3 shrink-0" />
<ArrowUpDown v-else class="h-3 w-3 shrink-0 text-muted-foreground/40" />
</div>
<div v-if="hashFieldTtlSupported" class="px-3 py-1 text-xs font-medium text-muted-foreground">{{ t("redis.columnTTL") }}</div>
<div />
</div>
<RecycleScroller class="flex-1 overflow-y-auto" :items="hashCollectionRows" :item-size="REDIS_COLLECTION_ROW_HEIGHT" :buffer="600" :skip-hover="true" key-field="id">
@@ -2597,6 +2699,19 @@ defineExpose({ focusSearch });
>
<div class="px-3 py-1.5 text-blue-500 truncate border-r">{{ formatValue(row.value.field) }}</div>
<div class="px-3 py-1.5 truncate text-muted-foreground">{{ formatValue(row.value.value) }}</div>
<div v-if="hashFieldTtlSupported" class="px-2 py-1 flex items-center min-w-0">
<Button
v-if="redisBlobText(row.value.field) && row.value.field_ttl !== undefined"
variant="ghost"
size="sm"
class="h-6 min-w-0 max-w-full justify-start px-1.5 text-xs font-normal text-muted-foreground hover:text-foreground"
:title="t('redis.expiry')"
@click.stop="startEditHashFieldTtl(redisBlobText(row.value.field))"
>
<span class="truncate">{{ hashFieldTtlLabel(row.value) }}</span>
</Button>
<span v-else class="px-1.5 text-xs text-muted-foreground">-</span>
</div>
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
@@ -2978,6 +3093,36 @@ defineExpose({ focusSearch });
<DangerConfirmDialog v-model:open="showDeleteConfirm" :message="t('dangerDialog.deleteMessage')" :details="deleteDetails" :confirm-label="t('dangerDialog.deleteConfirm')" @confirm="confirmDelete" />
<Dialog :open="showHashFieldTtlDialog" @update:open="handleHashFieldTtlOpenChange">
<DialogContent class="w-[calc(100vw-2rem)] sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t("redis.expiry") }}: {{ editingHashField ? formatValue(editingHashField) : "" }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3 py-2">
<Select v-model="hashFieldTtlMode" :disabled="savingHashFieldTtl">
<SelectTrigger :aria-label="t('redis.expiry')">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{{ t("redis.expiryNone") }}</SelectItem>
<SelectItem value="ttl">{{ t("redis.expiryTtl") }}</SelectItem>
<SelectItem value="at">{{ t("redis.expiryAt") }}</SelectItem>
</SelectContent>
</Select>
<Input v-if="hashFieldTtlMode === 'ttl'" v-model="hashFieldTtlInput" :disabled="savingHashFieldTtl" inputmode="numeric" :placeholder="t('redis.createKeyTtlPlaceholder')" @keydown.enter="saveHashFieldTtl" />
<DateTimePicker v-else-if="hashFieldTtlMode === 'at'" v-model="hashFieldExpireAt" :locale="locale" :disabled="savingHashFieldTtl" />
</div>
<DialogFooter>
<Button variant="ghost" :disabled="savingHashFieldTtl" @click="cancelEditHashFieldTtl">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="savingHashFieldTtl" @click="saveHashFieldTtl">
<Loader2 v-if="savingHashFieldTtl" class="h-4 w-4 animate-spin" />
<Save v-else class="h-4 w-4" />
{{ t("grid.save") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog :open="showMemberDetail" @update:open="handleMemberDetailOpenChange">
<DialogContent data-redis-member-detail class="relative flex h-[min(760px,85vh)] w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[960px]" :style="editorFontFamilyStyle" @close-auto-focus="finishMemberDetailClose" @pointer-down-outside.prevent @interact-outside.prevent>
<!--
+2
View File
@@ -414,6 +414,8 @@ export const redisSetString = forward("redisSetString");
export const redisDeleteKey = forward("redisDeleteKey");
export const redisHashSet = forward("redisHashSet");
export const redisHashDel = forward("redisHashDel");
export const redisHashFieldSetTtl = forward("redisHashFieldSetTtl");
export const redisHashFieldSetExpireAt = forward("redisHashFieldSetExpireAt");
export const redisListPush = forward("redisListPush");
export const redisListSet = forward("redisListSet");
export const redisListRemove = forward("redisListRemove");
+8
View File
@@ -2387,6 +2387,14 @@ export async function redisHashDel(connectionId: string, db: number, keyRaw: str
return post("/api/redis/hash-del", { connectionId, db, keyRaw, field });
}
export async function redisHashFieldSetTtl(connectionId: string, db: number, keyRaw: string, field: string, ttl: number): Promise<void> {
return post("/api/redis/hash-field-set-ttl", { connectionId, db, keyRaw, field, ttl });
}
export async function redisHashFieldSetExpireAt(connectionId: string, db: number, keyRaw: string, field: string, expireAt: number): Promise<void> {
return post("/api/redis/hash-field-set-expire-at", { connectionId, db, keyRaw, field, expireAt });
}
export async function redisListPush(connectionId: string, db: number, keyRaw: string, value: string, ttl?: number): Promise<void> {
return post("/api/redis/list-push", { connectionId, db, keyRaw, value, ttl });
}
+9
View File
@@ -2022,6 +2022,7 @@ export interface RedisSetItem {
export interface RedisHashItem {
field: RedisBlob;
value: RedisBlob;
field_ttl?: number;
}
export interface RedisZsetItem {
@@ -2235,6 +2236,14 @@ export async function redisHashDel(connectionId: string, db: number, keyRaw: str
return invoke("redis_hash_del", { connectionId, db, keyRaw, field });
}
export async function redisHashFieldSetTtl(connectionId: string, db: number, keyRaw: string, field: string, ttl: number): Promise<void> {
return invoke("redis_hash_field_set_ttl", { connectionId, db, keyRaw, field, ttl });
}
export async function redisHashFieldSetExpireAt(connectionId: string, db: number, keyRaw: string, field: string, expireAt: number): Promise<void> {
return invoke("redis_hash_field_set_expire_at", { connectionId, db, keyRaw, field, expireAt });
}
export async function redisListPush(connectionId: string, db: number, keyRaw: string, value: string, ttl?: number): Promise<void> {
return invoke("redis_list_push", { connectionId, db, keyRaw, value, ttl });
}
@@ -21,6 +21,8 @@ const ALLOWED_COMMANDS = new Set([
"HRANDFIELD",
"HSCAN",
"HSTRLEN",
"HPTTL",
"HTTL",
"HVALS",
"LINDEX",
"LLEN",
@@ -148,6 +150,11 @@ const CONFIRM_COMMANDS = new Set([
"RENAMENX",
"GETDEL",
"HDEL",
"HEXPIRE",
"HEXPIREAT",
"HPEXPIRE",
"HPEXPIREAT",
"HPERSIST",
"JSON.ARRPOP",
"JSON.ARRTRIM",
"JSON.CLEAR",
@@ -166,6 +166,8 @@ const RAW_COMMANDS: Record<string, Spec> = {
// ---- Hash ----
HDEL: [-3, "hash", "confirm"],
HEXPIRE: [-6, "hash", "confirm"],
HEXPIREAT: [-6, "hash", "confirm"],
HEXISTS: [3, "hash"],
HGET: [3, "hash"],
HGETALL: [2, "hash"],
@@ -180,6 +182,11 @@ const RAW_COMMANDS: Record<string, Spec> = {
HSET: [-4, "hash", "confirm"],
HSETNX: [4, "hash", "confirm"],
HSTRLEN: [3, "hash"],
HPERSIST: [-5, "hash", "confirm"],
HPEXPIRE: [-6, "hash", "confirm"],
HPEXPIREAT: [-6, "hash", "confirm"],
HPTTL: [-5, "hash"],
HTTL: [-5, "hash"],
HVALS: [2, "hash"],
// ---- Set ----