瀏覽代碼

fix: show a workflow's real created date, and its modified date, owner and triggers

The created date read "unknown" for every workflow, and it was two bugs
stacked, which is why fixing either one alone would have looked like it changed
nothing.

The API layer read data._createdAt. The database writes _created_at - the
metadata keys are snake_case - so the value was undefined and the guard in
WorkflowsPage rendered "unknown". Correcting only the key would still have
rendered "unknown", because the value is in NANOSECONDS, around 1.78e18, and
the same guard rejects anything above 8.64e15 as too large for a JS date.

Adds webui/src/utils/timestamps.ts with toMillis and readTimestamp: try both
spellings, and convert nanoseconds to milliseconds when the magnitude says so,
returning null rather than an Invalid Date when there is genuinely nothing
usable. Workflows and workflow groups both go through it - groups had the exact
same defect and showed "unknown" on every folder.

The card now also shows:

- Last modified, alongside created. Both carry the exact stamp as a tooltip,
  since "18 hours ago" is what you want at a glance but not what you want when
  comparing two workflows.
- Who owns it. This needed a backend change: the users list built its response
  from User::toJson(), which deliberately omits the id because the id is the
  document key. Without it, a workflow's ownerId could not be matched to a
  person at all. The id is now added to the response only, not to the stored
  document. Note _created_by exists but is empty on every record inspected, so
  ownerId is what actually identifies someone.
- Which triggers are configured, as badges. A workflow stores only each node's
  type string, so this joins against the node definitions to find the ones
  whose definition sets isTrigger.

The node count replaces the duplicated relative date in the footer.

Also fixes the systemd user units: they had no PartOf=smartbotic.target, so
"systemctl --user restart smartbotic.target" restarted nothing while still
reporting the target as active - a target is only a grouping and restart does
not reach its members through Wants alone. This bit during this very change: a
freshly built binary kept running as the old process, and the API kept returning
the old response, with systemctl insisting everything had restarted.

Verified in a browser against the running instance: cards render "Created about
9 hours ago" with a "2026-08-05 06:35" tooltip, a modified date, the owner
name, and a Click Trigger badge; folders show a real age instead of "unknown".
fszontagh 1 月之前
父節點
當前提交
7ddd1263ec

+ 7 - 1
src/webserver/api/user_controller.cpp

@@ -65,7 +65,13 @@ void UserController::listUsers(const httplib::Request& req, httplib::Response& r
     nlohmann::json response;
     response["users"] = nlohmann::json::array();
     for (const auto& user : result.value()) {
-        response["users"].push_back(user.toJson());
+        auto entry = user.toJson();
+        // toJson() leaves the id out on purpose - it is the document key, and
+        // writing it back into the document would duplicate it. An API caller
+        // still needs it: without one, a workflow's ownerId cannot be resolved
+        // to a name, which is why the workflow list could not show an owner.
+        entry["id"] = user.id;
+        response["users"].push_back(entry);
     }
     response["page"] = page;
     response["pageSize"] = page_size;

+ 6 - 0
systemd/user/smartbotic-runner.service

@@ -4,6 +4,12 @@ Documentation=https://github.com/smartbotic/smartbotic
 After=network.target smartbotic-webserver.service
 Requires=smartbotic-webserver.service
 
+# Without PartOf, "systemctl restart smartbotic.target" starts nothing and
+# stops nothing: a target is only a grouping, and restart/stop do not reach
+# its members through Wants alone. That silently leaves an old binary running
+# after a rebuild, with systemctl still reporting the target as active.
+PartOf=smartbotic.target
+
 [Service]
 Type=simple
 WorkingDirectory=/data/smartbotic

+ 6 - 0
systemd/user/smartbotic-webserver.service

@@ -3,6 +3,12 @@ Description=SmartBotic WebServer Service
 Documentation=https://github.com/smartbotic/smartbotic
 After=network.target
 
+# Without PartOf, "systemctl restart smartbotic.target" starts nothing and
+# stops nothing: a target is only a grouping, and restart/stop do not reach
+# its members through Wants alone. That silently leaves an old binary running
+# after a rebuild, with systemctl still reporting the target as active.
+PartOf=smartbotic.target
+
 [Service]
 Type=simple
 WorkingDirectory=/data/smartbotic

+ 21 - 0
webui/src/api/users.ts

@@ -0,0 +1,21 @@
+import { api } from './client'
+
+export interface User {
+  id: string
+  username: string
+  email: string
+  role: string
+  active: boolean
+  lastLogin: number
+}
+
+export const usersApi = {
+  /**
+   * List users. Admin only - the endpoint requires the admin role, so a
+   * non-admin caller gets a 403 and any caller must tolerate that.
+   */
+  list: async (page = 1, pageSize = 100) => {
+    const { data } = await api.get('/users', { params: { page, pageSize } })
+    return (data.users || []) as User[]
+  },
+}

+ 6 - 4
webui/src/api/workflowGroups.ts

@@ -1,4 +1,5 @@
 import { api } from './client'
+import { readTimestamp } from '../utils/timestamps'
 
 export interface WorkflowGroup {
   id: string
@@ -6,8 +7,8 @@ export interface WorkflowGroup {
   description?: string
   parentId?: string
   ownerId: string
-  createdAt: number
-  updatedAt: number
+  createdAt: number | null
+  updatedAt: number | null
 }
 
 // Transform backend group data to frontend format
@@ -18,8 +19,9 @@ function transformGroup(data: any): WorkflowGroup {
     description: data.description,
     parentId: data.parentId || undefined,
     ownerId: data.ownerId,
-    createdAt: data._createdAt,
-    updatedAt: data._updatedAt,
+    // Same snake_case nanosecond metadata as workflows - see utils/timestamps.
+    createdAt: readTimestamp(data, '_created_at', '_createdAt'),
+    updatedAt: readTimestamp(data, '_updated_at', '_updatedAt'),
   }
 }
 

+ 18 - 4
webui/src/api/workflows.ts

@@ -1,4 +1,5 @@
 import { api } from './client'
+import { readTimestamp } from '../utils/timestamps'
 
 export interface WorkflowNode {
   id: string
@@ -25,8 +26,11 @@ export interface Workflow {
   nodes: WorkflowNode[]
   connections: Connection[]
   settings: Record<string, any>
-  createdAt: number
-  updatedAt: number
+  createdAt: number | null
+  updatedAt: number | null
+  ownerId?: string
+  createdBy?: string
+  updatedBy?: string
 }
 
 export interface NodeOutput {
@@ -70,8 +74,18 @@ function transformWorkflow(data: any): Workflow {
     nodes: data.nodes || [],
     connections: data.connections || [],
     settings: data.settings || {},
-    createdAt: data._createdAt,
-    updatedAt: data._updatedAt,
+    // The database writes _created_at / _updated_at in snake_case and in
+    // nanoseconds. Reading _createdAt returned undefined, which is why every
+    // workflow showed "unknown". Both spellings are tried and the unit is
+    // normalised - see utils/timestamps.
+    createdAt: readTimestamp(data, '_created_at', '_createdAt'),
+    updatedAt: readTimestamp(data, '_updated_at', '_updatedAt'),
+    ownerId: data.ownerId,
+    // _created_by is present on the document but has been empty on every record
+    // inspected so far, so ownerId is the field that actually identifies a
+    // person. Kept here for when the backend starts populating it.
+    createdBy: data._created_by || undefined,
+    updatedBy: data._updated_by || undefined,
   }
 }
 

+ 8 - 3
webui/src/components/GroupCard.tsx

@@ -11,9 +11,14 @@ interface GroupCardProps {
   onDelete: () => void
 }
 
-// Helper to safely format timestamps
-function safeFormatDistanceToNow(timestamp: number): string {
-  if (!timestamp || timestamp < 0 || timestamp > 8640000000000000) {
+// Helper to safely format timestamps. The API layer normalises the database's
+// nanosecond, snake_case metadata into milliseconds and hands back null when a
+// document carries no usable timestamp.
+function safeFormatDistanceToNow(timestamp: number | null | undefined): string {
+  if (timestamp === null || timestamp === undefined) {
+    return 'unknown'
+  }
+  if (timestamp <= 0 || timestamp > 8640000000000000) {
     return 'unknown'
   }
   try {

+ 127 - 8
webui/src/pages/WorkflowsPage.tsx

@@ -1,10 +1,11 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
 import { Link, useNavigate, useSearchParams } from 'react-router-dom'
-import { workflowsApi, Workflow } from '../api/workflows'
+import { workflowsApi, nodesApi, Workflow } from '../api/workflows'
+import { usersApi } from '../api/users'
 import { workflowGroupsApi, WorkflowGroup } from '../api/workflowGroups'
-import { Plus, Play, Pause, Trash2, MoreVertical, AlertTriangle, X, FolderPlus, FolderInput } from 'lucide-react'
+import { Plus, Play, Pause, Trash2, MoreVertical, AlertTriangle, X, FolderPlus, FolderInput, Clock, User, Zap } from 'lucide-react'
 import { useState } from 'react'
-import { formatDistanceToNow } from 'date-fns'
+import { formatDistanceToNow, format } from 'date-fns'
 import { GroupBreadcrumb } from '../components/GroupBreadcrumb'
 import { GroupCard } from '../components/GroupCard'
 import {
@@ -14,10 +15,14 @@ import {
   MoveItemModal,
 } from '../components/GroupModals'
 
-// Helper to safely format timestamps that might be invalid or too large
-function safeFormatDistanceToNow(timestamp: number): string {
-  // Check for invalid timestamps (too large, negative, or zero)
-  if (!timestamp || timestamp < 0 || timestamp > 8640000000000000) {
+// Timestamps arrive already normalised to milliseconds by the API layer, which
+// is where the nanosecond and snake_case handling lives. A null here means the
+// document genuinely carries no usable timestamp, not that the unit was wrong.
+function safeFormatDistanceToNow(timestamp: number | null | undefined): string {
+  if (timestamp === null || timestamp === undefined) {
+    return 'unknown'
+  }
+  if (timestamp <= 0 || timestamp > 8640000000000000) {
     return 'unknown'
   }
   try {
@@ -27,6 +32,45 @@ function safeFormatDistanceToNow(timestamp: number): string {
   }
 }
 
+function formatExact(timestamp: number | null | undefined): string {
+  if (timestamp === null || timestamp === undefined || timestamp <= 0) {
+    return 'unknown'
+  }
+  try {
+    return format(timestamp, 'yyyy-MM-dd HH:mm')
+  } catch {
+    return 'unknown'
+  }
+}
+
+interface TriggerSummary {
+  type: string
+  label: string
+}
+
+/**
+ * Which trigger nodes a workflow contains. A trigger is a node whose definition
+ * carries isTrigger, so this needs the definitions the page already loads -
+ * the workflow itself only stores each node's type string.
+ */
+function findTriggers(
+  workflow: Workflow,
+  triggerTypes: Map<string, string>
+): TriggerSummary[] {
+  const found: TriggerSummary[] = []
+  const seen = new Set<string>()
+
+  for (const node of workflow.nodes || []) {
+    const label = triggerTypes.get(node.type)
+    if (label === undefined || seen.has(node.type)) {
+      continue
+    }
+    seen.add(node.type)
+    found.push({ type: node.type, label })
+  }
+  return found
+}
+
 export default function WorkflowsPage() {
   const queryClient = useQueryClient()
   const navigate = useNavigate()
@@ -66,6 +110,34 @@ export default function WorkflowsPage() {
   })
   const workflows: Workflow[] = workflowsData?.workflows || []
 
+  // Node definitions, purely to learn which node types are triggers. A workflow
+  // stores only a node's type string, so the definitions are the only place
+  // that says whether it starts a run.
+  const { data: nodesData } = useQuery({
+    queryKey: ['node-definitions'],
+    queryFn: () => nodesApi.list(),
+    staleTime: 5 * 60 * 1000,
+  })
+  const triggerTypes = new Map<string, string>()
+  for (const definition of nodesData?.nodes || []) {
+    if (definition.isTrigger) {
+      triggerTypes.set(definition.id, definition.name || definition.id)
+    }
+  }
+
+  // Owner names. The endpoint is admin-only, so a non-admin simply gets no
+  // names and the card falls back to showing nothing rather than erroring.
+  const { data: usersData } = useQuery({
+    queryKey: ['users'],
+    queryFn: () => usersApi.list(),
+    staleTime: 5 * 60 * 1000,
+    retry: false,
+  })
+  const userNames = new Map<string, string>()
+  for (const user of usersData || []) {
+    userNames.set(user.id, user.username)
+  }
+
   const isLoading = isLoadingGroups || isLoadingWorkflows
 
   // Navigate to a group
@@ -265,6 +337,10 @@ export default function WorkflowsPage() {
                     key={workflow.id || Math.random()}
                     workflow={workflow}
                     groupId={currentGroupId}
+                    ownerName={
+                      workflow.ownerId ? userNames.get(workflow.ownerId) : undefined
+                    }
+                    triggers={findTriggers(workflow, triggerTypes)}
                     onToggleActive={() => {
                       if (workflow.id) {
                         toggleActiveMutation.mutate({ id: workflow.id, active: workflow.active })
@@ -393,12 +469,16 @@ export default function WorkflowsPage() {
 function WorkflowCard({
   workflow,
   groupId,
+  ownerName,
+  triggers,
   onToggleActive,
   onMove,
   onDelete,
 }: {
   workflow: Workflow
   groupId: string | null
+  ownerName?: string
+  triggers: TriggerSummary[]
   onToggleActive: () => void
   onMove: () => void
   onDelete: () => void
@@ -478,6 +558,45 @@ function WorkflowCard({
         </div>
       </div>
 
+      {triggers.length > 0 && (
+        <div className="flex flex-wrap items-center gap-1.5 mb-3">
+          <Zap className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400 shrink-0" />
+          {triggers.map((trigger) => (
+            <span
+              key={trigger.type}
+              className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
+              title={trigger.type}
+            >
+              {trigger.label}
+            </span>
+          ))}
+        </div>
+      )}
+
+      <div className="space-y-1 mb-3 text-xs text-gray-500 dark:text-gray-400">
+        <div className="flex items-center gap-1.5">
+          <Clock className="w-3.5 h-3.5 shrink-0" />
+          {/* The exact date sits in the tooltip because "3 months ago" is the
+              useful form at a glance, but the precise stamp is what someone
+              needs when they are actually comparing two workflows. */}
+          <span title={formatExact(workflow.createdAt)}>
+            Created {safeFormatDistanceToNow(workflow.createdAt)}
+          </span>
+        </div>
+        <div className="flex items-center gap-1.5">
+          <Clock className="w-3.5 h-3.5 shrink-0" />
+          <span title={formatExact(workflow.updatedAt)}>
+            Modified {safeFormatDistanceToNow(workflow.updatedAt)}
+          </span>
+        </div>
+        {(ownerName || workflow.createdBy) && (
+          <div className="flex items-center gap-1.5">
+            <User className="w-3.5 h-3.5 shrink-0" />
+            <span>{ownerName || workflow.createdBy}</span>
+          </div>
+        )}
+      </div>
+
       <div className="flex items-center justify-between text-sm">
         <span
           className={`px-2 py-0.5 rounded-full text-xs font-medium ${
@@ -489,7 +608,7 @@ function WorkflowCard({
           {workflow.active ? 'Active' : 'Inactive'}
         </span>
         <span className="text-gray-400 dark:text-gray-500">
-          {safeFormatDistanceToNow(workflow.updatedAt)}
+          {workflow.nodes?.length || 0} node{workflow.nodes?.length === 1 ? '' : 's'}
         </span>
       </div>
     </div>

+ 69 - 0
webui/src/utils/timestamps.ts

@@ -0,0 +1,69 @@
+/**
+ * Timestamp normalisation for values coming out of the smartbotic-database.
+ *
+ * The daemon stamps documents with `_created_at` and `_updated_at` in
+ * NANOSECONDS - around 1.78e18. Everything else in the platform works in
+ * milliseconds: Date.now() in nodes, TimeUtils::nowMs() in C++, and the
+ * startedAt / finishedAt the engine writes onto an execution.
+ *
+ * Two traps live here, and the workflow list fell into both at once:
+ *
+ *  - The metadata keys are snake_case (`_created_at`). Reading `_createdAt`
+ *    returns undefined and every date renders as "unknown".
+ *  - A nanosecond value is far larger than the largest date JavaScript accepts
+ *    (8.64e15 ms). Passing one to new Date() or date-fns yields Invalid Date,
+ *    so fixing only the key name still renders "unknown" - which makes the
+ *    first fix look like it did nothing.
+ */
+
+// The largest instant the ECMAScript Date type can represent, in milliseconds.
+const MAX_JS_DATE_MS = 8640000000000000
+
+// Well past any plausible millisecond timestamp (1e15 ms is the year 33658) and
+// well below any plausible nanosecond one, so it separates the two units
+// without having to be told which was meant.
+const NANOSECOND_THRESHOLD = 1e15
+
+/**
+ * Convert a database timestamp to milliseconds, tolerating either unit.
+ * Returns null when the value is missing or cannot be a real instant, so
+ * callers can render their own placeholder rather than an Invalid Date.
+ */
+export function toMillis(value: unknown): number | null {
+  const raw = typeof value === 'string' ? Number(value) : value
+
+  if (typeof raw !== 'number' || !isFinite(raw) || raw <= 0) {
+    return null
+  }
+
+  const millis = raw > NANOSECOND_THRESHOLD ? Math.floor(raw / 1000000) : raw
+
+  if (millis > MAX_JS_DATE_MS) {
+    return null
+  }
+
+  return millis
+}
+
+/**
+ * Read a document's timestamp regardless of which spelling it carries.
+ *
+ * The database writes snake_case, but parts of this codebase were written
+ * against camelCase and some responses are reshaped before they arrive. Trying
+ * both costs nothing and removes a whole class of silent "unknown".
+ */
+export function readTimestamp(
+  data: Record<string, any> | null | undefined,
+  ...keys: string[]
+): number | null {
+  if (!data) {
+    return null
+  }
+  for (const key of keys) {
+    const millis = toMillis(data[key])
+    if (millis !== null) {
+      return millis
+    }
+  }
+  return null
+}