|
@@ -0,0 +1,274 @@
|
|
|
|
|
+import { useEffect, useMemo, useState } from 'react'
|
|
|
|
|
+import { useQuery } from '@tanstack/react-query'
|
|
|
|
|
+import { AlertTriangle, Infinity as InfinityIcon, Info } from 'lucide-react'
|
|
|
|
|
+import { workflowsApi } from '../../api/workflows'
|
|
|
|
|
+
|
|
|
|
|
+interface Props {
|
|
|
|
|
+ workflowId?: string
|
|
|
|
|
+ /** The workflow's own retention, or undefined when it inherits the project's. */
|
|
|
|
|
+ ttlSeconds?: number
|
|
|
|
|
+ onChange: (ttlSeconds: number | undefined) => void
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Durations somebody would actually choose. A free-form number of seconds is a
|
|
|
|
|
+// unit conversion the reader has to do in their head to know what they picked.
|
|
|
|
|
+const PRESETS: { label: string; seconds: number }[] = [
|
|
|
|
|
+ { label: '1 day', seconds: 86400 },
|
|
|
|
|
+ { label: '7 days', seconds: 604800 },
|
|
|
|
|
+ { label: '30 days', seconds: 2592000 },
|
|
|
|
|
+ { label: '90 days', seconds: 7776000 },
|
|
|
|
|
+ { label: '1 year', seconds: 31536000 },
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+export function describeDuration(seconds: number): string {
|
|
|
|
|
+ if (seconds <= 0) return 'for ever'
|
|
|
|
|
+ const preset = PRESETS.find((p) => p.seconds === seconds)
|
|
|
|
|
+ if (preset) return preset.label
|
|
|
|
|
+ if (seconds % 86400 === 0) return `${seconds / 86400} days`
|
|
|
|
|
+ if (seconds % 3600 === 0) return `${seconds / 3600} hours`
|
|
|
|
|
+ if (seconds % 60 === 0) return `${seconds / 60} minutes`
|
|
|
|
|
+ return `${seconds} seconds`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function describeAge(ms: number): string {
|
|
|
|
|
+ if (ms <= 0) return 'no time at all'
|
|
|
|
|
+ const days = ms / 86400000
|
|
|
|
|
+ if (days >= 1) return `${days < 10 ? days.toFixed(1) : Math.round(days)} days`
|
|
|
|
|
+ const hours = ms / 3600000
|
|
|
|
|
+ if (hours >= 1) return `${hours < 10 ? hours.toFixed(1) : Math.round(hours)} hours`
|
|
|
|
|
+ return `${Math.max(1, Math.round(ms / 60000))} minutes`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function WorkflowRetentionSettings({ workflowId, ttlSeconds, onChange }: Props) {
|
|
|
|
|
+ // The choice on screen is held here and handed up only when it is accepted.
|
|
|
|
|
+ //
|
|
|
|
|
+ // It used to go straight up on every change of the dropdown, and the editor
|
|
|
|
|
+ // autosaves - so picking an option to read what it would cost applied it two
|
|
|
|
|
+ // seconds later, and the warning saying "this removes 122 records" arrived
|
|
|
|
|
+ // after the records had gone. Everything else destructive here asks first;
|
|
|
|
|
+ // this asked nothing while doing the most.
|
|
|
|
|
+ const [selected, setSelected] = useState<number | undefined>(ttlSeconds)
|
|
|
|
|
+ useEffect(() => setSelected(ttlSeconds), [ttlSeconds])
|
|
|
|
|
+
|
|
|
|
|
+ const inherits = selected === undefined
|
|
|
|
|
+ const dirty = selected !== ttlSeconds
|
|
|
|
|
+ const [custom, setCustom] = useState<string>(
|
|
|
|
|
+ ttlSeconds !== undefined && ttlSeconds > 0 && !PRESETS.some((p) => p.seconds === ttlSeconds)
|
|
|
|
|
+ ? String(ttlSeconds)
|
|
|
|
|
+ : ''
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ // What is in force now, so the inherit option can say what it inherits rather
|
|
|
|
|
+ // than leaving the reader to go and look.
|
|
|
|
|
+ const { data: inForce } = useQuery({
|
|
|
|
|
+ queryKey: ['workflow-retention-current', workflowId],
|
|
|
|
|
+ queryFn: () => workflowsApi.getRetention(workflowId!),
|
|
|
|
|
+ enabled: !!workflowId,
|
|
|
|
|
+ retry: false,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // What the choice on screen would cost. Debounced: typing "90" passes through
|
|
|
|
|
+ // 9 on the way, and 9 seconds would report the whole history as about to go.
|
|
|
|
|
+ const [pending, setPending] = useState<number | undefined>(selected)
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const timer = setTimeout(() => setPending(selected), 400)
|
|
|
|
|
+ return () => clearTimeout(timer)
|
|
|
|
|
+ }, [selected])
|
|
|
|
|
+
|
|
|
|
|
+ const effective = pending !== undefined ? pending : inForce?.current?.ttlSeconds
|
|
|
|
|
+ const { data: preview, isFetching } = useQuery({
|
|
|
|
|
+ queryKey: ['workflow-retention-preview', workflowId, effective],
|
|
|
|
|
+ queryFn: () => workflowsApi.getRetention(workflowId!, effective!),
|
|
|
|
|
+ enabled: !!workflowId && effective !== undefined,
|
|
|
|
|
+ retry: false,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const goingNow = useMemo(() => {
|
|
|
|
|
+ if (!preview) return 0
|
|
|
|
|
+ return preview.executions.expiringNow + preview.documents.expiringNow
|
|
|
|
|
+ }, [preview])
|
|
|
|
|
+
|
|
|
|
|
+ const held = useMemo(() => {
|
|
|
|
|
+ if (!preview) return 0
|
|
|
|
|
+ return preview.executions.total + preview.documents.total
|
|
|
|
|
+ }, [preview])
|
|
|
|
|
+
|
|
|
|
|
+ const oldest = useMemo(() => {
|
|
|
|
|
+ if (!preview) return 0
|
|
|
|
|
+ return Math.max(preview.executions.oldestAgeMs, preview.documents.oldestAgeMs)
|
|
|
|
|
+ }, [preview])
|
|
|
|
|
+
|
|
|
|
|
+ const selectValue = inherits
|
|
|
|
|
+ ? 'inherit'
|
|
|
|
|
+ : selected === 0
|
|
|
|
|
+ ? 'forever'
|
|
|
|
|
+ : PRESETS.some((p) => p.seconds === selected)
|
|
|
|
|
+ ? String(selected)
|
|
|
|
|
+ : 'custom'
|
|
|
|
|
+
|
|
|
|
|
+ const describeChoice = () =>
|
|
|
|
|
+ selected === undefined
|
|
|
|
|
+ ? "the project's setting"
|
|
|
|
|
+ : selected === 0
|
|
|
|
|
+ ? 'keep for ever'
|
|
|
|
|
+ : `keep for ${describeDuration(selected)}`
|
|
|
|
|
+
|
|
|
|
|
+ const apply = () => {
|
|
|
|
|
+ // The count is something to accept, not something to be told afterwards.
|
|
|
|
|
+ // Only when data actually goes - agreeing to a change that removes nothing
|
|
|
|
|
+ // is a dialog that teaches people to dismiss dialogs.
|
|
|
|
|
+ if (goingNow > 0) {
|
|
|
|
|
+ const ok = confirm(
|
|
|
|
|
+ `Change this workflow to ${describeChoice()}?\n\n` +
|
|
|
|
|
+ `${goingNow.toLocaleString()} of ${held.toLocaleString()} records are already older ` +
|
|
|
|
|
+ `than that and are removed as soon as this is saved. This cannot be undone.`
|
|
|
|
|
+ )
|
|
|
|
|
+ if (!ok) return
|
|
|
|
|
+ }
|
|
|
|
|
+ onChange(selected)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ <select
|
|
|
|
|
+ value={selectValue}
|
|
|
|
|
+ onChange={(e) => {
|
|
|
|
|
+ const v = e.target.value
|
|
|
|
|
+ if (v === 'inherit') return setSelected(undefined)
|
|
|
|
|
+ if (v === 'forever') return setSelected(0)
|
|
|
|
|
+ if (v === 'custom') return setSelected(Number(custom) || 86400)
|
|
|
|
|
+ setSelected(Number(v))
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100"
|
|
|
|
|
+ >
|
|
|
|
|
+ <option value="inherit">
|
|
|
|
|
+ {inForce?.current?.inherited && inForce.current.ttlSeconds > 0
|
|
|
|
|
+ ? `Use the project's setting (${describeDuration(inForce.current.ttlSeconds)})`
|
|
|
|
|
+ : inForce?.current?.inherited
|
|
|
|
|
+ ? "Use the project's setting (keep for ever)"
|
|
|
|
|
+ : "Use the project's setting"}
|
|
|
|
|
+ </option>
|
|
|
|
|
+ {PRESETS.map((p) => (
|
|
|
|
|
+ <option key={p.seconds} value={p.seconds}>
|
|
|
|
|
+ Keep for {p.label}
|
|
|
|
|
+ </option>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ <option value="custom">Keep for a set number of seconds</option>
|
|
|
|
|
+ <option value="forever">Keep for ever</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+
|
|
|
|
|
+ {selectValue === 'custom' && (
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ min={1}
|
|
|
|
|
+ value={custom}
|
|
|
|
|
+ onChange={(e) => {
|
|
|
|
|
+ setCustom(e.target.value)
|
|
|
|
|
+ const n = Number(e.target.value)
|
|
|
|
|
+ if (n > 0) setSelected(n)
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="w-40 px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span className="text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ seconds{Number(custom) > 0 ? ` - ${describeDuration(Number(custom))}` : ''}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ <p className="text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ How long this workflow's runs and the data it stores are kept, counted from
|
|
|
|
|
+ when each was written. The same setting covers its execution history and its
|
|
|
|
|
+ own storage, so a run and the data it produced go together.
|
|
|
|
|
+ </p>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Picking an option shows what it would do; nothing is written until it
|
|
|
|
|
+ is applied here. The editor autosaves, so a setting that took effect on
|
|
|
|
|
+ the dropdown alone would be applied while its warning was still being
|
|
|
|
|
+ read - which is exactly what happened. */}
|
|
|
|
|
+ {dirty && (
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={apply}
|
|
|
|
|
+ className={`px-3 py-1.5 text-xs rounded-lg text-white ${
|
|
|
|
|
+ goingNow > 0
|
|
|
|
|
+ ? 'bg-amber-600 hover:bg-amber-700'
|
|
|
|
|
+ : 'bg-primary-600 hover:bg-primary-700'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {goingNow > 0
|
|
|
|
|
+ ? `Apply, and remove ${goingNow.toLocaleString()} ${goingNow === 1 ? 'record' : 'records'}`
|
|
|
|
|
+ : 'Apply this setting'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => setSelected(ttlSeconds)}
|
|
|
|
|
+ className="px-3 py-1.5 text-xs rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700"
|
|
|
|
|
+ >
|
|
|
|
|
+ Cancel
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <span className="text-xs text-gray-400">Not saved yet</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* What this costs, counted rather than guessed. */}
|
|
|
|
|
+ {workflowId && effective !== undefined && (
|
|
|
|
|
+ <div className="rounded-lg border border-gray-200 dark:border-slate-700 p-3 text-xs">
|
|
|
|
|
+ {isFetching && !preview ? (
|
|
|
|
|
+ <span className="text-gray-500 dark:text-gray-400">Counting what this holds...</span>
|
|
|
|
|
+ ) : !preview ? null : goingNow > 0 ? (
|
|
|
|
|
+ <div className="flex gap-2">
|
|
|
|
|
+ <AlertTriangle className="w-4 h-4 flex-shrink-0 text-amber-500" />
|
|
|
|
|
+ <div className="text-gray-700 dark:text-gray-300">
|
|
|
|
|
+ <p className="font-medium text-amber-700 dark:text-amber-400">
|
|
|
|
|
+ Saving this removes {goingNow.toLocaleString()} of {held.toLocaleString()}{' '}
|
|
|
|
|
+ {held === 1 ? 'record' : 'records'} straight away.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <p className="mt-1">
|
|
|
|
|
+ A retention runs from when data was written, and this one is shorter
|
|
|
|
|
+ than the age of that data - the oldest here goes back {describeAge(oldest)}.
|
|
|
|
|
+ {preview.executions.expiringNow > 0 && (
|
|
|
|
|
+ <> {preview.executions.expiringNow.toLocaleString()} of them are runs from
|
|
|
|
|
+ the execution history.</>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : effective === 0 ? (
|
|
|
|
|
+ <div className="flex gap-2">
|
|
|
|
|
+ <InfinityIcon className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
|
|
|
|
+ <div className="text-gray-600 dark:text-gray-400">
|
|
|
|
|
+ <p>
|
|
|
|
|
+ Nothing here is ever removed. {held.toLocaleString()}{' '}
|
|
|
|
|
+ {held === 1 ? 'record' : 'records'} held now, and it only grows - a
|
|
|
|
|
+ workflow that runs often will keep every run of it for ever, and the
|
|
|
|
|
+ data outlives the workflow itself.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="flex gap-2">
|
|
|
|
|
+ <Info className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
|
|
|
|
+ <p className="text-gray-600 dark:text-gray-400">
|
|
|
|
|
+ Nothing is removed now. {held.toLocaleString()}{' '}
|
|
|
|
|
+ {held === 1 ? 'record' : 'records'} held, the oldest {describeAge(oldest)} old,
|
|
|
|
|
+ all of it inside {describeDuration(effective)}.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {preview && !preview.filesReachable && (
|
|
|
|
|
+ // Said here rather than left to be found out. This is a real limit of
|
|
|
|
|
+ // the file store, not an oversight in the setting.
|
|
|
|
|
+ <p className="mt-2 pt-2 border-t border-gray-200 dark:border-slate-700 text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ Files this workflow has already stored are not covered. A file's expiry
|
|
|
|
|
+ can only be set when it is uploaded, so they keep the retention that was
|
|
|
|
|
+ in force when they were written. From now on, new ones take this setting.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|