From 74d0ce429a9d29f1aeed2f052bfe34f28ff08fc9 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Thu, 16 Apr 2026 23:13:01 +0200 Subject: [PATCH] feat db-clean-ui and unified content udm --- src/App.tsx | 3 +- src/api/workflowApi.ts | 2 + .../editor/Automation2FlowEditor.module.css | 10 + .../FlowEditor/editor/FlowCanvas.tsx | 7 + .../FlowEditor/editor/NodeListItem.tsx | 13 +- .../nodes/shared/AiBadge.module.css | 24 + .../FlowEditor/nodes/shared/AiBadge.tsx | 25 + .../FlowEditor/nodes/shared/constants.ts | 2 + src/components/FolderTree/FolderTree.tsx | 32 +- src/components/UnifiedDataBar/FilesTab.tsx | 16 +- src/components/UnifiedDataBar/SourcesTab.tsx | 55 +- .../UnifiedDataBar/UnifiedDataBar.tsx | 25 +- src/config/pageRegistry.tsx | 2 + .../admin/AdminDatabaseHealthPage.module.css | 6 + src/pages/admin/AdminDatabaseHealthPage.tsx | 638 ++++++++++++++++++ src/pages/admin/index.ts | 1 + src/pages/views/workspace/WorkspaceInput.tsx | 111 +-- src/pages/views/workspace/WorkspacePage.tsx | 33 +- 18 files changed, 884 insertions(+), 121 deletions(-) create mode 100644 src/components/FlowEditor/nodes/shared/AiBadge.module.css create mode 100644 src/components/FlowEditor/nodes/shared/AiBadge.tsx create mode 100644 src/pages/admin/AdminDatabaseHealthPage.module.css create mode 100644 src/pages/admin/AdminDatabaseHealthPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 983bec1..c6fecb4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,7 +39,7 @@ import { GDPRPage } from './pages/GDPR'; import StorePage from './pages/Store'; import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage'; import { FeatureViewPage } from './pages/FeatureView'; -import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin'; +import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage } from './pages/admin'; import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; @@ -213,6 +213,7 @@ function App() { } /> } /> + } /> } /> } /> } /> diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 347ad0b..a321c40 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -60,6 +60,8 @@ export interface NodeType { meta?: { icon?: string; color?: string; + /** True if this node performs an LLM / AI call (credits). */ + usesAi?: boolean; method?: string; action?: string; }; diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css index d52166b..b2f5605 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css @@ -152,8 +152,18 @@ min-width: 0; } +.nodeItemLabelRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + width: 100%; +} + .nodeItemLabel { display: block; + flex: 1; + min-width: 0; font-size: 0.875rem; font-weight: 500; color: var(--text-primary, #333); diff --git a/src/components/FlowEditor/editor/FlowCanvas.tsx b/src/components/FlowEditor/editor/FlowCanvas.tsx index 3589a2d..ae236ea 100644 --- a/src/components/FlowEditor/editor/FlowCanvas.tsx +++ b/src/components/FlowEditor/editor/FlowCanvas.tsx @@ -8,6 +8,7 @@ import type { NodeType } from '../../../api/workflowApi'; import styles from './Automation2FlowEditor.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { AiBadge } from '../nodes/shared/AiBadge'; export interface CanvasNode { id: string; @@ -798,6 +799,12 @@ export const FlowCanvas: React.FC = ({ nodes, handleNodeMouseDown(e, node.id); }} > + {nt?.meta?.usesAi === true && ( + + )} {handles.map(({ index, isOutput }) => { const pos = getHandlePosition(node, index); const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`); diff --git a/src/components/FlowEditor/editor/NodeListItem.tsx b/src/components/FlowEditor/editor/NodeListItem.tsx index 165f421..43b0c03 100644 --- a/src/components/FlowEditor/editor/NodeListItem.tsx +++ b/src/components/FlowEditor/editor/NodeListItem.tsx @@ -5,9 +5,11 @@ import React from 'react'; import type { NodeType } from '../../../api/workflowApi'; +import { useLanguage } from '../../../providers/language/LanguageContext'; import { getCategoryIcon } from '../nodes/shared/utils'; import type { GetLabelFn } from '../nodes/shared/utils'; import styles from './Automation2FlowEditor.module.css'; +import { AiBadge } from '../nodes/shared/AiBadge'; interface NodeListItemProps { node: NodeType; @@ -22,6 +24,7 @@ export const NodeListItem: React.FC = ({ getLabel, getCategoryIcon: getIcon = getCategoryIcon, }) => { + const { t } = useLanguage(); const desc = getLabel(node.description, language); return (
= ({ {getIcon(node.category)}
- {getLabel(node.label, language)} + + {getLabel(node.label, language)} + {node.meta?.usesAi === true && ( + + )} + {desc}
{desc &&
{desc}
} diff --git a/src/components/FlowEditor/nodes/shared/AiBadge.module.css b/src/components/FlowEditor/nodes/shared/AiBadge.module.css new file mode 100644 index 0000000..233ff03 --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/AiBadge.module.css @@ -0,0 +1,24 @@ +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.12rem 0.38rem; + border-radius: 4px; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + background: linear-gradient(135deg, #7c4dff 0%, #9c27b0 100%); + color: #fff; + line-height: 1; + flex-shrink: 0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.badgeCanvas { + position: absolute; + top: 4px; + right: 6px; + z-index: 3; + pointer-events: auto; +} diff --git a/src/components/FlowEditor/nodes/shared/AiBadge.tsx b/src/components/FlowEditor/nodes/shared/AiBadge.tsx new file mode 100644 index 0000000..70b4486 --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/AiBadge.tsx @@ -0,0 +1,25 @@ +/** + * Small label for workflow nodes that consume AI credits (LLM calls). + */ + +import React from 'react'; +import badgeStyles from './AiBadge.module.css'; + +export interface AiBadgeProps { + /** Tooltip (e.g. cost / credits hint). */ + title: string; + /** Canvas nodes: fixed top-right on the node card. */ + variant?: 'canvas' | 'palette'; +} + +export const AiBadge: React.FC = ({ title, variant = 'palette' }) => { + const cls = + variant === 'canvas' + ? `${badgeStyles.badge} ${badgeStyles.badgeCanvas}` + : badgeStyles.badge; + return ( + + AI + + ); +}; diff --git a/src/components/FlowEditor/nodes/shared/constants.ts b/src/components/FlowEditor/nodes/shared/constants.ts index b323fec..91d7359 100644 --- a/src/components/FlowEditor/nodes/shared/constants.ts +++ b/src/components/FlowEditor/nodes/shared/constants.ts @@ -12,9 +12,11 @@ export const CATEGORY_ORDER = [ 'input', 'flow', 'data', + 'context', 'ai', 'file', 'email', 'sharepoint', 'clickup', + 'trustee', ] as const; diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx index 15a4d79..fc102f4 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -29,6 +29,7 @@ export interface FolderNode { isProtected?: boolean; isReadonly?: boolean; icon?: string; + neutralize?: boolean; } export interface FileNode { @@ -75,6 +76,8 @@ export interface FolderTreeProps { onDownloadFolder?: (folderId: string, folderName: string) => Promise; onScopeChange?: (fileId: string, newScope: string) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; + onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void; + onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; } /* ── Helpers ───────────────────────────────────────────────────────────── */ @@ -180,6 +183,7 @@ interface SelectionCtx { onDeleteFolders?: (folderIds: string[]) => Promise; onScopeChange?: (fileId: string, newScope: string) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; + onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; } /* ── File node (leaf) ─────────────────────────────────────────────────── */ @@ -262,6 +266,11 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { {!renaming && ( + {sel.onSendToChat && ( + + )} {sel.onRenameFile && !multiSelected && ( + )} {!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( )} + {onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + )} {node.expanded && node.tables && node.tables.length > 0 && ( @@ -1375,6 +1401,7 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ onAdd={onAddTable} isAdded={isTableAdded(node.featureInstanceId, table.tableName)} isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`} + onSendToChat={onSendToChat} /> ))} @@ -1397,10 +1424,11 @@ interface _FeatureTableRowProps { onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; isAdded: boolean; isAdding: boolean; + onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void; } const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ - featureNode, table, onAdd, isAdded, isAdding, + featureNode, table, onAdd, isAdded, isAdding, onSendToChat, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); @@ -1423,6 +1451,25 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ {tableLabel} + {hovered && onSendToChat && ( + + )} {hovered && !isAdded && ( + + + {/* Summary */} +
+ {t('{dbs} Datenbanken', { dbs: totals.dbs })} + {t('{tables} Tabellen', { tables: totals.tables })} + {t('{rows} Zeilen (ca.)', { rows: _formatNumber(totals.rows) })} + {t('Total {size}', { size: _formatBytes(totals.size) })} + {t('Index {size}', { size: _formatBytes(totals.idx) })} +
+ +
+ +
+ + ); +}; + + +// --------------------------------------------------------------------------- +// OrphansTab +// --------------------------------------------------------------------------- + +const OrphansTab: React.FC = () => { + const { t } = useLanguage(); + const toast = useToast(); + const { confirm, ConfirmDialog } = useConfirm(); + + const [allOrphans, setAllOrphans] = useState([]); + const [loading, setLoading] = useState(false); + const [cleaning, setCleaning] = useState(null); + const [cleaningAll, setCleaningAll] = useState(false); + const [onlyProblems, setOnlyProblems] = useState(true); + const [dbFilter, setDbFilter] = useState(''); + + const _fetchOrphans = useCallback(async () => { + try { + setLoading(true); + const params = dbFilter ? `?db=${encodeURIComponent(dbFilter)}` : ''; + const res = await api.get(`/api/admin/database-health/orphans${params}`); + const rows = (res.data.orphans || []).map((o: any, i: number) => ({ + ...o, + id: `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}-${i}`, + })); + setAllOrphans(rows); + } catch { + setAllOrphans([]); + } finally { + setLoading(false); + } + }, [dbFilter]); + + useEffect(() => { _fetchOrphans(); }, [_fetchOrphans]); + + const displayed = useMemo( + () => onlyProblems ? allOrphans.filter(o => o.orphanCount > 0) : allOrphans, + [allOrphans, onlyProblems], + ); + + const { visibleData, pagination, refetch, fetchFilterValues } = _useClientPagination(displayed); + + const databases = useMemo( + () => Array.from(new Set(allOrphans.map(o => o.sourceDb))).sort(), + [allOrphans], + ); + + const totalOrphans = useMemo(() => allOrphans.reduce((s, o) => s + o.orphanCount, 0), [allOrphans]); + + const _cleanOne = async (o: OrphanEntry) => { + const ok = await confirm( + t('Orphans bereinigen'), + t('{count} verwaiste Einträge in {table}.{column} löschen?', { count: o.orphanCount, table: o.sourceTable, column: o.sourceColumn }), + ); + if (!ok) return; + const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`; + setCleaning(key); + try { + const res = await api.post('/api/admin/database-health/orphans/clean', { + db: o.sourceDb, + table: o.sourceTable, + column: o.sourceColumn, + }); + toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: res.data.deleted })); + _fetchOrphans(); + } catch (err: any) { + toast.showError(err.response?.data?.detail || t('Fehler beim Bereinigen')); + } finally { + setCleaning(null); + } + }; + + const _cleanAll = async () => { + const ok = await confirm( + t('Alle Orphans bereinigen'), + t('{count} verwaiste Einträge in {relations} Beziehungen löschen?', { + count: totalOrphans, + relations: allOrphans.filter(o => o.orphanCount > 0).length, + }), + ); + if (!ok) return; + setCleaningAll(true); + try { + const res = await api.post('/api/admin/database-health/orphans/clean-all'); + const results: CleanResult[] = res.data.results || []; + const totalDeleted = results.reduce((s, r) => s + r.deleted, 0); + const errors = results.filter(r => r.error); + if (errors.length > 0) { + toast.showWarning(t('{deleted} gelöscht, {errors} Fehler', { deleted: totalDeleted, errors: errors.length })); + } else { + toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: totalDeleted })); + } + _fetchOrphans(); + } catch (err: any) { + toast.showError(err.response?.data?.detail || t('Fehler beim Bereinigen')); + } finally { + setCleaningAll(false); + } + }; + + const columns: ColumnConfig[] = useMemo(() => [ + { + key: 'sourceDb', + label: t('Source DB'), + sortable: true, + filterable: true, + searchable: true, + width: 180, + filterOptions: databases, + }, + { + key: 'sourceTable', + label: t('Tabelle'), + sortable: true, + searchable: true, + width: 180, + }, + { + key: 'sourceColumn', + label: t('FK-Spalte'), + sortable: true, + searchable: true, + width: 150, + }, + { + key: 'targetTable', + label: t('Referenz'), + sortable: true, + width: 220, + formatter: (_val: string, row: OrphanEntry) => { + const isCrossDb = row.sourceDb !== row.targetDb; + return ( + + {row.targetTable}.{row.targetColumn} + {isCrossDb && ( + + {t('cross-db')} + + )} + + ); + }, + }, + { + key: 'orphanCount', + label: t('Orphans'), + type: 'number', + sortable: true, + width: 100, + formatter: (v: number) => ( + 0 ? { color: 'var(--danger-color, #e53e3e)', fontWeight: 600 } : undefined}> + {_formatNumber(v)} + + ), + }, + ], [t, databases]); + + return ( +
+ + + {/* Controls */} +
+
+ + +
+
+ +
+
+ + {totalOrphans > 0 && ( + + )} +
+
+ + {totalOrphans > 0 && ( +
+ + {t('{count} verwaiste Einträge in {relations} Beziehungen gefunden', { + count: _formatNumber(totalOrphans), + relations: allOrphans.filter(o => o.orphanCount > 0).length, + })} +
+ )} + +
+ , + onClick: (row: OrphanEntry) => _cleanOne(row), + visible: (row: OrphanEntry) => row.orphanCount > 0, + loading: (row: OrphanEntry) => cleaning === `${row.sourceDb}.${row.sourceTable}.${row.sourceColumn}` || cleaningAll, + title: t('Orphans löschen'), + }, + ]} + hookData={{ + refetch, + pagination, + fetchFilterValues, + }} + emptyMessage={onlyProblems ? t('Keine Orphans gefunden') : t('Keine FK-Beziehungen gefunden')} + /> +
+
+ ); +}; + + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export const AdminDatabaseHealthPage: React.FC = () => { + const { t } = useLanguage(); + + const tabs = useMemo(() => [ + { + id: 'stats', + label: t('Statistiken'), + content: , + }, + { + id: 'orphans', + label: t('Orphan Cleanup'), + content: , + }, + ], [t]); + + return ( +
+
+
+

{t('Datenbank-Gesundheit')}

+

{t('Tabellenstatistiken und verwaiste Datensätze')}

+
+
+ + +
+ ); +}; + +export default AdminDatabaseHealthPage; diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts index dc67667..74bc916 100644 --- a/src/pages/admin/index.ts +++ b/src/pages/admin/index.ts @@ -18,3 +18,4 @@ export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage'; export { AdminLogsPage } from './AdminLogsPage'; export { AdminLanguagesPage } from './AdminLanguagesPage'; export { AdminDemoConfigPage } from './AdminDemoConfigPage'; +export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage'; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index 9cc49ba..4e15428 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -545,116 +545,7 @@ export const WorkspaceInput: React.FC = ({ instanceId: _ins {uploading ? '...' : '+'} - {(dataSources.length > 0 || featureDataSources.length > 0) && ( -
- - {showSourcePicker && ( -
-
- Active Sources auswählen -
- {dataSources.map(ds => { - const isSelected = attachedDataSourceIds.includes(ds.id); - return ( -
_toggleDataSource(ds.id)} - style={{ - padding: '8px 12px', cursor: 'pointer', fontSize: 13, - display: 'flex', alignItems: 'center', gap: 8, - background: isSelected ? '#e8f5e9' : 'transparent', - }} - onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }} - onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }} - > - - {isSelected ? '✓' : ''} - - - {ds.label || ds.path || ds.id} - -
- ); - })} - {featureDataSources.length > 0 && ( - <> -
- Feature Data Sources -
- {featureDataSources.map(fds => { - const isSelected = attachedFeatureDataSourceIds.includes(fds.id); - return ( -
_toggleFeatureDataSource(fds.id)} - style={{ - padding: '8px 12px', cursor: 'pointer', fontSize: 13, - display: 'flex', alignItems: 'center', gap: 8, - background: isSelected ? '#f3e5f5' : 'transparent', - }} - onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }} - onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }} - > - - {isSelected ? '✓' : ''} - - - {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'} - - - {fds.label || fds.featureCode} – {fds.tableName} - -
- ); - })} - - )} -
- )} -
- )} + {/* Source picker removed — data sources are now attached directly from the UDB Sources/Files tabs via "send to chat" buttons */} {onProviderSelectionChange && providerSelection && ( = ({ persistentInstance workspace.refreshFeatureDataSources(); }, [workspace]); + const _handleSendToChat_Files = useCallback((items: AddToChat_FileItem[]) => { + setPendingFiles(prev => { + const existing = new Set(prev.map(f => f.fileId)); + const toAdd: PendingFile[] = []; + for (const item of items) { + if (!existing.has(item.id)) { + toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type }); + existing.add(item.id); + } + } + return [...prev, ...toAdd]; + }); + }, []); + + const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => { + try { + await api.post(`/api/workspace/${instanceId}/feature-datasources`, { + featureInstanceId: params.featureInstanceId, + featureCode: params.featureCode, + tableName: params.tableName || '', + objectKey: params.objectKey, + label: params.label, + }); + workspace.refreshFeatureDataSources(); + } catch (err) { + console.error('Failed to add feature source to chat:', err); + } + }, [instanceId, workspace]); + const _leftPanelBody = ( = ({ persistentInstance onDeleteChat={_handleDeleteChat} onFileSelect={_handleFileSelect} onSourcesChanged={_handleSourcesChanged} + onSendToChat_Files={_handleSendToChat_Files} + onSendToChat_FeatureSource={_handleSendToChat_FeatureSource} /> );