feat db-clean-ui and unified content udm
This commit is contained in:
parent
a79da7c337
commit
74d0ce429a
18 changed files with 884 additions and 121 deletions
|
|
@ -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() {
|
|||
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
<Route path="languages" element={null} />
|
||||
<Route path="database-health" element={<AdminDatabaseHealthPage />} />
|
||||
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<FlowCanvasProps> = ({ nodes,
|
|||
handleNodeMouseDown(e, node.id);
|
||||
}}
|
||||
>
|
||||
{nt?.meta?.usesAi === true && (
|
||||
<AiBadge
|
||||
variant="canvas"
|
||||
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
|
||||
/>
|
||||
)}
|
||||
{handles.map(({ index, isOutput }) => {
|
||||
const pos = getHandlePosition(node, index);
|
||||
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);
|
||||
|
|
|
|||
|
|
@ -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<NodeListItemProps> = ({
|
|||
getLabel,
|
||||
getCategoryIcon: getIcon = getCategoryIcon,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const desc = getLabel(node.description, language);
|
||||
return (
|
||||
<div
|
||||
|
|
@ -44,7 +47,15 @@ export const NodeListItem: React.FC<NodeListItemProps> = ({
|
|||
{getIcon(node.category)}
|
||||
</div>
|
||||
<div className={styles.nodeItemInfo}>
|
||||
<span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span>
|
||||
<span className={styles.nodeItemLabelRow}>
|
||||
<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>
|
||||
</div>
|
||||
{desc && <div className={styles.nodeItemTooltip}>{desc}</div>}
|
||||
|
|
|
|||
24
src/components/FlowEditor/nodes/shared/AiBadge.module.css
Normal file
24
src/components/FlowEditor/nodes/shared/AiBadge.module.css
Normal 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;
|
||||
}
|
||||
25
src/components/FlowEditor/nodes/shared/AiBadge.tsx
Normal file
25
src/components/FlowEditor/nodes/shared/AiBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -12,9 +12,11 @@ export const CATEGORY_ORDER = [
|
|||
'input',
|
||||
'flow',
|
||||
'data',
|
||||
'context',
|
||||
'ai',
|
||||
'file',
|
||||
'email',
|
||||
'sharepoint',
|
||||
'clickup',
|
||||
'trustee',
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
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<void>;
|
||||
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 && (
|
||||
<span className={styles.rightZone}>
|
||||
<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 && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
|
||||
<FaPen />
|
||||
|
|
@ -351,6 +360,7 @@ interface TreeNodeProps {
|
|||
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
|
||||
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
|
||||
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
||||
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
|
||||
}
|
||||
|
||||
function _TreeNode({
|
||||
|
|
@ -358,7 +368,7 @@ function _TreeNode({
|
|||
promptFolderName,
|
||||
onToggle, onSelect,
|
||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||
onDownloadFolder,
|
||||
onDownloadFolder, onFolderNeutralizeToggle,
|
||||
}: TreeNodeProps) {
|
||||
const { t } = useLanguage();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
|
|
@ -514,11 +524,26 @@ function _TreeNode({
|
|||
)}
|
||||
{!isProtected && (
|
||||
<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) && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
|
||||
<FaDownload />
|
||||
</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) && (
|
||||
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
|
||||
<FaPlus />
|
||||
|
|
@ -575,6 +600,7 @@ function _TreeNode({
|
|||
onMoveFile={onMoveFile}
|
||||
onMoveFiles={onMoveFiles}
|
||||
onDownloadFolder={onDownloadFolder}
|
||||
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
|
||||
/>
|
||||
))}
|
||||
{folderFiles.map((file) => (
|
||||
|
|
@ -735,8 +761,9 @@ export default function FolderTree({
|
|||
onDeleteFolders,
|
||||
onScopeChange,
|
||||
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)
|
||||
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
|
||||
|
|
@ -821,6 +848,7 @@ export default function FolderTree({
|
|||
onMoveFile={onMoveFile}
|
||||
onMoveFiles={onMoveFiles}
|
||||
onDownloadFolder={onDownloadFolder}
|
||||
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
|
||||
/>
|
||||
))}
|
||||
{rootFiles.map((file) => (
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ import { useLanguage } from '../../providers/language/LanguageContext';
|
|||
interface FilesTabProps {
|
||||
context: UdbContext;
|
||||
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 [searchQuery, setSearchQuery] = useState('');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
|
@ -46,6 +47,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
|||
name: f.name,
|
||||
parentId: f.parentId ?? null,
|
||||
fileCount: f.fileCount ?? 0,
|
||||
neutralize: (f as any).neutralize ?? false,
|
||||
}));
|
||||
}, [folders]);
|
||||
|
||||
|
|
@ -166,6 +168,16 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
|||
}
|
||||
}, [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) {
|
||||
return <div className={styles.loading}>{t('Dateien laden')}</div>;
|
||||
}
|
||||
|
|
@ -256,6 +268,8 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
|||
onDownloadFolder={handleDownloadFolder}
|
||||
onScopeChange={_onScopeChange}
|
||||
onNeutralizeToggle={_onNeutralizeToggle}
|
||||
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
|
||||
onSendToChat={onSendToChat}
|
||||
/>
|
||||
|
||||
{_fileNodes.length === 0 && (
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ interface ParentRecordNode {
|
|||
interface SourcesTabProps {
|
||||
context: UdbContext;
|
||||
onSourcesChanged?: () => void;
|
||||
onSendToChat_FeatureSource?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void;
|
||||
}
|
||||
|
||||
/* ─── Icons ──────────────────────────────────────────────────────────── */
|
||||
|
|
@ -339,7 +340,7 @@ function _Spinner(): React.ReactElement {
|
|||
|
||||
/* ─── Component ──────────────────────────────────────────────────────── */
|
||||
|
||||
const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) => {
|
||||
const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSendToChat_FeatureSource }) => {
|
||||
const { t } = useLanguage();
|
||||
const _scopeLabel = (scope: string) => ({
|
||||
personal: t('Persönlich'),
|
||||
|
|
@ -1094,6 +1095,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
|
|||
expandedParentGroups={expandedParentGroups}
|
||||
loadingParentGroup={loadingParentGroup}
|
||||
addingParentKey={addingParentKey}
|
||||
onSendToChat={onSendToChat_FeatureSource}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1221,12 +1223,13 @@ interface _MandateGroupViewProps {
|
|||
expandedParentGroups: Set<string>;
|
||||
loadingParentGroup: string | null;
|
||||
addingParentKey: string | null;
|
||||
onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void;
|
||||
}
|
||||
|
||||
const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({
|
||||
group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey,
|
||||
onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded,
|
||||
expandedParentGroups, loadingParentGroup, addingParentKey,
|
||||
expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat,
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const chevron = group.expanded ? '\u25BE' : '\u25B8';
|
||||
|
|
@ -1270,6 +1273,7 @@ const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({
|
|||
expandedParentGroups={expandedParentGroups}
|
||||
loadingParentGroup={loadingParentGroup}
|
||||
addingParentKey={addingParentKey}
|
||||
onSendToChat={onSendToChat}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1293,12 +1297,13 @@ interface _FeatureNodeViewProps {
|
|||
expandedParentGroups: Set<string>;
|
||||
loadingParentGroup: string | null;
|
||||
addingParentKey: string | null;
|
||||
onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void;
|
||||
}
|
||||
|
||||
const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
|
||||
node, onToggle, onAddTable, isTableAdded, addingKey,
|
||||
onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded,
|
||||
expandedParentGroups, loadingParentGroup, addingParentKey,
|
||||
expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
|
@ -1333,6 +1338,27 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
|
|||
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
|
||||
{node.tableCount} {t('Tabellen')}
|
||||
</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>
|
||||
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -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> = ({
|
|||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }}>
|
||||
{tableLabel}
|
||||
</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 && (
|
||||
<button
|
||||
onClick={() => onAdd(featureNode, table)}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,20 @@ export interface UdbContext {
|
|||
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 {
|
||||
context: UdbContext;
|
||||
activeTab?: UdbTab;
|
||||
|
|
@ -27,6 +41,8 @@ interface UnifiedDataBarProps {
|
|||
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||
onFileSelect?: (fileId: string, fileName?: string) => void;
|
||||
onSourcesChanged?: () => void;
|
||||
onSendToChat_Files?: (items: AddToChat_FileItem[]) => void;
|
||||
onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +68,8 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
|||
onChatDragStart,
|
||||
onFileSelect,
|
||||
onSourcesChanged,
|
||||
onSendToChat_Files,
|
||||
onSendToChat_FeatureSource,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -95,10 +113,15 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
|||
<FilesTab
|
||||
context={context}
|
||||
onFileSelect={onFileSelect}
|
||||
onSendToChat={onSendToChat_Files}
|
||||
/>
|
||||
)}
|
||||
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
|
||||
<SourcesTab context={context} onSourcesChanged={onSourcesChanged} />
|
||||
<SourcesTab
|
||||
context={context}
|
||||
onSourcesChanged={onSourcesChanged}
|
||||
onSendToChat_FeatureSource={onSendToChat_FeatureSource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'page.admin.automation-logs': <FaClipboardList />,
|
||||
'page.admin.logs': <FaFileAlt />,
|
||||
'page.admin.languages': <FaGlobe />,
|
||||
'page.admin.databaseHealth': <FaDatabase />,
|
||||
'page.admin.database-health': <FaDatabase />,
|
||||
'page.admin.demoConfig': <FaCubes />,
|
||||
'page.admin.demo-config': <FaCubes />,
|
||||
'page.admin.mandate-wizard': <FaHatWizard />,
|
||||
|
|
|
|||
6
src/pages/admin/AdminDatabaseHealthPage.module.css
Normal file
6
src/pages/admin/AdminDatabaseHealthPage.module.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* AdminDatabaseHealthPage Styles
|
||||
*
|
||||
* Minimal — table rendering is handled by FormGeneratorTable.
|
||||
* Only page-specific overrides live here.
|
||||
*/
|
||||
638
src/pages/admin/AdminDatabaseHealthPage.tsx
Normal file
638
src/pages/admin/AdminDatabaseHealthPage.tsx
Normal 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;
|
||||
|
|
@ -18,3 +18,4 @@ export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
|||
export { AdminLogsPage } from './AdminLogsPage';
|
||||
export { AdminLanguagesPage } from './AdminLanguagesPage';
|
||||
export { AdminDemoConfigPage } from './AdminDemoConfigPage';
|
||||
export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage';
|
||||
|
|
|
|||
|
|
@ -545,116 +545,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
|
|||
{uploading ? '...' : '+'}
|
||||
</button>
|
||||
|
||||
{(dataSources.length > 0 || featureDataSources.length > 0) && (
|
||||
<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>
|
||||
)}
|
||||
{/* Source picker removed — data sources are now attached directly from the UDB Sources/Files tabs via "send to chat" buttons */}
|
||||
|
||||
{onProviderSelectionChange && providerSelection && (
|
||||
<ProviderMultiSelect
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { WorkspaceInput } from './WorkspaceInput';
|
|||
import { FilePreview } from './FilePreview';
|
||||
import { ToolActivityLog } from './ToolActivityLog';
|
||||
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 { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector';
|
||||
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
||||
|
|
@ -279,6 +279,35 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ 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 = (
|
||||
<UnifiedDataBar
|
||||
context={_udbContext}
|
||||
|
|
@ -291,6 +320,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
onDeleteChat={_handleDeleteChat}
|
||||
onFileSelect={_handleFileSelect}
|
||||
onSourcesChanged={_handleSourcesChanged}
|
||||
onSendToChat_Files={_handleSendToChat_Files}
|
||||
onSendToChat_FeatureSource={_handleSendToChat_FeatureSource}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue