Compare commits

..

47 commits

Author SHA1 Message Date
639cac2e33 fixes udb
Some checks failed
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
Deploy Nyla Frontend to Integration / deploy (push) Failing after 2s
2026-05-27 16:48:52 +02:00
0331a59da3 config fix
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
2026-05-25 17:35:52 +02:00
3cc2f4decf db fixed import
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
2026-05-25 16:30:35 +02:00
12868fdd17 Pydantic FK als Single Source of Truth
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
2026-05-25 15:14:09 +02:00
50c05e91d7 db backup-restore with fk
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
2026-05-25 14:33:50 +02:00
036e6a38db fixed db stream upload
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 43s
2026-05-24 14:59:05 +02:00
8d24d57719 fixed db download
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 49s
2026-05-24 08:59:46 +02:00
554d798ae2 dbsync
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
2026-05-24 08:15:06 +02:00
9047304934 fix: use printf for SSH key to preserve trailing newline
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 49s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 03:22:40 +02:00
7a228f0181 fix: rewrite workflows for Infomaniak SSH deploy, fix API URLs
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 51s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 03:03:47 +02:00
a7921d409e fix: use full GitHub URLs for Azure actions (not mirrored on Forgejo)
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 12s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:42:29 +02:00
077dbca759 fix: add env file cleanup step to ui-nyla workflows
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 2s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:40:42 +02:00
6dbf91afb2 refactor: migrate to Forgejo workflows, normalize env file names, remove GitHub Actions
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 1s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:34:21 +02:00
f35e22c7f4 Sync: full codebase from GitHub frontend_nyla main
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 2s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 23:54:30 +02:00
Ida
234ffa7896 updated deployment file and env file
All checks were successful
Deploy Nyla Frontend / build-and-deploy (push) Successful in 10m33s
2026-05-22 10:24:04 +02:00
Ida
86a3ac647c fix:wrong base url
All checks were successful
Deploy Nyla Frontend / build-and-deploy (push) Successful in 10m28s
2026-05-22 07:53:36 +02:00
Ida
0f1f9781b7 fix: wrong backend on porta instance
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Has been cancelled
2026-05-22 07:52:06 +02:00
Ida
1a4f18392c sudo berechtigung zur rsync installation wieder entfernt
All checks were successful
Deploy Nyla Frontend / build-and-deploy (push) Successful in 10m28s
2026-05-21 08:55:29 +02:00
Ida
9d18e743bc sudo berechtigung zur rsync installation
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Failing after 5m41s
2026-05-21 08:47:08 +02:00
Ida
638f18cd55 rsync vor deployment installieren
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Failing after 5m43s
2026-05-21 08:38:02 +02:00
Ida
f1234fedb3 updated deployment workflow to forgejo
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Failing after 6m11s
2026-05-21 08:29:02 +02:00
Patrick Motsch
4f2745cc2e
Merge pull request #86 from valueonag/int
All checks were successful
Deploy Nyla / deploy (push) Successful in 2s
Int
2026-05-19 22:35:29 +02:00
Patrick Motsch
ca6261fb1a
Merge pull request #85 from valueonag/int
All checks were successful
Deploy Nyla / deploy (push) Successful in 3s
Int
2026-05-17 00:10:06 +02:00
Patrick Motsch
9b6edec74e
Merge pull request #80 from valueonag/int
Int
2026-05-10 22:24:04 +02:00
idittrich-valueon
956a226b1b
Merge pull request #75 from valueonag/int
Int
2026-05-04 14:09:37 +02:00
Ida
4356394fd8 forgejo setup 2026-04-21 10:35:36 +02:00
Ida
02e7701329 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
1c6f1ac435 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
98a14a5394 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
bcc632927d trigger deploy 2026-04-21 10:35:36 +02:00
Ida
c5b27c0fbd trigger deploy 2026-04-21 10:35:36 +02:00
Ida
a9bdb2d4d4 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
893326e51d add forgejo deploy workflow 2026-04-21 10:35:36 +02:00
Patrick Motsch
9e872910f4
Merge pull request #52 from valueonag/int
Int
2026-04-21 07:48:36 +02:00
Patrick Motsch
3997c6ec63
Merge pull request #50 from valueonag/int
Int
2026-04-21 00:57:41 +02:00
Patrick Motsch
7c35c7117b
Merge pull request #47 from valueonag/int
Int
2026-04-20 19:11:14 +02:00
Patrick Motsch
13af1dbb05
Merge pull request #45 from valueonag/int
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Has been cancelled
Int
2026-04-19 01:39:32 +02:00
Patrick Motsch
bbd78696e6
Merge pull request #42 from valueonag/int
Int
2026-04-17 21:54:34 +02:00
Patrick Motsch
0178de9650
Merge pull request #34 from valueonag/int
Some checks are pending
Deploy Nyla Frontend to Production / build-and-deploy (push) Waiting to run
Int
2026-04-14 16:28:18 +02:00
Patrick Motsch
a6241fb296
Merge pull request #32 from valueonag/int
Int
2026-04-14 13:32:25 +02:00
Patrick Motsch
51cad2cab6
Merge pull request #28 from valueonag/int
core class for system attributes sysCreated / sysModified
2026-04-04 19:15:21 +02:00
Patrick Motsch
ccb6da36f0
Merge pull request #27 from valueonag/int
Int
2026-04-04 16:21:11 +02:00
Patrick Motsch
e1d06e2a9d
Merge pull request #24 from valueonag/int
Int
2026-03-23 10:39:04 +01:00
Patrick Motsch
2ade186821
Merge pull request #22 from valueonag/int
fixed rendering issues
2026-03-22 11:11:45 +01:00
Patrick Motsch
708687a5e4
Merge pull request #21 from valueonag/int
Int
2026-03-22 01:28:21 +01:00
Patrick Motsch
197bc51632
Merge pull request #20 from valueonag/int
Int
2026-03-19 13:44:20 +01:00
Patrick Motsch
d3c3a5d465
Merge pull request #15 from valueonag/int
fix feature instance passing
2026-03-13 08:31:28 +01:00
46 changed files with 1606 additions and 1877 deletions

View file

@ -1,144 +0,0 @@
# Implement RBAC Roles Page
## Overview
Implement the RBAC roles admin page following the exact pattern used in `mandates.ts`. This includes creating the API file, custom hook for state management, updating the page configuration with CreateButton header button, and adding translations in all three languages (German, English, French).
## Files to Create/Modify
### 1. Create API File: `frontend_nyla/src/api/roleApi.ts`
- Follow the pattern from `mandateApi.ts`
- Implement all required endpoints:
- `fetchRoles()` - GET /api/rbac/roles (with pagination support)
- `fetchRoleById()` - GET /api/rbac/roles/{roleId}
- `fetchRoleOptions()` - GET /api/rbac/roles/options
- `createRole()` - POST /api/rbac/roles
- `updateRole()` - PUT /api/rbac/roles/{roleId}
- `deleteRole()` - DELETE /api/rbac/roles/{roleId}
- Include TypeScript types: `Role`, `RoleUpdateData`, `PaginationParams`, `PaginatedResponse`
### 2. Create Hook: `frontend_nyla/src/hooks/useAdminRbacRoles.ts`
- Follow the exact pattern from `useAdminMandates.ts`
- Create two hooks:
- `useRbacRoles()` - Main hook for data fetching and state management
- Fetch roles with pagination support
- Fetch attributes from `/api/attributes/Role` using `fetchAttributes(request, 'Role')`
- Fetch permissions using `checkPermission('DATA', 'Role')`
- Implement `generateEditFieldsFromAttributes()` using `attributeTypeMapper` utilities
- Implement `generateCreateFieldsFromAttributes()` using `attributeTypeMapper` utilities
- Implement `ensureAttributesLoaded()` for EditActionButton
- Implement optimistic updates (`removeOptimistically`, `updateOptimistically`)
- Return pagination info, attributes, permissions, and all required functions
- `useRbacRoleOperations()` - Operations hook for CRUD
- `handleRoleDelete()` - Delete with loading state tracking
- `handleRoleCreate()` - Create with error handling
- `handleRoleUpdate()` - Update with error handling
- Track loading states in Sets (deletingRoles, editingRoles, creatingRole)
- Return error states (deleteError, createError, updateError)
### 3. Update Page Configuration: `frontend_nyla/src/core/PageManager/data/pages/admin/rbac-role.ts`
- Follow the exact structure from `mandates.ts`
- Import `FaPlus` from `react-icons/fa` for the create button icon
- Create `createRbacRolesHook()` factory function that:
- Uses `useRbacRoles()` and `useRbacRoleOperations()`
- Converts attributes to columns using `attributesToColumns()` helper
- Implements `handleDeleteSingle` and `handleDeleteMultiple` callbacks
- Returns all required data for FormGeneratorTable
- Update `rbacRolePageData`:
- Add header button with `FaPlus` icon for creating roles (following mandates.ts pattern):
```typescript
headerButtons: [
{
id: 'add-role',
label: 'admin.rbac-role.new_button',
variant: 'primary',
size: 'md',
icon: FaPlus,
formConfig: {
fields: [], // Empty array - fields will be generated dynamically from attributes
popupTitle: 'admin.rbac-role.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleRoleCreate',
successMessage: 'admin.rbac-role.create.success',
errorMessage: 'admin.rbac-role.create.error'
}
}
]
```
- Add table content section with:
- `hookFactory: createRbacRolesHook`
- Action buttons: edit and delete (following mandates pattern)
- Configure edit button with `fetchItemFunctionName: 'fetchRoleById'`
- Configure delete button with proper operation names
- Add permission-based disabled logic
- Keep existing privilege checker (sysadmin only)
### 4. Update Translations: All three locale files
- **German (`frontend_nyla/src/locales/de.ts`)**: Add missing translations after line 756:
- `'admin.rbac-role.new_button': 'Rolle hinzufügen'`
- `'admin.rbac-role.action.edit': 'Bearbeiten'`
- `'admin.rbac-role.action.delete': 'Löschen'`
- `'admin.rbac-role.modal.create.title': 'Neue Rolle erstellen'`
- `'admin.rbac-role.create.success': 'Rolle erfolgreich erstellt'`
- `'admin.rbac-role.create.error': 'Fehler beim Erstellen der Rolle'`
- **English (`frontend_nyla/src/locales/en.ts`)**: Add missing translations after line 756:
- `'admin.rbac-role.new_button': 'Add Role'`
- `'admin.rbac-role.action.edit': 'Edit'`
- `'admin.rbac-role.action.delete': 'Delete'`
- `'admin.rbac-role.modal.create.title': 'Create New Role'`
- `'admin.rbac-role.create.success': 'Role created successfully'`
- `'admin.rbac-role.create.error': 'Error creating role'`
- **French (`frontend_nyla/src/locales/fr.ts`)**: Add missing translations after line 756:
- `'admin.rbac-role.new_button': 'Ajouter un rôle'`
- `'admin.rbac-role.action.edit': 'Modifier'`
- `'admin.rbac-role.action.delete': 'Supprimer'`
- `'admin.rbac-role.modal.create.title': 'Créer un nouveau rôle'`
- `'admin.rbac-role.create.success': 'Rôle créé avec succès'`
- `'admin.rbac-role.create.error': 'Erreur lors de la création du rôle'`
## Implementation Details
### API File Structure
- Use `ApiRequestFunction` type from `useApi`
- Support pagination parameters (page, pageSize, sort, filters, search)
- Handle both paginated and non-paginated responses
- Use `/api/rbac/roles` as base URL
- Use `/api/attributes/Role` for attributes endpoint
### Hook Pattern
- Use `useApiRequest` hook for API calls
- Use `usePermissions` hook for permission checking
- Use `getUserDataCache()` to check authentication before fetching
- Implement attribute type mapping using utilities from `attributeTypeMapper.ts`:
- `isCheckboxType()`, `isSelectType()`, `isMultiselectType()`, `isDateTimeType()`, `isTextareaType()`
- Filter out non-editable fields (id, readonly fields, etc.)
- Handle options arrays and option references
### Page Configuration Pattern
- Use `attributesToColumns()` helper to convert attributes to column config
- Disable filtering for date/timestamp fields using `isDateTimeType()`
- Configure action buttons with proper field mappings and operation names
- Use permission-based disabled logic for buttons
- Set `entityType: 'Role'` for EditActionButton
- Add header button using CreateButton component pattern (via formConfig in headerButtons)
## Key Dependencies
- `useApiRequest` from `hooks/useApi`
- `usePermissions` from `hooks/usePermissions`
- `fetchAttributes` from `api/attributesApi`
- `attributeTypeMapper` utilities from `utils/attributeTypeMapper`
- `FormGeneratorTable` component
- `EditActionButton` and `DeleteActionButton` components
- `CreateButton` component (rendered via PageRenderer from headerButtons formConfig)
- `FaPlus` icon from `react-icons/fa`
## Testing Considerations
- Verify all API endpoints are called correctly
- Ensure attributes are fetched from `/api/attributes/Role`
- Verify permission checks work correctly
- Test create, edit, delete operations
- Verify optimistic updates work
- Check that date/timestamp fields are not filterable
- Verify CreateButton appears in header and opens create modal
- Verify translations work in all three languages

View file

@ -1,50 +0,0 @@
name: Deploy Nyla Frontend INT
on:
push:
branches:
- int
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Copy environment file
run: cp config/env-porta-int.env .env
- name: Install dependencies
run: npm ci
- name: Build React app
run: npm run build
- name: Deploy to Infomaniak VM
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
apt-get update && apt-get install -y rsync
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
rsync -az --delete \
-e "ssh -i ~/.ssh/deploy_key" \
./dist/ \
ubuntu@porta-int.poweron.swiss:/srv/nyla/current/dist/
ssh -i ~/.ssh/deploy_key ubuntu@porta-int.poweron.swiss \
"sudo systemctl reload nginx"

View file

@ -1,51 +0,0 @@
name: Deploy Nyla Frontend
on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Copy environment file
run: cp config/env-porta.env .env
- name: Install dependencies
run: npm ci
- name: Build React app
run: npm run build
- name: Deploy to Infomaniak VM
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
apt-get update && apt-get install -y rsync
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
rsync -az --delete \
-e "ssh -i ~/.ssh/deploy_key" \
./dist/ \
ubuntu@porta.poweron.swiss:/srv/nyla/current/dist/
ssh -i ~/.ssh/deploy_key ubuntu@porta.poweron.swiss \
"sudo systemctl reload nginx"

View file

@ -0,0 +1,53 @@
name: Deploy Nyla Frontend to Integration
on:
push:
branches:
- int
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
SERVER_HOST: porta-int.poweron.swiss
SERVER_USER: ubuntu
APP_DIR: /srv/nyla/current
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
- name: Deploy
run: |
ssh -i ~/.ssh/deploy_key ${{ env.SERVER_USER }}@${{ env.SERVER_HOST }} "
set -e
cd ${{ env.APP_DIR }}
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/ui-nyla.git
git fetch origin int
git reset --hard origin/int
cp config/env-int.env .env
rm -f config/env-*.env
npm ci
npm run build:int
"
- name: Health Check
run: |
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" \
https://${{ env.SERVER_HOST }}/ || echo "000")
if [ "$HTTP_STATUS" = "200" ]; then
echo "Health check passed! (HTTP $HTTP_STATUS)"
else
echo "Health check returned HTTP $HTTP_STATUS"
fi

View file

@ -0,0 +1,53 @@
name: Deploy Nyla Frontend to Production
on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
SERVER_HOST: porta.poweron.swiss
SERVER_USER: ubuntu
APP_DIR: /srv/nyla/current
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
- name: Deploy
run: |
ssh -i ~/.ssh/deploy_key ${{ env.SERVER_USER }}@${{ env.SERVER_HOST }} "
set -e
cd ${{ env.APP_DIR }}
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/ui-nyla.git
git fetch origin main
git reset --hard origin/main
cp config/env-prod.env .env
rm -f config/env-*.env
npm ci
npm run build:prod
"
- name: Health Check
run: |
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" \
https://${{ env.SERVER_HOST }}/ || echo "000")
if [ "$HTTP_STATUS" = "200" ]; then
echo "Health check passed! (HTTP $HTTP_STATUS)"
else
echo "Health check returned HTTP $HTTP_STATUS"
fi

View file

@ -1,71 +0,0 @@
name: Deploy Nyla Frontend to Integration
on:
push:
branches:
- int
workflow_dispatch:
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'
- name: Copy integration environment file
run: |
cp config/env-poweron-nyla-int.env .env
- name: Install dependencies
run: |
npm ci
npm install express
- name: Build React app for integration
run: npm run build:int
- name: Prepare deployment package
run: |
# Create deployment package with build files and necessary configs
mkdir deploy
cp -r dist/* deploy/
# Create a simple server.js for serving the app
echo "const express = require('express');" > deploy/server.js
echo "const path = require('path');" >> deploy/server.js
echo "const app = express();" >> deploy/server.js
echo "app.use(express.static(path.join(__dirname)));" >> deploy/server.js
echo "app.get('/*', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });" >> deploy/server.js
echo "const port = process.env.PORT || 8080;" >> deploy/server.js
echo "app.listen(port, () => console.log('Server running on port', port));" >> deploy/server.js
# Create a new package.json for deployment
echo '{
"name": "frontend-int",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}' > deploy/package.json
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3
with:
app-name: 'poweron-nyla-int'
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_POWERON_NYLA_INT }}
package: ./deploy

View file

@ -1,71 +0,0 @@
name: Deploy Nyla Frontend to Production
on:
push:
branches:
- main
workflow_dispatch:
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'
- name: Copy production environment file
run: |
cp config/env-poweron-nyla-prod.env .env
- name: Install dependencies
run: |
npm ci
npm install express
- name: Build React app for production
run: npm run build:prod
- name: Prepare deployment package
run: |
# Create deployment package with build files and necessary configs
mkdir deploy
cp -r dist/* deploy/
# Create a simple server.js for serving the app
echo "const express = require('express');" > deploy/server.js
echo "const path = require('path');" >> deploy/server.js
echo "const app = express();" >> deploy/server.js
echo "app.use(express.static(path.join(__dirname)));" >> deploy/server.js
echo "app.get('/*', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });" >> deploy/server.js
echo "const port = process.env.PORT || 8080;" >> deploy/server.js
echo "app.listen(port, () => console.log('Server running on port', port));" >> deploy/server.js
# Create a new package.json for deployment
echo '{
"name": "frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}' > deploy/package.json
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3
with:
app-name: 'poweron-nyla'
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_POWERON_NYLA }}
package: ./deploy

View file

@ -1,178 +1,22 @@
/** /**
* Simple Configuration Service * Configuration reads mandatory env vars set by .env (copied from config/env-*.env by CI).
* Centralized access to environment variables with fallbacks *
* NO silent fallbacks for critical values.
* If VITE_API_BASE_URL is missing the app fails loudly at startup.
*
* Vite replaces import.meta.env.VITE_* statically at build time.
* Dynamic access via import.meta.env[key] does NOT work in production builds.
* Therefore each variable must be accessed with its literal property name.
*/ */
// API Configuration const _apiBaseUrl: string = import.meta.env.VITE_API_BASE_URL;
export const getApiBaseUrl = (): string => {
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
};
export const getApiTimeout = (): number => { if (!_apiBaseUrl) {
return parseInt(import.meta.env.VITE_API_TIMEOUT || '10000'); throw new Error(
}; 'Missing required env variable: VITE_API_BASE_URL. Ensure .env is present (cp config/env-<env>.env .env).'
);
}
// App Configuration export const getApiBaseUrl = (): string => _apiBaseUrl;
export const getAppName = (): string => {
return import.meta.env.VITE_APP_NAME || 'PowerOn';
};
export const getAppVersion = (): string => { export const getAppName = (): string => import.meta.env.VITE_APP_NAME || 'PowerOn';
return import.meta.env.VITE_APP_VERSION || '0.0.0';
};
export const getAppEnvironment = (): string => {
return import.meta.env.VITE_APP_ENVIRONMENT || 'dev';
};
// Environment Detection
export const isDevelopment = (): boolean => {
return import.meta.env.MODE === 'development' || getAppEnvironment() === 'dev';
};
export const isProduction = (): boolean => {
return import.meta.env.MODE === 'production' || getAppEnvironment() === 'prod';
};
export const isIntegration = (): boolean => {
return getAppEnvironment() === 'int';
};
// Debug Configuration
export const isDebugMode = (): boolean => {
return import.meta.env.VITE_DEBUG === 'true';
};
export const getLogLevel = (): string => {
return import.meta.env.VITE_LOG_LEVEL || 'info';
};
export const isConsoleLogsEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_CONSOLE_LOGS === 'true';
};
// Microsoft Authentication
export const getMicrosoftClientId = (): string | undefined => {
return import.meta.env.VITE_MICROSOFT_CLIENT_ID;
};
export const getMicrosoftTenantId = (): string | undefined => {
return import.meta.env.VITE_MICROSOFT_TENANT_ID;
};
export const getEntraClientSecret = (): string | undefined => {
return import.meta.env.VITE_ENTRA_CLIENT_SECRET;
};
export const getEntraAuthority = (): string | undefined => {
return import.meta.env.VITE_ENTRA_AUTHORITY;
};
export const getEntraRedirectPath = (): string | undefined => {
return import.meta.env.VITE_ENTRA_REDIRECT_PATH;
};
export const getEntraRedirectUri = (): string | undefined => {
return import.meta.env.VITE_ENTRA_REDIRECT_URI;
};
// Feature Flags (if needed in the future)
export const isFeatureEnabled = (feature: string): boolean => {
const envKey = `VITE_ENABLE_${feature.toUpperCase()}`;
return import.meta.env[envKey] === 'true';
};
// Analytics and Monitoring
export const isAnalyticsEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_ANALYTICS === 'true';
};
export const isErrorReportingEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_ERROR_REPORTING === 'true';
};
export const isPerformanceMonitoringEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_PERFORMANCE_MONITORING === 'true';
};
// Development Server (for dev environment)
export const getDevServerPort = (): number => {
return parseInt(import.meta.env.VITE_DEV_SERVER_PORT || '5176');
};
export const getDevServerHost = (): string => {
return import.meta.env.VITE_DEV_SERVER_HOST || 'localhost';
};
export const isDevServerHttps = (): boolean => {
return import.meta.env.VITE_DEV_SERVER_HTTPS === 'true';
};
// Security Configuration
export const isHttpsEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_HTTPS === 'true';
};
export const isCspEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_CSP === 'true';
};
// Test Configuration
export const isMockDataEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_MOCK_DATA === 'true';
};
export const isTestMode = (): boolean => {
return import.meta.env.VITE_ENABLE_TEST_MODE === 'true';
};
// Convenience object for easy destructuring
export const config = {
// API
getApiBaseUrl,
getApiTimeout,
// App
getAppName,
getAppVersion,
getAppEnvironment,
// Environment
isDevelopment,
isProduction,
isIntegration,
// Debug
isDebugMode,
getLogLevel,
isConsoleLogsEnabled,
// Microsoft Auth
getMicrosoftClientId,
getMicrosoftTenantId,
getEntraClientSecret,
getEntraAuthority,
getEntraRedirectPath,
getEntraRedirectUri,
// Features
isFeatureEnabled,
// Analytics
isAnalyticsEnabled,
isErrorReportingEnabled,
isPerformanceMonitoringEnabled,
// Dev Server
getDevServerPort,
getDevServerHost,
isDevServerHttps,
// Security
isHttpsEnabled,
isCspEnabled,
// Test
isMockDataEnabled,
isTestMode,
};

View file

@ -2,5 +2,5 @@
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName) # Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env. # Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL="http://localhost:8000/" VITE_API_BASE_URL="http://localhost:8000"
VITE_APP_NAME=PowerOn Nyla dev VITE_APP_NAME=PowerOn Nyla dev

View file

@ -2,5 +2,5 @@
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName) # Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env. # Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL=https://gateway-int.poweron.swiss VITE_API_BASE_URL=https://api-int.poweron.swiss
VITE_APP_NAME=Poweron Nyla int VITE_APP_NAME=Poweron Nyla int

View file

@ -1,6 +0,0 @@
# Environment: porta (Forgejo deploy → porta.poweron.swiss)
# Consumed by: Vite build + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL=https://api-int.poweron.swiss
VITE_APP_NAME=PORTA

View file

@ -1,6 +0,0 @@
# Environment: porta (Forgejo deploy → porta.poweron.swiss)
# Consumed by: Vite build + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL=https://api.poweron.swiss
VITE_APP_NAME=PORTA

View file

@ -2,5 +2,5 @@
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName) # Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env. # Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL=https://gateway-prod.poweron.swiss VITE_API_BASE_URL=https://api.poweron.swiss
VITE_APP_NAME=PowerOn Nyla VITE_APP_NAME=PowerOn Nyla

View file

@ -1,12 +1 @@
// Export simple configuration service export { getApiBaseUrl, getAppName } from './config';
export * from './config';
// Re-export commonly used functions
export {
getApiBaseUrl,
getAppName,
isDevelopment,
isProduction,
isDebugMode,
config
} from './config';

View file

@ -341,7 +341,7 @@ export interface DataSourceSettings {
export interface CostEstimate { export interface CostEstimate {
estimatedTokens: number; estimatedTokens: number;
estimatedUsd: number; estimatedChf: number;
basis: { basis: {
kind: string; kind: string;
limits: Record<string, number>; limits: Record<string, number>;
@ -373,24 +373,9 @@ export async function getDataSourceCostEstimate(
}); });
} }
export interface PatchFlagResponse { // Flag toggles (neutralize / scope / ragIndexEnabled) now go through the
sourceId: string; // generic UDB endpoint POST /api/udb/node/{key}/flag/{flag}; see
resetDescendantIds: string[]; // `UdbSourcesProvider` and the wiki UDB reference page.
updatedAncestors: { id: string; [key: string]: any }[];
[key: string]: any;
}
export async function patchDataSourceRagIndex(
request: ApiRequestFunction,
dataSourceId: string,
ragIndexEnabled: boolean | null
): Promise<PatchFlagResponse> {
return await request({
url: `/api/datasources/${dataSourceId}/rag-index`,
method: 'patch',
data: { ragIndexEnabled }
});
}
// ============================================================================ // ============================================================================
// RAG INVENTORY // RAG INVENTORY

View file

@ -36,7 +36,6 @@ export interface PaginationParams {
search?: string; search?: string;
viewKey?: string; viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>; groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
owner?: 'all' | 'me' | 'shared';
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -110,7 +109,6 @@ export async function fetchFiles(
if (params.search) paginationObj.search = params.search; if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey; if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels; if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
if (params.owner) requestParams.owner = params.owner;
if (Object.keys(paginationObj).length > 0) { if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj); requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -11,6 +11,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Modal } from '../UiComponents/Modal/Modal'; import { Modal } from '../UiComponents/Modal/Modal';
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa'; import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './AddConnectionWizard.module.css'; import styles from './AddConnectionWizard.module.css';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -74,6 +75,8 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
onMsftAdminConsent, onMsftAdminConsent,
isConnecting = false, isConnecting = false,
}) => { }) => {
const { t } = useLanguage();
const [state, setState] = useState<WizardState>({ const [state, setState] = useState<WizardState>({
currentStep: 'connector', currentStep: 'connector',
connector: null, connector: null,
@ -125,7 +128,7 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
}; };
return ( return (
<Modal open={open} onClose={handleClose} title="Verbindung hinzufügen" size="md" closeOnEscape> <Modal open={open} onClose={handleClose} title={t('Verbindung hinzufügen')} size="md" closeOnEscape>
{/* Stepper */} {/* Stepper */}
<div className={styles.stepper}> <div className={styles.stepper}>
{steps.map((s, i) => ( {steps.map((s, i) => (
@ -146,8 +149,8 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
{/* ---- Step: Connector ---- */} {/* ---- Step: Connector ---- */}
{state.currentStep === 'connector' && ( {state.currentStep === 'connector' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Anbieter wählen</h3> <h3 className={styles.stepTitle}>{t('Anbieter wählen')}</h3>
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p> <p className={styles.stepHint}>{t('Welchen Dienst möchtest du verbinden?')}</p>
<div className={styles.connectorGrid}> <div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => ( {(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
<button <button
@ -167,25 +170,23 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
{/* ---- Step: Consent ---- */} {/* ---- Step: Consent ---- */}
{state.currentStep === 'consent' && ( {state.currentStep === 'consent' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Wissensdatenbank</h3> <h3 className={styles.stepTitle}>{t('Wissensdatenbank')}</h3>
<p className={styles.stepBody}> <p className={styles.stepBody}>
Möchtest du Inhalte aus dieser Verbindung in deine persönliche {t('Möchtest du Inhalte aus dieser Verbindung in deine persönliche Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen aus {provider} zurückgreifen kann?', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : t('diesem Dienst') })}
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
aus {state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'} zurückgreifen kann?
</p> </p>
<p className={styles.stepHint}> <p className={styles.stepHint}>
Du kannst dies später jederzeit in der UDB pro Datenquelle steuern. {t('Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.')}
</p> </p>
<div className={styles.consentButtons}> <div className={styles.consentButtons}>
<button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}> <button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}>
<FaCheck /> Ja, aktivieren <FaCheck /> {t('Ja, aktivieren')}
</button> </button>
<button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}> <button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}>
Nein, überspringen {t('Nein, überspringen')}
</button> </button>
</div> </div>
<div className={styles.stepNavLeft}> <div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button> <button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
</div> </div>
</div> </div>
)} )}
@ -196,13 +197,12 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
<div style={{ textAlign: 'center', marginBottom: 16 }}> <div style={{ textAlign: 'center', marginBottom: 16 }}>
<FaShieldAlt size={32} style={{ color: '#00a4ef' }} /> <FaShieldAlt size={32} style={{ color: '#00a4ef' }} />
</div> </div>
<h3 className={styles.stepTitle}>Organisations-Zustimmung (optional)</h3> <h3 className={styles.stepTitle}>{t('Organisations-Zustimmung (optional)')}</h3>
<p className={styles.stepBody}> <p className={styles.stepBody}>
Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. {t('Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. So müssen andere Benutzer nicht einzeln bestätigen.')}
So müssen andere Benutzer nicht einzeln bestätigen.
</p> </p>
<p className={styles.stepHint}> <p className={styles.stepHint}>
Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt. {t('Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.')}
</p> </p>
<div className={styles.consentButtons}> <div className={styles.consentButtons}>
<button <button
@ -210,14 +210,14 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
className={styles.consentButtonYes} className={styles.consentButtonYes}
onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }} onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
> >
<FaShieldAlt /> Admin-Zustimmung erteilen <FaShieldAlt /> {t('Admin-Zustimmung erteilen')}
</button> </button>
<button type="button" className={styles.consentButtonNo} onClick={goNext}> <button type="button" className={styles.consentButtonNo} onClick={goNext}>
Überspringen {t('Überspringen')}
</button> </button>
</div> </div>
<div className={styles.stepNavLeft}> <div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button> <button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
</div> </div>
</div> </div>
)} )}
@ -225,9 +225,9 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
{/* ---- Step: Infomaniak PAT ---- */} {/* ---- Step: Infomaniak PAT ---- */}
{state.currentStep === 'infomaniakPat' && ( {state.currentStep === 'infomaniakPat' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Infomaniak Personal Access Token</h3> <h3 className={styles.stepTitle}>{t('Infomaniak Personal Access Token')}</h3>
<p className={styles.stepBody}> <p className={styles.stepBody}>
Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein. {t('Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.')}
</p> </p>
<input <input
type="password" type="password"
@ -238,14 +238,14 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
autoFocus autoFocus
/> />
<div className={styles.stepNav}> <div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button> <button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
<button <button
type="button" type="button"
className={styles.navConnect} className={styles.navConnect}
onClick={handleFinalConnect} onClick={handleFinalConnect}
disabled={isConnecting || !state.infomaniakToken.trim()} disabled={isConnecting || !state.infomaniakToken.trim()}
> >
{isConnecting ? 'Verbinden…' : 'Verbinden'} {isConnecting ? t('Verbinden…') : t('Verbinden')}
{!isConnecting && <FaArrowRight size={12} />} {!isConnecting && <FaArrowRight size={12} />}
</button> </button>
</div> </div>
@ -255,31 +255,31 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
{/* ---- Step: Connect ---- */} {/* ---- Step: Connect ---- */}
{state.currentStep === 'connect' && ( {state.currentStep === 'connect' && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Verbindung herstellen</h3> <h3 className={styles.stepTitle}>{t('Verbindung herstellen')}</h3>
<div className={styles.summary}> <div className={styles.summary}>
<div className={styles.summaryRow}> <div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anbieter</span> <span className={styles.summaryKey}>{t('Anbieter')}</span>
<span className={styles.summaryVal}> <span className={styles.summaryVal}>
{state.connector && CONNECTOR_ICONS[state.connector]}&nbsp; {state.connector && CONNECTOR_ICONS[state.connector]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'} {state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span> </span>
</div> </div>
<div className={styles.summaryRow}> <div className={styles.summaryRow}>
<span className={styles.summaryKey}>Wissensdatenbank</span> <span className={styles.summaryKey}>{t('Wissensdatenbank')}</span>
<span className={styles.summaryVal}> <span className={styles.summaryVal}>
{state.knowledgeEnabled ? 'Aktiv' : 'Nicht aktiv'} {state.knowledgeEnabled ? t('Aktiv') : t('Nicht aktiv')}
</span> </span>
</div> </div>
</div> </div>
<div className={styles.stepNav}> <div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button> <button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
<button <button
type="button" type="button"
className={styles.navConnect} className={styles.navConnect}
onClick={handleFinalConnect} onClick={handleFinalConnect}
disabled={isConnecting} disabled={isConnecting}
> >
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`} {isConnecting ? t('Verbinden…') : t('Mit {provider} verbinden', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : '…' })}
{!isConnecting && <FaArrowRight size={12} />} {!isConnecting && <FaArrowRight size={12} />}
</button> </button>
</div> </div>

View file

@ -158,7 +158,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [versions, setVersions] = useState<AutoVersion[]>([]); const [versions, setVersions] = useState<AutoVersion[]>([]);
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null); const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false); const [versionLoading, setVersionLoading] = useState(false);
const didBootstrapEmptyCanvasRef = useRef(false);
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId); const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
@ -599,22 +598,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
useEffect(() => { useEffect(() => {
if (loading || nodeTypes.length === 0) return; if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) { if (currentWorkflowId || initialWorkflowId) return;
didBootstrapEmptyCanvasRef.current = false; if (canvasNodes.length > 0) return;
return;
}
if (didBootstrapEmptyCanvasRef.current) return;
didBootstrapEmptyCanvasRef.current = true;
if (canvasNodes.length === 0 && canvasConnections.length === 0 && invocations.length === 0) {
return;
}
console.debug(`${LOG} bootstrapping empty canvas`, {
currentWorkflowId,
initialWorkflowId,
canvasNodes: canvasNodes.length,
canvasConnections: canvasConnections.length,
invocations: invocations.length,
});
applyGraphWithSync({ nodes: [], connections: [] }, [], { applyGraphWithSync({ nodes: [], connections: [] }, [], {
skipHistory: true, skipHistory: true,
}); });
@ -624,9 +609,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
currentWorkflowId, currentWorkflowId,
initialWorkflowId, initialWorkflowId,
canvasNodes.length, canvasNodes.length,
canvasConnections.length,
invocations.length,
applyGraphWithSync, applyGraphWithSync,
t,
]); ]);
const toggleCategory = useCallback((id: string) => { const toggleCategory = useCallback((id: string) => {

View file

@ -20,8 +20,6 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
import { AiBadge } from '../nodes/shared/AiBadge'; import { AiBadge } from '../nodes/shared/AiBadge';
import { switchOutputLabel } from '../nodes/shared/graphUtils'; import { switchOutputLabel } from '../nodes/shared/graphUtils';
const LOG = '[FlowCanvas]';
export interface CanvasNode { export interface CanvasNode {
id: string; id: string;
type: string; type: string;
@ -844,8 +842,6 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
const onHistoryCheckpointRef = useRef(onHistoryCheckpoint); const onHistoryCheckpointRef = useRef(onHistoryCheckpoint);
onHistoryCheckpointRef.current = onHistoryCheckpoint; onHistoryCheckpointRef.current = onHistoryCheckpoint;
const onSelectionChangeRef = useRef(onSelectionChange);
onSelectionChangeRef.current = onSelectionChange;
const emitHistoryCheckpoint = useCallback(() => { const emitHistoryCheckpoint = useCallback(() => {
onHistoryCheckpointRef.current?.(); onHistoryCheckpointRef.current?.();
@ -1023,19 +1019,12 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
] ]
); );
const lastEmittedSelectionRef = useRef<{ nodeId: string | null; signature: string | null }>({
nodeId: null,
signature: null,
});
useEffect(() => { useEffect(() => {
if (onSelectionChange) {
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null; const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
const signature = node ? JSON.stringify(node) : null; onSelectionChange(node);
const last = lastEmittedSelectionRef.current; }
if (last.nodeId === selectedNodeId && last.signature === signature) return; }, [selectedNodeId, nodes, onSelectionChange]);
lastEmittedSelectionRef.current = { nodeId: selectedNodeId, signature };
onSelectionChangeRef.current?.(node);
}, [selectedNodeId, nodes]);
const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => { const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => {
e.stopPropagation(); e.stopPropagation();
@ -1099,11 +1088,6 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
const handleDrop = useCallback( const handleDrop = useCallback(
async (e: React.DragEvent) => { async (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
console.debug(`${LOG} drop received`, {
types: Array.from(e.dataTransfer.types),
clientX: e.clientX,
clientY: e.clientY,
});
// 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab) // 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab)
if (onExternalDrop) { if (onExternalDrop) {
const reservedMimes = new Set([ const reservedMimes = new Set([
@ -1129,35 +1113,16 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
} }
// 2) Standard: Node-Type aus der NodeSidebar // 2) Standard: Node-Type aus der NodeSidebar
const raw = e.dataTransfer.getData('application/json'); const raw = e.dataTransfer.getData('application/json');
if (!raw || !containerRef.current) { if (!raw || !containerRef.current) return;
console.debug(`${LOG} drop ignored`, {
hasRaw: Boolean(raw),
hasContainer: Boolean(containerRef.current),
});
return;
}
try { try {
const { type } = JSON.parse(raw); const { type } = JSON.parse(raw);
const el = containerRef.current; const el = containerRef.current;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2; const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2;
const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2; const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2;
console.debug(`${LOG} placing node from drop`, {
type,
raw,
dropX: x,
dropY: y,
panOffset,
zoom,
});
onDropNodeType(type, Math.max(0, x), Math.max(0, y)); onDropNodeType(type, Math.max(0, x), Math.max(0, y));
emitHistoryCheckpoint(); emitHistoryCheckpoint();
} catch (error) { } catch (_) {}
console.debug(`${LOG} drop parse failed`, {
raw,
error,
});
}
}, },
[onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint] [onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint]
); );

View file

@ -9,7 +9,6 @@ import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } f
import type { ApiRequestFunction } from '../../../api/workflowApi'; import type { ApiRequestFunction } from '../../../api/workflowApi';
import { getLabel } from '../nodes/shared/utils'; import { getLabel } from '../nodes/shared/utils';
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers'; import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
import { ContextBuilderRenderer } from '../nodes/frontendTypeRenderers/ContextBuilderRenderer';
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker'; import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
import { findRequiredErrors } from '../nodes/shared/paramValidation'; import { findRequiredErrors } from '../nodes/shared/paramValidation';
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
@ -254,7 +253,6 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
for (const param of sortedParameters) { for (const param of sortedParameters) {
if (param.frontendType === 'hidden') continue; if (param.frontendType === 'hidden') continue;
if (param.name === 'context') continue;
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue; if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue; if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
@ -380,15 +378,6 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
t, t,
]); ]);
const extractContentContextParam = useMemo((): NodeTypeParameter | null => {
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
const param = sortedParameters.find((p) => p.name === 'context') ?? null;
if (!param) return null;
if (param.frontendType === 'hidden') return null;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
return param;
}, [node, nodeType, sortedParameters, params]);
if (!node || !nodeType) return null; if (!node || !nodeType) return null;
const isTrigger = node.type.startsWith('trigger.'); const isTrigger = node.type.startsWith('trigger.');
@ -494,71 +483,11 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
</div> </div>
)} )}
{extractContentAccordionItems !== null ? ( {extractContentAccordionItems !== null ? (
<>
{extractContentContextParam ? (
<div
key={`${node.id}-${extractContentContextParam.name}`}
style={{ marginBottom: 8, minWidth: 0, maxWidth: '100%' }}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{extractContentContextParam.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && extractContentContextParam.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{extractContentContextParam.type}
</span>
)}
</div>
<ContextBuilderRenderer
param={extractContentContextParam}
value={workflowParamUiValue(params, extractContentContextParam)}
onChange={(val: unknown) => updateParam(extractContentContextParam.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
/>
</div>
) : null}
{extractContentAccordionItems.length > 0 ? (
<AccordionList<string> <AccordionList<string>
key={`${node.id}-extract-accordion`} key={`${node.id}-extract-accordion`}
defaultOpenId={null} defaultOpenId={null}
items={extractContentAccordionItems} items={extractContentAccordionItems}
/> />
) : null}
</>
) : ( ) : (
parameters.map((param: NodeTypeParameter) => { parameters.map((param: NodeTypeParameter) => {
// Safety net: hidden params have no UI footprint at all — no row, // Safety net: hidden params have no UI footprint at all — no row,

View file

@ -1,48 +0,0 @@
.wrapper {
position: relative;
width: 100%;
}
.input {
width: 100%;
padding: 3px 30px 3px 6px;
font-size: 12px;
border: 1px solid var(--border-color, #ccc);
border-radius: 3px;
outline: none;
box-sizing: border-box;
background: var(--color-bg, #fff);
color: var(--color-text, #334155);
}
.input:focus {
border-color: var(--primary-color, #F25843);
}
.input::placeholder {
color: var(--color-text-muted, #94a3b8);
}
.clearBtn {
position: absolute;
right: 5px;
top: 3px;
bottom: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 25px;
padding: 0;
border: none;
border-radius: 3px;
background: none;
cursor: pointer;
font-size: 25px;
line-height: 1;
color: var(--color-text-secondary, #94a3b8);
}
.clearBtn:hover {
background: none;
color: var(--color-text-secondary, #94a3b8);
}

View file

@ -1,69 +0,0 @@
import React, { type Ref } from 'react';
import styles from './FilterSearchInput.module.css';
export interface FilterSearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
inputRef?: Ref<HTMLInputElement>;
onInputClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
onFocus?: () => void;
onBlur?: () => void;
/** When set, only `inputClassName` styles the input (for floating-label toolbar search). */
variant?: 'compact' | 'inherit';
inputClassName?: string;
wrapperClassName?: string;
clearTitle?: string;
}
export function FilterSearchInput({
value,
onChange,
placeholder = 'Filter...',
inputRef,
onInputClick,
onFocus,
onBlur,
variant = 'compact',
inputClassName,
wrapperClassName,
clearTitle = 'Eingabe löschen',
}: FilterSearchInputProps) {
const inputClass = variant === 'inherit'
? inputClassName
: inputClassName
? `${styles.input} ${inputClassName}`
: styles.input;
return (
<div className={wrapperClassName ? `${styles.wrapper} ${wrapperClassName}` : styles.wrapper}>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={inputClass}
onClick={onInputClick}
onFocus={onFocus}
onBlur={onBlur}
/>
{value && (
<button
type="button"
className={styles.clearBtn}
onClick={(e) => {
e.stopPropagation();
onChange('');
}}
onMouseDown={(e) => e.preventDefault()}
title={clearTitle}
tabIndex={-1}
aria-label={clearTitle}
>
×
</button>
)}
</div>
);
}

View file

@ -1,2 +0,0 @@
export { FilterSearchInput } from './FilterSearchInput';
export type { FilterSearchInputProps } from './FilterSearchInput';

View file

@ -168,7 +168,7 @@
.searchInput { .searchInput {
width: 100%; width: 100%;
height: 40px; height: 40px;
padding: 8px 28px 8px 12px; padding: 8px 12px;
border: 1px solid var(--color-border, #E2E8F0); border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;

View file

@ -2,7 +2,6 @@ import React from 'react';
import type { IconType } from 'react-icons'; import type { IconType } from 'react-icons';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorControls.module.css'; import styles from './FormGeneratorControls.module.css';
import { FilterSearchInput } from '../FilterSearchInput';
import { Button } from '../../UiComponents/Button'; import { Button } from '../../UiComponents/Button';
import { IoIosRefresh } from "react-icons/io"; import { IoIosRefresh } from "react-icons/io";
import { FaTrash, FaDownload } from "react-icons/fa"; import { FaTrash, FaDownload } from "react-icons/fa";
@ -190,15 +189,14 @@ export function FormGeneratorControls({
<div className={styles.searchContainer}> <div className={styles.searchContainer}>
{searchable && ( {searchable && (
<div className={styles.floatingLabelInput}> <div className={styles.floatingLabelInput}>
<FilterSearchInput <input
variant="inherit" type="text"
value={searchTerm}
onChange={onSearchChange}
placeholder=" " placeholder=" "
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
onFocus={() => onSearchFocus(true)} onFocus={() => onSearchFocus(true)}
onBlur={() => onSearchFocus(false)} onBlur={() => onSearchFocus(false)}
inputClassName={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`} className={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`}
clearTitle={t('Suche löschen')}
/> />
<label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}> <label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}>
{t('Suchen...')} {t('Suchen...')}

View file

@ -69,7 +69,6 @@ import {
import { formatUnixTimestamp } from '../../../utils/time'; import { formatUnixTimestamp } from '../../../utils/time';
import { applyFrontendFormat } from '../../../utils/applyFrontendFormat'; import { applyFrontendFormat } from '../../../utils/applyFrontendFormat';
import { FormGeneratorControls } from '../FormGeneratorControls'; import { FormGeneratorControls } from '../FormGeneratorControls';
import { FilterSearchInput } from '../FilterSearchInput';
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue'; import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
import { import {
isDateTimeType, isDateTimeType,
@ -447,11 +446,22 @@ function FilterValuesList({
<> <>
{showSearch && ( {showSearch && (
<div style={{ padding: '4px 6px', borderBottom: '1px solid var(--border-color, #ddd)' }}> <div style={{ padding: '4px 6px', borderBottom: '1px solid var(--border-color, #ddd)' }}>
<FilterSearchInput <input
inputRef={searchInputRef} ref={searchInputRef}
type="text"
value={searchTerm} value={searchTerm}
onChange={(value) => { setSearchTerm(value); setDisplayCount(_FILTER_PAGE_SIZE); }} onChange={(e) => { setSearchTerm(e.target.value); setDisplayCount(_FILTER_PAGE_SIZE); }}
onInputClick={(e) => e.stopPropagation()} placeholder="Filter..."
style={{
width: '100%',
padding: '3px 6px',
fontSize: '12px',
border: '1px solid var(--border-color, #ccc)',
borderRadius: '3px',
outline: 'none',
boxSizing: 'border-box',
}}
onClick={(e) => e.stopPropagation()}
/> />
</div> </div>
)} )}

View file

@ -33,6 +33,19 @@ const _SCOPE_EMOJIS: Record<string, string> = {
const _NEUTRALIZE_ON_EMOJI = '\uD83D\uDD12'; // closed padlock const _NEUTRALIZE_ON_EMOJI = '\uD83D\uDD12'; // closed padlock
const _NEUTRALIZE_OFF_EMOJI = '\uD83D\uDD13'; // open padlock const _NEUTRALIZE_OFF_EMOJI = '\uD83D\uDD13'; // open padlock
const _RAG_ON_EMOJI = '\uD83E\uDDE0'; // brain
const _RAG_OFF_EMOJI = '\uD83E\uDDE0'; // brain (greyed via CSS filter when off)
/** CSS for the OFF-state of a boolean flag button. We desaturate the colour
* emoji and dim it so the on/off transition is obvious at a glance, even
* when the on/off glyph itself is similar (e.g. brain vs greyed-brain). */
const _OFF_STATE_STYLE: React.CSSProperties = {
filter: 'grayscale(1)',
opacity: 0.45,
};
/** Uniform symbol for any flag whose effective value is 'mixed' across children. */
const _MIXED_SYMBOL = '\u25E9';
/** Internal action keys reserved by the tree for the built-in flag buttons. */ /** Internal action keys reserved by the tree for the built-in flag buttons. */
const _ACTION_SCOPE = '__scope__'; const _ACTION_SCOPE = '__scope__';
@ -196,6 +209,8 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
isDragging, isDragging,
ownership, ownership,
compact, compact,
selectable,
pendingActions,
provider, provider,
onToggleExpand, onToggleExpand,
onToggleSelect, onToggleSelect,
@ -208,6 +223,9 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
onSendToChat, onSendToChat,
onCycleScope, onCycleScope,
onToggleNeutralize, onToggleNeutralize,
onToggleRagIndex,
onCreateChild,
onExtraAction,
onDragStart, onDragStart,
onDragOver, onDragOver,
onDragLeave, onDragLeave,
@ -276,6 +294,12 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
const canDelete = isOwn && provider.canDelete?.(node); const canDelete = isOwn && provider.canDelete?.(node);
const canPatchScope = isOwn && provider.canPatchScope?.(node); const canPatchScope = isOwn && provider.canPatchScope?.(node);
const canPatchNeutralize = isOwn && provider.canPatchNeutralize?.(node); const canPatchNeutralize = isOwn && provider.canPatchNeutralize?.(node);
const canPatchRagIndex = isOwn && provider.canPatchRagIndex?.(node);
const canCreateChild =
isOwn &&
!!provider.createChild &&
node.type === 'folder' &&
(provider.canCreate ? provider.canCreate(node.id) : true);
const rowClasses = [ const rowClasses = [
styles.nodeRow, styles.nodeRow,
@ -308,7 +332,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
> >
<div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} /> <div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} />
{!hideRowActionButtons && ( {selectable && !hideRowActionButtons && (
<input <input
type="checkbox" type="checkbox"
className={styles.nodeCheckbox} className={styles.nodeCheckbox}
@ -366,6 +390,20 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
{!hideRowActionButtons && ( {!hideRowActionButtons && (
<> <>
<div className={styles.nodeActionsHover}> <div className={styles.nodeActionsHover}>
{canCreateChild && onCreateChild && (
<button
className={styles.emojiBtn}
onClick={(e) => {
e.stopPropagation();
onCreateChild(node.id);
}}
title="Neuer Unterordner"
tabIndex={-1}
>
{'\u2795'}
</button>
)}
{canRename && ( {canRename && (
<button <button
className={styles.emojiBtn} className={styles.emojiBtn}
@ -380,7 +418,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
</button> </button>
)} )}
{node.type !== 'folder' && ( {node.type !== 'folder' && provider.downloadNode && (
<button <button
className={styles.emojiBtn} className={styles.emojiBtn}
onClick={(e) => { onClick={(e) => {
@ -410,6 +448,49 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
</div> </div>
<div className={styles.nodeActionsPersistent}> <div className={styles.nodeActionsPersistent}>
{/* Order (left-to-right): extraActions (e.g. settings) -> RAG -> sendToChat -> scope -> neutralize. */}
{node.extraActions?.map((action) => (
<button
key={action.key}
className={`${styles.emojiBtn} ${action.disabled ? styles.emojiBtnReadonly : ''}`}
onClick={(e) => {
e.stopPropagation();
if (!action.disabled) onExtraAction(node.id, action);
}}
title={action.tooltip}
tabIndex={-1}
disabled={action.disabled}
>
{pendingActions.has(action.key)
? <span className={styles.flagSpinner} />
: action.value === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: action.icon}
</button>
))}
{node.ragIndexEnabled !== undefined && (
<button
className={`${styles.emojiBtn} ${canPatchRagIndex ? '' : styles.emojiBtnReadonly}`}
onClick={(e) => {
e.stopPropagation();
if (canPatchRagIndex) onToggleRagIndex(node);
}}
title={node.ragIndexEnabled === 'mixed'
? 'Gemischt - Klick setzt explizit'
: node.ragIndexEnabled ? 'RAG-Indexierung an' : 'RAG-Indexierung aus'}
tabIndex={-1}
>
{pendingActions.has(_ACTION_RAG)
? <span className={styles.flagSpinner} />
: node.ragIndexEnabled === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: node.ragIndexEnabled === true
? _RAG_ON_EMOJI
: <span style={_OFF_STATE_STYLE}>{_RAG_OFF_EMOJI}</span>}
</button>
)}
{onSendToChat && ( {onSendToChat && (
<button <button
className={styles.emojiBtn} className={styles.emojiBtn}
@ -431,10 +512,16 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
e.stopPropagation(); e.stopPropagation();
if (canPatchScope) onCycleScope(node); if (canPatchScope) onCycleScope(node);
}} }}
title={`Scope: ${node.scope}`} title={node.scope === 'mixed'
? 'Gemischt - Klick setzt explizit'
: `Scope: ${node.scope}`}
tabIndex={-1} tabIndex={-1}
> >
{_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal} {pendingActions.has(_ACTION_SCOPE)
? <span className={styles.flagSpinner} />
: node.scope === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: (_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal)}
</button> </button>
)} )}
@ -445,11 +532,18 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
e.stopPropagation(); e.stopPropagation();
if (canPatchNeutralize) onToggleNeutralize(node); if (canPatchNeutralize) onToggleNeutralize(node);
}} }}
title={node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'} title={node.neutralize === 'mixed'
? 'Gemischt - Klick setzt explizit'
: node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
tabIndex={-1} tabIndex={-1}
style={{ opacity: node.neutralize ? 1 : 0.35 }}
> >
{node.neutralize ? _NEUTRALIZE_ON_EMOJI : _NEUTRALIZE_OFF_EMOJI} {pendingActions.has(_ACTION_NEUTRALIZE)
? <span className={styles.flagSpinner} />
: node.neutralize === 'mixed'
? <span className={styles.flagMixed}>{_MIXED_SYMBOL}</span>
: node.neutralize === true
? _NEUTRALIZE_ON_EMOJI
: <span style={_OFF_STATE_STYLE}>{_NEUTRALIZE_OFF_EMOJI}</span>}
</button> </button>
)} )}
</div> </div>
@ -474,7 +568,6 @@ export function FormGeneratorTree<T = any>({
onSendToChat, onSendToChat,
allowCreateFolder = true, allowCreateFolder = true,
selectable = true, selectable = true,
refreshAfterAction = false,
className, className,
embedMaxHeight, embedMaxHeight,
hideRowActionButtons = false, hideRowActionButtons = false,
@ -520,29 +613,10 @@ export function FormGeneratorTree<T = any>({
); );
/** After a toggle, collect all currently visible node IDs and ask the /** After a toggle, refetch children for root + all expanded parents so the
* provider for their updated attributes. Patches only attribute fields * backend-authoritative effective flag values are current. No attribute-only
* (neutralize, scope, ragIndexEnabled) on existing nodes no structural * shortcut the backend is the single source of truth (spec 2026-05-18). */
* reload. Falls back to full refetch if provider doesn't implement
* refreshAttributes. */
const _refreshVisibleAttributes = useCallback(async () => { const _refreshVisibleAttributes = useCallback(async () => {
if (provider.refreshAttributes) {
const visibleIds = flatEntriesRef.current.map((e) => e.node.id);
if (visibleIds.length === 0) return;
const attrs = await provider.refreshAttributes(visibleIds);
setNodes((prev) =>
prev.map((n) => {
const update = attrs.get(n.id);
if (!update) return n;
const patched: Partial<typeof n> = {};
if (n.neutralize !== undefined && update.neutralize !== undefined) patched.neutralize = update.neutralize;
if (n.scope !== undefined && update.scope !== undefined) patched.scope = update.scope;
if (n.ragIndexEnabled !== undefined && update.ragIndexEnabled !== undefined) patched.ragIndexEnabled = update.ragIndexEnabled;
if (Object.keys(patched).length === 0) return n;
return { ...n, ...patched };
}),
);
} else {
const expandedList: (string | null)[] = [null, ...Array.from(expandedIds)]; const expandedList: (string | null)[] = [null, ...Array.from(expandedIds)];
const fetched = await Promise.all( const fetched = await Promise.all(
expandedList.map((p) => provider.loadChildren(p, ownership)), expandedList.map((p) => provider.loadChildren(p, ownership)),
@ -555,13 +629,12 @@ export function FormGeneratorTree<T = any>({
}); });
return [...keepers, ...fetched.flat()]; return [...keepers, ...fetched.flat()];
}); });
}
}, [expandedIds, provider, ownership]); }, [expandedIds, provider, ownership]);
/** Wrap any async action with pending-state tracking so the tree can show /** Wrap any async action with pending-state tracking so the tree can show
* a spinner over the corresponding button. Generic no domain knowledge. * a spinner over the corresponding button. Generic no domain knowledge.
* When `refreshAfterAction` is enabled, the spinner stays on until the * Always refetches all expanded parents after the action completes so the
* refreshed attributes have been written into state. */ * backend-authoritative values are rendered. */
const _runAction = useCallback( const _runAction = useCallback(
async (nodeId: string, actionKey: string, fn: () => Promise<void> | void) => { async (nodeId: string, actionKey: string, fn: () => Promise<void> | void) => {
setPendingActions((prev) => { setPendingActions((prev) => {
@ -573,9 +646,7 @@ export function FormGeneratorTree<T = any>({
}); });
try { try {
await fn(); await fn();
if (refreshAfterAction || provider.refreshAttributes) {
await _refreshVisibleAttributes(); await _refreshVisibleAttributes();
}
} finally { } finally {
setPendingActions((prev) => { setPendingActions((prev) => {
const next = new Map(prev); const next = new Map(prev);
@ -587,7 +658,7 @@ export function FormGeneratorTree<T = any>({
}); });
} }
}, },
[refreshAfterAction, _refreshVisibleAttributes], [_refreshVisibleAttributes],
); );
const _loadRoot = useCallback(async () => { const _loadRoot = useCallback(async () => {
@ -610,6 +681,45 @@ export function FormGeneratorTree<T = any>({
_loadRoot(); _loadRoot();
}, [_loadRoot]); }, [_loadRoot]);
/** Auto-expand nodes with `defaultExpanded=true` from backend, one-shot per id.
* Fetches children first, then sets expandedIds + merges atomically so the
* expanded arrow never appears without visible children. */
useEffect(() => {
const targets = nodes.filter(
(n) => n.defaultExpanded === true && !autoExpandedRef.current.has(n.id),
);
if (targets.length === 0) return;
const targetIds = targets.map((t) => t.id);
for (const id of targetIds) autoExpandedRef.current.add(id);
let cancelled = false;
(async () => {
const childMap = _buildChildMap(nodes);
const toFetch = targetIds.filter((id) => {
const existing = childMap.get(id);
return !existing || existing.length === 0;
});
if (toFetch.length > 0) {
const results = await Promise.all(
toFetch.map((id) =>
provider.loadChildren(id, ownership).catch(() => [] as TreeNode<T>[]),
),
);
if (cancelled) return;
const flat = results.flat();
if (flat.length > 0) {
setNodes((prev) => _mergeNodes(prev, flat));
}
}
if (cancelled) return;
setExpandedIds((prev) => {
const next = new Set(prev);
for (const id of targetIds) next.add(id);
return next;
});
})();
return () => { cancelled = true; };
}, [nodes, provider, ownership, _mergeNodes]);
const flatEntriesRaw = useMemo( const flatEntriesRaw = useMemo(
() => _flatten(nodes, expandedIds, confirmedEmptyFolderIds), () => _flatten(nodes, expandedIds, confirmedEmptyFolderIds),
[nodes, expandedIds, confirmedEmptyFolderIds], [nodes, expandedIds, confirmedEmptyFolderIds],

View file

@ -1106,19 +1106,15 @@ describe('FormGeneratorTree', () => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// refreshAfterAction (backend-authoritative mode) // Always refetch after action (backend-authoritative, spec 2026-05-18)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('refreshAfterAction', () => { describe('refetch after action', () => {
it('refetches null + expanded parents after a flag toggle', async () => { it('refetches null + expanded parents after a flag toggle', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]); const provider = _createMockProvider([_ownFolder]);
render( render(
<FormGeneratorTree <FormGeneratorTree provider={provider} ownership="own" />,
provider={provider}
ownership="own"
refreshAfterAction
/>,
); );
await waitFor(() => { await waitFor(() => {
@ -1139,28 +1135,6 @@ describe('FormGeneratorTree', () => {
expect(newCalls.length).toBeGreaterThan(initialLoadCalls); expect(newCalls.length).toBeGreaterThan(initialLoadCalls);
expect(newCalls.some(c => c[0] === null && c[1] === 'own')).toBe(true); expect(newCalls.some(c => c[0] === null && c[1] === 'own')).toBe(true);
}); });
it('does NOT refetch when refreshAfterAction is false (default)', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const initialLoadCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).mock.calls.length;
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
await user.click(neutralizeBtn);
await waitFor(() => {
expect(provider.patchNeutralize).toHaveBeenCalled();
});
const newCalls = (provider.loadChildren as ReturnType<typeof vi.fn>).mock.calls.length;
expect(newCalls).toBe(initialLoadCalls);
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -1,6 +1,7 @@
import { FaFolder, FaFile, FaTrash } from 'react-icons/fa'; import { FaFolder, FaFile, FaTrash } from 'react-icons/fa';
import type { TreeNodeProvider, TreeNode, Ownership, ScopeValue, TreeBatchAction } from '../types'; import type { TreeNodeProvider, TreeNode, Ownership, ScopeValue, TreeBatchAction } from '../types';
import api from '../../../../api'; import api from '../../../../api';
import { getUserDataCache } from '../../../../utils/userCache';
interface FolderData { interface FolderData {
id: string; id: string;
@ -116,7 +117,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
if ((f.parentId ?? null) === null) out.add(f.id); if ((f.parentId ?? null) === null) out.add(f.id);
} }
const paginationParam = JSON.stringify({ filters: { folderId: null }, pageSize: 500 }); const paginationParam = JSON.stringify({ filters: { folderId: null }, pageSize: 500 });
const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam, owner } }); const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam } });
const data = filesRes.data; const data = filesRes.data;
const rawFiles: FileData[] = (data && typeof data === 'object' && 'items' in data) const rawFiles: FileData[] = (data && typeof data === 'object' && 'items' in data)
? (Array.isArray(data.items) ? data.items : []) ? (Array.isArray(data.items) ? data.items : [])
@ -168,7 +169,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
} }
const paginationParam = JSON.stringify({ filters, pageSize: 500 }); const paginationParam = JSON.stringify({ filters, pageSize: 500 });
const filesRes = await api.get('/api/files/list', { const filesRes = await api.get('/api/files/list', {
params: { pagination: paginationParam, owner }, params: { pagination: paginationParam },
}); });
const data = filesRes.data; const data = filesRes.data;
let rawFiles: FileData[] = []; let rawFiles: FileData[] = [];
@ -178,6 +179,10 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
rawFiles = data; rawFiles = data;
} }
let matched = rawFiles.filter((f) => (f.folderId ?? null) === apiParentId); let matched = rawFiles.filter((f) => (f.folderId ?? null) === apiParentId);
if (ownership === 'shared') {
const myId = getUserDataCache()?.id;
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
}
const fileNodes = matched.map((f) => _mapFileToNode(f, ownership)); const fileNodes = matched.map((f) => _mapFileToNode(f, ownership));
if (apiParentId === null) { if (apiParentId === null) {
for (const n of fileNodes) n.parentId = synthRootId; for (const n of fileNodes) n.parentId = synthRootId;
@ -294,19 +299,6 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
); );
}, },
async refreshAttributes(ids: string[]) {
const res = await api.post('/api/files/attributes', { ids });
const raw: Record<string, { neutralize?: boolean | 'mixed'; scope?: string | 'mixed' }> = res.data ?? {};
const result = new Map<string, { neutralize?: boolean | 'mixed'; scope?: ScopeValue | 'mixed' }>();
for (const [id, attrs] of Object.entries(raw)) {
result.set(id, {
neutralize: attrs.neutralize,
scope: attrs.scope as ScopeValue | 'mixed',
});
}
return result;
},
getBatchActions(): TreeBatchAction[] { getBatchActions(): TreeBatchAction[] {
return [ return [
{ {

View file

@ -88,16 +88,6 @@ export interface TreeNodeProvider<T = any> {
patchRagIndex?(ids: string[], ragIndexEnabled: boolean): Promise<void>; patchRagIndex?(ids: string[], ragIndexEnabled: boolean): Promise<void>;
downloadNode?(node: TreeNode<T>): Promise<void>; downloadNode?(node: TreeNode<T>): Promise<void>;
getBatchActions?(): TreeBatchAction[]; getBatchActions?(): TreeBatchAction[];
/** After a toggle action, the tree collects all currently visible node IDs
* and calls this method. The provider asks the backend for the current
* attribute values (incl. mixed) of exactly those IDs. The tree then
* patches only the attribute fields on existing nodes no structural
* reload. If not implemented, the tree falls back to _refetchAllExpanded. */
refreshAttributes?(ids: string[]): Promise<Map<string, {
neutralize?: boolean | 'mixed';
scope?: ScopeValue | 'mixed';
ragIndexEnabled?: boolean | 'mixed';
}>>;
/** Called during drag-start to let the provider inject domain-specific MIME /** Called during drag-start to let the provider inject domain-specific MIME
* types into the DataTransfer (e.g. `application/datasource`). The generic * types into the DataTransfer (e.g. `application/datasource`). The generic
* tree always sets `application/tree-items` and `text/plain`; this hook * tree always sets `application/tree-items` and `text/plain`; this hook
@ -123,14 +113,6 @@ export interface FormGeneratorTreeProps<T = any> {
/** When false, hides checkboxes, multi-select keyboard bindings and the /** When false, hides checkboxes, multi-select keyboard bindings and the
* batch-action toolbar. Default true (backward compatible). */ * batch-action toolbar. Default true (backward compatible). */
selectable?: boolean; selectable?: boolean;
/** When true, after every flag-toggle / extra-action the tree refetches
* children for `null` and every currently expanded id, then atomically
* replaces the affected nodes. Optimistic local-state updates are skipped
* in this mode -- the backend is the single source of truth.
*
* Default `false` for backward-compat with FilesTab and other consumers
* that rely on the optimistic-update path. */
refreshAfterAction?: boolean;
className?: string; className?: string;
/** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */ /** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */
embedMaxHeight?: number; embedMaxHeight?: number;

View file

@ -29,7 +29,7 @@ interface ChatsTabProps {
onSelectChat?: (chatId: string, featureInstanceId: string) => void; onSelectChat?: (chatId: string, featureInstanceId: string) => void;
onDragStart?: (chatId: string, event: React.DragEvent) => void; onDragStart?: (chatId: string, event: React.DragEvent) => void;
activeWorkflowId?: string; activeWorkflowId?: string;
chatListRefreshKey?: number; onCreateNew?: () => void;
onRenameChat?: (chatId: string, newName: string) => void | Promise<void>; onRenameChat?: (chatId: string, newName: string) => void | Promise<void>;
onDeleteChat?: (chatId: string) => void | Promise<void>; onDeleteChat?: (chatId: string) => void | Promise<void>;
} }
@ -72,7 +72,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
onSelectChat, onSelectChat,
onDragStart, onDragStart,
activeWorkflowId, activeWorkflowId,
chatListRefreshKey, onCreateNew,
onRenameChat, onRenameChat,
onDeleteChat, onDeleteChat,
}) => { }) => {
@ -82,14 +82,13 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filter, setFilter] = useState<ChatFilter>('active'); const [filter, setFilter] = useState<ChatFilter>('active');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()); const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [hasLoadedOnce, setHasLoadedOnce] = useState(false); const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const renameInputRef = useRef<HTMLInputElement>(null); const renameInputRef = useRef<HTMLInputElement>(null);
const groupsRef = useRef(groups);
groupsRef.current = groups;
const _loadChats = useCallback(async (serverSearch?: string) => { const _loadChats = useCallback(async (serverSearch?: string) => {
setLoading(true);
try { try {
const params: Record<string, unknown> = { includeArchived: true }; const params: Record<string, unknown> = { includeArchived: true };
if (serverSearch) params.search = serverSearch; if (serverSearch) params.search = serverSearch;
@ -141,7 +140,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
} catch (err) { } catch (err) {
console.error('Failed to load chats:', err); console.error('Failed to load chats:', err);
} finally { } finally {
setHasLoadedOnce(true); setLoading(false);
} }
}, [context.instanceId, t]); }, [context.instanceId, t]);
@ -164,12 +163,6 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
} }
}, [activeWorkflowId]); }, [activeWorkflowId]);
useEffect(() => {
if (chatListRefreshKey) {
_loadChats();
}
}, [chatListRefreshKey, _loadChats]);
useEffect(() => { useEffect(() => {
if (editingId && renameInputRef.current) { if (editingId && renameInputRef.current) {
renameInputRef.current.focus(); renameInputRef.current.focus();
@ -195,18 +188,8 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
const trimmed = editName.trim(); const trimmed = editName.trim();
setEditingId(null); setEditingId(null);
if (!trimmed || !onRenameChat) return; if (!trimmed || !onRenameChat) return;
const prev = groupsRef.current;
setGroups(gs => gs.map(g => ({
...g,
chats: g.chats.map(c => (c.id === chatId ? { ...c, label: trimmed } : c)),
})));
try {
await onRenameChat(chatId, trimmed); await onRenameChat(chatId, trimmed);
_loadChats(); _loadChats();
} catch (err) {
console.error('Failed to rename chat:', err);
setGroups(prev);
}
}; };
const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => { const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => {
@ -218,41 +201,23 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
} }
}; };
const _setChatStatus = useCallback((chatId: string, status: string) => {
setGroups(gs => gs.map(g => ({
...g,
chats: g.chats.map(c => (c.id === chatId ? { ...c, status } : c)),
})));
}, []);
const _removeChat = useCallback((chatId: string) => {
setGroups(gs => gs.map(g => ({
...g,
chats: g.chats.filter(c => c.id !== chatId),
})).filter(g => g.chats.length > 0));
}, []);
const _archiveChat = useCallback(async (chatId: string) => { const _archiveChat = useCallback(async (chatId: string) => {
const prev = groupsRef.current;
_setChatStatus(chatId, 'archived');
try { try {
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' }); await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' });
_loadChats();
} catch (err) { } catch (err) {
console.error('Failed to archive chat:', err); console.error('Failed to archive chat:', err);
setGroups(prev);
} }
}, [context.instanceId, _setChatStatus]); }, [context.instanceId, _loadChats]);
const _restoreChat = useCallback(async (chatId: string) => { const _restoreChat = useCallback(async (chatId: string) => {
const prev = groupsRef.current;
_setChatStatus(chatId, 'active');
try { try {
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' }); await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' });
_loadChats();
} catch (err) { } catch (err) {
console.error('Failed to restore chat:', err); console.error('Failed to restore chat:', err);
setGroups(prev);
} }
}, [context.instanceId, _setChatStatus]); }, [context.instanceId, _loadChats]);
const _isArchived = (chat: ChatItem) => chat.status === 'archived'; const _isArchived = (chat: ChatItem) => chat.status === 'archived';
@ -346,17 +311,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
{onDeleteChat && ( {onDeleteChat && (
<button <button
className={`${styles.actionBtn} ${styles.actionBtnDanger}`} className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={async (e) => { onClick={async (e) => { e.stopPropagation(); await onDeleteChat(chat.id); _loadChats(); }}
e.stopPropagation();
const prev = groupsRef.current;
_removeChat(chat.id);
try {
await onDeleteChat(chat.id);
} catch (err) {
console.error('Failed to delete chat:', err);
setGroups(prev);
}
}}
title={t('Löschen')} title={t('Löschen')}
> >
🗑 🗑
@ -379,6 +334,8 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
return labels[code] || code; return labels[code] || code;
}; };
if (loading) return <div className={styles.loading}>{t('Chats werden geladen…')}</div>;
return ( return (
<div className={styles.chatsTab}> <div className={styles.chatsTab}>
<div className={styles.toolbar}> <div className={styles.toolbar}>
@ -389,6 +346,11 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
{onCreateNew && (
<button className={styles.createBtn} onClick={() => { onCreateNew(); setTimeout(_loadChats, 500); }} title={t('Neuer Chat')}>
+
</button>
)}
<button <button
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`} className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
onClick={() => setFlatMode(!flatMode)} onClick={() => setFlatMode(!flatMode)}
@ -475,7 +437,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
</div> </div>
)} )}
{hasLoadedOnce && _allChats.length === 0 && ( {_allChats.length === 0 && (
<div className={styles.emptyState}> <div className={styles.emptyState}>
{filter === 'archived' ? t('Keine archivierten Chats') : t('Keine aktiven Chats')} {filter === 'archived' ? t('Keine archivierten Chats') : t('Keine aktiven Chats')}
</div> </div>

View file

@ -6,7 +6,7 @@
* *
* 1. Connection knowledgeIngestionEnabled master switch + mail/clickup prefs * 1. Connection knowledgeIngestionEnabled master switch + mail/clickup prefs
* 2. DataSource RAG-Limits maxBytes/maxFileSize/maxItems/maxDepth (or clickup variants) * 2. DataSource RAG-Limits maxBytes/maxFileSize/maxItems/maxDepth (or clickup variants)
* 3. Cost estimate indicative, non-binding USD figure * 3. Cost estimate indicative, non-binding CHF figure
* *
* Why a single modal: * Why a single modal:
* - The architectural rule is "no icon inflation in the UDB". One opens * - The architectural rule is "no icon inflation in the UDB". One opens
@ -302,7 +302,7 @@ export const DataSourceSettingsModal: React.FC<Props> = ({
}}> }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontSize: 13 }}>{t('Voll-Sync (geschätzt)')}</span> <span style={{ fontSize: 13 }}>{t('Voll-Sync (geschätzt)')}</span>
<span style={{ fontSize: 16, fontWeight: 600 }}>~ {cost.estimatedUsd.toFixed(4)} USD</span> <span style={{ fontSize: 16, fontWeight: 600 }}>~ {cost.estimatedChf.toFixed(4)} CHF</span>
</div> </div>
<div style={{ fontSize: 11, color: '#777', marginTop: 4 }}> <div style={{ fontSize: 11, color: '#777', marginTop: 4 }}>
~ {cost.estimatedTokens.toLocaleString()} {t('Tokens')} · {cost.basis.notes} ~ {cost.estimatedTokens.toLocaleString()} {t('Tokens')} · {cost.basis.notes}

View file

@ -81,60 +81,6 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.uploadCircleButton {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
color: #f25843;
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.uploadCircleButton:disabled {
cursor: not-allowed;
}
.uploadCircleWrap {
position: relative;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.uploadCircleSvg {
position: absolute;
inset: 0;
transform: rotate(-90deg);
}
.uploadCircleTrack {
fill: none;
stroke: rgba(242, 88, 67, 0.25);
stroke-width: 2;
}
.uploadCircleProgress {
fill: none;
stroke: #f25843;
stroke-width: 2;
stroke-linecap: round;
transition: stroke-dashoffset 120ms linear;
}
.uploadCircleText {
font-size: 8px;
font-weight: 700;
line-height: 1;
color: #f25843;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.fileRow:hover { .fileRow:hover {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);

View file

@ -28,8 +28,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
const uploadRunIdRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const provider = useMemo(() => createFolderFileProvider(), []); const provider = useMemo(() => createFolderFileProvider(), []);
@ -56,41 +54,21 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!context.instanceId || uploading) return; if (!context.instanceId || uploading) return;
uploadRunIdRef.current += 1;
const runId = uploadRunIdRef.current;
setUploading(true); setUploading(true);
setUploadProgressPercent(0);
try { try {
const files = Array.from(fileList); for (const file of Array.from(fileList)) {
const totalFiles = files.length || 1;
for (const [index, file] of files.entries()) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('featureInstanceId', context.instanceId); formData.append('featureInstanceId', context.instanceId);
await api.post('/api/files/upload', formData, { await api.post('/api/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: progressEvent => {
if (uploadRunIdRef.current !== runId) return;
if (!progressEvent.total) return;
const fileProgress = Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total));
const baseProgress = (index / totalFiles) * 100;
const scaledFileProgress = fileProgress / totalFiles;
setUploadProgressPercent(Math.min(100, Math.round(baseProgress + scaledFileProgress)));
},
}); });
} }
if (uploadRunIdRef.current === runId) setUploadProgressPercent(100);
_handleRefresh(); _handleRefresh();
} catch (err) { } catch (err) {
console.error('File upload failed:', err); console.error('File upload failed:', err);
} finally { } finally {
if (uploadRunIdRef.current === runId) {
setUploading(false); setUploading(false);
// Let 100% render briefly, then reset.
window.setTimeout(() => {
if (uploadRunIdRef.current === runId) setUploadProgressPercent(0);
}, 250);
}
} }
}, [context.instanceId, uploading, _handleRefresh]); }, [context.instanceId, uploading, _handleRefresh]);
@ -157,10 +135,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]); onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
}, [onSendToChat]); }, [onSendToChat]);
const circleRadius = 11;
const circleCircumference = 2 * Math.PI * circleRadius;
const circleOffset = circleCircumference * (1 - uploadProgressPercent / 100);
return ( return (
<div <div
className={styles.filesTab} className={styles.filesTab}
@ -196,26 +170,10 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={uploading} disabled={uploading}
className={styles.uploadCircleButton} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
title={t('Dateien hochladen')} title={t('Dateien hochladen')}
> >
{uploading ? ( {uploading ? '...' : '+'}
<span className={styles.uploadCircleWrap} aria-hidden="true">
<svg className={styles.uploadCircleSvg} viewBox="0 0 24 24">
<circle className={styles.uploadCircleTrack} cx="12" cy="12" r={circleRadius} />
<circle
className={styles.uploadCircleProgress}
cx="12"
cy="12"
r={circleRadius}
style={{ strokeDasharray: `${circleCircumference}`, strokeDashoffset: `${circleOffset}` }}
/>
</svg>
<span className={styles.uploadCircleText}>{uploadProgressPercent}%</span>
</span>
) : (
'+'
)}
</button> </button>
<button <button
onClick={_handleRefresh} onClick={_handleRefresh}
@ -243,7 +201,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
title={t('Eigene')} title={t('Eigene')}
compact={true} compact={true}
showFilter={true} showFilter={true}
refreshAfterAction
onNodeClick={_handleNodeClickWithImport} onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat} onSendToChat={_handleSendToChat}
/> />
@ -255,7 +212,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
compact={true} compact={true}
collapsible={true} collapsible={true}
defaultCollapsed={true} defaultCollapsed={true}
refreshAfterAction
emptyMessage={t('Keine geteilten Dateien')} emptyMessage={t('Keine geteilten Dateien')}
onNodeClick={_handleNodeClickWithImport} onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat} onSendToChat={_handleSendToChat}

View file

@ -4,12 +4,12 @@
* SourcesTab UDB tab for personal connections + mandate data. * SourcesTab UDB tab for personal connections + mandate data.
* *
* Architecture: * Architecture:
* - Backend is the single source of truth (`POST /api/workspace/{instanceId}/tree/children`). * - Backend is the single source of truth (`POST /api/udb/tree/children`).
* - Tree mechanism: generic `FormGeneratorTree` with a UDB-specific provider. * - Tree mechanism: generic `FormGeneratorTree` with a UDB-specific provider.
* - Inheritance, mixed-state aggregation and cascade-NULL on patch are * - Inheritance, mixed-state aggregation and cascade-NULL on patch are
* ALL handled by the backend; the frontend never recomputes effective values. * ALL handled by the backend; the frontend never recomputes effective values.
* - Every flag toggle goes through `refreshAfterAction`: PATCH -> refetch all * - Every flag toggle: PATCH -> refetch all expanded parents via
* expanded parents -> atomic state replace. No optimistic updates. * loadChildren -> atomic state replace. No optimistic updates.
*/ */
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
@ -67,7 +67,6 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context }) => {
compact compact
selectable={false} selectable={false}
allowCreateFolder={false} allowCreateFolder={false}
refreshAfterAction
emptyMessage={t('Keine Datenquellen.')} emptyMessage={t('Keine Datenquellen.')}
/> />
</div> </div>

View file

@ -3,19 +3,28 @@
/** /**
* UdbSourcesProvider TreeNodeProvider for the UDB Sources tab. * UdbSourcesProvider TreeNodeProvider for the UDB Sources tab.
* *
* Single responsibility: translate the backend tree contract * Single responsibility: translate the generic UDB backend tree contract
* (POST /api/workspace/{instanceId}/tree/children nodesByParent map) into * (POST /api/udb/tree/children -> nodesByParent map) into the generic
* the generic TreeNode shape that FormGeneratorTree consumes, and forward * TreeNode shape that FormGeneratorTree consumes, and forward flag
* flag PATCHes to the existing /api/datasources/{id}/{flag} endpoints. * mutations to the single generic UDB endpoint
* (POST /api/udb/node/{key}/flag/{flag}).
* *
* No effective-value computation, no inheritance logic, no mixed-state math: * No effective-value computation, no inheritance logic, no mixed-state math:
* the backend is the single source of truth. The provider only: * the backend is the single source of truth. The provider only:
* 1. caches the most recently loaded backend node payload per id, so PATCHes * 1. caches the most recently loaded backend node payload per id so the
* can resolve the implicit DataSource record (creating it lazily when the * drag/settings handlers have direct access to coordinates,
* backend reports `canBeAdded=true`),
* 2. emits stable display ordering via `displayOrder`, * 2. emits stable display ordering via `displayOrder`,
* 3. hides flag affordances on synthetic container nodes (synthRoot, * 3. hides flag affordances on synthetic container nodes (synthRoot,
* mandateGroup) by leaving the corresponding TreeNode field undefined. * mandateGroup) by leaving the corresponding TreeNode field undefined.
*
* The UDB API endpoints are intentionally feature-agnostic; they do not
* carry an `instanceId` in the path. The legacy `instanceId` argument is
* still accepted by `createUdbSourcesProvider` because:
* - the drag payloads for "create personal DataSource" / "create FDS"
* hit feature-instance-scoped helper endpoints under /api/workspace/
* to keep ingestion/RAG bound to the caller's workspace,
* - the rootKey is namespaced per instance so two UDBs on the same
* screen never collide.
*/ */
import React from 'react'; import React from 'react';
@ -137,6 +146,11 @@ function _isSyntheticContainer(kind: UdbBackendKind): boolean {
return kind === 'synthRoot' || kind === 'mandateGroup'; return kind === 'synthRoot' || kind === 'mandateGroup';
} }
/** FDS-family kinds (no `scope` attribute; visibility is feature RBAC). */
function _isFdsKind(kind: UdbBackendKind): boolean {
return kind === 'featureNode' || kind === 'fdsTable' || kind === 'fdsRecord' || kind === 'fdsField';
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mapping: backend payload -> generic TreeNode // Mapping: backend payload -> generic TreeNode
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -165,7 +179,14 @@ function _mapBackendNode(
// Fields expose ONLY neutralize (mapped to parent table's // Fields expose ONLY neutralize (mapped to parent table's
// neutralizeFields list). Scope and RAG are not field-level concepts. // neutralizeFields list). Scope and RAG are not field-level concepts.
node.neutralize = n.effectiveNeutralize; node.neutralize = n.effectiveNeutralize;
} else if (_isFdsKind(n.kind)) {
// FDS records have neutralize + ragIndexEnabled, but no scope.
node.neutralize = n.effectiveNeutralize;
if (n.supportsRag) {
node.ragIndexEnabled = n.effectiveRagIndexEnabled;
}
} else { } else {
// DataSource family carries the full three-flag set.
node.scope = n.effectiveScope as ScopeValue | 'mixed'; node.scope = n.effectiveScope as ScopeValue | 'mixed';
node.neutralize = n.effectiveNeutralize; node.neutralize = n.effectiveNeutralize;
if (n.supportsRag) { if (n.supportsRag) {
@ -201,11 +222,12 @@ export function createUdbSourcesProvider(
instanceId: string, instanceId: string,
onOpenSettings: (dataSourceId: string, label: string) => void, onOpenSettings: (dataSourceId: string, label: string) => void,
): UdbSourcesProviderHandle { ): UdbSourcesProviderHandle {
// Per-id cache of the most recent backend payload. Updated by every // Per-id cache of the most recent backend payload. Used by the
// `loadChildren` call. Read by patch/ensureRecord paths. // settings/drag handlers (NOT by the generic flag PATCH path; that
// identifies the node purely by its tree key).
const nodeCache = new Map<string, UdbBackendNode>(); const nodeCache = new Map<string, UdbBackendNode>();
async function _ensureRecord(node: UdbBackendNode): Promise<string | null> { async function _ensureRecordForSettings(node: UdbBackendNode): Promise<string | null> {
if (node.dataSourceId) return node.dataSourceId; if (node.dataSourceId) return node.dataSourceId;
try { try {
if (node.kind === 'connection' || node.kind === 'service' if (node.kind === 'connection' || node.kind === 'service'
@ -244,13 +266,13 @@ export function createUdbSourcesProvider(
return newId; return newId;
} }
} catch (err) { } catch (err) {
console.error('[UdbSourcesProvider] ensureRecord failed', err); console.error('[UdbSourcesProvider] ensureRecordForSettings failed', err);
} }
return null; return null;
} }
async function _onSettingsClick(node: UdbBackendNode): Promise<void> { async function _onSettingsClick(node: UdbBackendNode): Promise<void> {
const dsId = await _ensureRecord(node); const dsId = await _ensureRecordForSettings(node);
if (!dsId) { if (!dsId) {
console.warn('[UdbSourcesProvider] settings click: cannot ensure record', node.key); console.warn('[UdbSourcesProvider] settings click: cannot ensure record', node.key);
return; return;
@ -258,79 +280,19 @@ export function createUdbSourcesProvider(
onOpenSettings(dsId, node.label); onOpenSettings(dsId, node.label);
} }
/** fdsField-specific neutralize: ensure the parent fdsTable record exists, /** Forward a flag mutation to the generic UDB endpoint. The backend
* read its current `neutralizeFields` list, add or remove the field, * resolves the node from `nodeKey`, runs the polymorphic `canEdit`
* PATCH the new list back. Backend treats the FDS-record as the single * permission check, and applies the cascade-reset. */
* source of truth for per-field neutralization. */
async function _patchFieldNeutralize(fieldNodeId: string, neutralize: boolean): Promise<void> {
const fieldNode = nodeCache.get(fieldNodeId);
if (!fieldNode || fieldNode.kind !== 'fdsField') {
console.warn('[UdbSourcesProvider] field-neutralize target missing', fieldNodeId);
return;
}
const fieldName = fieldNode.fieldName;
const featureInstanceId = fieldNode.featureInstanceId;
const tableName = fieldNode.tableName;
if (!fieldName || !featureInstanceId || !tableName) {
console.warn('[UdbSourcesProvider] field-neutralize missing context', fieldNode);
return;
}
// Resolve the parent fdsTable record. Use the node's dataSourceId if
// already known (synthesized by the backend); otherwise create the
// record via _ensureRecord on a synthetic table-shaped node.
let dsId = fieldNode.dataSourceId;
if (!dsId) {
const tableNode: UdbBackendNode = {
...fieldNode,
kind: 'fdsTable',
key: `fdstbl|${featureInstanceId}|${tableName}`,
};
dsId = await _ensureRecord(tableNode);
}
if (!dsId) return;
// The parent fdsTable node carries `neutralizeFields` in its payload;
// pull it from the cache. Falls back to the field's effective state if
// the parent isn't cached for some reason.
const tableKey = `fdstbl|${featureInstanceId}|${tableName}`;
const tableNode = nodeCache.get(tableKey);
const currentList: string[] =
tableNode && Array.isArray(tableNode.neutralizeFields)
? [...tableNode.neutralizeFields]
: [];
const set = new Set(currentList);
if (neutralize) set.add(fieldName);
else set.delete(fieldName);
const newList = Array.from(set);
try {
await api.patch(`/api/datasources/${dsId}/neutralize-fields`, { neutralizeFields: newList });
// Keep the cache in sync so subsequent toggles in the same session
// start from the right baseline.
if (tableNode) {
nodeCache.set(tableKey, { ...tableNode, neutralizeFields: newList });
}
} catch (err) {
console.error('[UdbSourcesProvider] patch neutralize-fields failed', { fieldNodeId, err });
throw err;
}
}
async function _patchFlag( async function _patchFlag(
ids: string[], ids: string[],
flag: 'scope' | 'neutralize' | 'rag-index', flag: 'neutralize' | 'scope' | 'ragIndexEnabled',
body: Record<string, unknown>, value: unknown,
): Promise<void> { ): Promise<void> {
for (const id of ids) { for (const nodeKey of ids) {
const cached = nodeCache.get(id);
if (!cached) {
console.warn('[UdbSourcesProvider] patch target not in cache', id);
continue;
}
const dsId = await _ensureRecord(cached);
if (!dsId) continue;
try { try {
await api.patch(`/api/datasources/${dsId}/${flag}`, body); await api.post(`/api/udb/node/${encodeURIComponent(nodeKey)}/flag/${flag}`, { value });
} catch (err) { } catch (err) {
console.error('[UdbSourcesProvider] patch failed', { id, flag, err }); console.error('[UdbSourcesProvider] patch failed', { nodeKey, flag, err });
throw err; throw err;
} }
} }
@ -340,7 +302,7 @@ export function createUdbSourcesProvider(
rootKey: `udb-sources-${instanceId}`, rootKey: `udb-sources-${instanceId}`,
async loadChildren(parentId, _ownership) { async loadChildren(parentId, _ownership) {
const res = await api.post(`/api/workspace/${instanceId}/tree/children`, { const res = await api.post(`/api/udb/tree/children`, {
parents: [parentId], parents: [parentId],
}); });
const nodesByParent = res.data?.nodesByParent || {}; const nodesByParent = res.data?.nodesByParent || {};
@ -352,8 +314,8 @@ export function createUdbSourcesProvider(
canPatchScope(node) { canPatchScope(node) {
const data = node.data; const data = node.data;
// Field-level scope makes no sense; it's inherited from the parent table. // Scope only exists on DataSource family; FDS / synthetic containers / fields hide it.
return !!data && !_isSyntheticContainer(data.kind) && data.kind !== 'fdsField'; return !!data && !_isSyntheticContainer(data.kind) && !_isFdsKind(data.kind);
}, },
canPatchNeutralize(node) { canPatchNeutralize(node) {
@ -363,7 +325,7 @@ export function createUdbSourcesProvider(
canPatchRagIndex(node) { canPatchRagIndex(node) {
const data = node.data; const data = node.data;
// RAG is not a field-level concept either; only the table-record carries it. // RAG exists at the data-source level (DS root / FDS table+rows), never on fields.
return !!data && data.supportsRag === true && data.kind !== 'fdsField'; return !!data && data.supportsRag === true && data.kind !== 'fdsField';
}, },
@ -371,26 +333,15 @@ export function createUdbSourcesProvider(
// Backend cascades NULL on descendants automatically based on the // Backend cascades NULL on descendants automatically based on the
// existence of explicit child records; the cascadeChildren flag is the // existence of explicit child records; the cascadeChildren flag is the
// FilesTab convention and is irrelevant here. // FilesTab convention and is irrelevant here.
await _patchFlag(ids, 'scope', { scope }); await _patchFlag(ids, 'scope', scope);
}, },
async patchNeutralize(ids, neutralize) { async patchNeutralize(ids, neutralize) {
// fdsField nodes don't have their own DB record — they are addressed await _patchFlag(ids, 'neutralize', neutralize);
// via the parent fdsTable's `neutralizeFields` array. Split the batch
// accordingly and dispatch each kind to the right endpoint.
const fieldIds: string[] = [];
const otherIds: string[] = [];
for (const id of ids) {
const cached = nodeCache.get(id);
if (cached?.kind === 'fdsField') fieldIds.push(id);
else otherIds.push(id);
}
if (otherIds.length > 0) await _patchFlag(otherIds, 'neutralize', { neutralize });
for (const fieldId of fieldIds) await _patchFieldNeutralize(fieldId, neutralize);
}, },
async patchRagIndex(ids, ragIndexEnabled) { async patchRagIndex(ids, ragIndexEnabled) {
await _patchFlag(ids, 'rag-index', { ragIndexEnabled }); await _patchFlag(ids, 'ragIndexEnabled', ragIndexEnabled);
}, },
customizeDragData(node, dataTransfer) { customizeDragData(node, dataTransfer) {
@ -424,30 +375,6 @@ export function createUdbSourcesProvider(
} }
}, },
async refreshAttributes(ids: string[]) {
const res = await api.post(`/api/workspace/${instanceId}/tree/attributes`, {
keys: ids,
});
const raw: Record<string, {
effectiveNeutralize?: boolean | 'mixed';
effectiveScope?: string | 'mixed';
effectiveRagIndexEnabled?: boolean | 'mixed';
}> = res.data?.attributes ?? {};
const result = new Map<string, {
neutralize?: boolean | 'mixed';
scope?: ScopeValue | 'mixed';
ragIndexEnabled?: boolean | 'mixed';
}>();
for (const [key, attrs] of Object.entries(raw)) {
result.set(key, {
neutralize: attrs.effectiveNeutralize,
scope: attrs.effectiveScope as ScopeValue | 'mixed',
ragIndexEnabled: attrs.effectiveRagIndexEnabled,
});
}
return result;
},
_diagnosticGetCacheSize() { _diagnosticGetCacheSize() {
return nodeCache.size; return nodeCache.size;
}, },

View file

@ -47,8 +47,8 @@ interface UnifiedDataBarProps {
hideTabs?: UdbTab[]; hideTabs?: UdbTab[];
onSelectChat?: (chatId: string, featureInstanceId: string) => void; onSelectChat?: (chatId: string, featureInstanceId: string) => void;
activeWorkflowId?: string; activeWorkflowId?: string;
onCreateNewChat?: () => void;
onRenameChat?: (chatId: string, newName: string) => void; onRenameChat?: (chatId: string, newName: string) => void;
chatListRefreshKey?: number;
onDeleteChat?: (chatId: string) => void; onDeleteChat?: (chatId: string) => void;
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;
@ -78,8 +78,8 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
hideTabs, hideTabs,
onSelectChat, onSelectChat,
activeWorkflowId, activeWorkflowId,
onCreateNewChat,
onRenameChat, onRenameChat,
chatListRefreshKey,
onDeleteChat, onDeleteChat,
onChatDragStart, onChatDragStart,
onFileSelect, onFileSelect,
@ -122,7 +122,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
onSelectChat={onSelectChat} onSelectChat={onSelectChat}
onDragStart={onChatDragStart} onDragStart={onChatDragStart}
activeWorkflowId={activeWorkflowId} activeWorkflowId={activeWorkflowId}
chatListRefreshKey={chatListRefreshKey} onCreateNew={onCreateNewChat}
onRenameChat={onRenameChat} onRenameChat={onRenameChat}
onDeleteChat={onDeleteChat} onDeleteChat={onDeleteChat}
/> />

View file

@ -60,6 +60,50 @@ function _makeSynthRootNode(): UdbBackendNode {
}; };
} }
function _makeFdsTableNode(): UdbBackendNode {
return {
key: 'fdstbl|fi1|Kontakte',
kind: 'fdsTable',
parentKey: 'feat|m1|trustee|fi1',
label: 'Kontakte',
icon: 'table',
hasChildren: true,
dataSourceId: 'fds-1',
modelType: 'FeatureDataSource',
effectiveNeutralize: false,
effectiveScope: 'personal',
effectiveRagIndexEnabled: false,
supportsRag: true,
canBeAdded: false,
featureInstanceId: 'fi1',
featureCode: 'trustee',
tableName: 'Kontakte',
objectKey: 'data.feature.trustee.Kontakte',
neutralizeFields: [],
};
}
function _makeFdsFieldNode(): UdbBackendNode {
return {
key: 'fdsfld|fi1|Kontakte|email',
kind: 'fdsField',
parentKey: 'fdstbl|fi1|Kontakte',
label: 'email',
icon: 'field',
hasChildren: false,
dataSourceId: 'fds-1',
modelType: 'FeatureDataSource',
effectiveNeutralize: false,
effectiveScope: 'personal',
effectiveRagIndexEnabled: false,
supportsRag: false,
canBeAdded: false,
featureInstanceId: 'fi1',
tableName: 'Kontakte',
fieldName: 'email',
};
}
const _instanceId = 'inst-42'; const _instanceId = 'inst-42';
beforeEach(() => { beforeEach(() => {
@ -68,23 +112,23 @@ beforeEach(() => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// loadChildren // loadChildren -> POST /api/udb/tree/children (feature-agnostic)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('UdbSourcesProvider.loadChildren', () => { describe('UdbSourcesProvider.loadChildren', () => {
it('calls POST /api/workspace/{instanceId}/tree/children with parents=[parentId]', async () => { it('calls POST /api/udb/tree/children with parents=[parentId]', async () => {
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [] } } }); apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.loadChildren(null, 'own'); await provider.loadChildren(null, 'own');
expect(apiMock.post).toHaveBeenCalledWith( expect(apiMock.post).toHaveBeenCalledWith(
`/api/workspace/${_instanceId}/tree/children`, `/api/udb/tree/children`,
{ parents: [null] }, { parents: [null] },
); );
}); });
it('maps backend nodes to TreeNode shape with flag-bearer fields', async () => { it('maps DS-family backend nodes to TreeNode shape with all three flags', async () => {
const conn = _makeBackendNode(); const conn = _makeBackendNode();
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } }); apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
@ -94,103 +138,42 @@ describe('UdbSourcesProvider.loadChildren', () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
const tn = result[0]; const tn = result[0];
expect(tn.id).toBe('conn|c1'); expect(tn.id).toBe('conn|c1');
expect(tn.name).toBe('My Microsoft');
expect(tn.parentId).toBe('personalRoot');
expect(tn.ownership).toBe('own');
expect(tn.scope).toBe('personal'); expect(tn.scope).toBe('personal');
expect(tn.neutralize).toBe(false); expect(tn.neutralize).toBe(false);
expect(tn.ragIndexEnabled).toBe(false); expect(tn.ragIndexEnabled).toBe(false);
expect(tn.type).toBe('folder');
}); });
it('hides scope/neutralize/ragIndexEnabled on synthetic containers', async () => { it('FDS table maps to only 2 flags (no scope)', async () => {
const tbl = _makeFdsTableNode();
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'feat|m1|trustee|fi1': [tbl] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const [tn] = await provider.loadChildren('feat|m1|trustee|fi1', 'own');
expect(tn.neutralize).toBe(false);
expect(tn.ragIndexEnabled).toBe(false);
expect(tn.scope).toBeUndefined();
});
it('FDS field maps to only neutralize (no scope, no rag)', async () => {
const fld = _makeFdsFieldNode();
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'fdstbl|fi1|Kontakte': [fld] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const [tn] = await provider.loadChildren('fdstbl|fi1|Kontakte', 'own');
expect(tn.neutralize).toBe(false);
expect(tn.scope).toBeUndefined();
expect(tn.ragIndexEnabled).toBeUndefined();
});
it('hides all flags on synthetic containers', async () => {
const root = _makeSynthRootNode(); const root = _makeSynthRootNode();
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [root] } } }); apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [root] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const result = await provider.loadChildren(null, 'own'); const [tn] = await provider.loadChildren(null, 'own');
expect(tn.scope).toBeUndefined();
expect(result).toHaveLength(1); expect(tn.neutralize).toBeUndefined();
expect(result[0].scope).toBeUndefined(); expect(tn.ragIndexEnabled).toBeUndefined();
expect(result[0].neutralize).toBeUndefined();
expect(result[0].ragIndexEnabled).toBeUndefined();
expect(result[0].displayOrder).toBe(0);
});
it('omits ragIndexEnabled when supportsRag is false', async () => {
const node = _makeBackendNode({
key: 'mgrp|m1',
kind: 'mandateGroup',
parentKey: null,
supportsRag: false,
});
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [node] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const result = await provider.loadChildren(null, 'own');
expect(result[0].ragIndexEnabled).toBeUndefined();
expect(result[0].scope).toBeUndefined();
expect(result[0].neutralize).toBeUndefined();
});
it('attaches the settings extraAction on every data-source-root, even without a record yet', async () => {
const onSettings = vi.fn();
const withId = _makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false });
const withoutId = _makeBackendNode({ key: 'conn|c2', dataSourceId: null });
apiMock.post.mockResolvedValue({
data: { nodesByParent: { personalRoot: [withId, withoutId] } },
});
const provider = createUdbSourcesProvider(_instanceId, onSettings);
const result = await provider.loadChildren('personalRoot', 'own');
expect(result[0].extraActions).toHaveLength(1);
expect(result[0].extraActions?.[0].key).toBe('settings');
await result[0].extraActions?.[0].onClick?.();
expect(onSettings).toHaveBeenCalledWith('ds-1', 'My Microsoft');
// The conn without a record still gets a settings button (always visible
// on data-source-roots). Click triggers an _ensureRecord POST first.
expect(result[1].extraActions).toHaveLength(1);
expect(result[1].extraActions?.[0].key).toBe('settings');
});
it('hides the settings extraAction on non-root nodes (folders, files, services, ...)', async () => {
const folder = _makeBackendNode({ kind: 'folder', dataSourceId: 'ds-9' });
apiMock.post.mockResolvedValue({
data: { nodesByParent: { 'conn|c1': [folder] } },
});
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const result = await provider.loadChildren('conn|c1', 'own');
expect(result[0].extraActions).toBeUndefined();
});
it('forwards defaultExpanded from backend payload to the TreeNode', async () => {
const expanded = _makeBackendNode({
key: 'personalRoot',
kind: 'synthRoot',
defaultExpanded: true,
});
apiMock.post.mockResolvedValue({
data: { nodesByParent: { __root__: [expanded] } },
});
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const [node] = await provider.loadChildren(null, 'own');
expect(node.defaultExpanded).toBe(true);
});
it('populates the internal cache so subsequent patches can resolve nodes', async () => {
apiMock.post.mockResolvedValue({
data: { nodesByParent: { personalRoot: [_makeBackendNode()] } },
});
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
expect(provider._diagnosticGetCacheSize()).toBe(0);
await provider.loadChildren('personalRoot', 'own');
expect(provider._diagnosticGetCacheSize()).toBe(1);
}); });
}); });
@ -199,7 +182,7 @@ describe('UdbSourcesProvider.loadChildren', () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('UdbSourcesProvider.canPatch*', () => { describe('UdbSourcesProvider.canPatch*', () => {
it('canPatchScope is false for synthetic containers', async () => { it('canPatch* all false for synthetic containers', async () => {
apiMock.post.mockResolvedValue({ apiMock.post.mockResolvedValue({
data: { nodesByParent: { __root__: [_makeSynthRootNode()] } }, data: { nodesByParent: { __root__: [_makeSynthRootNode()] } },
}); });
@ -210,175 +193,84 @@ describe('UdbSourcesProvider.canPatch*', () => {
expect(provider.canPatchRagIndex?.(synthNode)).toBe(false); expect(provider.canPatchRagIndex?.(synthNode)).toBe(false);
}); });
it('canPatchRagIndex requires supportsRag=true', async () => { it('canPatchScope is false for any FDS kind', async () => {
apiMock.post.mockResolvedValue({ apiMock.post.mockResolvedValue({
data: { data: { nodesByParent: { 'feat|m1|trustee|fi1': [_makeFdsTableNode()] } },
nodesByParent: {
personalRoot: [
_makeBackendNode({ key: 'a', supportsRag: true }),
_makeBackendNode({ key: 'b', supportsRag: false }),
],
},
},
}); });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const [a, b] = await provider.loadChildren('personalRoot', 'own'); const [tbl] = await provider.loadChildren('feat|m1|trustee|fi1', 'own');
expect(provider.canPatchRagIndex?.(a)).toBe(true); expect(provider.canPatchScope?.(tbl)).toBe(false);
expect(provider.canPatchRagIndex?.(b)).toBe(false); expect(provider.canPatchNeutralize?.(tbl)).toBe(true);
expect(provider.canPatchRagIndex?.(tbl)).toBe(true);
});
it('canPatchRagIndex is false on fdsField (only neutralize at field level)', async () => {
apiMock.post.mockResolvedValue({
data: { nodesByParent: { 'fdstbl|fi1|Kontakte': [_makeFdsFieldNode()] } },
});
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
const [fld] = await provider.loadChildren('fdstbl|fi1|Kontakte', 'own');
expect(provider.canPatchNeutralize?.(fld)).toBe(true);
expect(provider.canPatchRagIndex?.(fld)).toBe(false);
expect(provider.canPatchScope?.(fld)).toBe(false);
}); });
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// patch flow: ensureRecord + PATCH // patch flow -> POST /api/udb/node/{key}/flag/{flag}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('UdbSourcesProvider.patchScope', () => { describe('UdbSourcesProvider.patchScope', () => {
it('PATCHes existing dataSourceId without creating a new record', async () => { it('POSTs to /api/udb/node/{key}/flag/scope with the new value', async () => {
apiMock.post.mockResolvedValueOnce({ apiMock.post.mockResolvedValue({ data: {} });
data: {
nodesByParent: {
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-existing', canBeAdded: false })],
},
},
});
apiMock.patch.mockResolvedValue({ data: {} });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.loadChildren('personalRoot', 'own');
await provider.patchScope?.(['conn|c1'], 'mandate', true); await provider.patchScope?.(['conn|c1'], 'mandate', true);
expect(apiMock.patch).toHaveBeenCalledWith( expect(apiMock.post).toHaveBeenCalledWith(
`/api/datasources/ds-existing/scope`, `/api/udb/node/${encodeURIComponent('conn|c1')}/flag/scope`,
{ scope: 'mandate' }, { value: 'mandate' },
); );
// Only one POST: the loadChildren call. No POST datasources.
expect(apiMock.post).toHaveBeenCalledTimes(1);
});
it('creates a DataSource record first when canBeAdded=true', async () => {
apiMock.post
.mockResolvedValueOnce({
data: {
nodesByParent: {
personalRoot: [_makeBackendNode({ dataSourceId: null, canBeAdded: true })],
},
},
})
.mockResolvedValueOnce({ data: { id: 'ds-new' } });
apiMock.patch.mockResolvedValue({ data: {} });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.loadChildren('personalRoot', 'own');
await provider.patchScope?.(['conn|c1'], 'mandate', true);
expect(apiMock.post).toHaveBeenNthCalledWith(
2,
`/api/workspace/${_instanceId}/datasources`,
expect.objectContaining({
connectionId: 'c1',
sourceType: 'msft',
path: '/',
label: 'My Microsoft',
}),
);
expect(apiMock.patch).toHaveBeenCalledWith(
`/api/datasources/ds-new/scope`,
{ scope: 'mandate' },
);
});
it('skips silently when target node is not in cache', async () => {
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.patchScope?.(['unknown'], 'personal', false);
expect(apiMock.patch).not.toHaveBeenCalled();
}); });
}); });
describe('UdbSourcesProvider.patchNeutralize', () => { describe('UdbSourcesProvider.patchNeutralize', () => {
it('PATCHes /neutralize with the supplied boolean', async () => { it('POSTs to /api/udb/node/{key}/flag/neutralize', async () => {
apiMock.post.mockResolvedValueOnce({ apiMock.post.mockResolvedValue({ data: {} });
data: {
nodesByParent: {
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })],
},
},
});
apiMock.patch.mockResolvedValue({ data: {} });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.loadChildren('personalRoot', 'own');
await provider.patchNeutralize?.(['conn|c1'], true); await provider.patchNeutralize?.(['conn|c1'], true);
expect(apiMock.patch).toHaveBeenCalledWith( expect(apiMock.post).toHaveBeenCalledWith(
`/api/datasources/ds-1/neutralize`, `/api/udb/node/${encodeURIComponent('conn|c1')}/flag/neutralize`,
{ neutralize: true }, { value: true },
);
});
it('uses the same generic endpoint for FDS field nodes (no kind-split)', async () => {
apiMock.post.mockResolvedValue({ data: {} });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.patchNeutralize?.(['fdsfld|fi1|Kontakte|email'], true);
expect(apiMock.post).toHaveBeenCalledTimes(1);
expect(apiMock.post).toHaveBeenCalledWith(
`/api/udb/node/${encodeURIComponent('fdsfld|fi1|Kontakte|email')}/flag/neutralize`,
{ value: true },
); );
}); });
}); });
describe('UdbSourcesProvider.patchRagIndex', () => { describe('UdbSourcesProvider.patchRagIndex', () => {
it('PATCHes /rag-index with the supplied boolean (note dash in URL, camelCase in body)', async () => { it('POSTs to /api/udb/node/{key}/flag/ragIndexEnabled', async () => {
apiMock.post.mockResolvedValueOnce({ apiMock.post.mockResolvedValue({ data: {} });
data: {
nodesByParent: {
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })],
},
},
});
apiMock.patch.mockResolvedValue({ data: {} });
const provider = createUdbSourcesProvider(_instanceId, vi.fn()); const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.loadChildren('personalRoot', 'own');
await provider.patchRagIndex?.(['conn|c1'], true); await provider.patchRagIndex?.(['conn|c1'], true);
expect(apiMock.patch).toHaveBeenCalledWith( expect(apiMock.post).toHaveBeenCalledWith(
`/api/datasources/ds-1/rag-index`, `/api/udb/node/${encodeURIComponent('conn|c1')}/flag/ragIndexEnabled`,
{ ragIndexEnabled: true }, { value: true },
);
});
it('routes to feature-datasources when the cached node is a featureNode', async () => {
const featureNode: UdbBackendNode = {
key: 'feat|m1|trustee|inst-1',
kind: 'featureNode',
parentKey: 'mgrp|m1',
label: 'Trustee',
icon: 'mdi-database',
hasChildren: true,
dataSourceId: null,
modelType: null,
effectiveNeutralize: false,
effectiveScope: 'personal',
effectiveRagIndexEnabled: false,
supportsRag: true,
canBeAdded: true,
featureInstanceId: 'inst-1',
featureCode: 'trustee',
mandateId: 'm1',
tableName: '*',
};
apiMock.post
.mockResolvedValueOnce({ data: { nodesByParent: { 'mgrp|m1': [featureNode] } } })
.mockResolvedValueOnce({ data: { id: 'fds-new' } });
apiMock.patch.mockResolvedValue({ data: {} });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.loadChildren('mgrp|m1', 'own');
await provider.patchRagIndex?.([featureNode.key], true);
expect(apiMock.post).toHaveBeenNthCalledWith(
2,
`/api/workspace/${_instanceId}/feature-datasources`,
expect.objectContaining({
featureInstanceId: 'inst-1',
featureCode: 'trustee',
tableName: '*',
objectKey: 'data.feature.trustee.*',
}),
);
expect(apiMock.patch).toHaveBeenCalledWith(
`/api/datasources/fds-new/rag-index`,
{ ragIndexEnabled: true },
); );
}); });
}); });

View file

@ -69,7 +69,6 @@ export interface PaginationParams {
filters?: Record<string, any>; filters?: Record<string, any>;
search?: string; search?: string;
viewKey?: string; viewKey?: string;
owner?: 'all' | 'me' | 'shared';
} }
// Files list hook // Files list hook
@ -151,7 +150,6 @@ export function useUserFiles() {
groupField: string; groupField: string;
groupDirection?: 'asc' | 'desc'; groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>; groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
owner?: 'all' | 'me' | 'shared';
}) => { }) => {
const levels = base.groupByLevels?.length const levels = base.groupByLevels?.length
? base.groupByLevels ? base.groupByLevels
@ -166,11 +164,7 @@ export function useUserFiles() {
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort; if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey; if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
const { data } = await api.get('/api/files/list', { const { data } = await api.get('/api/files/list', {
params: { params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
mode: 'groupSummary',
pagination: JSON.stringify(pObj),
...(base.owner ? { owner: base.owner } : {}),
},
}); });
return Array.isArray(data?.groups) ? data.groups : []; return Array.isArray(data?.groups) ? data.groups : [];
}, },
@ -198,10 +192,7 @@ export function useUserFiles() {
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search; if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey; if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
const { data } = await api.get('/api/files/list', { const { data } = await api.get('/api/files/list', {
params: { params: { pagination: JSON.stringify(pObj) },
pagination: JSON.stringify(pObj),
...(paginationParams.owner ? { owner: paginationParams.owner } : {}),
},
}); });
if (data && typeof data === 'object' && 'items' in data) { if (data && typeof data === 'object' && 'items' in data) {
return { items: data.items, pagination: data.pagination }; return { items: data.items, pagination: data.pagination };
@ -417,7 +408,6 @@ export function useFileOperations() {
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set()); const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
const [editingFiles, setEditingFiles] = useState<Set<string>>(new Set()); const [editingFiles, setEditingFiles] = useState<Set<string>>(new Set());
const [uploadingFile, setUploadingFile] = useState(false); const [uploadingFile, setUploadingFile] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [isLoading] = useState(false); const [isLoading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null); const [downloadError, setDownloadError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
@ -574,11 +564,9 @@ export function useFileOperations() {
file: globalThis.File, file: globalThis.File,
workflowId?: string, workflowId?: string,
featureInstanceId?: string, featureInstanceId?: string,
onProgress?: (progress: number) => void,
) => { ) => {
setUploadError(null); setUploadError(null);
setUploadingFile(true); setUploadingFile(true);
setUploadProgress(0);
try { try {
@ -605,14 +593,7 @@ export function useFileOperations() {
// Do NOT set Content-Type manually axios sets multipart/form-data with boundary for FormData // Do NOT set Content-Type manually axios sets multipart/form-data with boundary for FormData
const response = await api.post('/api/files/upload', formData, { const response = await api.post('/api/files/upload', formData);
onUploadProgress: progressEvent => {
if (!progressEvent.total) return;
const progress = Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total));
setUploadProgress(progress);
onProgress?.(progress);
},
});
const fileData = response.data; const fileData = response.data;
// Check if the response indicates a duplicate file // Check if the response indicates a duplicate file
@ -644,7 +625,6 @@ export function useFileOperations() {
return { success: false, error: errorMessage }; return { success: false, error: errorMessage };
} finally { } finally {
setUploadingFile(false); setUploadingFile(false);
setUploadProgress(0);
} }
}; };
@ -769,7 +749,6 @@ export function useFileOperations() {
deletingFiles, deletingFiles,
editingFiles, editingFiles,
uploadingFile, uploadingFile,
uploadProgress,
downloadError, downloadError,
deleteError, deleteError,
uploadError, uploadError,

View file

@ -178,7 +178,7 @@ const StorePage: React.FC = () => {
)} )}
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && ( {subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
<span className={styles.bannerSeparator}> <span className={styles.bannerSeparator}>
{t('Testphase endet am')}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()} {t('Testphase endet am')}: {new Date(Number(subscriptionInfo.trialEndsAt) * 1000).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</span> </span>
)} )}
</div> </div>

View file

@ -1,16 +1,17 @@
/** /**
* AdminDatabaseHealthPage * AdminDatabaseHealthPage
* *
* SysAdmin-only page with two tabs: * SysAdmin-only page with three tabs:
* 1. Table Statistics pg_stat data for every table across all databases * 1. Table Statistics pg_stat data for every table across all databases
* 2. Orphan Cleanup FK orphan detection with per-relation + batch cleanup * 2. Orphan Cleanup FK orphan detection with per-relation + batch cleanup
* 3. Migration Database backup (export) and restore (import)
* *
* Both tabs use FormGeneratorTable with a client-side pagination/sort/filter * Both Stats/Orphan tabs use FormGeneratorTable with a client-side pagination/sort/filter
* adapter (the backend returns all rows at once; the dataset is small enough). * adapter (the backend returns all rows at once; the dataset is small enough).
*/ */
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle, FaDownload } from 'react-icons/fa'; import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle, FaDownload, FaUpload, FaDatabase, FaInfoCircle, FaCheckCircle } from 'react-icons/fa';
import api from '../../api'; import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
@ -721,6 +722,937 @@ const OrphansTab: React.FC = () => {
}; };
// ---------------------------------------------------------------------------
// Types (Migration)
// ---------------------------------------------------------------------------
interface MigrationDatabase {
name: string;
tableCount: number;
recordCount: number;
}
interface ValidationSummaryItem {
database: string;
tableCount: number;
recordCount: number;
registered: boolean;
}
interface ValidationResult {
valid: boolean;
summary: ValidationSummaryItem[];
warnings: string[];
systemObjectsFound: Array<{ type: string; label: string; payloadId: string }>;
}
interface ProgressLogEntry {
ts: string;
message: string;
status: 'info' | 'success' | 'error';
}
// ---------------------------------------------------------------------------
// MigrationTab
// ---------------------------------------------------------------------------
const MigrationTab: React.FC = () => {
const { t } = useLanguage();
const toast = useToast();
const { confirm, ConfirmDialog } = useConfirm();
// --- Backup state ---
const [databases, setDatabases] = useState<MigrationDatabase[]>([]);
const [selectedDbs, setSelectedDbs] = useState<Set<string>>(new Set());
const [loadingDbs, setLoadingDbs] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportLog, setExportLog] = useState<ProgressLogEntry[]>([]);
const [instanceLabel, setInstanceLabel] = useState('unknown');
// --- Restore state ---
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [validating, setValidating] = useState(false);
const [validation, setValidation] = useState<ValidationResult | null>(null);
const [importMode, setImportMode] = useState<'replace' | 'merge'>('merge');
const [importing, setImporting] = useState(false);
const [importLog, setImportLog] = useState<ProgressLogEntry[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const exportLogRef = useRef<HTMLDivElement>(null);
const importLogRef = useRef<HTMLDivElement>(null);
// --- Fetch databases ---
const _fetchDatabases = useCallback(async () => {
try {
setLoadingDbs(true);
const res = await api.get('/api/admin/database-health/migration/databases');
const dbs: MigrationDatabase[] = res.data.databases || [];
setDatabases(dbs);
setSelectedDbs(new Set(dbs.map(d => d.name)));
if (res.data.instanceLabel) setInstanceLabel(res.data.instanceLabel);
} catch {
setDatabases([]);
} finally {
setLoadingDbs(false);
}
}, []);
useEffect(() => { _fetchDatabases(); }, [_fetchDatabases]);
// --- Backup: DB selection ---
const allSelected = databases.length > 0 && selectedDbs.size === databases.length;
const _toggleAll = () => {
if (allSelected) {
setSelectedDbs(new Set());
} else {
setSelectedDbs(new Set(databases.map(d => d.name)));
}
};
const _toggleDb = (name: string) => {
setSelectedDbs(prev => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
// --- Backup: Export (per-DB with progress) ---
const _addExportLog = useCallback((message: string, logStatus: ProgressLogEntry['status'] = 'info') => {
const ts = new Date().toLocaleTimeString();
setExportLog(prev => [...prev, { ts, message, status: logStatus }]);
setTimeout(() => exportLogRef.current?.scrollTo({ top: exportLogRef.current.scrollHeight }), 50);
}, []);
const _startExport = async () => {
if (selectedDbs.size === 0) return;
setExporting(true);
setExportLog([]);
const dbList = Array.from(selectedDbs);
const isFullExport = allSelected;
const totalDbs = dbList.length;
_addExportLog(t('Export gestartet: {count} Datenbanken', { count: totalDbs }));
let token = '';
try {
const startRes = await api.post('/api/admin/database-health/migration/export-start');
token = startRes.data.token;
} catch (err: any) {
_addExportLog(t('Fehler beim Starten des Exports: {error}', { error: String(err) }), 'error');
toast.showError(t('Export fehlgeschlagen'));
setExporting(false);
return;
}
let totalTables = 0;
let totalRecords = 0;
let errors = 0;
let exportedCount = 0;
for (let i = 0; i < dbList.length; i++) {
const dbName = dbList[i];
_addExportLog(t('Exportiere {index}/{total}: {db}...', { index: i + 1, total: totalDbs, db: dbName }));
try {
const res = await api.get('/api/admin/database-health/migration/export-single', {
params: { token, database: dbName },
});
totalTables += res.data.tableCount || 0;
totalRecords += res.data.totalRecords || 0;
exportedCount++;
_addExportLog(
t('{db}: {tables} Tabellen, {records} Datensaetze', {
db: dbName, tables: res.data.tableCount || 0, records: res.data.totalRecords || 0,
}),
'success',
);
} catch (err: any) {
errors++;
const detail = err?.response?.data?.detail;
_addExportLog(
t('Fehler bei {db}: {error}', { db: dbName, error: typeof detail === 'string' ? detail : String(err) }),
'error',
);
}
}
if (exportedCount === 0) {
_addExportLog(t('Export abgebrochen: keine Daten exportiert'), 'error');
toast.showError(t('Export fehlgeschlagen'));
setExporting(false);
return;
}
_addExportLog(t('Erstelle Exportdatei...'));
try {
const ts = new Date().toISOString().replace(/:/g, '-').slice(0, 19);
const scope = isFullExport ? 'full' : 'partial';
const filename = `db_backup_${instanceLabel}_${scope}_${ts}.json`;
const res = await api.get('/api/admin/database-health/migration/export-download', {
params: { token, filename },
responseType: 'blob',
});
const blob = new Blob([res.data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
_addExportLog(
t('Export abgeschlossen: {dbs} Datenbanken, {tables} Tabellen, {records} Datensaetze', {
dbs: exportedCount, tables: totalTables, records: totalRecords,
}),
'success',
);
if (errors > 0) {
toast.showWarning(t('Export mit {count} Fehlern abgeschlossen', { count: errors }));
} else {
toast.showSuccess(t('Export erfolgreich'));
}
} catch (err: any) {
_addExportLog(t('Fehler beim Download der Exportdatei: {error}', { error: String(err) }), 'error');
toast.showError(t('Export fehlgeschlagen'));
} finally {
setExporting(false);
}
};
// --- Restore: File upload ---
const _handleFileSelect = (file: File) => {
setUploadedFile(file);
setValidation(null);
_validateFile(file);
};
const _onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) _handleFileSelect(file);
if (e.target) e.target.value = '';
};
const _onDrop = (e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files?.[0];
if (file && file.name.endsWith('.json')) {
_handleFileSelect(file);
}
};
const _onDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
// --- Restore: Validate (uploads file to server, streams to disk) ---
const importTokenRef = useRef('');
const _validateFile = async (file: File) => {
setValidating(true);
setValidation(null);
importTokenRef.current = '';
try {
const formData = new FormData();
formData.append('file', file);
const res = await api.post('/api/admin/database-health/migration/upload-import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 0,
});
importTokenRef.current = res.data.token || '';
setValidation({
valid: res.data.valid,
summary: (res.data.databases || []).map((d: any) => ({
database: d.database,
tableCount: d.tableCount,
recordCount: d.recordCount,
registered: true,
})),
warnings: res.data.warnings || [],
systemObjectsFound: res.data.systemObjectsFound || [],
});
} catch (err: any) {
const detail = err?.response?.data?.detail;
setValidation({
valid: false, summary: [], systemObjectsFound: [],
warnings: [typeof detail === 'string' ? detail : t('Upload oder Validierung fehlgeschlagen')],
});
} finally {
setValidating(false);
}
};
// --- Restore: Import (per-DB with progress) ---
const _addImportLog = useCallback((message: string, logStatus: ProgressLogEntry['status'] = 'info') => {
const ts = new Date().toLocaleTimeString();
setImportLog(prev => [...prev, { ts, message, status: logStatus }]);
setTimeout(() => importLogRef.current?.scrollTo({ top: importLogRef.current.scrollHeight }), 50);
}, []);
const _startImport = async () => {
if (!importTokenRef.current || !validation?.valid || importing) return;
setImporting(true);
const modeLabel = importMode === 'replace'
? t('Neu (Datenbank leeren und importieren)')
: t('Zusammenfuehren (bestehende Daten belassen, neue ergaenzen)');
const ok = await confirm(
t('Import mit Modus "{mode}" starten? Dieser Vorgang kann nicht rueckgaengig gemacht werden.', { mode: modeLabel }),
{ title: t('Import starten'), variant: importMode === 'replace' ? 'danger' : 'primary' },
);
if (!ok) { setImporting(false); return; }
setImportLog([]);
const token = importTokenRef.current;
const dbList = validation.summary.filter(s => s.registered);
const totalDbs = dbList.length;
let totalRecords = 0;
let errors = 0;
_addImportLog(t('Import gestartet: {count} Datenbanken', { count: totalDbs }));
for (let i = 0; i < dbList.length; i++) {
const dbInfo = dbList[i];
_addImportLog(
t('Importiere {index}/{total}: {db} ({records} Datensaetze)...', {
index: i + 1, total: totalDbs, db: dbInfo.database, records: dbInfo.recordCount,
}),
);
try {
const res = await api.post('/api/admin/database-health/migration/import-single', {
token,
database: dbInfo.database,
mode: importMode,
});
const result = res.data;
totalRecords += result.recordCount || 0;
const dbWarnings: string[] = result.warnings || [];
for (const w of dbWarnings) {
_addImportLog(t('Warnung: {msg}', { msg: w }), 'error');
}
_addImportLog(
t('{db}: {count} Datensaetze importiert', { db: dbInfo.database, count: result.recordCount || 0 }),
dbWarnings.length > 0 ? 'error' : 'success',
);
} catch (err: any) {
errors++;
const detail = err?.response?.data?.detail;
_addImportLog(
t('Fehler bei {db}: {error}', {
db: dbInfo.database,
error: typeof detail === 'string' ? detail : String(err),
}),
'error',
);
}
}
try { await api.post('/api/admin/database-health/migration/import-done', { token }); } catch { /* ignore */ }
_addImportLog(
t('Import abgeschlossen: {records} Datensaetze in {dbs} Datenbanken', {
records: totalRecords, dbs: totalDbs,
}),
'success',
);
if (errors > 0) {
toast.showWarning(t('{count} Datensaetze importiert, {errors} Fehler', { count: totalRecords, errors }));
} else {
toast.showSuccess(t('{count} Datensaetze erfolgreich importiert', { count: totalRecords }));
}
importTokenRef.current = '';
setImporting(false);
};
const _resetUpload = () => {
setUploadedFile(null);
setValidation(null);
importTokenRef.current = '';
};
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'auto', gap: '2rem', padding: '0.5rem 0' }}>
<ConfirmDialog />
{/* ---- BACKUP SECTION ---- */}
<section>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--text-primary)', margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FaDownload /> {t('Backup')}
</h2>
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', margin: '0 0 0.75rem 0' }}>
{t('Datenbanken fuer Export auswaehlen')}
</p>
{loadingDbs ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
</div>
) : (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem', marginBottom: '1rem' }}>
<label className={styles.checkboxLabel} style={{ fontWeight: 600 }}>
<input type="checkbox" checked={allSelected} onChange={_toggleAll} />
{t('Alle Datenbanken')}
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', paddingLeft: '0.5rem' }}>
{databases.map(db => (
<label key={db.name} className={styles.checkboxLabel}>
<input
type="checkbox"
checked={selectedDbs.has(db.name)}
onChange={() => _toggleDb(db.name)}
/>
<span>{db.name}</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
({db.tableCount} {t('Tabellen')}, ~{_formatNumber(db.recordCount)} {t('Zeilen')})
</span>
</label>
))}
</div>
</div>
<button
className={styles.primaryButton}
onClick={_startExport}
disabled={exporting || selectedDbs.size === 0}
>
{exporting ? (
<><FaSync className="spinning" /> {t('Export laeuft...')}</>
) : (
<><FaDownload /> {t('Export starten')}</>
)}
</button>
{exportLog.length > 0 && (
<div
ref={exportLogRef}
style={{
marginTop: '0.75rem',
maxHeight: '200px',
overflow: 'auto',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
padding: '0.5rem 0.75rem',
fontFamily: 'monospace',
fontSize: '0.8125rem',
lineHeight: '1.6',
}}
>
{exportLog.map((entry, i) => (
<div key={i} style={{
color: entry.status === 'error' ? 'var(--danger-color, #e53e3e)'
: entry.status === 'success' ? '#388e3c' : 'var(--text-secondary)',
}}>
<span style={{ color: 'var(--text-tertiary)', marginRight: '0.5rem' }}>{entry.ts}</span>
{entry.message}
</div>
))}
</div>
)}
</>
)}
</section>
{/* ---- DIVIDER ---- */}
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: 0 }} />
{/* ---- RESTORE SECTION ---- */}
<section>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--text-primary)', margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FaUpload /> {t('Restore')}
</h2>
{/* File upload zone */}
{!uploadedFile ? (
<div
onDrop={_onDrop}
onDragOver={_onDragOver}
onClick={() => fileInputRef.current?.click()}
style={{
border: '2px dashed var(--border-color)',
borderRadius: '8px',
padding: '2rem',
textAlign: 'center',
cursor: 'pointer',
background: 'var(--bg-secondary)',
transition: 'border-color 0.2s',
}}
>
<FaUpload style={{ fontSize: '1.5rem', color: 'var(--text-tertiary)', marginBottom: '0.5rem' }} />
<p style={{ margin: 0, fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
{t('Datei hier ablegen oder klicken')}
</p>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
{t('JSON-Datei hochladen')}
</p>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={_onFileInputChange}
style={{ display: 'none' }}
/>
</div>
) : (
<div>
{/* File info */}
<div className={styles.infoBox} style={{ justifyContent: 'space-between' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FaDatabase />
{uploadedFile.name} ({_formatBytes(uploadedFile.size)})
</span>
<button
className={styles.secondaryButton}
onClick={_resetUpload}
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}
>
{t('Andere Datei')}
</button>
</div>
{/* Validation */}
{validating && (
<div className={styles.loadingContainer} style={{ padding: '1rem' }}>
<div className={styles.spinner} />
<span>{t('Validierung laeuft...')}</span>
</div>
)}
{validation && !validating && (
<div style={{ marginTop: '1rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 0.75rem 0' }}>
{t('Pruefung')}
</h3>
{/* Validation warnings */}
{validation.warnings.length > 0 && (
<div className={styles.infoBox} style={{
background: 'var(--warning-bg, #fffbeb)',
borderColor: 'var(--warning-color, #d69e2e)',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '0.25rem',
}}>
{validation.warnings.map((w, i) => (
<span key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FaExclamationTriangle style={{ color: 'var(--warning-color, #d69e2e)', flexShrink: 0 }} /> {w}
</span>
))}
</div>
)}
{/* Summary table */}
{validation.summary.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem', marginBottom: '1rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color)' }}>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>{t('Datenbank')}</th>
<th style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>{t('Tabellen')}</th>
<th style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>{t('Datensaetze')}</th>
<th style={{ textAlign: 'center', padding: '0.5rem 0.75rem' }}>{t('Status')}</th>
</tr>
</thead>
<tbody>
{validation.summary.map(s => (
<tr key={s.database} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '0.5rem 0.75rem' }}>{s.database}</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>{s.tableCount}</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>{_formatNumber(s.recordCount)}</td>
<td style={{ textAlign: 'center', padding: '0.5rem 0.75rem' }}>
{s.registered ? (
<FaCheckCircle style={{ color: '#388e3c' }} />
) : (
<FaExclamationTriangle style={{ color: 'var(--warning-color, #d69e2e)' }} />
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* System objects info */}
{validation.systemObjectsFound.length > 0 && (
<div className={styles.infoBox} style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '0.25rem' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontWeight: 500 }}>
<FaInfoCircle style={{ color: 'var(--primary-color, #f25843)' }} />
{t('Systemdaten werden beim Import nicht geloescht')}
</span>
<span style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)', paddingLeft: '1.5rem' }}>
{validation.systemObjectsFound.map(o => o.label).join(', ')}
</span>
</div>
)}
{/* Import settings */}
{validation.valid && (
<div style={{ marginTop: '1rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 0.75rem 0' }}>
{t('Import-Einstellungen')}
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1rem' }}>
<label className={styles.checkboxLabel} style={{ cursor: 'pointer' }}>
<input
type="radio"
name="importMode"
checked={importMode === 'merge'}
onChange={() => setImportMode('merge')}
/>
<div>
<div style={{ fontWeight: 500 }}>{t('Zusammenfuehren (bestehende Daten belassen, neue ergaenzen)')}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
{t('Bestehende Datensaetze bleiben erhalten, nur fehlende werden eingefuegt')}
</div>
</div>
</label>
<label className={styles.checkboxLabel} style={{ cursor: 'pointer' }}>
<input
type="radio"
name="importMode"
checked={importMode === 'replace'}
onChange={() => setImportMode('replace')}
/>
<div>
<div style={{ fontWeight: 500 }}>{t('Neu (Datenbank leeren und importieren)')}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
{t('Bestehende Daten werden geloescht und durch importierte Daten ersetzt')}
</div>
</div>
</label>
</div>
{importMode === 'replace' && (
<div className={styles.infoBox} style={{
background: 'var(--danger-bg, #fff5f5)',
borderColor: 'var(--danger-color, #e53e3e)',
}}>
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--danger-color, #e53e3e)', flexShrink: 0 }} />
{t('Achtung: Bestehende Daten werden unwiderruflich geloescht. Erstellen Sie zuerst ein Backup.')}
</div>
)}
<button
className={importMode === 'replace' ? styles.dangerButton : styles.primaryButton}
onClick={_startImport}
disabled={importing}
style={{ marginTop: '0.5rem' }}
>
{importing ? (
<><FaSync className="spinning" /> {t('Import laeuft...')}</>
) : (
<><FaUpload /> {t('Import starten')}</>
)}
</button>
{importLog.length > 0 && (
<div
ref={importLogRef}
style={{
marginTop: '0.75rem',
maxHeight: '200px',
overflow: 'auto',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
padding: '0.5rem 0.75rem',
fontFamily: 'monospace',
fontSize: '0.8125rem',
lineHeight: '1.6',
}}
>
{importLog.map((entry, i) => (
<div key={i} style={{
color: entry.status === 'error' ? 'var(--danger-color, #e53e3e)'
: entry.status === 'success' ? '#388e3c' : 'var(--text-secondary)',
}}>
<span style={{ color: 'var(--text-tertiary)', marginRight: '0.5rem' }}>{entry.ts}</span>
{entry.message}
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</section>
</div>
);
};
// ---------------------------------------------------------------------------
// LegacyCleanupTab
// ---------------------------------------------------------------------------
interface LegacyTable {
id: string;
db: string;
table: string;
rowCount: number;
sizeBytes: number;
}
const LegacyCleanupTab: React.FC = () => {
const { t } = useLanguage();
const toast = useToast();
const { confirm, ConfirmDialog } = useConfirm();
const [allLegacy, setAllLegacy] = useState<LegacyTable[]>([]);
const [loading, setLoading] = useState(false);
const [dropping, setDropping] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [droppingBatch, setDroppingBatch] = useState(false);
const _fetchLegacy = useCallback(async () => {
try {
setLoading(true);
const res = await api.get('/api/admin/database-health/legacy-tables');
const rows: LegacyTable[] = (res.data.legacyTables || []).map((t: any) => ({
...t,
id: `${t.db}.${t.table}`,
}));
setAllLegacy(rows);
setSelected(new Set());
} catch {
setAllLegacy([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { _fetchLegacy(); }, [_fetchLegacy]);
const { visibleData, pagination, refetch, fetchFilterValues } = _useClientPagination(allLegacy);
const databases = useMemo(
() => Array.from(new Set(allLegacy.map(l => l.db))).sort(),
[allLegacy],
);
const totals = useMemo(() => {
let rows = 0, size = 0;
for (const l of allLegacy) { rows += l.rowCount; size += l.sizeBytes; }
return { count: allLegacy.length, rows, size, dbs: databases.length };
}, [allLegacy, databases]);
const _dropOne = async (entry: LegacyTable) => {
const ok = await confirm(
t('Legacy-Tabelle {db}.{table} ({rows} Zeilen, {size}) unwiderruflich löschen?', {
db: entry.db, table: entry.table, rows: _formatNumber(entry.rowCount), size: _formatBytes(entry.sizeBytes),
}),
{ title: t('Legacy-Tabelle löschen'), variant: 'danger' },
);
if (!ok) return;
setDropping(entry.id);
try {
await api.post('/api/admin/database-health/legacy-tables/drop', { db: entry.db, table: entry.table });
toast.showSuccess(t('{db}.{table} gelöscht', { db: entry.db, table: entry.table }));
_fetchLegacy();
} catch (err: any) {
const detail = err?.response?.data?.detail;
toast.showError(typeof detail === 'string' ? detail : t('Fehler beim Löschen'));
} finally {
setDropping(null);
}
};
const _dropSelected = async () => {
if (selected.size === 0) return;
const selectedEntries = allLegacy.filter(l => selected.has(l.id));
const totalRows = selectedEntries.reduce((s, l) => s + l.rowCount, 0);
const ok = await confirm(
t('{count} Legacy-Tabellen mit insgesamt {rows} Zeilen unwiderruflich löschen?', {
count: selected.size, rows: _formatNumber(totalRows),
}),
{ title: t('Ausgewählte löschen'), variant: 'danger' },
);
if (!ok) return;
setDroppingBatch(true);
let deleted = 0;
let errors = 0;
for (const entry of selectedEntries) {
try {
await api.post('/api/admin/database-health/legacy-tables/drop', { db: entry.db, table: entry.table });
deleted++;
} catch {
errors++;
}
}
if (errors > 0) {
toast.showWarning(t('{deleted} gelöscht, {errors} Fehler', { deleted, errors }));
} else {
toast.showSuccess(t('{deleted} Legacy-Tabellen gelöscht', { deleted }));
}
setDroppingBatch(false);
_fetchLegacy();
};
const _toggleSelect = (id: string) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const _toggleAll = () => {
if (selected.size === allLegacy.length) {
setSelected(new Set());
} else {
setSelected(new Set(allLegacy.map(l => l.id)));
}
};
const columns: ColumnConfig[] = useMemo(() => [
{
key: '_select',
label: '',
width: 40,
formatter: (_val: any, row: LegacyTable) => (
<input
type="checkbox"
checked={selected.has(row.id)}
onChange={() => _toggleSelect(row.id)}
/>
),
},
{
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: 250,
formatter: (v: string) => (
<span style={{ color: 'var(--danger-color, #e53e3e)' }}>
<code>{v}</code>
<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('kein Modell')}
</span>
</span>
),
},
{
key: 'rowCount',
label: t('Zeilen (ca.)'),
type: 'number' as const,
sortable: true,
width: 120,
formatter: (v: number) => _formatNumber(v),
},
{
key: 'sizeBytes',
label: t('Grösse'),
type: 'number' as const,
sortable: true,
width: 120,
formatter: (v: number) => _formatBytes(v),
},
], [t, databases, selected]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<ConfirmDialog />
<div className={styles.filterSection}>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={_fetchLegacy} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
</button>
<label className={styles.checkboxLabel} style={{ fontWeight: 600 }}>
<input type="checkbox" checked={selected.size === allLegacy.length && allLegacy.length > 0} onChange={_toggleAll} />
{t('Alle')}
</label>
{selected.size > 0 && (
<button className={styles.dangerButton} onClick={_dropSelected} disabled={droppingBatch || loading}>
<FaTrashAlt className={droppingBatch ? 'spinning' : ''} />
{t('Ausgewählte löschen')} ({selected.size})
</button>
)}
</div>
</div>
{allLegacy.length > 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} Legacy-Tabellen in {dbs} Datenbanken ({rows} Zeilen, {size})', {
count: totals.count, dbs: totals.dbs, rows: _formatNumber(totals.rows), size: _formatBytes(totals.size),
})}
</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: 'drop',
icon: <FaTrashAlt />,
onClick: (row: LegacyTable) => _dropOne(row),
loading: (row: LegacyTable) => dropping === row.id || droppingBatch,
title: t('Tabelle löschen'),
},
]}
hookData={{
refetch,
pagination,
fetchFilterValues,
}}
emptyMessage={t('Keine Legacy-Tabellen gefunden')}
/>
</div>
</div>
);
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Page // Page
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -739,6 +1671,16 @@ export const AdminDatabaseHealthPage: React.FC = () => {
label: t('Orphan Cleanup'), label: t('Orphan Cleanup'),
content: <OrphansTab />, content: <OrphansTab />,
}, },
{
id: 'legacy',
label: t('Legacy Cleanup'),
content: <LegacyCleanupTab />,
},
{
id: 'migration',
label: t('Migration'),
content: <MigrationTab />,
},
], [t]); ], [t]);
return ( return (
@ -746,7 +1688,7 @@ export const AdminDatabaseHealthPage: React.FC = () => {
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1> <h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1>
<p className={styles.pageSubtitle}>{t('Tabellenstatistiken und verwaiste Datensätze')}</p> <p className={styles.pageSubtitle}>{t('Tabellenstatistiken, verwaiste Datensaetze und Migration')}</p>
</div> </div>
</div> </div>

View file

@ -31,17 +31,6 @@ interface UserFile {
} }
type ViewMode = 'folder' | 'all'; type ViewMode = 'folder' | 'all';
type FileOwnerScope = 'all' | 'me' | 'shared';
function normalizeFolderFilterId(folderId: string | null): string | null {
if (!folderId) return null;
if (folderId.startsWith('__filesRoot:')) return null;
return folderId;
}
function isSyntheticRootFolderId(folderId: string | null): boolean {
return Boolean(folderId && folderId.startsWith('__filesRoot:'));
}
export const FilesPage: React.FC = () => { export const FilesPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -78,16 +67,14 @@ export const FilesPage: React.FC = () => {
handleInlineUpdate, handleInlineUpdate,
deletingFiles, deletingFiles,
downloadingFiles, downloadingFiles,
uploadingFile,
previewingFiles, previewingFiles,
} = useFileOperations(); } = useFileOperations();
const [editingFile, setEditingFile] = useState<UserFile | null>(null); const [editingFile, setEditingFile] = useState<UserFile | null>(null);
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]); const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null); const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [selectedOwnership, setSelectedOwnership] = useState<'own' | 'shared' | null>('own');
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null); const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
const [isUploadingBatch, setIsUploadingBatch] = useState(false);
const [treeWidth, setTreeWidth] = useState(300); const [treeWidth, setTreeWidth] = useState(300);
const [treeVisible, setTreeVisible] = useState(true); const [treeVisible, setTreeVisible] = useState(true);
@ -116,24 +103,14 @@ export const FilesPage: React.FC = () => {
const _tableRefetch = useCallback(async (params?: any) => { const _tableRefetch = useCallback(async (params?: any) => {
const nextParams = { ...(params || {}) }; const nextParams = { ...(params || {}) };
const nextFilters = { ...(nextParams.filters || {}) }; const nextFilters = { ...(nextParams.filters || {}) };
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId); if (viewMode === 'folder' && selectedFolderId) {
const rootSelected = isSyntheticRootFolderId(selectedFolderId); nextFilters.folderId = selectedFolderId;
const owner: FileOwnerScope =
selectedOwnership === 'own'
? 'me'
: selectedOwnership === 'shared'
? 'shared'
: 'all';
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
nextFilters.folderId = normalizedFolderId;
} else { } else {
delete nextFilters.folderId; delete nextFilters.folderId;
} }
nextParams.filters = nextFilters; nextParams.filters = nextFilters;
if (owner !== 'all') nextParams.owner = owner;
else delete nextParams.owner;
await tableRefetch(nextParams); await tableRefetch(nextParams);
}, [tableRefetch, selectedFolderId, selectedOwnership, viewMode]); }, [tableRefetch, selectedFolderId, viewMode]);
const fetchGroupSectionSummaries = useCallback( const fetchGroupSectionSummaries = useCallback(
async (base: { async (base: {
@ -145,20 +122,12 @@ export const FilesPage: React.FC = () => {
groupDirection?: 'asc' | 'desc'; groupDirection?: 'asc' | 'desc';
}) => { }) => {
const filters = { ...(base.filters || {}) }; const filters = { ...(base.filters || {}) };
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId); if (viewMode === 'folder' && selectedFolderId) {
const rootSelected = isSyntheticRootFolderId(selectedFolderId); filters.folderId = selectedFolderId;
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
filters.folderId = normalizedFolderId;
} }
const owner: FileOwnerScope = return fetchGroupSectionSummariesFromHook({ ...base, filters });
selectedOwnership === 'own'
? 'me'
: selectedOwnership === 'shared'
? 'shared'
: 'all';
return fetchGroupSectionSummariesFromHook({ ...base, filters, owner });
}, },
[fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId, selectedOwnership], [fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId],
); );
const refetchForSection = useCallback( const refetchForSection = useCallback(
@ -168,20 +137,12 @@ export const FilesPage: React.FC = () => {
parentColumnFilters?: Record<string, unknown>, parentColumnFilters?: Record<string, unknown>,
) => { ) => {
const merged = { ...(parentColumnFilters || {}) }; const merged = { ...(parentColumnFilters || {}) };
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId); if (viewMode === 'folder' && selectedFolderId) {
const rootSelected = isSyntheticRootFolderId(selectedFolderId); merged.folderId = selectedFolderId;
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
merged.folderId = normalizedFolderId;
} }
const owner: FileOwnerScope = return refetchForSectionFromHook(paginationParams, sectionFilter, merged);
selectedOwnership === 'own'
? 'me'
: selectedOwnership === 'shared'
? 'shared'
: 'all';
return refetchForSectionFromHook({ ...paginationParams, owner }, sectionFilter, merged);
}, },
[refetchForSectionFromHook, viewMode, selectedFolderId, selectedOwnership], [refetchForSectionFromHook, viewMode, selectedFolderId],
); );
const _refreshAll = useCallback(async () => { const _refreshAll = useCallback(async () => {
@ -191,15 +152,14 @@ export const FilesPage: React.FC = () => {
useEffect(() => { useEffect(() => {
_tableRefetch({ page: 1, pageSize: 25 }); _tableRefetch({ page: 1, pageSize: 25 });
}, [selectedFolderId, selectedOwnership, viewMode, _tableRefetch]); }, [selectedFolderId, viewMode, _tableRefetch]);
// ── Tree interaction ────────────────────────────────────────────────── // ── Tree interaction ──────────────────────────────────────────────────
const _handleTreeNodeClick = useCallback((node: TreeNode) => { const _handleTreeNodeClick = useCallback((node: TreeNode) => {
setSelectedOwnership(node.ownership);
if (node.type === 'folder') { if (node.type === 'folder') {
setSelectedFolderId(node.id); setSelectedFolderId(node.id);
} else if (node.type === 'file') { } else if (node.type === 'file') {
setSelectedFolderId(node.parentId ?? null); setSelectedFolderId(node.parentId);
setHighlightedFileId(node.id); setHighlightedFileId(node.id);
requestAnimationFrame(() => { requestAnimationFrame(() => {
const row = document.querySelector('tr[data-highlighted="true"]'); const row = document.querySelector('tr[data-highlighted="true"]');
@ -304,22 +264,12 @@ export const FilesPage: React.FC = () => {
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files; const picked = e.target.files;
if (picked && picked.length > 0) { if (picked && picked.length > 0) {
setIsUploadingBatch(true);
setUploadProgressPercent(0);
try {
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
const files = Array.from(picked); for (const file of Array.from(picked)) {
const totalFiles = files.length; const result = await handleFileUpload(file);
for (const [index, file] of files.entries()) {
const result = await handleFileUpload(file, undefined, undefined, fileProgress => {
const baseProgress = (index / totalFiles) * 100;
const scaledFileProgress = fileProgress / totalFiles;
setUploadProgressPercent(Math.min(100, Math.round(baseProgress + scaledFileProgress)));
});
if (result?.success) successCount++; else errorCount++; if (result?.success) successCount++; else errorCount++;
} }
setUploadProgressPercent(100);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
await _tableRefetch(); await _tableRefetch();
setTreeKey(k => k + 1); setTreeKey(k => k + 1);
@ -333,10 +283,6 @@ export const FilesPage: React.FC = () => {
} else if (errorCount > 0) { } else if (errorCount > 0) {
showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount })); showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount }));
} }
} finally {
setIsUploadingBatch(false);
setUploadProgressPercent(0);
}
} }
}; };
@ -490,39 +436,8 @@ export const FilesPage: React.FC = () => {
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
{canCreate && ( {canCreate && (
<button <button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
className={styles.primaryButton} <FaUpload /> {uploadingFile ? t('Wird hochgeladen...') : t('Datei hochladen')}
onClick={handleUploadClick}
disabled={isUploadingBatch}
style={{ position: 'relative', overflow: 'hidden' }}
>
{isUploadingBatch && (
<span
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: `${uploadProgressPercent}%`,
background: 'rgba(255, 255, 255, 0.25)',
transition: 'width 120ms linear',
}}
/>
)}
<span
style={{
position: 'relative',
zIndex: 1,
display: 'inline-flex',
alignItems: 'center',
gap: 6,
}}
>
<FaUpload />
<span>{t('Datei hochladen')}</span>
{isUploadingBatch && <span>{uploadProgressPercent}%</span>}
</span>
</button> </button>
)} )}
</div> </div>

View file

@ -23,12 +23,16 @@ import { useLanguage } from '../../providers/language/LanguageContext';
const _formatCurrency = (amount: number) => const _formatCurrency = (amount: number) =>
new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount); new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount);
const _formatDate = (iso: string | null | undefined): string => { const _formatDate = (value: string | number | null | undefined): string => {
if (!iso) return '—'; if (value == null || value === '') return '—';
try { try {
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' }); const num = typeof value === 'number' ? value : Number(value);
const ms = !isNaN(num) && num < 1e12 ? num * 1000 : num;
const d = !isNaN(ms) ? new Date(ms) : new Date(value);
if (isNaN(d.getTime())) return String(value);
return d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { } catch {
return iso; return String(value);
} }
}; };

View file

@ -851,52 +851,6 @@
background: var(--surface-alt, #fafafa); background: var(--surface-alt, #fafafa);
} }
.panelTitleBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--surface-alt, #fafafa);
flex-shrink: 0;
}
.panelTitleBar .panelTitle {
padding: 0;
border: none;
background: none;
flex: 1;
min-width: 0;
}
.panelExpandBtn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: var(--surface-color, #fff);
color: var(--text-secondary, #666);
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.panelExpandBtn:hover {
background: var(--surface-alt, #f5f5f5);
color: var(--primary-color, #4A90D9);
border-color: var(--primary-color, #4A90D9);
}
.popupPanelList {
max-height: none;
padding: 0;
}
.transcriptList, .transcriptList,
.responseList { .responseList {
flex: 1; flex: 1;

View file

@ -25,7 +25,6 @@ import styles from './Teamsbot.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { Popup } from '../../../components/UiComponents/Popup';
/** /**
* TeamsbotSessionView - Live session view with real-time transcript and bot responses. * TeamsbotSessionView - Live session view with real-time transcript and bot responses.
@ -55,8 +54,6 @@ export const TeamsbotSessionView: React.FC = () => {
const [screenshotsLoading, setScreenshotsLoading] = useState(false); const [screenshotsLoading, setScreenshotsLoading] = useState(false);
const [screenshotsLoaded, setScreenshotsLoaded] = useState(false); const [screenshotsLoaded, setScreenshotsLoaded] = useState(false);
const [screenshotsExpanded, setScreenshotsExpanded] = useState(false); const [screenshotsExpanded, setScreenshotsExpanded] = useState(false);
const [transcriptPopupOpen, setTranscriptPopupOpen] = useState(false);
const [botResponsesPopupOpen, setBotResponsesPopupOpen] = useState(false);
const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{ const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{
status: string; status: string;
message?: string; message?: string;
@ -727,64 +724,6 @@ export const TeamsbotSessionView: React.FC = () => {
return colors[Math.abs(hash) % colors.length]; return colors[Math.abs(hash) % colors.length];
}; };
const _renderExpandIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
);
const _renderTranscriptList = (endRef?: React.RefObject<HTMLDivElement | null>) => (
<>
{transcripts.map((seg) => (
<div key={seg.id} className={styles.transcriptItem}>
<span className={styles.transcriptTime}>{_formatTime(seg.timestamp)}</span>
<span
className={styles.transcriptSpeaker}
style={{ color: _getSpeakerColor(seg.speaker || t('Unbekannt')) }}
>
{seg.speaker || t('Unbekannt')}:
</span>
<span className={styles.transcriptText}>{seg.text}</span>
</div>
))}
{endRef && <div ref={endRef} />}
{transcripts.length === 0 && (
<div className={styles.emptyState}>{t('Noch kein Transkript vorhanden')}</div>
)}
</>
);
const _renderBotResponsesList = () => (
<>
{botResponses.map((r) => (
<div key={r.id} className={styles.responseItem}>
<div className={styles.responseHeader}>
<span className={styles.responseIntent}>{r.detectedIntent}</span>
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
</div>
<div className={styles.responseText}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{r.responseText || ''}</ReactMarkdown>
</div>
{r.reasoning && (
<div className={styles.responseReasoning}>
<em>{t('Begründung: {text}', { text: r.reasoning })}</em>
</div>
)}
{(r.modelName || r.processingTime != null) && (
<div className={styles.responseMeta}>
<span>{r.modelName || ''}</span>
{r.processingTime != null && <span>{r.processingTime.toFixed(1)}s</span>}
{r.priceCHF != null && <span>{r.priceCHF.toFixed(4)} CHF</span>}
</div>
)}
</div>
))}
{botResponses.length === 0 && (
<div className={styles.emptyState}>{t('Noch keine Botantworten')}</div>
)}
</>
);
if (loading) return <div className={styles.loading}>{t('Sitzung laden')}</div>; if (loading) return <div className={styles.loading}>{t('Sitzung laden')}</div>;
if (noSessions) return ( if (noSessions) return (
<div className={styles.emptyState || styles.loading} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '1rem', padding: '3rem' }}> <div className={styles.emptyState || styles.loading} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '1rem', padding: '3rem' }}>
@ -1193,69 +1132,63 @@ export const TeamsbotSessionView: React.FC = () => {
<div className={styles.sessionContent}> <div className={styles.sessionContent}>
{/* Left: Transcript */} {/* Left: Transcript */}
<div className={styles.transcriptPanel}> <div className={styles.transcriptPanel}>
<div className={styles.panelTitleBar}>
<h4 className={styles.panelTitle}> <h4 className={styles.panelTitle}>
{t('Transkript ({count} Segmente)', { count: transcripts.length })} {t('Transkript ({count} Segmente)', { count: transcripts.length })}
</h4> </h4>
<button
type="button"
className={styles.panelExpandBtn}
onClick={() => setTranscriptPopupOpen(true)}
title={t('Vollbild')}
aria-label={t('Transkript im Vollbild anzeigen')}
>
{_renderExpandIcon()}
</button>
</div>
<div className={styles.transcriptList}> <div className={styles.transcriptList}>
{_renderTranscriptList(transcriptEndRef)} {transcripts.map((seg) => (
<div key={seg.id} className={styles.transcriptItem}>
<span className={styles.transcriptTime}>{_formatTime(seg.timestamp)}</span>
<span
className={styles.transcriptSpeaker}
style={{ color: _getSpeakerColor(seg.speaker || t('Unbekannt')) }}
>
{seg.speaker || t('Unbekannt')}:
</span>
<span className={styles.transcriptText}>{seg.text}</span>
</div>
))}
<div ref={transcriptEndRef} />
{transcripts.length === 0 && (
<div className={styles.emptyState}>{t('Noch kein Transkript vorhanden')}</div>
)}
</div> </div>
</div> </div>
{/* Right: Bot Responses */} {/* Right: Bot Responses */}
<div className={styles.responsesPanel}> <div className={styles.responsesPanel}>
<div className={styles.panelTitleBar}>
<h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4> <h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4>
<button
type="button"
className={styles.panelExpandBtn}
onClick={() => setBotResponsesPopupOpen(true)}
title={t('Vollbild')}
aria-label={t('Bot-Antworten im Vollbild anzeigen')}
>
{_renderExpandIcon()}
</button>
</div>
<div className={styles.responseList}> <div className={styles.responseList}>
{_renderBotResponsesList()} {botResponses.map((r) => (
<div key={r.id} className={styles.responseItem}>
<div className={styles.responseHeader}>
<span className={styles.responseIntent}>{r.detectedIntent}</span>
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
</div>
<div className={styles.responseText}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{r.responseText || ''}</ReactMarkdown>
</div>
{r.reasoning && (
<div className={styles.responseReasoning}>
<em>{t('Begründung: {text}', { text: r.reasoning })}</em>
</div>
)}
{(r.modelName || r.processingTime != null) && (
<div className={styles.responseMeta}>
<span>{r.modelName || ''}</span>
{r.processingTime != null && <span>{r.processingTime.toFixed(1)}s</span>}
{r.priceCHF != null && <span>{r.priceCHF.toFixed(4)} CHF</span>}
</div>
)}
</div>
))}
{botResponses.length === 0 && (
<div className={styles.emptyState}>{t('Noch keine Botantworten')}</div>
)}
</div> </div>
</div> </div>
</div> </div>
<Popup
isOpen={transcriptPopupOpen}
title={t('Transkript ({count} Segmente)', { count: transcripts.length })}
onClose={() => setTranscriptPopupOpen(false)}
size="fullscreen"
closeOnBackdropClick
>
<div className={`${styles.transcriptList} ${styles.popupPanelList}`}>
{_renderTranscriptList()}
</div>
</Popup>
<Popup
isOpen={botResponsesPopupOpen}
title={`Bot-Antworten (${botResponses.length})`}
onClose={() => setBotResponsesPopupOpen(false)}
size="fullscreen"
closeOnBackdropClick
>
<div className={`${styles.responseList} ${styles.popupPanelList}`}>
{_renderBotResponsesList()}
</div>
</Popup>
{/* Summary (for ended sessions) */} {/* Summary (for ended sessions) */}
{session.summary && ( {session.summary && (
<div className={styles.summaryCard}> <div className={styles.summaryCard}>

View file

@ -94,7 +94,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
); );
const [mobileLeftOpen, setMobileLeftOpen] = useState(false); const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
const [mobileRightOpen, setMobileRightOpen] = useState(false); const [mobileRightOpen, setMobileRightOpen] = useState(false);
const [chatListRefreshKey, setChatListRefreshKey] = useState(0);
useEffect(() => { useEffect(() => {
const _handleResize = () => { const _handleResize = () => {
@ -255,27 +254,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
workspace.loadWorkflow(wfId); workspace.loadWorkflow(wfId);
}; };
const sidebarHeaderBtnStyle: React.CSSProperties = {
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 14,
color: '#888',
};
const createChatBtnStyle: React.CSSProperties = {
...sidebarHeaderBtnStyle,
fontSize: 20,
fontWeight: 700,
lineHeight: 1,
color: 'var(--text-secondary, #555)',
};
const _handleCreateNewChat = useCallback(() => {
workspace.resetToNew();
setChatListRefreshKey(k => k + 1);
}, [workspace]);
const tabButtonStyle = (active: boolean): React.CSSProperties => ({ const tabButtonStyle = (active: boolean): React.CSSProperties => ({
flex: 1, flex: 1,
padding: '6px 0', padding: '6px 0',
@ -378,7 +356,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
onTabChange={setUdbTab} onTabChange={setUdbTab}
onSelectChat={_handleConversationSelect} onSelectChat={_handleConversationSelect}
activeWorkflowId={workspace.workflowId ?? undefined} activeWorkflowId={workspace.workflowId ?? undefined}
chatListRefreshKey={chatListRefreshKey} onCreateNewChat={workspace.resetToNew}
onRenameChat={_handleRenameChat} onRenameChat={_handleRenameChat}
onDeleteChat={_handleDeleteChat} onDeleteChat={_handleDeleteChat}
onFileSelect={_handleFileSelect} onFileSelect={_handleFileSelect}
@ -430,10 +408,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
}}> }}>
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span> <span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <button onClick={() => setLeftCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
<button onClick={_handleCreateNewChat} style={createChatBtnStyle} title={t('Neuer Chat')}>+</button>
<button onClick={() => setLeftCollapsed(true)} style={sidebarHeaderBtnStyle}></button>
</div>
</div> </div>
{_leftPanelBody} {_leftPanelBody}
</aside> </aside>
@ -629,11 +604,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
> >
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span> <span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button onClick={_handleCreateNewChat} style={createChatBtnStyle} title={t('Neuer Chat')}>+</button>
<button onClick={() => setMobileLeftOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button> <button onClick={() => setMobileLeftOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button>
</div> </div>
</div>
{_leftPanelBody} {_leftPanelBody}
</aside> </aside>
</div> </div>