feat(redis): view and edit Hash field expiry (HEXPIRE/HTTL/HPERSIST)
This commit is contained in:
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>
|
||||
<!--
|
||||
|
||||
Reference in new issue
Block a user