Explorar el Código

feat: filter, search and sort the executions list

The page listed the most recent executions and nothing else, against a
collection holding six thousand of them - so the only way to find a run was
to recognise it going past.

Filtering happens in the database, not the browser. Filtering a twenty-row
page client-side would have been decorative. GET /executions now takes a
comma separated status and triggerType, startedAfter and startedBefore, a
search term, and sortBy/sortOrder. The page has a search box, workflow and
trigger pickers, time range, sort, and status chips, all of it in the URL
query string so a filtered view can be linked and survives a refresh.

Searching needed care. The storage layer's "contains" does not substring
match a string field - it tests array membership, and returns nothing at all
without saying so - and "search" is fuzzy enough that "reddit" matched seven
unrelated workflows. Only "regex" behaves, and it is case sensitive with no
inline flag, so the term is compiled server-side into a literal
case-insensitive pattern: every letter becomes a two-case class and
everything else is escaped, which also stops a term like "c++" being read as
a pattern. Verified: three casings of "reddit" all return 101, and ".*"
matches nothing rather than everything.

Duration sorting is the exception and says so. Duration is not stored, so
nothing can order or page by it - the options read "this page" and a line
under the table says how many rows were ordered out of how many results.
Dropping it would have been tidier and less useful.

Two bugs fixed on the way: page and pageSize went through std::stoi, so
"?page=abc" threw out of the handler as an unexplained 500; and pageSize was
unbounded, so one request could pull the whole collection. Now clamped to
1-200.

Verified against live data: status=failed 2165 plus status=cancelled 9 equals
status=failed,cancelled 2174; filters compose; the URL round-trips on a cold
load; and the three empty states are distinguishable - filtered to nothing,
past the end of the results, and nothing has run at all. 63 passed.
fszontagh hace 1 mes
padre
commit
3bc189554b

+ 135 - 8
src/webserver/api/execution_controller.cpp

@@ -3,9 +3,101 @@
 #include "logging/logger.hpp"
 #include "proto/runner.grpc.pb.h"
 #include <grpcpp/grpcpp.h>
+#include <algorithm>
+#include <cctype>
 
 namespace smartbotic::webserver::api {
 
+namespace {
+
+// std::stoi throws on anything that is not a number, and an uncaught throw out
+// of a route handler turns "?page=abc" into a 500 with no explanation. A bad
+// value falls back to the default instead.
+int intParam(const httplib::Request& req, const char* name, int fallback) {
+    if (!req.has_param(name)) {
+        return fallback;
+    }
+    try {
+        return std::stoi(req.get_param_value(name));
+    } catch (const std::exception&) {
+        return fallback;
+    }
+}
+
+int64_t int64Param(const httplib::Request& req, const char* name, int64_t fallback) {
+    if (!req.has_param(name)) {
+        return fallback;
+    }
+    try {
+        return std::stoll(req.get_param_value(name));
+    } catch (const std::exception&) {
+        return fallback;
+    }
+}
+
+std::vector<std::string> splitCsv(const std::string& value) {
+    std::vector<std::string> parts;
+    std::string current;
+    for (char c : value) {
+        if (c == ',') {
+            if (!current.empty()) parts.push_back(current);
+            current.clear();
+        } else {
+            current += c;
+        }
+    }
+    if (!current.empty()) parts.push_back(current);
+    return parts;
+}
+
+// One value is an equality filter; several are an IN. Sending IN with a single
+// element would work too, but eq is the shape the database indexes best and the
+// shape every other caller already sends.
+void addSetFilter(storage::QueryOptions& options, const std::string& field,
+                  const std::string& csv) {
+    auto values = splitCsv(csv);
+    if (values.empty()) {
+        return;
+    }
+    if (values.size() == 1) {
+        options.filters.push_back({field, values.front()});
+        return;
+    }
+    options.filters.push_back({field, nlohmann::json{{"op", "in"}, {"value", values}}});
+}
+
+// Turn a user's search term into a regex that matches it literally, anywhere in
+// the field, in either case.
+//
+// The database's "contains" op does not match substrings of a string field (it
+// tests array membership) and its "search" op is fuzzy - searching "reddit"
+// with it returned seven unrelated workflows. "regex" is the only op that does
+// what a search box is expected to do, but it is case sensitive and it would
+// otherwise let a term like "c++" through as a pattern. Every letter becomes a
+// two-case character class and everything else is escaped, so the term can only
+// ever match itself.
+std::string literalCaseInsensitiveRegex(const std::string& term) {
+    static const std::string kMeta = R"(.^$|()[]{}*+?\)";
+    std::string pattern;
+    pattern.reserve(term.size() * 4);
+    for (unsigned char c : term) {
+        if (std::isalpha(c)) {
+            pattern += '[';
+            pattern += static_cast<char>(std::tolower(c));
+            pattern += static_cast<char>(std::toupper(c));
+            pattern += ']';
+        } else {
+            if (kMeta.find(static_cast<char>(c)) != std::string::npos) {
+                pattern += '\\';
+            }
+            pattern += static_cast<char>(c);
+        }
+    }
+    return pattern;
+}
+
+} // namespace
+
 ExecutionController::ExecutionController(storage::StorageClient& storage,
                                          auth::AuthMiddleware& middleware,
                                          WebSocketServer& ws_server,
@@ -69,21 +161,56 @@ void ExecutionController::listExecutions(const httplib::Request& req, httplib::R
         options.filters.push_back({"workflowId", req.get_param_value("workflowId")});
     }
 
+    // status and triggerType accept a comma separated list, because the useful
+    // question on this listing is "show me failed and cancelled", not one at a
+    // time.
     if (req.has_param("status")) {
-        options.filters.push_back({"status", req.get_param_value("status")});
+        addSetFilter(options, "status", req.get_param_value("status"));
+    }
+
+    if (req.has_param("triggerType")) {
+        addSetFilter(options, "triggerType", req.get_param_value("triggerType"));
     }
 
-    int page = 1;
-    int page_size = 20;
-    if (req.has_param("page")) {
-        page = std::stoi(req.get_param_value("page"));
+    // Milliseconds, matching startedAt as the engine writes it. Zero means "not
+    // set" here - a run started at epoch 0 does not exist, and treating 0 as a
+    // real bound would silently drop everything for startedBefore.
+    const int64_t started_after = int64Param(req, "startedAfter", 0);
+    if (started_after > 0) {
+        options.filters.push_back({"startedAt", nlohmann::json{{"op", "gte"}, {"value", started_after}}});
     }
-    if (req.has_param("pageSize")) {
-        page_size = std::stoi(req.get_param_value("pageSize"));
+    const int64_t started_before = int64Param(req, "startedBefore", 0);
+    if (started_before > 0) {
+        options.filters.push_back({"startedAt", nlohmann::json{{"op", "lte"}, {"value", started_before}}});
     }
+
+    // Filters are ANDed by the database, so one box cannot search two fields at
+    // once. An execution id is recognisable by its prefix, so the term is routed
+    // to whichever of the two fields it can plausibly match.
+    if (req.has_param("search")) {
+        const std::string term = req.get_param_value("search");
+        if (!term.empty()) {
+            const char* field = term.starts_with("exec_") ? "_id" : "workflowName";
+            options.filters.push_back(
+                {field, nlohmann::json{{"op", "regex"}, {"value", literalCaseInsensitiveRegex(term)}}});
+        }
+    }
+
+    int page = std::max(1, intParam(req, "page", 1));
+    // Capped because page_size reaches the database as a limit; an unbounded one
+    // would let a single request pull the whole collection.
+    int page_size = std::clamp(intParam(req, "pageSize", 20), 1, 200);
     options.page = page;
     options.page_size = page_size;
-    options.sorts.push_back({"startedAt", false});
+
+    // Only fields the database can order on. Duration is not stored, so it
+    // cannot be sorted here - the client sorts the current page by it instead.
+    std::string sort_by = req.has_param("sortBy") ? req.get_param_value("sortBy") : "startedAt";
+    if (sort_by != "startedAt" && sort_by != "finishedAt") {
+        sort_by = "startedAt";
+    }
+    const bool ascending = req.has_param("sortOrder") && req.get_param_value("sortOrder") == "asc";
+    options.sorts.push_back({sort_by, ascending});
 
     // The view projects server-side, so no field list is needed here and the
     // database never sends nodeExecutions or workflowSnapshot. A missing view is

+ 58 - 0
webui/src/api/workflows.ts

@@ -396,6 +396,31 @@ function transformExecutionDetail(data: any): ExecutionDetail {
   }
 }
 
+export interface ExecutionQuery {
+  workflowId?: string
+  /** One or more statuses. Sent as a comma separated list; the backend turns several into an IN filter. */
+  status?: string[]
+  /** One or more trigger types, same encoding as status. */
+  triggerType?: string[]
+  /** Matched against the workflow name, or against the execution id when the term starts with "exec_". */
+  search?: string
+  /** Milliseconds. Bounds on startedAt, both inclusive. */
+  startedAfter?: number
+  startedBefore?: number
+  sortBy?: 'startedAt' | 'finishedAt'
+  sortOrder?: 'asc' | 'desc'
+  page?: number
+  pageSize?: number
+}
+
+export interface ExecutionListResult {
+  executions: ExecutionListItem[]
+  total: number
+  page: number
+  pageSize: number
+  hasMore: boolean
+}
+
 export const executionsApi = {
   list: async (workflowId?: string, status?: string, page = 1, pageSize = 20): Promise<{ executions: ExecutionListItem[]; total: number; page: number; pageSize: number; hasMore: boolean }> => {
     const response = await api.get('/executions', {
@@ -410,6 +435,39 @@ export const executionsApi = {
     }
   },
 
+  /**
+   * The filtering form of list(). Every filter here is applied by the database,
+   * so the counts and the pages are over the whole collection rather than over
+   * whatever one page happened to contain.
+   *
+   * Empty arrays and empty strings are omitted rather than sent: an absent
+   * filter means "no restriction", and sending "" would ask for records whose
+   * field is literally empty.
+   */
+  query: async (query: ExecutionQuery = {}): Promise<ExecutionListResult> => {
+    const params: Record<string, any> = {
+      page: query.page ?? 1,
+      pageSize: query.pageSize ?? 20,
+    }
+    if (query.workflowId) params.workflowId = query.workflowId
+    if (query.status?.length) params.status = query.status.join(',')
+    if (query.triggerType?.length) params.triggerType = query.triggerType.join(',')
+    if (query.search?.trim()) params.search = query.search.trim()
+    if (query.startedAfter) params.startedAfter = query.startedAfter
+    if (query.startedBefore) params.startedBefore = query.startedBefore
+    if (query.sortBy) params.sortBy = query.sortBy
+    if (query.sortOrder) params.sortOrder = query.sortOrder
+
+    const response = await api.get('/executions', { params })
+    return {
+      executions: (response.data.executions || []).map(transformExecutionListItem),
+      total: response.data.total || 0,
+      page: response.data.page || params.page,
+      pageSize: response.data.pageSize || params.pageSize,
+      hasMore: response.data.hasMore || false,
+    }
+  },
+
   get: async (id: string): Promise<ExecutionDetail> => {
     const response = await api.get(`/executions/${id}`)
     return transformExecutionDetail(response.data)

+ 359 - 27
webui/src/pages/ExecutionsPage.tsx

@@ -1,10 +1,11 @@
-import { useState, useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import { executionsApi } from '../api/workflows'
+import { useState, useEffect, useMemo } from 'react'
+import { useNavigate, useSearchParams } from 'react-router-dom'
+import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'
+import { executionsApi, workflowsApi, nodesApi, type ExecutionListItem } from '../api/workflows'
 import { useExecutionListUpdates } from '../hooks/useExecutionListUpdates'
+import { toMillis } from '../utils/timestamps'
 import { formatDistanceToNow } from 'date-fns'
-import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, AlertCircle, Octagon, Square, Hourglass } from 'lucide-react'
+import { CheckCircle, XCircle, Clock, Ban, RefreshCw, ChevronLeft, ChevronRight, AlertCircle, Octagon, Square, Hourglass, Search, X } from 'lucide-react'
 import clsx from 'clsx'
 
 // Helper to safely format timestamps that might be invalid or too large
@@ -22,9 +23,9 @@ function safeFormatDistanceToNow(timestamp: number): string {
 // An execution that has not finished has finishedAt 0, so its duration has to
 // be measured against now. "Running..." told you nothing about whether a run had
 // been going for two seconds or two hours.
-function formatElapsed(startedAt: number, finishedAt: number, now: number): string {
+function formatElapsed(startedAt: number | null, finishedAt: number | null, now: number): string {
   if (!startedAt) return '-'
-  const end = finishedAt && finishedAt > 0 ? finishedAt : now
+  const end = finishedAt ?? now
   const seconds = (end - startedAt) / 1000
   if (seconds < 0) return '-'
   if (seconds < 60) return `${seconds.toFixed(2)}s`
@@ -40,10 +41,114 @@ const IN_FLIGHT = ['running', 'pending', 'waiting', 'resuming']
 
 const PAGE_SIZE = 20
 
+// Offered as filters. "pending" and "resuming" are deliberately absent: they are
+// momentary states nobody can catch on purpose, and they are covered by the
+// in-flight handling elsewhere on the row.
+const STATUS_FILTERS = ['running', 'waiting', 'completed', 'failed', 'cancelled'] as const
+
+type RangeKey = 'any' | '15m' | '1h' | '24h' | '7d' | '30d'
+
+const RANGES: { key: RangeKey; label: string; ms: number }[] = [
+  { key: 'any', label: 'Any time', ms: 0 },
+  { key: '15m', label: 'Last 15 minutes', ms: 15 * 60 * 1000 },
+  { key: '1h', label: 'Last hour', ms: 60 * 60 * 1000 },
+  { key: '24h', label: 'Last 24 hours', ms: 24 * 60 * 60 * 1000 },
+  { key: '7d', label: 'Last 7 days', ms: 7 * 24 * 60 * 60 * 1000 },
+  { key: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 },
+]
+
+// The first two are ordered by the database over the whole result set. The
+// duration ones cannot be: duration is not a stored field, so nothing can sort
+// or page by it server-side, and they reorder the page in the browser instead.
+// The labels say so rather than letting the numbers imply a global ranking.
+type SortKey = 'newest' | 'oldest' | 'longest' | 'shortest'
+
+const SORTS: { key: SortKey; label: string }[] = [
+  { key: 'newest', label: 'Newest first' },
+  { key: 'oldest', label: 'Oldest first' },
+  { key: 'longest', label: 'Longest duration (this page)' },
+  { key: 'shortest', label: 'Shortest duration (this page)' },
+]
+
+// Trigger types the engine writes itself, so they never appear among the node
+// definitions. Without these the trigger filter would be missing the most
+// common value of all, "manual".
+const ENGINE_TRIGGER_TYPES: { value: string; label: string }[] = [
+  { value: 'manual', label: 'Manual' },
+  { value: 'webhook', label: 'Webhook' },
+  { value: 'schedule', label: 'Schedule' },
+  { value: 'workflow', label: 'Called by another workflow' },
+  { value: 'error-workflow', label: 'Error handler' },
+]
+
+function durationOf(execution: ExecutionListItem, now: number): number {
+  const started = toMillis(execution.startedAt)
+  if (started === null) return -1
+  return (toMillis(execution.finishedAt) ?? now) - started
+}
+
 export default function ExecutionsPage() {
   const navigate = useNavigate()
   const queryClient = useQueryClient()
-  const [page, setPage] = useState(1)
+  const [searchParams, setSearchParams] = useSearchParams()
+
+  // The URL is the single source of truth for the filters, so a filtered view
+  // can be linked to and survives a reload.
+  const search = searchParams.get('q') || ''
+  const statuses = (searchParams.get('status') || '').split(',').filter(Boolean)
+  const workflowId = searchParams.get('workflow') || ''
+  const triggerType = searchParams.get('trigger') || ''
+  const range = (searchParams.get('range') || 'any') as RangeKey
+  const sort = (searchParams.get('sort') || 'newest') as SortKey
+  const page = Math.max(1, Number(searchParams.get('page')) || 1)
+
+  const hasFilters =
+    search !== '' || statuses.length > 0 || workflowId !== '' || triggerType !== '' || range !== 'any'
+
+  // Built from the previous params rather than from the ones this render closed
+  // over: the search box writes on a timer, and a filter changed while that
+  // timer was pending would otherwise be undone when it fired.
+  const updateParams = (changes: Record<string, string | null>, keepPage = false) => {
+    setSearchParams(previous => {
+      const next = new URLSearchParams(previous)
+      for (const [key, value] of Object.entries(changes)) {
+        if (value === null || value === '') {
+          next.delete(key)
+        } else {
+          next.set(key, value)
+        }
+      }
+      // Any change to what is being asked for invalidates the page number: page
+      // 7 of the old result set says nothing about the new one.
+      if (!keepPage) {
+        next.delete('page')
+      }
+      return next
+    }, { replace: true })
+  }
+
+  // The search box is typed into, so it holds its own value and pushes it to the
+  // URL once typing stops. Writing every keystroke to the URL would fire a query
+  // per character.
+  const [searchInput, setSearchInput] = useState(search)
+  useEffect(() => {
+    setSearchInput(search)
+  }, [search])
+  useEffect(() => {
+    if (searchInput === search) return
+    const timer = setTimeout(() => updateParams({ q: searchInput }), 300)
+    return () => clearTimeout(timer)
+  }, [searchInput])
+
+  // The range presets are relative to now, but the boundary is fixed when the
+  // range is chosen and not recomputed per render or per page. A sliding
+  // boundary would move the result set underneath the pagination, so paging
+  // through "Last hour" would skip and repeat rows.
+  const startedAfter = useMemo(() => {
+    const found = RANGES.find(r => r.key === range)
+    if (!found || found.ms === 0) return undefined
+    return Date.now() - found.ms
+  }, [range])
 
   // The workflow editor already renders an execution across the canvas with each
   // node's input and output, so open it there rather than building a second
@@ -55,11 +160,73 @@ export default function ExecutionsPage() {
     navigate(`/workflows/${execution.workflowId}?execution=${encodeURIComponent(execution.id)}`)
   }
 
-  const { data, isLoading, refetch } = useQuery({
-    queryKey: ['executions', page],
-    queryFn: () => executionsApi.list(undefined, undefined, page, PAGE_SIZE),
+  const { data, isLoading, isFetching, refetch } = useQuery({
+    // sort belongs in the key because "oldest first" is a different request, not
+    // a different rendering of the same one.
+    queryKey: ['executions', { search, statuses, workflowId, triggerType, range, sort, page }],
+    queryFn: () =>
+      executionsApi.query({
+        search,
+        status: statuses,
+        triggerType: triggerType ? [triggerType] : undefined,
+        workflowId: workflowId || undefined,
+        startedAfter,
+        sortOrder: sort === 'oldest' ? 'asc' : 'desc',
+        page,
+        pageSize: PAGE_SIZE,
+      }),
+    // Without this the table empties on every filter change and the layout
+    // jumps; the old rows stay until the new ones arrive.
+    placeholderData: keepPreviousData,
+  })
+
+  // Names for the workflow filter. A large pageSize because this is a picker,
+  // not a listing, and paging it would hide workflows from the filter.
+  const { data: workflowsData } = useQuery({
+    queryKey: ['workflows', 'all-for-filter'],
+    queryFn: () => workflowsApi.list(1, 200),
+    staleTime: 5 * 60 * 1000,
   })
 
+  // Trigger node definitions, to name the trigger types with something a person
+  // recognises rather than the node id stored on the execution.
+  const { data: nodesData } = useQuery({
+    queryKey: ['node-definitions'],
+    queryFn: () => nodesApi.list(),
+    staleTime: 5 * 60 * 1000,
+  })
+
+  const triggerOptions = useMemo(() => {
+    const options = new Map<string, string>()
+    for (const engine of ENGINE_TRIGGER_TYPES) {
+      options.set(engine.value, engine.label)
+    }
+    for (const definition of nodesData?.nodes || []) {
+      if (definition.isTrigger) {
+        options.set(definition.id, definition.name || definition.id)
+      }
+    }
+    // A run recorded under a trigger type this build no longer knows about would
+    // otherwise silently vanish from the dropdown while still selected in the URL.
+    if (triggerType && !options.has(triggerType)) {
+      options.set(triggerType, triggerType)
+    }
+    return [...options.entries()].sort((a, b) => a[1].localeCompare(b[1]))
+  }, [nodesData, triggerType])
+
+  const workflowOptions = useMemo(() => {
+    const options = new Map<string, string>()
+    for (const workflow of workflowsData?.workflows || []) {
+      options.set(workflow.id, workflow.name || workflow.id)
+    }
+    // Executions outlive the workflows that produced them, so a filtered link
+    // can name a workflow that has since been deleted. Keep it selectable.
+    if (workflowId && !options.has(workflowId)) {
+      options.set(workflowId, workflowId)
+    }
+    return [...options.entries()].sort((a, b) => a[1].localeCompare(b[1]))
+  }, [workflowsData, workflowId])
+
   // Subscribe to WebSocket for real-time updates instead of polling
   useExecutionListUpdates()
 
@@ -67,7 +234,7 @@ export default function ExecutionsPage() {
   // guard this would re-render the page once a second for ever on a list of
   // finished runs.
   const [now, setNow] = useState(() => Date.now())
-  const hasInFlight = (data?.executions || []).some((e: any) => IN_FLIGHT.includes(e.status))
+  const hasInFlight = (data?.executions || []).some(e => IN_FLIGHT.includes(e.status))
   useEffect(() => {
     if (!hasInFlight) return
     const timer = setInterval(() => setNow(Date.now()), 1000)
@@ -81,11 +248,30 @@ export default function ExecutionsPage() {
     },
   })
 
-  const executions = data?.executions || []
   const total = data?.total || 0
   const totalPages = Math.ceil(total / PAGE_SIZE)
   const hasMore = data?.hasMore || false
 
+  const executions = useMemo(() => {
+    const rows = data?.executions || []
+    if (sort !== 'longest' && sort !== 'shortest') {
+      return rows
+    }
+    const direction = sort === 'longest' ? -1 : 1
+    return [...rows].sort((a, b) => (durationOf(a, now) - durationOf(b, now)) * direction)
+  }, [data, sort, now])
+
+  const toggleStatus = (status: string) => {
+    const next = statuses.includes(status)
+      ? statuses.filter(s => s !== status)
+      : [...statuses, status]
+    updateParams({ status: next.join(',') })
+  }
+
+  const clearFilters = () => {
+    setSearchParams(new URLSearchParams(), { replace: true })
+  }
+
   const getStatusIcon = (status: string) => {
     switch (status) {
       case 'completed':
@@ -123,26 +309,156 @@ export default function ExecutionsPage() {
     }
   }
 
+  const selectClass =
+    'px-3 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg text-gray-700 dark:text-gray-200'
+
   return (
     <div className="h-full overflow-auto p-6">
-      <div className="flex items-center justify-between mb-6">
+      <div className="flex items-center justify-between mb-4">
         <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Executions</h1>
         <button
           onClick={() => refetch()}
           className="flex items-center gap-2 px-3 py-1.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"
         >
-          <RefreshCw className="w-4 h-4" />
+          <RefreshCw className={clsx('w-4 h-4', isFetching && 'animate-spin')} />
           Refresh
         </button>
       </div>
 
+      <div className="mb-4 space-y-3">
+        <div className="flex flex-wrap items-center gap-2">
+          <div className="relative">
+            <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
+            <input
+              value={searchInput}
+              onChange={e => setSearchInput(e.target.value)}
+              placeholder="Search workflow name, or paste an execution id"
+              className="w-80 pl-9 pr-8 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg text-gray-700 dark:text-gray-200 placeholder-gray-400"
+            />
+            {searchInput && (
+              <button
+                onClick={() => setSearchInput('')}
+                title="Clear the search"
+                className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
+              >
+                <X className="w-4 h-4" />
+              </button>
+            )}
+          </div>
+
+          <select
+            value={workflowId}
+            onChange={e => updateParams({ workflow: e.target.value })}
+            className={selectClass}
+          >
+            <option value="">All workflows</option>
+            {workflowOptions.map(([id, name]) => (
+              <option key={id} value={id}>{name}</option>
+            ))}
+          </select>
+
+          <select
+            value={triggerType}
+            onChange={e => updateParams({ trigger: e.target.value })}
+            className={selectClass}
+          >
+            <option value="">All triggers</option>
+            {triggerOptions.map(([value, label]) => (
+              <option key={value} value={value}>{label}</option>
+            ))}
+          </select>
+
+          <select
+            value={range}
+            onChange={e => updateParams({ range: e.target.value === 'any' ? null : e.target.value })}
+            className={selectClass}
+          >
+            {RANGES.map(r => (
+              <option key={r.key} value={r.key}>{r.label}</option>
+            ))}
+          </select>
+
+          <select
+            value={sort}
+            onChange={e => updateParams({ sort: e.target.value === 'newest' ? null : e.target.value })}
+            className={selectClass}
+          >
+            {SORTS.map(s => (
+              <option key={s.key} value={s.key}>{s.label}</option>
+            ))}
+          </select>
+
+          {hasFilters && (
+            <button
+              onClick={clearFilters}
+              className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg"
+            >
+              <X className="w-4 h-4" />
+              Clear filters
+            </button>
+          )}
+        </div>
+
+        <div className="flex flex-wrap items-center gap-2">
+          {STATUS_FILTERS.map(status => (
+            <button
+              key={status}
+              onClick={() => toggleStatus(status)}
+              className={clsx(
+                'px-3 py-1 rounded-full text-xs font-medium border capitalize',
+                statuses.includes(status)
+                  ? 'border-primary-500 ring-1 ring-primary-500 ' + getStatusBadgeClass(status)
+                  : 'border-gray-200 dark:border-slate-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700'
+              )}
+            >
+              {status}
+            </button>
+          ))}
+          {statuses.length === 0 && (
+            <span className="text-xs text-gray-400 dark:text-gray-500">All statuses</span>
+          )}
+        </div>
+      </div>
+
       {isLoading ? (
         <div className="flex items-center justify-center py-12">
           <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
         </div>
       ) : executions.length === 0 ? (
         <div className="text-center py-12 bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700">
-          <p className="text-gray-500 dark:text-gray-400">No executions yet</p>
+          {/* Three different nothings, which need three different answers: an
+              empty page past the end of the results, a filter that excludes
+              everything, and a platform on which nothing has ever run. */}
+          {page > 1 ? (
+            <>
+              <p className="text-gray-500 dark:text-gray-400">
+                Nothing on page {page}. The results may have changed since this link was made.
+              </p>
+              <button
+                onClick={() => updateParams({ page: null }, false)}
+                className="mt-3 px-3 py-1.5 text-sm text-primary-600 dark:text-primary-400 hover:underline"
+              >
+                Back to the first page
+              </button>
+            </>
+          ) : hasFilters ? (
+            <>
+              <p className="text-gray-500 dark:text-gray-400">No executions match these filters.</p>
+              <button
+                onClick={clearFilters}
+                className="mt-3 px-3 py-1.5 text-sm text-primary-600 dark:text-primary-400 hover:underline"
+              >
+                Clear filters
+              </button>
+            </>
+          ) : (
+            <>
+              <p className="text-gray-500 dark:text-gray-400">No executions yet</p>
+              <p className="mt-1 text-sm text-gray-400 dark:text-gray-500">
+                A run appears here as soon as a workflow is started, by hand or by a trigger.
+              </p>
+            </>
+          )}
         </div>
       ) : (
         <>
@@ -174,7 +490,10 @@ export default function ExecutionsPage() {
                 </tr>
               </thead>
               <tbody className="divide-y divide-gray-200 dark:divide-slate-700">
-                {executions.map((execution: any) => (
+                {executions.map(execution => {
+                  const startedAt = toMillis(execution.startedAt)
+                  const finishedAt = toMillis(execution.finishedAt)
+                  return (
                   <tr
                     key={execution.id}
                     onClick={() => openExecution(execution)}
@@ -198,6 +517,9 @@ export default function ExecutionsPage() {
                       <span className="font-medium text-gray-900 dark:text-gray-100">
                         {execution.workflowName || execution.workflowId}
                       </span>
+                      <div className="text-xs text-gray-400 dark:text-gray-500 font-mono">
+                        {execution.id}
+                      </div>
                       {execution.error && (
                         <div
                           className="mt-1 flex items-start gap-1.5 text-red-600 dark:text-red-400 text-xs max-w-xl"
@@ -223,19 +545,20 @@ export default function ExecutionsPage() {
                       )}
                     </td>
                     <td className="px-4 py-3 align-top text-gray-600 dark:text-gray-400">
-                      {execution.triggerType}
+                      {execution.triggerType || '-'}
                     </td>
                     <td className="px-4 py-3 align-top text-gray-600 dark:text-gray-400 text-sm">
                       {execution.runnerId || '-'}
                     </td>
-                    <td className="px-4 py-3 align-top text-gray-500 dark:text-gray-400 text-sm">
-                      {execution.startedAt
-                        ? safeFormatDistanceToNow(execution.startedAt)
-                        : '-'}
+                    <td
+                      className="px-4 py-3 align-top text-gray-500 dark:text-gray-400 text-sm"
+                      title={startedAt ? new Date(startedAt).toLocaleString() : undefined}
+                    >
+                      {startedAt ? safeFormatDistanceToNow(startedAt) : '-'}
                     </td>
                     <td className="px-4 py-3 align-top text-gray-500 dark:text-gray-400 text-sm">
                       <span className={clsx(IN_FLIGHT.includes(execution.status) && 'tabular-nums')}>
-                        {formatElapsed(execution.startedAt, execution.finishedAt, now)}
+                        {formatElapsed(startedAt, finishedAt, now)}
                       </span>
                     </td>
                     <td className="px-4 py-3 align-top text-right">
@@ -256,19 +579,28 @@ export default function ExecutionsPage() {
                       )}
                     </td>
                   </tr>
-                ))}
+                  )
+                })}
               </tbody>
             </table>
           </div>
 
+          {(sort === 'longest' || sort === 'shortest') && (
+            <p className="mt-2 px-2 text-xs text-gray-500 dark:text-gray-400">
+              Duration is not stored, so it can only be ordered within the {executions.length} rows
+              on this page - not across all {total} results.
+            </p>
+          )}
+
           {/* Pagination */}
           <div className="flex items-center justify-between mt-4 px-2">
             <div className="text-sm text-gray-600 dark:text-gray-400">
-              Showing {((page - 1) * PAGE_SIZE) + 1} - {Math.min(page * PAGE_SIZE, total)} of {total} executions
+              Showing {((page - 1) * PAGE_SIZE) + 1} - {Math.min(page * PAGE_SIZE, total)} of {total}
+              {hasFilters ? ' matching executions' : ' executions'}
             </div>
             <div className="flex items-center gap-2">
               <button
-                onClick={() => setPage(p => Math.max(1, p - 1))}
+                onClick={() => updateParams({ page: page > 2 ? String(page - 1) : null }, true)}
                 disabled={page === 1}
                 className="flex items-center gap-1 px-3 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed"
               >
@@ -279,7 +611,7 @@ export default function ExecutionsPage() {
                 Page {page} of {totalPages || 1}
               </span>
               <button
-                onClick={() => setPage(p => p + 1)}
+                onClick={() => updateParams({ page: String(page + 1) }, true)}
                 disabled={!hasMore && page >= totalPages}
                 className="flex items-center gap-1 px-3 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed"
               >