This commit is contained in:
Timothy Jaeryang Baek committed 2026-07-17 00:54:13 -04:00
1 parent 27d7eafcd9
commit ce44b90eae
7 files changed
+686 -624

No files matched your search

@@ -4,73 +4,53 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import localizedFormat from 'dayjs/plugin/localizedFormat'; import localizedFormat from 'dayjs/plugin/localizedFormat';
import type i18nType from '$lib/i18n';
import { WEBUI_NAME, mobile, showSidebar } from '$lib/stores'; import { WEBUI_NAME } from '$lib/stores';
import { import {
updateAutomationById, getAutomationById,
toggleAutomationById, toggleAutomationById,
runAutomationById, runAutomationById,
deleteAutomationById, deleteAutomationById,
getAutomationRuns, getAutomationRuns,
type AutomationForm,
type AutomationResponse, type AutomationResponse,
type AutomationRunModel type AutomationRunModel
} from '$lib/apis/automations'; } from '$lib/apis/automations';
import AutomationModal from '$lib/components/AutomationModal.svelte';
import AutomationItemHeaderActions from '$lib/components/automations/AutomationItemHeaderActions.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte'; import ArrowRight from '$lib/components/icons/ArrowRight.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import SidebarIcon from '$lib/components/icons/Sidebar.svelte';
import ScheduleDropdown from '$lib/components/automations/ScheduleDropdown.svelte';
import ModelDropdown from '$lib/components/automations/ModelDropdown.svelte';
dayjs.extend(relativeTime);
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
const i18n = getContext('i18n'); const i18n: typeof i18nType = getContext('i18n');
const automationsLayout: any = getContext('automationsLayout');
export let automation: AutomationResponse; export let automation: AutomationResponse;
let name = ''; let is_active = automation.is_active;
let prompt = '';
let model_id = '';
let is_active = true;
let loading = false; let loading = false;
let saving = false;
let showDeleteConfirm = false; let showDeleteConfirm = false;
let showEditModal = false;
let runs: AutomationRunModel[] = []; let runs: AutomationRunModel[] = [];
let runsLoading = false; let runsLoading = false;
let hasMoreRuns = true; let hasMoreRuns = true;
let runsPage = 0; let runsPage = 0;
let isDirty = false;
let scheduleDropdown: ScheduleDropdown; const formatTime = (ts: number | null): string => {
if (!ts) return '-';
const formatRunTime = (ts: number): string => { return new Date(ts / 1_000_000).toLocaleString(undefined, {
const now = Date.now(); month: 'short',
const diff = now - ts / 1_000_000; day: 'numeric',
hour: '2-digit',
const seconds = Math.floor(diff / 1000); minute: '2-digit'
const minutes = Math.floor(seconds / 60); });
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const years = Math.floor(days / 365);
if (years > 0) return $i18n.t('{{COUNT}}y', { COUNT: years, context: 'time_ago' });
if (weeks > 0) return $i18n.t('{{COUNT}}w', { COUNT: weeks, context: 'time_ago' });
if (days > 0) return $i18n.t('{{COUNT}}d', { COUNT: days, context: 'time_ago' });
if (hours > 0) return $i18n.t('{{COUNT}}h', { COUNT: hours, context: 'time_ago' });
if (minutes > 0) return $i18n.t('{{COUNT}}m', { COUNT: minutes, context: 'time_ago' });
return $i18n.t('1m', { context: 'time_ago' });
}; };
const formatNextRun = (ts: number | null): string => { const formatNextRun = (ts: number | null): string => {
@@ -80,36 +60,57 @@
return d.format('L LT'); return d.format('L LT');
}; };
const saveHandler = async () => { const formatSchedule = (rrule: string): string => {
if (!name.trim() || !prompt.trim() || !model_id.trim()) { if (rrule.includes('COUNT=1')) {
toast.error($i18n.t('Name, prompt, and model are required')); const match = rrule.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
return; if (match) {
} const d = new Date(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`);
saving = true; return `${$i18n.t('Once')} · ${d.toLocaleDateString(undefined, {
try { month: 'short',
const form: AutomationForm = { day: 'numeric'
name: name.trim(), })} ${d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}`;
data: {
prompt: prompt.trim(),
model_id: model_id.trim(),
rrule: scheduleDropdown.buildRrule()
},
is_active
};
const updated = await updateAutomationById(localStorage.token, automation.id, form);
if (updated) {
automation = updated;
isDirty = false;
toast.success($i18n.t('Automation updated'));
} }
} catch (e: any) { return $i18n.t('Once');
toast.error(e?.detail ?? `${e}` ?? 'Failed to save');
} finally {
saving = false;
} }
const parts: Record<string, string> = {};
rrule
.replace('RRULE:', '')
.split(';')
.forEach((part) => {
const [key, value] = part.split('=');
if (key && value) parts[key] = value;
});
const freq = parts.FREQ || '';
const hour = parseInt(parts.BYHOUR || '0');
const minute = (parts.BYMINUTE || '0').padStart(2, '0');
const interval = parseInt(parts.INTERVAL || '1');
const ampm = hour >= 12 ? 'PM' : 'AM';
const hour12 = hour % 12 || 12;
const time = `${hour12}:${minute} ${ampm}`;
if (freq === 'MINUTELY')
return interval === 1
? $i18n.t('Every minute')
: $i18n.t('Every {{count}} minutes', { count: interval });
if (freq === 'HOURLY')
return interval === 1
? $i18n.t('Hourly')
: $i18n.t('Every {{count}} hours', { count: interval });
if (freq === 'DAILY') return `${$i18n.t('Daily at')} ${time}`;
if (freq === 'WEEKLY')
return parts.BYDAY
? `${parts.BYDAY} ${$i18n.t('at')} ${time}`
: `${$i18n.t('Weekly at')} ${time}`;
if (freq === 'MONTHLY')
return `${$i18n.t('Monthly')} ${parts.BYMONTHDAY ?? '1'} ${$i18n.t('at')} ${time}`;
return rrule;
}; };
const toggleHandler = async () => { const toggleHandler = async () => {
const previousState = automation.is_active;
const res = await toggleAutomationById(localStorage.token, automation.id).catch((err) => { const res = await toggleAutomationById(localStorage.token, automation.id).catch((err) => {
toast.error(`${err}`); toast.error(`${err}`);
return null; return null;
@@ -117,6 +118,8 @@
if (res) { if (res) {
is_active = res.is_active; is_active = res.is_active;
automation = res; automation = res;
} else {
is_active = previousState;
} }
}; };
@@ -133,6 +136,19 @@
loading = false; loading = false;
}; };
const editSavedHandler = async () => {
const updated = await getAutomationById(localStorage.token, automation.id).catch((err) => {
toast.error(`${err}`);
return null;
});
if (updated) {
automation = updated;
is_active = updated.is_active;
await loadRuns();
}
};
const deleteHandler = async () => { const deleteHandler = async () => {
const res = await deleteAutomationById(localStorage.token, automation.id).catch((err) => { const res = await deleteAutomationById(localStorage.token, automation.id).catch((err) => {
toast.error(`${err}`); toast.error(`${err}`);
@@ -173,10 +189,6 @@
runsLoading = false; runsLoading = false;
}; };
const markDirty = () => {
isDirty = true;
};
const onScroll = (e: Event) => { const onScroll = (e: Event) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 50) { if (target.scrollTop + target.clientHeight >= target.scrollHeight - 50) {
@@ -187,21 +199,29 @@
}; };
onMount(async () => { onMount(async () => {
name = automation.name;
prompt = automation.data.prompt;
model_id = automation.data.model_id;
is_active = automation.is_active; is_active = automation.is_active;
if (scheduleDropdown) {
scheduleDropdown.parseRrule(automation.data.rrule);
}
await loadRuns(); await loadRuns();
}); });
$: if (automation) {
automationsLayout?.setHeader({
itemName: automation.name,
actions: AutomationItemHeaderActions,
actionProps: {
isActive: is_active,
loading,
toggleHandler,
runNowHandler,
editHandler: () => (showEditModal = true),
deleteHandler: () => (showDeleteConfirm = true)
}
});
}
</script> </script>
<svelte:head> <svelte:head>
<title>{name || $i18n.t('Automation')} • {$WEBUI_NAME}</title> <title>{automation.name || $i18n.t('Automation')} • {$WEBUI_NAME}</title>
</svelte:head> </svelte:head>
<DeleteConfirmDialog <DeleteConfirmDialog
@@ -214,267 +234,124 @@
</div> </div>
</DeleteConfirmDialog> </DeleteConfirmDialog>
<div <AutomationModal bind:show={showEditModal} {automation} on:save={editSavedHandler} />
class="flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''} max-w-full"
>
<div class="flex-1 max-h-full flex flex-col pt-3 pb-1 px-3 md:px-[18px]">
<!-- Header Segment (Shrink-0 so it doesn't compress) -->
<div class="flex items-start justify-between gap-4 shrink-0 mb-0.5">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-0.5 md:gap-1.5 mb-1.5">
{#if $mobile}
<Tooltip content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}>
<button
id="sidebar-toggle-button"
class="cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition"
aria-label={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
on:click={() => {
showSidebar.set(!$showSidebar);
}}
type="button"
>
<div class="self-center p-1.5">
<SidebarIcon className="size-4" />
</div>
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Back')}>
<button
class="text-sm p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition"
aria-label={$i18n.t('Back')}
on:click={() => goto('/automations')}
type="button"
>
<ChevronLeft strokeWidth="2.5" />
</button>
</Tooltip>
<input
class="text-2xl w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Automation Name')}
bind:value={name}
on:input={markDirty}
/>
</div>
</div>
<div class="flex items-center gap-2 shrink-0"> <div class="h-full overflow-y-auto scrollbar-hidden">
<Tooltip content={$i18n.t('Delete')}> <div class="pb-1 px-1">
<button <div class="flex h-7 items-center px-3">
class="p-2 rounded-full bg-transparent hover:bg-gray-50 dark:hover:bg-gray-850 text-gray-500 hover:text-black dark:hover:text-white transition" <span class="w-24 shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
on:click={() => (showDeleteConfirm = true)} {$i18n.t('Status')}
type="button" </span>
> <span
<GarbageBin /> class="flex min-w-0 items-center gap-1.5 text-xs {is_active
</button> ? 'text-emerald-700 dark:text-emerald-400'
</Tooltip> : 'text-gray-600 dark:text-gray-400'}"
>
{#if isDirty} <svg class="size-1.5 shrink-0" viewBox="0 0 6 6" fill="currentColor" aria-hidden="true">
<button <circle cx="3" cy="3" r="3" />
class="px-3 py-1 text-sm bg-black text-white dark:bg-white dark:text-black rounded-full hover:opacity-90 transition flex items-center gap-1.5" </svg>
on:click={saveHandler} {is_active ? $i18n.t('Active') : $i18n.t('Paused')}
disabled={saving} </span>
type="button"
>
{$i18n.t('Save')}
{#if saving}
<Spinner className="size-3" />
{/if}
</button>
{/if}
<button
class="px-2.5 py-1 text-sm border border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-full flex items-center gap-1.5"
on:click={runNowHandler}
type="button"
disabled={loading}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.841Z"
/>
</svg>
<div class="hidden md:block">{$i18n.t('Run now')}</div>
{#if loading}
<Spinner className="size-3" />
{/if}
</button>
</div>
</div> </div>
<!-- Content Segment: Independent Scrolling Columns based on PromptEditor --> <div class="flex h-7 items-center px-3">
<div class="flex flex-col md:flex-row gap-4 flex-1 overflow-hidden pb-2 px-1"> <span class="w-24 shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
<!-- Main Input Column --> {$i18n.t('Schedule')}
<div class="flex-1 flex flex-col min-h-0 overflow-hidden"> </span>
<div class="flex items-center justify-between mb-2 shrink-0 px-1"> <span class="min-w-0 truncate text-xs text-gray-700 dark:text-gray-300">
<div class="text-gray-500 text-xs">{$i18n.t('Instructions')}</div> {formatSchedule(automation.data.rrule)}
</div> </span>
<div class="relative flex-1 min-h-0"> </div>
<div
class="bg-gray-50 dark:bg-gray-900 rounded-2xl p-4 border border-gray-100/50 dark:border-gray-850/50 h-full"
>
<textarea
class="w-full h-full text-sm bg-transparent outline-hidden resize-none placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={prompt}
on:input={markDirty}
placeholder={$i18n.t('Enter the prompt instructions for this automation...')}
/>
</div>
</div>
</div>
<!-- Sidebar Configuration Column --> <div class="flex h-7 items-center px-3">
<div class="hidden md:flex w-full md:w-66 shrink-0 overflow-y-auto px-1 flex-col gap-5"> <span class="w-24 shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
{$i18n.t('Model')}
</span>
<span class="min-w-0 truncate text-xs text-gray-700 dark:text-gray-300">
{automation.data.model_id}
</span>
</div>
<div class="flex h-7 items-center px-3">
<span class="w-24 shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
{$i18n.t('Next run')}
</span>
<span class="min-w-0 truncate text-xs text-gray-700 dark:text-gray-300">
{formatNextRun(automation.next_runs?.[0] ?? automation.next_run_at)}
</span>
</div>
<div class="flex h-7 items-center px-3">
<span class="w-24 shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
{$i18n.t('Last run')}
</span>
<span class="min-w-0 truncate text-xs text-gray-700 dark:text-gray-300">
{automation.last_run_at ? formatNextRun(automation.last_run_at) : $i18n.t('Never')}
</span>
</div>
</div>
<hr class="h-px my-1.5 border-0 bg-gray-100 dark:bg-gray-850" />
<div class="px-4 py-2">
<div class="mb-2 text-[11px] text-gray-400 dark:text-gray-500">{$i18n.t('Prompt')}</div>
<div
class="whitespace-pre-wrap font-mono text-xs leading-relaxed text-gray-700 dark:text-gray-300"
>
{automation.data.prompt}
</div>
</div>
<hr class="h-px my-1.5 border-0 bg-gray-100 dark:bg-gray-850" />
<div class="px-4 py-2">
<div class="mb-1 text-[11px] text-gray-400 dark:text-gray-500">{$i18n.t('Runs')}</div>
<div class="overflow-y-auto scrollbar-hidden" on:scroll={onScroll}>
{#if runsLoading && runs.length === 0}
<div class="flex justify-center py-8">
<Spinner className="size-4" />
</div>
{:else if runs.length === 0}
<div class="py-2 text-[11px] text-gray-400 dark:text-gray-600">
{$i18n.t('No runs yet')}
</div>
{:else}
<div> <div>
<div class="text-gray-500 text-xs mb-3">{$i18n.t('Configuration')}</div> {#each runs as run}
<div class="space-y-1"> <div class="flex h-7 items-center gap-2 text-xs">
<!-- Schedule -->
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('Repeats')}</span>
<ScheduleDropdown
bind:this={scheduleDropdown}
side="bottom"
align="end"
onChange={markDirty}
/>
</div>
<!-- Model -->
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('Model')}</span>
<ModelDropdown bind:model_id side="bottom" align="end" onChange={markDirty} />
</div>
</div>
</div>
<!-- Status section -->
<div>
<div class="text-gray-500 text-xs mb-3">{$i18n.t('Status')}</div>
<div class="space-y-2.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('State')}</span>
<span <span
class="flex items-center gap-1.5 text-xs {is_active class="h-1.5 w-1.5 shrink-0 rounded-full {run.status === 'success'
? 'text-emerald-700 dark:text-emerald-400' ? 'bg-emerald-500'
: 'text-gray-600 dark:text-gray-400'}" : 'bg-red-400'}"
> ></span>
<span <span class="text-gray-500 dark:text-gray-400">{formatTime(run.created_at)}</span>
class="inline-block size-1.5 rounded-full {is_active {#if run.chat_id}
? 'bg-emerald-500' <button
: 'bg-gray-400'}" class="group flex items-center gap-1 text-[0.6875rem] text-gray-400"
></span> on:click={() => {
{is_active ? $i18n.t('Active') : $i18n.t('Paused')} goto(`/c/${run.chat_id}`);
</span> }}
type="button"
>
<span class="group-hover:underline">{$i18n.t('View chat')}</span>
<ArrowRight className="size-2.5" strokeWidth="2" />
</button>
{/if}
{#if run.error}
<span class="truncate text-[0.6875rem] text-red-400" title={run.error}>
{run.error}
</span>
{/if}
</div> </div>
{/each}
<div class="flex items-center justify-between text-xs"> {#if runsLoading && runs.length > 0}
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('Next run')}</span> <div class="flex justify-center py-4">
<span class=" text-gray-700 dark:text-gray-300" <Spinner className="size-4" />
>{formatNextRun(automation.next_runs?.[0] ?? automation.next_run_at)}</span
>
</div> </div>
{/if}
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('Last ran')}</span>
<span class=" text-gray-700 dark:text-gray-300"
>{automation.last_run_at
? formatNextRun(automation.last_run_at)
: $i18n.t('Never')}</span
>
</div>
</div>
</div> </div>
{/if}
<div class="flex-1 flex flex-col min-h-0 -mx-1">
<div class="text-gray-500 text-xs mb-2 mx-1 shrink-0">
{$i18n.t('Execution Logs')}
</div>
<div class="flex-1 overflow-y-auto scrollbar-hidden w-full" on:scroll={onScroll}>
{#if runsLoading && runs.length === 0}
<div class="flex justify-center py-4">
<Spinner className="size-4" />
</div>
{:else if runs.length === 0}
<div class="text-xs text-gray-400 py-4 tabular-nums">
{$i18n.t('No execution logs available yet')}
</div>
{:else}
<div class="space-y-0.5 w-full">
{#each runs as run (run.id)}
<button
class="w-full text-left flex items-center gap-2.5 px-2.5 py-1.5 rounded-xl hover:bg-gray-100/80 dark:hover:bg-gray-850/80 transition-colors {run.chat_id
? 'cursor-pointer'
: 'cursor-default'}"
on:click={() => {
if (run.chat_id) goto(`/c/${run.chat_id}`);
}}
type="button"
>
<div class="shrink-0 flex items-center justify-center">
{#if run.status === 'success'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 text-emerald-500"
><path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/></svg
>
{:else if run.status === 'error'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 text-red-500"
><path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
clip-rule="evenodd"
/></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 text-blue-500"
><path
d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .414.336.75.75.75h4a.75.75 0 000-1.5h-3.25V5z"
/></svg
>
{/if}
</div>
<div class="flex-1 min-w-0">
<div class="text-xs text-gray-800 dark:text-gray-200 truncate">
{automation.name}
</div>
</div>
<span class="shrink-0 text-[10px] text-gray-500 font-mono"
>{formatRunTime(run.created_at)}</span
>
</button>
{/each}
{#if runsLoading && runs.length > 0}
<div class="flex justify-center py-4">
<Spinner className="size-4" />
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,70 @@
<script lang="ts">
import { getContext } from 'svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import Play from '$lib/components/icons/Play.svelte';
import type i18nType from '$lib/i18n';
const i18n: typeof i18nType = getContext('i18n');
export let isActive = true;
export let loading = false;
export let toggleHandler = () => {};
export let runNowHandler = () => {};
export let editHandler = () => {};
export let deleteHandler = () => {};
</script>
<div class="ml-auto flex shrink-0 items-center">
<Tooltip content={isActive ? $i18n.t('Active') : $i18n.t('Paused')}>
<div class="flex h-7 items-center px-1 mr-1">
<Switch
state={isActive}
on:change={toggleHandler}
ariaLabel={isActive ? $i18n.t('Active') : $i18n.t('Paused')}
/>
</div>
</Tooltip>
<Tooltip content={$i18n.t('Run now')}>
<button
class="flex size-7 items-center justify-center text-gray-400 transition disabled:opacity-60"
on:click={runNowHandler}
type="button"
disabled={loading}
aria-label={$i18n.t('Run now')}
>
{#if loading}
<Spinner className="size-3" />
{:else}
<Play className="size-3.5" strokeWidth="1.5" />
{/if}
</button>
</Tooltip>
<Tooltip content={$i18n.t('Edit')}>
<button
class="flex size-7 items-center justify-center text-gray-400 transition"
on:click={editHandler}
type="button"
aria-label={$i18n.t('Edit')}
>
<Pencil className="size-3.5" strokeWidth="1.7" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
class="flex size-7 items-center justify-center text-gray-400 transition"
on:click={deleteHandler}
type="button"
aria-label={$i18n.t('Delete')}
>
<GarbageBin className="size-3.5" />
</button>
</Tooltip>
</div>
@@ -0,0 +1,9 @@
<script lang="ts">
import SplitCreateButton from '$lib/components/common/SplitCreateButton.svelte';
export let actions: any[] = [];
</script>
<div class="ml-auto flex items-center gap-1">
<SplitCreateButton {actions} />
</div>
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
>
<path
d="M6.90588 4.53682C6.50592 4.2998 6 4.58808 6 5.05299V18.947C6 19.4119 6.50592 19.7002 6.90588 19.4632L18.629 12.5162C19.0211 12.2838 19.0211 11.7162 18.629 11.4838L6.90588 4.53682Z"
/>
</svg>
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import { getContext, onMount, setContext } from 'svelte';
import { writable } from 'svelte/store';
import { goto } from '$app/navigation';
import { config, mobile, showSidebar, user } from '$lib/stores';
import { getAutomationItems } from '$lib/apis/automations';
import type i18nType from '$lib/i18n';
import { formatNumber } from '$lib/utils';
import SidebarIcon from '$lib/components/icons/Sidebar.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n: typeof i18nType = getContext('i18n');
const total = writable<number | null>(null);
let itemName: string | null = null;
let HeaderActions: any = null;
let headerActionProps: Record<string, any> = {};
const setHeader = ({
itemName: nextItemName = null,
actions = null,
actionProps = {}
}: {
itemName?: string | null;
actions?: any;
actionProps?: Record<string, any>;
}) => {
itemName = nextItemName;
HeaderActions = actions;
headerActionProps = actionProps;
};
const clearHeader = () => {
itemName = null;
HeaderActions = null;
headerActionProps = {};
};
const refreshCount = async () => {
const res = await getAutomationItems(localStorage.token, null, 'all', 1).catch(() => null);
if (res) {
total.set(res.total);
}
};
setContext('automationsLayout', {
total,
setTotal: (value: number | null) => total.set(value),
refreshCount,
setHeader,
clearHeader
});
onMount(async () => {
if (
!($config?.features as any)?.enable_automations ||
($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false))
) {
goto('/');
return;
}
await refreshCount();
});
</script>
<div
class="flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''} max-w-full"
>
<div class="flex h-full min-h-0 flex-col">
<div class="shrink-0 px-2.5 pt-2 pb-1">
<div class="flex items-center gap-0.5 md:gap-1">
{#if $mobile}
<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center">
<Tooltip content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}>
<button
id="sidebar-toggle-button"
class="flex size-7 items-center justify-center text-gray-400 transition"
aria-label={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
on:click={() => {
showSidebar.set(!$showSidebar);
}}
type="button"
>
<SidebarIcon className="size-4" />
</button>
</Tooltip>
</div>
{/if}
<div class="flex w-full min-w-0 items-center">
<div class="flex min-w-0 flex-1 items-center gap-1 py-1">
{#if itemName}
<button
class="min-w-fit px-1 text-sm select-none transition"
aria-label={$i18n.t('Back')}
on:click={() => goto('/automations')}
type="button"
>
{$i18n.t('Automations')}
</button>
{:else}
<span class="min-w-fit px-1 text-sm select-none">{$i18n.t('Automations')}</span>
{/if}
<span class="text-sm text-gray-500 dark:text-gray-500">
{$total === null ? '' : formatNumber($total)}
</span>
{#if itemName}
<span class="text-sm text-gray-300 dark:text-gray-800 px-2">/</span>
<span class="min-w-0 flex-1 truncate text-sm text-gray-900 dark:text-white">
{itemName}
</span>
{/if}
</div>
{#if HeaderActions}
<svelte:component this={HeaderActions} {...headerActionProps} />
{/if}
</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-hidden">
<slot />
</div>
</div>
</div>
+247 -286
View File
@@ -3,7 +3,7 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { WEBUI_NAME, mobile, showSidebar, user, config } from '$lib/stores'; import { WEBUI_NAME, user, config } from '$lib/stores';
import { import {
createAutomation, createAutomation,
@@ -14,17 +14,18 @@
type AutomationForm, type AutomationForm,
type AutomationResponse type AutomationResponse
} from '$lib/apis/automations'; } from '$lib/apis/automations';
import type i18nType from '$lib/i18n';
// @ts-ignore file-saver ships without local typings in this repo.
import fileSaver from 'file-saver'; import fileSaver from 'file-saver';
import AutomationModal from '$lib/components/AutomationModal.svelte'; import AutomationModal from '$lib/components/AutomationModal.svelte';
import AutomationListHeaderActions from '$lib/components/automations/AutomationListHeaderActions.svelte';
import AutomationMenu from '$lib/components/automations/AutomationMenu.svelte'; import AutomationMenu from '$lib/components/automations/AutomationMenu.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Pagination from '$lib/components/common/Pagination.svelte'; import Pagination from '$lib/components/common/Pagination.svelte';
import SplitCreateButton from '$lib/components/common/SplitCreateButton.svelte';
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import SidebarIcon from '$lib/components/icons/Sidebar.svelte';
import Search from '$lib/components/icons/Search.svelte'; import Search from '$lib/components/icons/Search.svelte';
import XMark from '$lib/components/icons/XMark.svelte'; import XMark from '$lib/components/icons/XMark.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte'; import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
@@ -35,11 +36,11 @@
import Check from '$lib/components/icons/Check.svelte'; import Check from '$lib/components/icons/Check.svelte';
import CheckCircle from '$lib/components/icons/CheckCircle.svelte'; import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
import Minus from '$lib/components/icons/Minus.svelte'; import Minus from '$lib/components/icons/Minus.svelte';
import { formatNumber } from '$lib/utils';
const { saveAs } = fileSaver; const { saveAs } = fileSaver;
const i18n = getContext('i18n'); const i18n: typeof i18nType = getContext('i18n');
const automationsLayout: any = getContext('automationsLayout');
let loaded = false; let loaded = false;
let automations: AutomationResponse[] | null = null; let automations: AutomationResponse[] | null = null;
@@ -61,6 +62,35 @@
let importFiles: FileList | null = null; let importFiles: FileList | null = null;
let automationsImportInputElement: HTMLInputElement; let automationsImportInputElement: HTMLInputElement;
const syncHeader = () => {
automationsLayout?.setHeader({
itemName: null,
actions: AutomationListHeaderActions,
actionProps: {
actions: [
{
id: 'automations-new',
label: $i18n.t('Create'),
onClick: () => {
cloneFrom = null;
showCreateModal = true;
}
},
{
id: 'automations-import',
label: $i18n.t('Import JSON'),
onClick: () => automationsImportInputElement?.click()
},
{
id: 'automations-export',
label: $i18n.t('Export JSON'),
onClick: exportAutomations
}
]
}
});
};
const handleSearchInput = () => { const handleSearchInput = () => {
if (!loaded) return; if (!loaded) return;
@@ -99,6 +129,7 @@
if (res) { if (res) {
automations = res.items; automations = res.items;
total = res.total; total = res.total;
automationsLayout?.setTotal(total);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -275,9 +306,9 @@
return 'th'; return 'th';
}; };
onMount(async () => { onMount(() => {
if ( if (
!$config?.features?.enable_automations || !($config?.features as any)?.enable_automations ||
($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false)) ($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false))
) { ) {
goto('/'); goto('/');
@@ -285,6 +316,7 @@
} }
loaded = true; loaded = true;
syncHeader();
return () => { return () => {
clearTimeout(searchDebounceTimer); clearTimeout(searchDebounceTimer);
@@ -338,313 +370,242 @@
bind:show={showCreateModal} bind:show={showCreateModal}
automation={null} automation={null}
{cloneFrom} {cloneFrom}
on:save={(e) => { on:save={async (e) => {
getAutomationList(); await getAutomationList();
if (e.detail?.id) { if (e.detail?.id) {
goto(`/automations/${e.detail.id}`); goto(`/automations/${e.detail.id}`);
} }
}} }}
/> />
<div <div class="h-full overflow-y-auto px-2.5 pb-1">
class="flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar {#if loaded}
? 'md:max-w-[calc(100%-var(--sidebar-width))]' <div class="space-y-1">
: ''} max-w-full" <div class="flex h-8 flex-1 items-center w-full gap-2">
> <div class="flex min-w-0 flex-1 items-center">
<div class="flex-1 max-h-full overflow-y-auto"> <div class="self-center ml-1 mr-3">
{#if loaded} <Search className="size-3.5" />
<div class="pb-1 px-2.5 pt-2"> </div>
<div class="flex items-center gap-0.5 md:gap-1 mb-1"> <input
{#if $mobile} class="w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center"> bind:value={query}
<Tooltip on:input={handleSearchInput}
content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')} aria-label={$i18n.t('Search Automations')}
interactive={true} placeholder={$i18n.t('Search Automations')}
maxlength="500"
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full transition"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
handleSearchInput();
}}
> >
<button <XMark className="size-3" strokeWidth="2" />
id="sidebar-toggle-button" </button>
class="cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition"
on:click={() => {
showSidebar.set(!$showSidebar);
}}
>
<div class="self-center p-1.5">
<SidebarIcon className="size-4" />
</div>
</button>
</Tooltip>
</div> </div>
{/if} {/if}
</div>
<div class="flex w-full items-center"> <div
<div class="flex items-center gap-1 py-1 min-w-0"> class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none"
<span class="min-w-fit px-1 text-sm select-none">{$i18n.t('Automations')}</span> on:wheel={(e) => {
<span class="text-sm text-gray-500 dark:text-gray-500"> if (e.deltaY !== 0) {
{total === null ? '' : formatNumber(total)} e.preventDefault();
</span> e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<Select
bind:value={statusFilter}
align="end"
items={[
{ value: 'all', label: $i18n.t('All') },
{ value: 'active', label: $i18n.t('Active') },
{ value: 'paused', label: $i18n.t('Paused') }
]}
onChange={() => {
page = 1;
}}
triggerClass="relative h-8 w-full flex items-center gap-0.5 px-1.5 py-1.5 bg-transparent rounded-xl text-[13px] font-normal text-gray-700 transition dark:text-gray-200"
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span
class="inline-flex h-input w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
</svelte:fragment>
<svelte:fragment slot="item" let:item let:selected>
{item.label}
<div class="ml-auto {selected ? '' : 'invisible'}">
<Check />
</div>
</svelte:fragment>
</Select>
<Dropdown align="end">
<Tooltip content={$i18n.t('Actions')}>
<button
class="flex h-8 items-center gap-1.5 rounded-xl bg-transparent px-1.5 text-[13px] font-normal text-gray-700 transition dark:text-gray-200"
type="button"
>
<span>{$i18n.t('Actions')}</span>
<ChevronDown className="size-3" strokeWidth="2.5" />
</button>
</Tooltip>
<div slot="content">
<DropdownMenu className="w-[170px] shadow-sm">
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px]"
type="button"
on:click={() => bulkToggleHandler(true)}
>
<CheckCircle className="size-3.5" />
{$i18n.t('Enable All')}
</button>
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px]"
type="button"
on:click={() => bulkToggleHandler(false)}
>
<Minus className="size-3.5" />
{$i18n.t('Disable All')}
</button>
</DropdownMenu>
</div>
</Dropdown>
</div>
</div>
</div>
{#if automations === null || loading}
<div class="flex min-h-[calc(100dvh-13rem)] w-full items-center justify-center">
<Spinner className="size-5" />
</div>
{:else if (automations ?? []).length === 0}
<div class="flex min-h-[calc(100dvh-13rem)] w-full flex-col items-center justify-center">
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">
{query ? $i18n.t('No results found') : $i18n.t('No automations found')}
</div> </div>
<div class="text-center text-xs leading-5 text-gray-500">
<div class="ml-auto flex items-center gap-1"> {query
<SplitCreateButton ? $i18n.t('Try adjusting your search or filter to find what you are looking for.')
actions={[ : $i18n.t('Create scheduled prompts that run automatically on a recurring basis.')}
{
id: 'automations-new',
label: $i18n.t('Create'),
onClick: () => {
cloneFrom = null;
showCreateModal = true;
}
},
{
id: 'automations-import',
label: $i18n.t('Import JSON'),
onClick: () => automationsImportInputElement?.click()
},
{
id: 'automations-export',
label: $i18n.t('Export JSON'),
onClick: exportAutomations
}
]}
/>
</div> </div>
</div> </div>
</div> </div>
{:else}
<div class="space-y-1"> <div class="gap-y-0.5 grid my-1">
<div class="flex h-8 flex-1 items-center w-full gap-2"> {#each automations as automation (automation.id)}
<div class="flex min-w-0 flex-1 items-center">
<div class="self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class="w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search Automations')}
placeholder={$i18n.t('Search Automations')}
maxlength="500"
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
handleSearchInput();
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
<div <div
class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none" role="button"
on:wheel={(e) => { tabindex="0"
if (e.deltaY !== 0) { aria-label={$i18n.t('Open automation')}
class="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1 text-left transition"
on:click={() => {
goto(`/automations/${automation.id}`);
}}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY; goto(`/automations/${automation.id}`);
} }
}} }}
> >
<div <div class="min-w-0 flex-1">
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap" <Tooltip content={automation.name} placement="top-start">
> <div
<Select class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
bind:value={statusFilter} >
align="end" {automation.name}
items={[
{ value: 'all', label: $i18n.t('All') },
{ value: 'active', label: $i18n.t('Active') },
{ value: 'paused', label: $i18n.t('Paused') }
]}
onChange={() => {
page = 1;
}}
triggerClass="relative h-8 w-full flex items-center gap-0.5 px-1.5 py-1.5 bg-transparent rounded-xl text-[13px] font-normal text-gray-700 transition hover:text-gray-900 dark:text-gray-200 dark:hover:text-gray-100"
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span
class="inline-flex h-input w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
</svelte:fragment>
<svelte:fragment slot="item" let:item let:selected>
{item.label}
<div class="ml-auto {selected ? '' : 'invisible'}">
<Check />
</div>
</svelte:fragment>
</Select>
<Dropdown align="end">
<Tooltip content={$i18n.t('Actions')}>
<button
class="flex h-8 items-center gap-1.5 rounded-xl bg-transparent px-1.5 text-[13px] font-normal text-gray-700 transition hover:text-gray-900 dark:text-gray-200 dark:hover:text-gray-100"
type="button"
>
<span>{$i18n.t('Actions')}</span>
<ChevronDown className="size-3" strokeWidth="2.5" />
</button>
</Tooltip>
<div slot="content">
<DropdownMenu className="w-[170px] shadow-sm">
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 dark:hover:text-gray-100"
type="button"
on:click={() => bulkToggleHandler(true)}
>
<CheckCircle className="size-3.5" />
{$i18n.t('Enable All')}
</button>
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 dark:hover:text-gray-100"
type="button"
on:click={() => bulkToggleHandler(false)}
>
<Minus className="size-3.5" />
{$i18n.t('Disable All')}
</button>
</DropdownMenu>
</div> </div>
</Dropdown> </Tooltip>
<div class="truncate text-[11px] leading-4 text-gray-500 dark:text-gray-500">
{formatRRule(automation.data.rrule)}
</div>
</div> </div>
</div>
</div>
{#if automations === null || loading} <div class="flex shrink-0 flex-row items-center gap-1.5 self-center">
<div class="flex min-h-[calc(100dvh-13rem)] w-full items-center justify-center"> <AutomationMenu
<Spinner className="size-5" /> show={openAutomationMenuId === automation.id}
</div> editHandler={() => {
{:else if (automations ?? []).length === 0}
<div
class="flex min-h-[calc(100dvh-13rem)] w-full flex-col items-center justify-center"
>
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">
{query ? $i18n.t('No results found') : $i18n.t('No automations found')}
</div>
<div class="text-center text-xs leading-5 text-gray-500">
{query
? $i18n.t(
'Try adjusting your search or filter to find what you are looking for.'
)
: $i18n.t(
'Create scheduled prompts that run automatically on a recurring basis.'
)}
</div>
</div>
</div>
{:else}
<div class="gap-y-0.5 grid my-1">
{#each automations as automation (automation.id)}
<div
role="button"
tabindex="0"
aria-label={$i18n.t('Open automation')}
class="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1 text-left transition hover:bg-gray-50 focus:bg-gray-50 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
on:click={() => {
goto(`/automations/${automation.id}`); goto(`/automations/${automation.id}`);
}} }}
on:keydown={(e) => { cloneHandler={() => {
if (e.key === 'Enter' || e.key === ' ') { cloneHandler(automation);
e.preventDefault(); }}
goto(`/automations/${automation.id}`); runHandler={() => {
} runNowHandler(automation);
}}
deleteHandler={() => {
deleteTarget = automation;
showDeleteConfirm = true;
}}
onClose={() => {
openAutomationMenuId = null;
}} }}
> >
<div class="min-w-0 flex-1"> <button
<Tooltip content={automation.name} placement="top-start"> class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
<div type="button"
class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200" aria-label={$i18n.t('Automation Menu')}
> on:click={(e) => {
{automation.name} e.preventDefault();
</div> e.stopPropagation();
</Tooltip> openAutomationMenuId =
<div class="truncate text-[11px] leading-4 text-gray-500 dark:text-gray-500"> openAutomationMenuId === automation.id ? null : automation.id;
{formatRRule(automation.data.rrule)} }}
</div> >
</div> <EllipsisHorizontal className="size-4" />
</button>
</AutomationMenu>
<div class="flex shrink-0 flex-row items-center gap-1.5 self-center"> <button
<AutomationMenu class="flex h-6 items-center"
show={openAutomationMenuId === automation.id} type="button"
editHandler={() => { on:click={(e) => {
goto(`/automations/${automation.id}`); e.stopPropagation();
e.preventDefault();
}}
>
<Tooltip
content={automation.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={automation.is_active}
on:change={() => {
toggleHandler(automation);
}} }}
cloneHandler={() => { />
cloneHandler(automation); </Tooltip>
}} </button>
runHandler={() => {
runNowHandler(automation);
}}
deleteHandler={() => {
deleteTarget = automation;
showDeleteConfirm = true;
}}
onClose={() => {
openAutomationMenuId = null;
}}
>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
type="button"
aria-label={$i18n.t('Automation Menu')}
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
openAutomationMenuId =
openAutomationMenuId === automation.id ? null : automation.id;
}}
>
<EllipsisHorizontal className="size-4" />
</button>
</AutomationMenu>
<button
class="flex h-6 items-center"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Tooltip
content={automation.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={automation.is_active}
on:change={() => {
toggleHandler(automation);
}}
/>
</Tooltip>
</button>
</div>
</div>
{/each}
</div>
{#if total > 30}
<div class="flex justify-center mt-4 mb-2">
<Pagination bind:page count={total} perPage={30} />
</div> </div>
{/if} </div>
{/if} {/each}
</div> </div>
</div>
{:else} {#if (total ?? 0) > 30}
<div class="w-full h-full flex justify-center items-center"> <div class="flex justify-center mt-4 mb-2">
<Spinner className="size-5" /> <Pagination bind:page count={total ?? 0} perPage={30} />
</div> </div>
{/if} {/if}
</div> {/if}
</div>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}
</div> </div>
+6 -12
View File
@@ -1,25 +1,23 @@
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount, getContext } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { user, showSidebar, config } from '$lib/stores'; import { user, config } from '$lib/stores';
import { getAutomationById } from '$lib/apis/automations'; import { getAutomationById, type AutomationResponse } from '$lib/apis/automations';
import AutomationEditor from '$lib/components/automations/AutomationEditor.svelte'; import AutomationEditor from '$lib/components/automations/AutomationEditor.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
const i18n = getContext('i18n'); let automation: AutomationResponse | null = null;
let automation = null;
let loaded = false; let loaded = false;
$: automationId = $page.params.id; $: automationId = $page.params.id;
onMount(async () => { onMount(async () => {
if ( if (
!$config?.features?.enable_automations || !($config?.features as any)?.enable_automations ||
($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false)) ($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false))
) { ) {
goto('/'); goto('/');
@@ -47,11 +45,7 @@
{#if loaded && automation} {#if loaded && automation}
<AutomationEditor {automation} /> <AutomationEditor {automation} />
{:else} {:else}
<div <div class="flex h-full w-full items-center justify-center">
class="w-full h-screen max-h-[100dvh] flex justify-center items-center transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''}"
>
<Spinner className="size-5" /> <Spinner className="size-5" />
</div> </div>
{/if} {/if}