feat db-clean-ui and unified content udm

This commit is contained in:
ValueOn AG 2026-04-16 23:13:01 +02:00
parent a79da7c337
commit 74d0ce429a
18 changed files with 884 additions and 121 deletions

View file

@ -39,7 +39,7 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store'; import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage'; import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
import { FeatureViewPage } from './pages/FeatureView'; 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 { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
@ -213,6 +213,7 @@ function App() {
<Route path="subscriptions" element={<AdminSubscriptionsPage />} /> <Route path="subscriptions" element={<AdminSubscriptionsPage />} />
<Route path="logs" element={<AdminLogsPage />} /> <Route path="logs" element={<AdminLogsPage />} />
<Route path="languages" element={null} /> <Route path="languages" element={null} />
<Route path="database-health" element={<AdminDatabaseHealthPage />} />
<Route path="demo-config" element={<AdminDemoConfigPage />} /> <Route path="demo-config" element={<AdminDemoConfigPage />} />
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} /> <Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} /> <Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />

View file

@ -60,6 +60,8 @@ export interface NodeType {
meta?: { meta?: {
icon?: string; icon?: string;
color?: string; color?: string;
/** True if this node performs an LLM / AI call (credits). */
usesAi?: boolean;
method?: string; method?: string;
action?: string; action?: string;
}; };

View file

@ -152,8 +152,18 @@
min-width: 0; min-width: 0;
} }
.nodeItemLabelRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.35rem;
width: 100%;
}
.nodeItemLabel { .nodeItemLabel {
display: block; display: block;
flex: 1;
min-width: 0;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--text-primary, #333); color: var(--text-primary, #333);

View file

@ -8,6 +8,7 @@ import type { NodeType } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { AiBadge } from '../nodes/shared/AiBadge';
export interface CanvasNode { export interface CanvasNode {
id: string; id: string;
@ -798,6 +799,12 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
handleNodeMouseDown(e, node.id); handleNodeMouseDown(e, node.id);
}} }}
> >
{nt?.meta?.usesAi === true && (
<AiBadge
variant="canvas"
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
/>
)}
{handles.map(({ index, isOutput }) => { {handles.map(({ index, isOutput }) => {
const pos = getHandlePosition(node, index); const pos = getHandlePosition(node, index);
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`); const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);

View file

@ -5,9 +5,11 @@
import React from 'react'; import React from 'react';
import type { NodeType } from '../../../api/workflowApi'; import type { NodeType } from '../../../api/workflowApi';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { getCategoryIcon } from '../nodes/shared/utils'; import { getCategoryIcon } from '../nodes/shared/utils';
import type { GetLabelFn } from '../nodes/shared/utils'; import type { GetLabelFn } from '../nodes/shared/utils';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { AiBadge } from '../nodes/shared/AiBadge';
interface NodeListItemProps { interface NodeListItemProps {
node: NodeType; node: NodeType;
@ -22,6 +24,7 @@ export const NodeListItem: React.FC<NodeListItemProps> = ({
getLabel, getLabel,
getCategoryIcon: getIcon = getCategoryIcon, getCategoryIcon: getIcon = getCategoryIcon,
}) => { }) => {
const { t } = useLanguage();
const desc = getLabel(node.description, language); const desc = getLabel(node.description, language);
return ( return (
<div <div
@ -44,7 +47,15 @@ export const NodeListItem: React.FC<NodeListItemProps> = ({
{getIcon(node.category)} {getIcon(node.category)}
</div> </div>
<div className={styles.nodeItemInfo}> <div className={styles.nodeItemInfo}>
<span className={styles.nodeItemLabelRow}>
<span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span> <span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span>
{node.meta?.usesAi === true && (
<AiBadge
variant="palette"
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
/>
)}
</span>
<span className={styles.nodeItemDesc}>{desc}</span> <span className={styles.nodeItemDesc}>{desc}</span>
</div> </div>
{desc && <div className={styles.nodeItemTooltip}>{desc}</div>} {desc && <div className={styles.nodeItemTooltip}>{desc}</div>}

View file

@ -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;
}

View file

@ -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<AiBadgeProps> = ({ title, variant = 'palette' }) => {
const cls =
variant === 'canvas'
? `${badgeStyles.badge} ${badgeStyles.badgeCanvas}`
: badgeStyles.badge;
return (
<span className={cls} title={title} aria-label={title}>
AI
</span>
);
};

View file

@ -12,9 +12,11 @@ export const CATEGORY_ORDER = [
'input', 'input',
'flow', 'flow',
'data', 'data',
'context',
'ai', 'ai',
'file', 'file',
'email', 'email',
'sharepoint', 'sharepoint',
'clickup', 'clickup',
'trustee',
] as const; ] as const;

View file

@ -29,6 +29,7 @@ export interface FolderNode {
isProtected?: boolean; isProtected?: boolean;
isReadonly?: boolean; isReadonly?: boolean;
icon?: string; icon?: string;
neutralize?: boolean;
} }
export interface FileNode { export interface FileNode {
@ -75,6 +76,8 @@ export interface FolderTreeProps {
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>; onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void; onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => 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 ───────────────────────────────────────────────────────────── */ /* ── Helpers ───────────────────────────────────────────────────────────── */
@ -180,6 +183,7 @@ interface SelectionCtx {
onDeleteFolders?: (folderIds: string[]) => Promise<void>; onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void; onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
} }
/* ── File node (leaf) ─────────────────────────────────────────────────── */ /* ── File node (leaf) ─────────────────────────────────────────────────── */
@ -262,6 +266,11 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
{!renaming && ( {!renaming && (
<span className={styles.rightZone}> <span className={styles.rightZone}>
<span className={styles.actions}> <span className={styles.actions}>
{sel.onSendToChat && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}>
{'\u{1F4AC}'}
</button>
)}
{sel.onRenameFile && !multiSelected && ( {sel.onRenameFile && !multiSelected && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}> <button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
<FaPen /> <FaPen />
@ -351,6 +360,7 @@ interface TreeNodeProps {
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>; onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>; onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>; onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
} }
function _TreeNode({ function _TreeNode({
@ -358,7 +368,7 @@ function _TreeNode({
promptFolderName, promptFolderName,
onToggle, onSelect, onToggle, onSelect,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onDownloadFolder, onDownloadFolder, onFolderNeutralizeToggle,
}: TreeNodeProps) { }: TreeNodeProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
@ -514,11 +524,26 @@ function _TreeNode({
)} )}
{!isProtected && ( {!isProtected && (
<span className={styles.actions}> <span className={styles.actions}>
{sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}>
{'\u{1F4AC}'}
</button>
)}
{!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( {!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}> <button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
<FaDownload /> <FaDownload />
</button> </button>
)} )}
{onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); onFolderNeutralizeToggle(node.id, !node.neutralize); }}
title={node.neutralize ? t('Ordner-Neutralisierung aktiv, klicken zum Deaktivieren') : t('Ordner-Neutralisierung aus, klicken zum Aktivieren')}
style={{ fontSize: 14, opacity: node.neutralize ? 1 : 0.4 }}
>
{'\uD83D\uDD12'}
</button>
)}
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}> <button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
<FaPlus /> <FaPlus />
@ -575,6 +600,7 @@ function _TreeNode({
onMoveFile={onMoveFile} onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles} onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder} onDownloadFolder={onDownloadFolder}
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
/> />
))} ))}
{folderFiles.map((file) => ( {folderFiles.map((file) => (
@ -735,8 +761,9 @@ export default function FolderTree({
onDeleteFolders, onDeleteFolders,
onScopeChange, onScopeChange,
onNeutralizeToggle, onNeutralizeToggle,
onSendToChat,
}; };
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle]); }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle, onSendToChat]);
// Root drop handler: items dropped on the empty area go to root (null) // Root drop handler: items dropped on the empty area go to root (null)
const _handleRootDrop = useCallback(async (e: React.DragEvent) => { const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
@ -821,6 +848,7 @@ export default function FolderTree({
onMoveFile={onMoveFile} onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles} onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder} onDownloadFolder={onDownloadFolder}
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
/> />
))} ))}
{rootFiles.map((file) => ( {rootFiles.map((file) => (

View file

@ -10,9 +10,10 @@ import { useLanguage } from '../../providers/language/LanguageContext';
interface FilesTabProps { interface FilesTabProps {
context: UdbContext; context: UdbContext;
onFileSelect?: (fileId: string, fileName?: string) => void; onFileSelect?: (fileId: string, fileName?: string) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
} }
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => { const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@ -46,6 +47,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
name: f.name, name: f.name,
parentId: f.parentId ?? null, parentId: f.parentId ?? null,
fileCount: f.fileCount ?? 0, fileCount: f.fileCount ?? 0,
neutralize: (f as any).neutralize ?? false,
})); }));
}, [folders]); }, [folders]);
@ -166,6 +168,16 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
} }
}, [updateTreeFileNode, refreshTreeFiles]); }, [updateTreeFileNode, refreshTreeFiles]);
const _onFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => {
try {
await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue });
await refreshFolders();
await refreshTreeFiles();
} catch (err) {
console.error('Failed to toggle folder neutralize:', err);
}
}, [refreshFolders, refreshTreeFiles]);
if (treeFilesLoading && treeFileNodes.length === 0) { if (treeFilesLoading && treeFileNodes.length === 0) {
return <div className={styles.loading}>{t('Dateien laden')}</div>; return <div className={styles.loading}>{t('Dateien laden')}</div>;
} }
@ -256,6 +268,8 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
onDownloadFolder={handleDownloadFolder} onDownloadFolder={handleDownloadFolder}
onScopeChange={_onScopeChange} onScopeChange={_onScopeChange}
onNeutralizeToggle={_onNeutralizeToggle} onNeutralizeToggle={_onNeutralizeToggle}
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
onSendToChat={onSendToChat}
/> />
{_fileNodes.length === 0 && ( {_fileNodes.length === 0 && (

View file

@ -106,6 +106,7 @@ interface ParentRecordNode {
interface SourcesTabProps { interface SourcesTabProps {
context: UdbContext; context: UdbContext;
onSourcesChanged?: () => void; onSourcesChanged?: () => void;
onSendToChat_FeatureSource?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void;
} }
/* ─── Icons ──────────────────────────────────────────────────────────── */ /* ─── Icons ──────────────────────────────────────────────────────────── */
@ -339,7 +340,7 @@ function _Spinner(): React.ReactElement {
/* ─── Component ──────────────────────────────────────────────────────── */ /* ─── Component ──────────────────────────────────────────────────────── */
const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) => { const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSendToChat_FeatureSource }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const _scopeLabel = (scope: string) => ({ const _scopeLabel = (scope: string) => ({
personal: t('Persönlich'), personal: t('Persönlich'),
@ -1094,6 +1095,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
expandedParentGroups={expandedParentGroups} expandedParentGroups={expandedParentGroups}
loadingParentGroup={loadingParentGroup} loadingParentGroup={loadingParentGroup}
addingParentKey={addingParentKey} addingParentKey={addingParentKey}
onSendToChat={onSendToChat_FeatureSource}
/> />
))} ))}
</div> </div>
@ -1221,12 +1223,13 @@ interface _MandateGroupViewProps {
expandedParentGroups: Set<string>; expandedParentGroups: Set<string>;
loadingParentGroup: string | null; loadingParentGroup: string | null;
addingParentKey: string | null; addingParentKey: string | null;
onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void;
} }
const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({
group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey, group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey,
onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded,
expandedParentGroups, loadingParentGroup, addingParentKey, expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat,
}) => { }) => {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const chevron = group.expanded ? '\u25BE' : '\u25B8'; const chevron = group.expanded ? '\u25BE' : '\u25B8';
@ -1270,6 +1273,7 @@ const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({
expandedParentGroups={expandedParentGroups} expandedParentGroups={expandedParentGroups}
loadingParentGroup={loadingParentGroup} loadingParentGroup={loadingParentGroup}
addingParentKey={addingParentKey} addingParentKey={addingParentKey}
onSendToChat={onSendToChat}
/> />
))} ))}
</div> </div>
@ -1293,12 +1297,13 @@ interface _FeatureNodeViewProps {
expandedParentGroups: Set<string>; expandedParentGroups: Set<string>;
loadingParentGroup: string | null; loadingParentGroup: string | null;
addingParentKey: string | null; addingParentKey: string | null;
onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void;
} }
const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
node, onToggle, onAddTable, isTableAdded, addingKey, node, onToggle, onAddTable, isTableAdded, addingKey,
onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded,
expandedParentGroups, loadingParentGroup, addingParentKey, expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
@ -1333,6 +1338,27 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}> <span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
{node.tableCount} {t('Tabellen')} {node.tableCount} {t('Tabellen')}
</span> </span>
{hovered && onSendToChat && (
<button
onClick={(e) => {
e.stopPropagation();
onSendToChat({
featureInstanceId: node.featureInstanceId,
featureCode: node.featureCode,
objectKey: `data.feature.${node.featureCode}.*`,
label: node.label,
});
}}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1,
opacity: 0.7, color: '#7b1fa2',
}}
title={t('Alle Tabellen in Chat senden')}
>
{'\u{1F4AC}'}
</button>
)}
</div> </div>
{node.expanded && node.tables && node.tables.length > 0 && ( {node.expanded && node.tables && node.tables.length > 0 && (
@ -1375,6 +1401,7 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
onAdd={onAddTable} onAdd={onAddTable}
isAdded={isTableAdded(node.featureInstanceId, table.tableName)} isAdded={isTableAdded(node.featureInstanceId, table.tableName)}
isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`} isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`}
onSendToChat={onSendToChat}
/> />
))} ))}
</div> </div>
@ -1397,10 +1424,11 @@ interface _FeatureTableRowProps {
onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
isAdded: boolean; isAdded: boolean;
isAdding: boolean; isAdding: boolean;
onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void;
} }
const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
featureNode, table, onAdd, isAdded, isAdding, featureNode, table, onAdd, isAdded, isAdding, onSendToChat,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
@ -1423,6 +1451,25 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }}> <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }}>
{tableLabel} {tableLabel}
</span> </span>
{hovered && onSendToChat && (
<button
onClick={() => onSendToChat({
featureInstanceId: featureNode.featureInstanceId,
featureCode: featureNode.featureCode,
tableName: table.tableName,
objectKey: table.objectKey,
label: table.label || table.tableName,
})}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1,
opacity: 0.7, color: '#7b1fa2',
}}
title={t('In Chat senden')}
>
{'\u{1F4AC}'}
</button>
)}
{hovered && !isAdded && ( {hovered && !isAdded && (
<button <button
onClick={() => onAdd(featureNode, table)} onClick={() => onAdd(featureNode, table)}

View file

@ -14,6 +14,20 @@ export interface UdbContext {
userId?: string; userId?: string;
} }
export interface AddToChat_FileItem {
id: string;
type: 'file' | 'folder';
name: string;
}
export interface AddToChat_FeatureSource {
featureInstanceId: string;
featureCode: string;
tableName?: string;
objectKey: string;
label: string;
}
interface UnifiedDataBarProps { interface UnifiedDataBarProps {
context: UdbContext; context: UdbContext;
activeTab?: UdbTab; activeTab?: UdbTab;
@ -27,6 +41,8 @@ interface UnifiedDataBarProps {
onChatDragStart?: (chatId: string, event: React.DragEvent) => void; onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
onFileSelect?: (fileId: string, fileName?: string) => void; onFileSelect?: (fileId: string, fileName?: string) => void;
onSourcesChanged?: () => void; onSourcesChanged?: () => void;
onSendToChat_Files?: (items: AddToChat_FileItem[]) => void;
onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void;
className?: string; className?: string;
} }
@ -52,6 +68,8 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
onChatDragStart, onChatDragStart,
onFileSelect, onFileSelect,
onSourcesChanged, onSourcesChanged,
onSendToChat_Files,
onSendToChat_FeatureSource,
className, className,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -95,10 +113,15 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
<FilesTab <FilesTab
context={context} context={context}
onFileSelect={onFileSelect} onFileSelect={onFileSelect}
onSendToChat={onSendToChat_Files}
/> />
)} )}
{currentTab === 'sources' && !hideTabs?.includes('sources') && ( {currentTab === 'sources' && !hideTabs?.includes('sources') && (
<SourcesTab context={context} onSourcesChanged={onSourcesChanged} /> <SourcesTab
context={context}
onSourcesChanged={onSourcesChanged}
onSendToChat_FeatureSource={onSendToChat_FeatureSource}
/>
)} )}
</div> </div>
</div> </div>

View file

@ -83,6 +83,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.admin.automation-logs': <FaClipboardList />, 'page.admin.automation-logs': <FaClipboardList />,
'page.admin.logs': <FaFileAlt />, 'page.admin.logs': <FaFileAlt />,
'page.admin.languages': <FaGlobe />, 'page.admin.languages': <FaGlobe />,
'page.admin.databaseHealth': <FaDatabase />,
'page.admin.database-health': <FaDatabase />,
'page.admin.demoConfig': <FaCubes />, 'page.admin.demoConfig': <FaCubes />,
'page.admin.demo-config': <FaCubes />, 'page.admin.demo-config': <FaCubes />,
'page.admin.mandate-wizard': <FaHatWizard />, 'page.admin.mandate-wizard': <FaHatWizard />,

View file

@ -0,0 +1,6 @@
/**
* AdminDatabaseHealthPage Styles
*
* Minimal table rendering is handled by FormGeneratorTable.
* Only page-specific overrides live here.
*/

View file

@ -0,0 +1,638 @@
/**
* AdminDatabaseHealthPage
*
* SysAdmin-only page with two tabs:
* 1. Table Statistics pg_stat data for every table across all databases
* 2. Orphan Cleanup FK orphan detection with per-relation + batch cleanup
*
* Both tabs use FormGeneratorTable with a client-side pagination/sort/filter
* adapter (the backend returns all rows at once; the dataset is small enough).
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../hooks/useConfirm';
import { Tabs } from '../../components/UiComponents/Tabs/Tabs';
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface TableStat {
id: string;
db: string;
table: string;
estimatedRows: number;
totalSizeBytes: number;
indexSizeBytes: number;
lastVacuum: string | null;
lastAnalyze: string | null;
}
interface OrphanEntry {
id: string;
sourceDb: string;
sourceTable: string;
sourceColumn: string;
targetDb: string;
targetTable: string;
targetColumn: string;
orphanCount: number;
}
interface CleanResult {
db: string;
table: string;
column: string;
deleted: number;
error?: string;
}
interface PaginationParams {
page?: number;
pageSize?: number;
search?: string;
filters?: Record<string, any>;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}
interface PaginationMeta {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function _formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function _formatNumber(n: number): string {
return n.toLocaleString('de-CH');
}
// ---------------------------------------------------------------------------
// useClientPagination — adapts a static array to FormGeneratorTable's
// hookData.refetch / hookData.pagination contract.
// ---------------------------------------------------------------------------
function _useClientPagination<T extends Record<string, any>>(allData: T[]) {
const [visibleData, setVisibleData] = useState<T[]>([]);
const [pagination, setPagination] = useState<PaginationMeta>({
currentPage: 1, pageSize: 50, totalItems: 0, totalPages: 1,
});
const allDataRef = useRef(allData);
allDataRef.current = allData;
const lastParamsRef = useRef<PaginationParams>({});
const fetchFilterValues = useCallback(async (columnKey: string, crossFilters?: Record<string, any>) => {
let source = allDataRef.current;
if (crossFilters && Object.keys(crossFilters).length > 0) {
source = source.filter(row => {
for (const [key, val] of Object.entries(crossFilters)) {
if (val === undefined || val === null || val === '') continue;
const cell = String(row[key] ?? '');
if (Array.isArray(val)) {
if (val.length > 0 && !val.includes(cell)) return false;
} else {
if (cell !== String(val)) return false;
}
}
return true;
});
}
const seen = new Set<string>();
for (const row of source) {
const v = row[columnKey];
if (v !== undefined && v !== null && String(v).trim()) {
seen.add(String(v));
}
}
return Array.from(seen).sort();
}, []);
const refetch = useCallback(async (params?: PaginationParams) => {
const p = params || lastParamsRef.current;
lastParamsRef.current = p;
const source = allDataRef.current;
const page = p.page || 1;
const pageSize = p.pageSize || 50;
const search = (p.search || '').toLowerCase();
const filters = p.filters || {};
const sorts = p.sort || [];
// 1) Filter
let filtered = source.filter(row => {
for (const [key, val] of Object.entries(filters)) {
if (val === undefined || val === null || val === '') continue;
const cell = String(row[key] ?? '');
if (Array.isArray(val)) {
if (val.length > 0 && !val.includes(cell)) return false;
} else {
if (cell !== String(val)) return false;
}
}
return true;
});
// 2) Search
if (search) {
filtered = filtered.filter(row =>
Object.values(row).some(v => String(v ?? '').toLowerCase().includes(search)),
);
}
// 3) Sort
if (sorts.length > 0) {
filtered.sort((a, b) => {
for (const s of sorts) {
const aVal = a[s.field];
const bVal = b[s.field];
let cmp = 0;
if (typeof aVal === 'number' && typeof bVal === 'number') {
cmp = aVal - bVal;
} else {
cmp = String(aVal ?? '').localeCompare(String(bVal ?? ''));
}
if (cmp !== 0) return s.direction === 'desc' ? -cmp : cmp;
}
return 0;
});
}
// 4) Paginate
const totalItems = filtered.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
const paged = filtered.slice(start, start + pageSize);
setVisibleData(paged);
setPagination({ currentPage: safePage, pageSize, totalItems, totalPages });
}, []);
// Re-apply whenever allData changes
useEffect(() => {
refetch(lastParamsRef.current);
}, [allData, refetch]);
return { visibleData, pagination, refetch, fetchFilterValues };
}
// ---------------------------------------------------------------------------
// StatsTab
// ---------------------------------------------------------------------------
const StatsTab: React.FC = () => {
const { t } = useLanguage();
const [allStats, setAllStats] = useState<TableStat[]>([]);
const [loading, setLoading] = useState(false);
const [dbFilter, setDbFilter] = useState<string>('');
const _fetchStats = useCallback(async () => {
try {
setLoading(true);
const params = dbFilter ? `?db=${encodeURIComponent(dbFilter)}` : '';
const res = await api.get(`/api/admin/database-health/stats${params}`);
const rows = (res.data.stats || []).map((s: any, i: number) => ({
...s,
id: `${s.db}-${s.table}-${i}`,
}));
setAllStats(rows);
} catch {
setAllStats([]);
} finally {
setLoading(false);
}
}, [dbFilter]);
useEffect(() => { _fetchStats(); }, [_fetchStats]);
const { visibleData, pagination, refetch, fetchFilterValues } = _useClientPagination(allStats);
const databases = useMemo(
() => Array.from(new Set(allStats.map(s => s.db))).sort(),
[allStats],
);
const totals = useMemo(() => {
let rows = 0, size = 0, idx = 0;
for (const s of allStats) {
rows += s.estimatedRows;
size += s.totalSizeBytes;
idx += s.indexSizeBytes;
}
return { rows, size, idx, tables: allStats.length, dbs: databases.length };
}, [allStats, databases]);
const columns: ColumnConfig[] = useMemo(() => [
{
key: 'db',
label: t('Datenbank'),
sortable: true,
filterable: true,
searchable: true,
width: 200,
filterOptions: databases,
},
{
key: 'table',
label: t('Tabelle'),
sortable: true,
searchable: true,
width: 200,
},
{
key: 'estimatedRows',
label: t('Zeilen (ca.)'),
type: 'number',
sortable: true,
width: 120,
formatter: (v: number) => _formatNumber(v),
},
{
key: 'totalSizeBytes',
label: t('Total Size'),
type: 'number',
sortable: true,
width: 120,
formatter: (v: number) => _formatBytes(v),
},
{
key: 'indexSizeBytes',
label: t('Index Size'),
type: 'number',
sortable: true,
width: 120,
formatter: (v: number) => _formatBytes(v),
},
{
key: 'lastVacuum',
label: t('Last Vacuum'),
sortable: true,
width: 170,
formatter: (v: string | null) => v ?? '—',
},
{
key: 'lastAnalyze',
label: t('Last Analyze'),
sortable: true,
width: 170,
formatter: (v: string | null) => v ?? '—',
},
], [t, databases]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{/* Controls */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Datenbank')}</label>
<select
className={styles.filterSelect}
value={dbFilter}
onChange={e => setDbFilter(e.target.value)}
>
<option value="">{t('Alle')}</option>
{databases.map(db => <option key={db} value={db}>{db}</option>)}
</select>
</div>
<button className={styles.secondaryButton} onClick={_fetchStats} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
{/* Summary */}
<div className={styles.filterSection} style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
<span className={styles.filterLabel}>{t('{dbs} Datenbanken', { dbs: totals.dbs })}</span>
<span className={styles.filterLabel}>{t('{tables} Tabellen', { tables: totals.tables })}</span>
<span className={styles.filterLabel}>{t('{rows} Zeilen (ca.)', { rows: _formatNumber(totals.rows) })}</span>
<span className={styles.filterLabel}>{t('Total {size}', { size: _formatBytes(totals.size) })}</span>
<span className={styles.filterLabel}>{t('Index {size}', { size: _formatBytes(totals.idx) })}</span>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable
data={visibleData}
columns={columns}
loading={loading}
sortable={true}
searchable={true}
filterable={true}
pagination={true}
pageSize={50}
selectable={false}
hookData={{
refetch,
pagination,
fetchFilterValues,
}}
emptyMessage={t('Keine Tabellen gefunden')}
/>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// OrphansTab
// ---------------------------------------------------------------------------
const OrphansTab: React.FC = () => {
const { t } = useLanguage();
const toast = useToast();
const { confirm, ConfirmDialog } = useConfirm();
const [allOrphans, setAllOrphans] = useState<OrphanEntry[]>([]);
const [loading, setLoading] = useState(false);
const [cleaning, setCleaning] = useState<string | null>(null);
const [cleaningAll, setCleaningAll] = useState(false);
const [onlyProblems, setOnlyProblems] = useState(true);
const [dbFilter, setDbFilter] = useState<string>('');
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 (
<span>
<code>{row.targetTable}.{row.targetColumn}</code>
{isCrossDb && (
<span style={{
marginLeft: '0.4rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
fontSize: '0.625rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.03em',
background: 'var(--primary-dark-bg, rgba(242, 88, 67, 0.12))',
color: 'var(--primary-color, #f25843)',
}}>
{t('cross-db')}
</span>
)}
</span>
);
},
},
{
key: 'orphanCount',
label: t('Orphans'),
type: 'number',
sortable: true,
width: 100,
formatter: (v: number) => (
<span style={v > 0 ? { color: 'var(--danger-color, #e53e3e)', fontWeight: 600 } : undefined}>
{_formatNumber(v)}
</span>
),
},
], [t, databases]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<ConfirmDialog />
{/* Controls */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Datenbank')}</label>
<select
className={styles.filterSelect}
value={dbFilter}
onChange={e => setDbFilter(e.target.value)}
>
<option value="">{t('Alle')}</option>
{databases.map(db => <option key={db} value={db}>{db}</option>)}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.checkboxLabel}>
<input type="checkbox" checked={onlyProblems} onChange={e => setOnlyProblems(e.target.checked)} />
{t('Nur Probleme')}
</label>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={_fetchOrphans} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
</button>
{totalOrphans > 0 && (
<button className={styles.dangerButton} onClick={_cleanAll} disabled={cleaningAll || loading}>
<FaBroom className={cleaningAll ? 'spinning' : ''} /> {t('Alle bereinigen')} ({_formatNumber(totalOrphans)})
</button>
)}
</div>
</div>
{totalOrphans > 0 && (
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
{t('{count} verwaiste Einträge in {relations} Beziehungen gefunden', {
count: _formatNumber(totalOrphans),
relations: allOrphans.filter(o => o.orphanCount > 0).length,
})}
</div>
)}
<div className={styles.tableContainer}>
<FormGeneratorTable
data={visibleData}
columns={columns}
loading={loading}
sortable={true}
searchable={true}
filterable={true}
pagination={true}
pageSize={50}
selectable={false}
customActions={[
{
id: 'clean',
icon: <FaTrashAlt />,
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')}
/>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export const AdminDatabaseHealthPage: React.FC = () => {
const { t } = useLanguage();
const tabs = useMemo(() => [
{
id: 'stats',
label: t('Statistiken'),
content: <StatsTab />,
},
{
id: 'orphans',
label: t('Orphan Cleanup'),
content: <OrphansTab />,
},
], [t]);
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1>
<p className={styles.pageSubtitle}>{t('Tabellenstatistiken und verwaiste Datensätze')}</p>
</div>
</div>
<Tabs tabs={tabs} defaultTabId="stats" />
</div>
);
};
export default AdminDatabaseHealthPage;

View file

@ -18,3 +18,4 @@ export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
export { AdminLogsPage } from './AdminLogsPage'; export { AdminLogsPage } from './AdminLogsPage';
export { AdminLanguagesPage } from './AdminLanguagesPage'; export { AdminLanguagesPage } from './AdminLanguagesPage';
export { AdminDemoConfigPage } from './AdminDemoConfigPage'; export { AdminDemoConfigPage } from './AdminDemoConfigPage';
export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage';

View file

@ -545,116 +545,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
{uploading ? '...' : '+'} {uploading ? '...' : '+'}
</button> </button>
{(dataSources.length > 0 || featureDataSources.length > 0) && ( {/* Source picker removed — data sources are now attached directly from the UDB Sources/Files tabs via "send to chat" buttons */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowSourcePicker(prev => !prev)}
disabled={isProcessing}
title={t('Datenquellen anhängen')}
style={{
width: _controlSize, height: _controlSize, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
background: (attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: (attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 ? '#2e7d32' : '#666',
cursor: isProcessing ? 'not-allowed' : 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: isProcessing ? 0.5 : 1,
position: 'relative',
}}
>
🔗
{(attachedDataSourceIds.length + attachedFeatureDataSourceIds.length) > 0 && (
<span style={{
position: 'absolute', top: -4, right: -4,
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{attachedDataSourceIds.length + attachedFeatureDataSourceIds.length}
</span>
)}
</button>
{showSourcePicker && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 240, maxHeight: 260, overflowY: 'auto',
}}>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>
Active Sources auswählen
</div>
{dataSources.map(ds => {
const isSelected = attachedDataSourceIds.includes(ds.id);
return (
<div
key={ds.id}
onClick={() => _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 = ''; }}
>
<span style={{
width: 16, height: 16, borderRadius: 3,
border: isSelected ? '2px solid #2e7d32' : '2px solid #ccc',
background: isSelected ? '#2e7d32' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 10, fontWeight: 700, flexShrink: 0,
}}>
{isSelected ? '✓' : ''}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ds.label || ds.path || ds.id}
</span>
</div>
);
})}
{featureDataSources.length > 0 && (
<>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
Feature Data Sources
</div>
{featureDataSources.map(fds => {
const isSelected = attachedFeatureDataSourceIds.includes(fds.id);
return (
<div
key={fds.id}
onClick={() => _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 = ''; }}
>
<span style={{
width: 16, height: 16, borderRadius: 3,
border: isSelected ? '2px solid #7b1fa2' : '2px solid #ccc',
background: isSelected ? '#7b1fa2' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 10, fontWeight: 700, flexShrink: 0,
}}>
{isSelected ? '✓' : ''}
</span>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 13, color: '#7b1fa2', flexShrink: 0 }}>
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fds.label || fds.featureCode} {fds.tableName}
</span>
</div>
);
})}
</>
)}
</div>
)}
</div>
)}
{onProviderSelectionChange && providerSelection && ( {onProviderSelectionChange && providerSelection && (
<ProviderMultiSelect <ProviderMultiSelect

View file

@ -17,7 +17,7 @@ import { WorkspaceInput } from './WorkspaceInput';
import { FilePreview } from './FilePreview'; import { FilePreview } from './FilePreview';
import { ToolActivityLog } from './ToolActivityLog'; import { ToolActivityLog } from './ToolActivityLog';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar';
import api from '../../../api'; import api from '../../../api';
import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector'; import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector'; import type { ProviderSelection } from '../../../components/ProviderSelector';
@ -279,6 +279,35 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
workspace.refreshFeatureDataSources(); workspace.refreshFeatureDataSources();
}, [workspace]); }, [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 = ( const _leftPanelBody = (
<UnifiedDataBar <UnifiedDataBar
context={_udbContext} context={_udbContext}
@ -291,6 +320,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
onDeleteChat={_handleDeleteChat} onDeleteChat={_handleDeleteChat}
onFileSelect={_handleFileSelect} onFileSelect={_handleFileSelect}
onSourcesChanged={_handleSourcesChanged} onSourcesChanged={_handleSourcesChanged}
onSendToChat_Files={_handleSendToChat_Files}
onSendToChat_FeatureSource={_handleSendToChat_FeatureSource}
/> />
); );