Skip to content

Commit 8a90edf

Browse files
ytkimirtiCahidArda
andauthored
feat: Add ttl support for hash keys (#11)
* chore: bump redis * feat: refactor ttl badge and add it for hash fields * fix: change formatTime function to show less details * fix: add min-w to hexpire text * fix: adjust text right --------- Co-authored-by: CahidArda <cahidardaooz@hotmail.com>
1 parent 9713e5b commit 8a90edf

15 files changed

+315
-108
lines changed

bun.lockb

0 Bytes
Binary file not shown.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"@tabler/icons-react": "^3.19.0",
4646
"@tanstack/react-query": "^5.32.0",
4747
"@types/bytes": "^3.1.4",
48-
"@upstash/redis": "^1.34.3",
48+
"@upstash/redis": "^1.34.8",
4949
"bytes": "^3.1.2",
5050
"react-hook-form": "^7.53.0",
5151
"react-resizable-panels": "^2.1.4",

src/components/databrowser/components/display/display-header.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { IconPlus } from "@tabler/icons-react"
55
import { Button } from "@/components/ui/button"
66

77
import { TypeTag } from "../type-tag"
8-
import { LengthBadge, SizeBadge, TTLBadge } from "./header-badges"
8+
import { HeaderTTLBadge, LengthBadge, SizeBadge } from "./header-badges"
99
import { KeyActions } from "./key-actions"
1010

1111
export const DisplayHeader = ({
@@ -49,7 +49,7 @@ export const DisplayHeader = ({
4949
<TypeTag variant={type} type="badge" />
5050
<SizeBadge dataKey={dataKey} />
5151
<LengthBadge dataKey={dataKey} type={type} content={content} />
52-
<TTLBadge dataKey={dataKey} />
52+
<HeaderTTLBadge dataKey={dataKey} />
5353
</div>
5454
</div>
5555
)

src/components/databrowser/components/display/display-list-edit.tsx

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { useDatabrowserStore } from "@/store"
33
import type { ListDataType } from "@/types"
44
import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form"
55

6+
import { cn } from "@/lib/utils"
67
import { Button } from "@/components/ui/button"
78
import { Spinner } from "@/components/ui/spinner"
89
import { SimpleTooltip } from "@/components/ui/tooltip"
910

1011
import { useFetchListItems } from "../../hooks"
1112
import { useEditListItem } from "../../hooks/use-edit-list-item"
1213
import { headerLabels } from "./display-list"
14+
import { HashFieldTTLBadge } from "./hash/hash-field-ttl-badge"
1315
import { useField } from "./input/use-field"
1416

1517
export const ListEditDisplay = ({
@@ -105,30 +107,43 @@ const ListEditForm = ({
105107
)}
106108
</div>
107109

108-
<div className="flex justify-end gap-2">
109-
<Button
110-
type="button"
111-
onClick={() => {
112-
setSelectedListItem(undefined)
113-
}}
114-
>
115-
Cancel
116-
</Button>
117-
<SimpleTooltip
118-
content={type === "stream" && !isNew ? "Streams are not mutable" : undefined}
119-
>
110+
<div
111+
className={cn(
112+
"flex items-center",
113+
type === "hash" && itemKey !== "" ? "justify-between" : "justify-end"
114+
)}
115+
>
116+
{type === "hash" && itemKey !== "" && (
117+
<HashFieldTTLBadge dataKey={dataKey} field={itemKey} />
118+
)}
119+
120+
<div className="flex gap-2">
120121
<Button
121-
variant="primary"
122-
type="submit"
123-
disabled={
124-
!form.formState.isValid || !form.formState.isDirty || (type === "stream" && !isNew)
125-
}
122+
type="button"
123+
onClick={() => {
124+
setSelectedListItem(undefined)
125+
}}
126126
>
127-
<Spinner isLoading={isPending} isLoadingText={"Saving"}>
128-
Save
129-
</Spinner>
127+
Cancel
130128
</Button>
131-
</SimpleTooltip>
129+
<SimpleTooltip
130+
content={type === "stream" && !isNew ? "Streams are not mutable" : undefined}
131+
>
132+
<Button
133+
variant="primary"
134+
type="submit"
135+
disabled={
136+
!form.formState.isValid ||
137+
!form.formState.isDirty ||
138+
(type === "stream" && !isNew)
139+
}
140+
>
141+
<Spinner isLoading={isPending} isLoadingText={"Saving"}>
142+
Save
143+
</Spinner>
144+
</Button>
145+
</SimpleTooltip>
146+
</div>
132147
</div>
133148
</form>
134149
</FormProvider>

src/components/databrowser/components/display/display-list.tsx

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { InfiniteScroll } from "../sidebar/infinite-scroll"
1414
import { DeleteAlertDialog } from "./delete-alert-dialog"
1515
import { DisplayHeader } from "./display-header"
1616
import { ListEditDisplay } from "./display-list-edit"
17+
import { HashFieldTTLInfo } from "./hash/hash-field-ttl-info"
1718

1819
export const headerLabels = {
1920
list: ["Index", "Content"],
@@ -72,6 +73,7 @@ export const ListItems = ({
7273
}) => {
7374
const { setSelectedListItem } = useDatabrowserStore()
7475
const keys = useMemo(() => query.data?.pages.flatMap((page) => page.keys) ?? [], [query.data])
76+
const fields = useMemo(() => keys.map((key) => key.key), [keys])
7577
const { mutate: editItem } = useEditListItem()
7678

7779
return (
@@ -84,7 +86,7 @@ export const ListItems = ({
8486
onClick={() => {
8587
setSelectedListItem({ key })
8688
}}
87-
className="h-10 border-b border-b-zinc-100 hover:bg-zinc-50"
89+
className={cn("h-10 border-b border-b-zinc-100 hover:bg-zinc-100 ")}
8890
>
8991
<td
9092
className={cn(
@@ -103,29 +105,38 @@ export const ListItems = ({
103105
)}
104106
{type !== "stream" && (
105107
<td
106-
width={20}
107-
className="px-3"
108+
className="w-0 min-w-0 p-0"
108109
onClick={(e) => {
109110
e.stopPropagation()
110111
}}
111112
>
112-
<DeleteAlertDialog
113-
deletionType="item"
114-
onDeleteConfirm={(e) => {
115-
e.stopPropagation()
116-
editItem({
117-
type,
118-
dataKey,
119-
itemKey: key,
120-
// For deletion
121-
newKey: undefined,
122-
})
123-
}}
124-
>
125-
<Button size="icon-sm" variant="secondary" onClick={(e) => e.stopPropagation()}>
126-
<IconTrash className="size-4 text-zinc-500" />
127-
</Button>
128-
</DeleteAlertDialog>
113+
<div className="flex items-center justify-end gap-2">
114+
{type === "hash" && (
115+
<HashFieldTTLInfo dataKey={dataKey} field={key} fields={fields} />
116+
)}
117+
<DeleteAlertDialog
118+
deletionType="item"
119+
onDeleteConfirm={(e) => {
120+
e.stopPropagation()
121+
editItem({
122+
type,
123+
dataKey,
124+
itemKey: key,
125+
// For deletion
126+
newKey: undefined,
127+
})
128+
}}
129+
>
130+
<Button
131+
className=""
132+
size="icon-sm"
133+
variant="secondary"
134+
onClick={(e) => e.stopPropagation()}
135+
>
136+
<IconTrash className="size-4 text-zinc-500" />
137+
</Button>
138+
</DeleteAlertDialog>
139+
</div>
129140
</td>
130141
)}
131142
</tr>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { useFetchHashFieldExpires } from "@/components/databrowser/hooks/use-fetch-hash-ttl"
2+
import { useSetHashTTL } from "@/components/databrowser/hooks/use-set-hash-ttl"
3+
4+
import { TTLBadge } from "../ttl-badge"
5+
6+
export const HashFieldTTLBadge = ({ dataKey, field }: { dataKey: string; field: string }) => {
7+
const { data } = useFetchHashFieldExpires({ dataKey, fields: [field] })
8+
const { mutate: setTTL, isPending } = useSetHashTTL()
9+
10+
const expireAt = data?.[field]
11+
12+
return (
13+
<TTLBadge
14+
label="Field TTL:"
15+
expireAt={expireAt}
16+
setTTL={(ttl) => setTTL({ dataKey, field, ttl })}
17+
isPending={isPending}
18+
/>
19+
)
20+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useEffect, useState } from "react"
2+
3+
import { formatTime } from "@/lib/utils"
4+
import { useFetchHashFieldExpires } from "@/components/databrowser/hooks/use-fetch-hash-ttl"
5+
6+
import { calculateTTL, TTL_INFINITE, TTL_NOT_FOUND } from "../ttl-badge"
7+
8+
export const HashFieldTTLInfo = ({
9+
dataKey,
10+
field,
11+
fields,
12+
}: {
13+
dataKey: string
14+
field: string
15+
fields: string[]
16+
}) => {
17+
const { data } = useFetchHashFieldExpires({ dataKey, fields })
18+
const expireAt = data?.[field]
19+
20+
const [ttl, setTTL] = useState(() => calculateTTL(expireAt))
21+
22+
useEffect(() => {
23+
setTTL(calculateTTL(expireAt))
24+
const interval = setInterval(() => {
25+
setTTL(calculateTTL(expireAt))
26+
}, 1000)
27+
28+
return () => clearInterval(interval)
29+
}, [expireAt])
30+
31+
if (!expireAt || expireAt === TTL_NOT_FOUND || expireAt === TTL_INFINITE) return
32+
33+
return (
34+
<span className="block min-w-[30px] whitespace-nowrap text-right text-red-600">
35+
{formatTime(ttl ?? 0)}
36+
</span>
37+
)
38+
}

src/components/databrowser/components/display/header-badges.tsx

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
1-
import { useEffect } from "react"
21
import { type DataType } from "@/types"
3-
import { IconChevronDown } from "@tabler/icons-react"
42
import bytes from "bytes"
53

6-
import { queryClient } from "@/lib/clients"
7-
import { formatTime } from "@/lib/utils"
84
import { Skeleton } from "@/components/ui/skeleton"
95

10-
import { FETCH_TTL_QUERY_KEY, useFetchTTL } from "../../hooks"
11-
import { useDeleteKeyCache } from "../../hooks/use-delete-key-cache"
6+
import { useFetchKeyExpire, useSetTTL } from "../../hooks"
127
import { useFetchKeyLength } from "../../hooks/use-fetch-key-length"
138
import { useFetchKeySize } from "../../hooks/use-fetch-key-size"
14-
import { TTLPopover } from "./ttl-popover"
9+
import { TTLBadge } from "./ttl-badge"
1510

1611
export const LengthBadge = ({
1712
dataKey,
@@ -50,43 +45,16 @@ export const SizeBadge = ({ dataKey }: { dataKey: string }) => {
5045
)
5146
}
5247

53-
const TTL_INFINITE = -1
54-
const TTL_NOT_FOUND = -2
55-
56-
export const TTLBadge = ({ dataKey }: { dataKey: string }) => {
57-
const { data: ttl } = useFetchTTL(dataKey)
58-
const { deleteKeyCache } = useDeleteKeyCache()
59-
60-
// Tick the ttl query every second
61-
useEffect(() => {
62-
const interval = setInterval(() => {
63-
queryClient.setQueryData([FETCH_TTL_QUERY_KEY, dataKey], (ttl?: number) => {
64-
if (ttl === undefined || ttl === TTL_INFINITE) return ttl
65-
66-
if (ttl <= 1) {
67-
deleteKeyCache(dataKey)
68-
return TTL_NOT_FOUND
69-
}
70-
return ttl - 1
71-
})
72-
}, 1000)
73-
74-
return () => clearInterval(interval)
75-
}, [])
48+
export const HeaderTTLBadge = ({ dataKey }: { dataKey: string }) => {
49+
const { data: expireAt } = useFetchKeyExpire(dataKey)
50+
const { mutate: setTTL, isPending } = useSetTTL()
7651

7752
return (
78-
<Badge label="TTL:">
79-
{ttl === undefined ? (
80-
<Skeleton className="ml-1 h-3 w-10 rounded-md opacity-50" />
81-
) : (
82-
<TTLPopover dataKey={dataKey} ttl={ttl}>
83-
<div className="flex gap-[2px]">
84-
{ttl === TTL_INFINITE ? "Forever" : formatTime(ttl)}
85-
<IconChevronDown className="mt-[1px] text-zinc-400" size={12} />
86-
</div>
87-
</TTLPopover>
88-
)}
89-
</Badge>
53+
<TTLBadge
54+
expireAt={expireAt}
55+
setTTL={(ttl) => setTTL({ dataKey, ttl })}
56+
isPending={isPending}
57+
/>
9058
)
9159
}
9260

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useEffect, useState } from "react"
2+
import { IconChevronDown } from "@tabler/icons-react"
3+
4+
import { formatTime } from "@/lib/utils"
5+
import { Skeleton } from "@/components/ui/skeleton"
6+
7+
import { Badge } from "./header-badges"
8+
import { TTLPopover } from "./ttl-popover"
9+
10+
export const TTL_INFINITE = -1
11+
export const TTL_NOT_FOUND = -2
12+
13+
export const calculateTTL = (expireAt?: number) => {
14+
if (!expireAt) return
15+
if (expireAt === TTL_INFINITE) return TTL_INFINITE
16+
return Math.max(0, Math.floor((expireAt - Date.now()) / 1000))
17+
}
18+
19+
export const TTLBadge = ({
20+
label = "TTL:",
21+
expireAt,
22+
setTTL,
23+
isPending,
24+
}: {
25+
label?: string
26+
expireAt?: number
27+
setTTL: (ttl: number) => void
28+
isPending: boolean
29+
}) => {
30+
const [ttl, setTTLLabel] = useState(() => calculateTTL(expireAt))
31+
32+
// Update ttl every second
33+
useEffect(() => {
34+
setTTLLabel(calculateTTL(expireAt))
35+
const interval = setInterval(() => {
36+
setTTLLabel(calculateTTL(expireAt))
37+
}, 1000)
38+
39+
return () => clearInterval(interval)
40+
}, [expireAt])
41+
42+
return (
43+
<Badge label={label}>
44+
{ttl === undefined ? (
45+
<Skeleton className="ml-1 h-3 w-10 rounded-md opacity-50" />
46+
) : (
47+
<TTLPopover ttl={ttl} setTTL={setTTL} isPending={isPending}>
48+
<div className="flex gap-[2px]">
49+
{ttl === TTL_INFINITE ? "Forever" : formatTime(ttl)}
50+
<IconChevronDown className="mt-[1px] text-zinc-400" size={12} />
51+
</div>
52+
</TTLPopover>
53+
)}
54+
</Badge>
55+
)
56+
}

0 commit comments

Comments
 (0)