Compare commits
47 commits
639cac2e33
...
f56ddbf0e2
| Author | SHA1 | Date | |
|---|---|---|---|
| f56ddbf0e2 | |||
| 3c34a068fc | |||
| 654a400313 | |||
| c5d7d85dda | |||
| eb35e4b463 | |||
| fe7321c84d | |||
| 90e1fbc40f | |||
| d2e6cc215f | |||
| c273392944 | |||
| b51f6b64db | |||
| 12a9b6d0b8 | |||
| 4f7afb6123 | |||
| 42ca066474 | |||
| 73d5a1e102 | |||
| 96cdf38c18 | |||
| 30c7d076cf | |||
| df2336eb13 | |||
| 9e6aef3c97 | |||
| a8ced9d8b8 | |||
| c89718ffd2 | |||
| 8a619457a6 | |||
| 5e34b0dbf7 | |||
| 9488a7d95c | |||
| f617a2d701 | |||
| c61ccf4330 | |||
| 919ad061e1 | |||
| bef2aa8b83 | |||
| 8c2f61670c | |||
| 5f5df47e25 | |||
| 2e6fce188d | |||
| 41eaa63d49 | |||
| bc30ae7bd2 | |||
| 0336bec81a | |||
| 2966f2a2f0 | |||
| c7a75cbab8 | |||
| d754a995d4 | |||
| 4572e084da | |||
| 50f816a771 | |||
| f914857dc4 | |||
| 7d2e1d6e08 | |||
| 2d828c41e0 | |||
| 6213fff90a | |||
| 3ddb83912e | |||
| 38d15da99a | |||
| 07bcc83eeb | |||
| 25abb6fff9 | |||
| 9b0923b9da |
46 changed files with 1877 additions and 1606 deletions
144
.cursor/plans/implement_rbac_roles_page_8dd9fac6.plan.md
Normal file
144
.cursor/plans/implement_rbac_roles_page_8dd9fac6.plan.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# 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
|
||||
50
.forgejo/workflows/deploy-int.yml
Normal file
50
.forgejo/workflows/deploy-int.yml
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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"
|
||||
51
.forgejo/workflows/deploy.yml
Normal file
51
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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"
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
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
|
||||
71
.github/workflows/poweron_nyla_int.yml
vendored
Normal file
71
.github/workflows/poweron_nyla_int.yml
vendored
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
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
|
||||
71
.github/workflows/poweron_nyla_main.yml
vendored
Normal file
71
.github/workflows/poweron_nyla_main.yml
vendored
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
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
|
||||
188
config/config.ts
188
config/config.ts
|
|
@ -1,22 +1,178 @@
|
|||
/**
|
||||
* Configuration — reads mandatory env vars set by .env (copied from config/env-*.env by CI).
|
||||
*
|
||||
* 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.
|
||||
* Simple Configuration Service
|
||||
* Centralized access to environment variables with fallbacks
|
||||
*/
|
||||
|
||||
const _apiBaseUrl: string = import.meta.env.VITE_API_BASE_URL;
|
||||
// API Configuration
|
||||
export const getApiBaseUrl = (): string => {
|
||||
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
};
|
||||
|
||||
if (!_apiBaseUrl) {
|
||||
throw new Error(
|
||||
'Missing required env variable: VITE_API_BASE_URL. Ensure .env is present (cp config/env-<env>.env .env).'
|
||||
);
|
||||
}
|
||||
export const getApiTimeout = (): number => {
|
||||
return parseInt(import.meta.env.VITE_API_TIMEOUT || '10000');
|
||||
};
|
||||
|
||||
export const getApiBaseUrl = (): string => _apiBaseUrl;
|
||||
// App Configuration
|
||||
export const getAppName = (): string => {
|
||||
return import.meta.env.VITE_APP_NAME || 'PowerOn';
|
||||
};
|
||||
|
||||
export const getAppName = (): string => import.meta.env.VITE_APP_NAME || 'PowerOn';
|
||||
export const getAppVersion = (): string => {
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
6
config/env-porta-int.env
Normal file
6
config/env-porta-int.env
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# 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
|
||||
6
config/env-porta.env
Normal file
6
config/env-porta.env
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# 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
|
||||
|
|
@ -2,5 +2,5 @@
|
|||
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
|
||||
# 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
|
||||
|
|
@ -2,5 +2,5 @@
|
|||
# Consumed by: Vite build (title) + 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_API_BASE_URL=https://gateway-int.poweron.swiss
|
||||
VITE_APP_NAME=Poweron Nyla int
|
||||
|
|
@ -2,5 +2,5 @@
|
|||
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
|
||||
# Auth and secrets live on the gateway — never in frontend env.
|
||||
|
||||
VITE_API_BASE_URL=https://api.poweron.swiss
|
||||
VITE_API_BASE_URL=https://gateway-prod.poweron.swiss
|
||||
VITE_APP_NAME=PowerOn Nyla
|
||||
|
|
@ -1 +1,12 @@
|
|||
export { getApiBaseUrl, getAppName } from './config';
|
||||
// Export simple configuration service
|
||||
export * from './config';
|
||||
|
||||
// Re-export commonly used functions
|
||||
export {
|
||||
getApiBaseUrl,
|
||||
getAppName,
|
||||
isDevelopment,
|
||||
isProduction,
|
||||
isDebugMode,
|
||||
config
|
||||
} from './config';
|
||||
|
|
|
|||
|
|
@ -341,7 +341,7 @@ export interface DataSourceSettings {
|
|||
|
||||
export interface CostEstimate {
|
||||
estimatedTokens: number;
|
||||
estimatedChf: number;
|
||||
estimatedUsd: number;
|
||||
basis: {
|
||||
kind: string;
|
||||
limits: Record<string, number>;
|
||||
|
|
@ -373,9 +373,24 @@ export async function getDataSourceCostEstimate(
|
|||
});
|
||||
}
|
||||
|
||||
// Flag toggles (neutralize / scope / ragIndexEnabled) now go through the
|
||||
// generic UDB endpoint POST /api/udb/node/{key}/flag/{flag}; see
|
||||
// `UdbSourcesProvider` and the wiki UDB reference page.
|
||||
export interface PatchFlagResponse {
|
||||
sourceId: string;
|
||||
resetDescendantIds: string[];
|
||||
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
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export interface PaginationParams {
|
|||
search?: string;
|
||||
viewKey?: string;
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||
owner?: 'all' | 'me' | 'shared';
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
|
|
@ -109,6 +110,7 @@ export async function fetchFiles(
|
|||
if (params.search) paginationObj.search = params.search;
|
||||
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||
if (params.owner) requestParams.owner = params.owner;
|
||||
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Modal } from '../UiComponents/Modal/Modal';
|
||||
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import styles from './AddConnectionWizard.module.css';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -75,8 +74,6 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
|||
onMsftAdminConsent,
|
||||
isConnecting = false,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const [state, setState] = useState<WizardState>({
|
||||
currentStep: 'connector',
|
||||
connector: null,
|
||||
|
|
@ -128,7 +125,7 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleClose} title={t('Verbindung hinzufügen')} size="md" closeOnEscape>
|
||||
<Modal open={open} onClose={handleClose} title="Verbindung hinzufügen" size="md" closeOnEscape>
|
||||
{/* Stepper */}
|
||||
<div className={styles.stepper}>
|
||||
{steps.map((s, i) => (
|
||||
|
|
@ -149,8 +146,8 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
|||
{/* ---- Step: Connector ---- */}
|
||||
{state.currentStep === 'connector' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>{t('Anbieter wählen')}</h3>
|
||||
<p className={styles.stepHint}>{t('Welchen Dienst möchtest du verbinden?')}</p>
|
||||
<h3 className={styles.stepTitle}>Anbieter wählen</h3>
|
||||
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
|
||||
<div className={styles.connectorGrid}>
|
||||
{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
|
||||
<button
|
||||
|
|
@ -170,23 +167,25 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
|||
{/* ---- Step: Consent ---- */}
|
||||
{state.currentStep === 'consent' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>{t('Wissensdatenbank')}</h3>
|
||||
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
|
||||
<p className={styles.stepBody}>
|
||||
{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') })}
|
||||
Möchtest du Inhalte aus dieser Verbindung in deine persönliche
|
||||
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
|
||||
aus {state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'} zurückgreifen kann?
|
||||
</p>
|
||||
<p className={styles.stepHint}>
|
||||
{t('Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.')}
|
||||
Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.
|
||||
</p>
|
||||
<div className={styles.consentButtons}>
|
||||
<button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}>
|
||||
<FaCheck /> {t('Ja, aktivieren')}
|
||||
<FaCheck /> Ja, aktivieren
|
||||
</button>
|
||||
<button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}>
|
||||
{t('Nein, überspringen')}
|
||||
Nein, überspringen
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.stepNavLeft}>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -197,12 +196,13 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
|||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<FaShieldAlt size={32} style={{ color: '#00a4ef' }} />
|
||||
</div>
|
||||
<h3 className={styles.stepTitle}>{t('Organisations-Zustimmung (optional)')}</h3>
|
||||
<h3 className={styles.stepTitle}>Organisations-Zustimmung (optional)</h3>
|
||||
<p className={styles.stepBody}>
|
||||
{t('Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. So müssen andere Benutzer nicht einzeln bestätigen.')}
|
||||
Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen.
|
||||
So müssen andere Benutzer nicht einzeln bestätigen.
|
||||
</p>
|
||||
<p className={styles.stepHint}>
|
||||
{t('Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.')}
|
||||
Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.
|
||||
</p>
|
||||
<div className={styles.consentButtons}>
|
||||
<button
|
||||
|
|
@ -210,14 +210,14 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
|||
className={styles.consentButtonYes}
|
||||
onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
|
||||
>
|
||||
<FaShieldAlt /> {t('Admin-Zustimmung erteilen')}
|
||||
<FaShieldAlt /> Admin-Zustimmung erteilen
|
||||
</button>
|
||||
<button type="button" className={styles.consentButtonNo} onClick={goNext}>
|
||||
{t('Überspringen')}
|
||||
Überspringen
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.stepNavLeft}>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -225,9 +225,9 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
|||
{/* ---- Step: Infomaniak PAT ---- */}
|
||||
{state.currentStep === 'infomaniakPat' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>{t('Infomaniak Personal Access Token')}</h3>
|
||||
<h3 className={styles.stepTitle}>Infomaniak Personal Access Token</h3>
|
||||
<p className={styles.stepBody}>
|
||||
{t('Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.')}
|
||||
Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
|
|
@ -238,14 +238,14 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
|||
autoFocus
|
||||
/>
|
||||
<div className={styles.stepNav}>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navConnect}
|
||||
onClick={handleFinalConnect}
|
||||
disabled={isConnecting || !state.infomaniakToken.trim()}
|
||||
>
|
||||
{isConnecting ? t('Verbinden…') : t('Verbinden')}
|
||||
{isConnecting ? 'Verbinden…' : 'Verbinden'}
|
||||
{!isConnecting && <FaArrowRight size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -255,31 +255,31 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
|||
{/* ---- Step: Connect ---- */}
|
||||
{state.currentStep === 'connect' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>{t('Verbindung herstellen')}</h3>
|
||||
<h3 className={styles.stepTitle}>Verbindung herstellen</h3>
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.summaryRow}>
|
||||
<span className={styles.summaryKey}>{t('Anbieter')}</span>
|
||||
<span className={styles.summaryKey}>Anbieter</span>
|
||||
<span className={styles.summaryVal}>
|
||||
{state.connector && CONNECTOR_ICONS[state.connector]}
|
||||
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.summaryRow}>
|
||||
<span className={styles.summaryKey}>{t('Wissensdatenbank')}</span>
|
||||
<span className={styles.summaryKey}>Wissensdatenbank</span>
|
||||
<span className={styles.summaryVal}>
|
||||
{state.knowledgeEnabled ? t('Aktiv') : t('Nicht aktiv')}
|
||||
{state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.stepNav}>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>Zurück</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navConnect}
|
||||
onClick={handleFinalConnect}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
{isConnecting ? t('Verbinden…') : t('Mit {provider} verbinden', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : '…' })}
|
||||
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}
|
||||
{!isConnecting && <FaArrowRight size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
const [versions, setVersions] = useState<AutoVersion[]>([]);
|
||||
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
||||
const [versionLoading, setVersionLoading] = useState(false);
|
||||
const didBootstrapEmptyCanvasRef = useRef(false);
|
||||
|
||||
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
|
||||
|
||||
|
|
@ -598,8 +599,22 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
|
||||
useEffect(() => {
|
||||
if (loading || nodeTypes.length === 0) return;
|
||||
if (currentWorkflowId || initialWorkflowId) return;
|
||||
if (canvasNodes.length > 0) return;
|
||||
if (currentWorkflowId || initialWorkflowId) {
|
||||
didBootstrapEmptyCanvasRef.current = false;
|
||||
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: [] }, [], {
|
||||
skipHistory: true,
|
||||
});
|
||||
|
|
@ -609,8 +624,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
currentWorkflowId,
|
||||
initialWorkflowId,
|
||||
canvasNodes.length,
|
||||
canvasConnections.length,
|
||||
invocations.length,
|
||||
applyGraphWithSync,
|
||||
t,
|
||||
]);
|
||||
|
||||
const toggleCategory = useCallback((id: string) => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
|||
import { AiBadge } from '../nodes/shared/AiBadge';
|
||||
import { switchOutputLabel } from '../nodes/shared/graphUtils';
|
||||
|
||||
const LOG = '[FlowCanvas]';
|
||||
|
||||
export interface CanvasNode {
|
||||
id: string;
|
||||
type: string;
|
||||
|
|
@ -842,6 +844,8 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
|||
|
||||
const onHistoryCheckpointRef = useRef(onHistoryCheckpoint);
|
||||
onHistoryCheckpointRef.current = onHistoryCheckpoint;
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
|
||||
const emitHistoryCheckpoint = useCallback(() => {
|
||||
onHistoryCheckpointRef.current?.();
|
||||
|
|
@ -1019,12 +1023,19 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
|||
]
|
||||
);
|
||||
|
||||
const lastEmittedSelectionRef = useRef<{ nodeId: string | null; signature: string | null }>({
|
||||
nodeId: null,
|
||||
signature: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (onSelectionChange) {
|
||||
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
|
||||
onSelectionChange(node);
|
||||
}
|
||||
}, [selectedNodeId, nodes, onSelectionChange]);
|
||||
const signature = node ? JSON.stringify(node) : null;
|
||||
const last = lastEmittedSelectionRef.current;
|
||||
if (last.nodeId === selectedNodeId && last.signature === signature) return;
|
||||
lastEmittedSelectionRef.current = { nodeId: selectedNodeId, signature };
|
||||
onSelectionChangeRef.current?.(node);
|
||||
}, [selectedNodeId, nodes]);
|
||||
|
||||
const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -1088,6 +1099,11 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
|||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
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)
|
||||
if (onExternalDrop) {
|
||||
const reservedMimes = new Set([
|
||||
|
|
@ -1113,16 +1129,35 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
|||
}
|
||||
// 2) Standard: Node-Type aus der NodeSidebar
|
||||
const raw = e.dataTransfer.getData('application/json');
|
||||
if (!raw || !containerRef.current) return;
|
||||
if (!raw || !containerRef.current) {
|
||||
console.debug(`${LOG} drop ignored`, {
|
||||
hasRaw: Boolean(raw),
|
||||
hasContainer: Boolean(containerRef.current),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { type } = JSON.parse(raw);
|
||||
const el = containerRef.current;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 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));
|
||||
emitHistoryCheckpoint();
|
||||
} catch (_) {}
|
||||
} catch (error) {
|
||||
console.debug(`${LOG} drop parse failed`, {
|
||||
raw,
|
||||
error,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } f
|
|||
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
||||
import { getLabel } from '../nodes/shared/utils';
|
||||
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
||||
import { ContextBuilderRenderer } from '../nodes/frontendTypeRenderers/ContextBuilderRenderer';
|
||||
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
|
||||
import { findRequiredErrors } from '../nodes/shared/paramValidation';
|
||||
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
|
||||
|
|
@ -253,6 +254,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
|
||||
for (const param of sortedParameters) {
|
||||
if (param.frontendType === 'hidden') continue;
|
||||
if (param.name === 'context') continue;
|
||||
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
|
||||
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
|
||||
|
||||
|
|
@ -378,6 +380,15 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
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;
|
||||
|
||||
const isTrigger = node.type.startsWith('trigger.');
|
||||
|
|
@ -483,11 +494,71 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
</div>
|
||||
)}
|
||||
{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>
|
||||
key={`${node.id}-extract-accordion`}
|
||||
defaultOpenId={null}
|
||||
items={extractContentAccordionItems}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
parameters.map((param: NodeTypeParameter) => {
|
||||
// Safety net: hidden params have no UI footprint at all — no row,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
.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);
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
2
src/components/FormGenerator/FilterSearchInput/index.ts
Normal file
2
src/components/FormGenerator/FilterSearchInput/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { FilterSearchInput } from './FilterSearchInput';
|
||||
export type { FilterSearchInputProps } from './FilterSearchInput';
|
||||
|
|
@ -168,7 +168,7 @@
|
|||
.searchInput {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 8px 12px;
|
||||
padding: 8px 28px 8px 12px;
|
||||
border: 1px solid var(--color-border, #E2E8F0);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import type { IconType } from 'react-icons';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './FormGeneratorControls.module.css';
|
||||
import { FilterSearchInput } from '../FilterSearchInput';
|
||||
import { Button } from '../../UiComponents/Button';
|
||||
import { IoIosRefresh } from "react-icons/io";
|
||||
import { FaTrash, FaDownload } from "react-icons/fa";
|
||||
|
|
@ -189,14 +190,15 @@ export function FormGeneratorControls({
|
|||
<div className={styles.searchContainer}>
|
||||
{searchable && (
|
||||
<div className={styles.floatingLabelInput}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder=" "
|
||||
<FilterSearchInput
|
||||
variant="inherit"
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onChange={onSearchChange}
|
||||
placeholder=" "
|
||||
onFocus={() => onSearchFocus(true)}
|
||||
onBlur={() => onSearchFocus(false)}
|
||||
className={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`}
|
||||
inputClassName={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`}
|
||||
clearTitle={t('Suche löschen')}
|
||||
/>
|
||||
<label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}>
|
||||
{t('Suchen...')}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ import {
|
|||
import { formatUnixTimestamp } from '../../../utils/time';
|
||||
import { applyFrontendFormat } from '../../../utils/applyFrontendFormat';
|
||||
import { FormGeneratorControls } from '../FormGeneratorControls';
|
||||
import { FilterSearchInput } from '../FilterSearchInput';
|
||||
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
|
||||
import {
|
||||
isDateTimeType,
|
||||
|
|
@ -446,22 +447,11 @@ function FilterValuesList({
|
|||
<>
|
||||
{showSearch && (
|
||||
<div style={{ padding: '4px 6px', borderBottom: '1px solid var(--border-color, #ddd)' }}>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
<FilterSearchInput
|
||||
inputRef={searchInputRef}
|
||||
value={searchTerm}
|
||||
onChange={(e) => { setSearchTerm(e.target.value); setDisplayCount(_FILTER_PAGE_SIZE); }}
|
||||
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()}
|
||||
onChange={(value) => { setSearchTerm(value); setDisplayCount(_FILTER_PAGE_SIZE); }}
|
||||
onInputClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -33,19 +33,6 @@ const _SCOPE_EMOJIS: Record<string, string> = {
|
|||
|
||||
const _NEUTRALIZE_ON_EMOJI = '\uD83D\uDD12'; // closed 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. */
|
||||
const _ACTION_SCOPE = '__scope__';
|
||||
|
|
@ -209,8 +196,6 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
isDragging,
|
||||
ownership,
|
||||
compact,
|
||||
selectable,
|
||||
pendingActions,
|
||||
provider,
|
||||
onToggleExpand,
|
||||
onToggleSelect,
|
||||
|
|
@ -223,9 +208,6 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
onSendToChat,
|
||||
onCycleScope,
|
||||
onToggleNeutralize,
|
||||
onToggleRagIndex,
|
||||
onCreateChild,
|
||||
onExtraAction,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
|
|
@ -294,12 +276,6 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
const canDelete = isOwn && provider.canDelete?.(node);
|
||||
const canPatchScope = isOwn && provider.canPatchScope?.(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 = [
|
||||
styles.nodeRow,
|
||||
|
|
@ -332,7 +308,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
>
|
||||
<div className={styles.indentSpacer} style={{ width: depth * INDENT_PX }} />
|
||||
|
||||
{selectable && !hideRowActionButtons && (
|
||||
{!hideRowActionButtons && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className={styles.nodeCheckbox}
|
||||
|
|
@ -390,20 +366,6 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
{!hideRowActionButtons && (
|
||||
<>
|
||||
<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 && (
|
||||
<button
|
||||
className={styles.emojiBtn}
|
||||
|
|
@ -418,7 +380,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
</button>
|
||||
)}
|
||||
|
||||
{node.type !== 'folder' && provider.downloadNode && (
|
||||
{node.type !== 'folder' && (
|
||||
<button
|
||||
className={styles.emojiBtn}
|
||||
onClick={(e) => {
|
||||
|
|
@ -448,49 +410,6 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
</div>
|
||||
|
||||
<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 && (
|
||||
<button
|
||||
className={styles.emojiBtn}
|
||||
|
|
@ -512,16 +431,10 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
e.stopPropagation();
|
||||
if (canPatchScope) onCycleScope(node);
|
||||
}}
|
||||
title={node.scope === 'mixed'
|
||||
? 'Gemischt - Klick setzt explizit'
|
||||
: `Scope: ${node.scope}`}
|
||||
title={`Scope: ${node.scope}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{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)}
|
||||
{_SCOPE_EMOJIS[node.scope] ?? _SCOPE_EMOJIS.personal}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
@ -532,18 +445,11 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
|||
e.stopPropagation();
|
||||
if (canPatchNeutralize) onToggleNeutralize(node);
|
||||
}}
|
||||
title={node.neutralize === 'mixed'
|
||||
? 'Gemischt - Klick setzt explizit'
|
||||
: node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
|
||||
title={node.neutralize ? 'Neutralisiert' : 'Nicht neutralisiert'}
|
||||
tabIndex={-1}
|
||||
style={{ opacity: node.neutralize ? 1 : 0.35 }}
|
||||
>
|
||||
{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>}
|
||||
{node.neutralize ? _NEUTRALIZE_ON_EMOJI : _NEUTRALIZE_OFF_EMOJI}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -568,6 +474,7 @@ export function FormGeneratorTree<T = any>({
|
|||
onSendToChat,
|
||||
allowCreateFolder = true,
|
||||
selectable = true,
|
||||
refreshAfterAction = false,
|
||||
className,
|
||||
embedMaxHeight,
|
||||
hideRowActionButtons = false,
|
||||
|
|
@ -613,10 +520,29 @@ export function FormGeneratorTree<T = any>({
|
|||
);
|
||||
|
||||
|
||||
/** After a toggle, refetch children for root + all expanded parents so the
|
||||
* backend-authoritative effective flag values are current. No attribute-only
|
||||
* shortcut — the backend is the single source of truth (spec 2026-05-18). */
|
||||
/** After a toggle, collect all currently visible node IDs and ask the
|
||||
* provider for their updated attributes. Patches only attribute fields
|
||||
* (neutralize, scope, ragIndexEnabled) on existing nodes — no structural
|
||||
* reload. Falls back to full refetch if provider doesn't implement
|
||||
* refreshAttributes. */
|
||||
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 fetched = await Promise.all(
|
||||
expandedList.map((p) => provider.loadChildren(p, ownership)),
|
||||
|
|
@ -629,12 +555,13 @@ export function FormGeneratorTree<T = any>({
|
|||
});
|
||||
return [...keepers, ...fetched.flat()];
|
||||
});
|
||||
}
|
||||
}, [expandedIds, provider, ownership]);
|
||||
|
||||
/** Wrap any async action with pending-state tracking so the tree can show
|
||||
* a spinner over the corresponding button. Generic — no domain knowledge.
|
||||
* Always refetches all expanded parents after the action completes so the
|
||||
* backend-authoritative values are rendered. */
|
||||
* When `refreshAfterAction` is enabled, the spinner stays on until the
|
||||
* refreshed attributes have been written into state. */
|
||||
const _runAction = useCallback(
|
||||
async (nodeId: string, actionKey: string, fn: () => Promise<void> | void) => {
|
||||
setPendingActions((prev) => {
|
||||
|
|
@ -646,7 +573,9 @@ export function FormGeneratorTree<T = any>({
|
|||
});
|
||||
try {
|
||||
await fn();
|
||||
if (refreshAfterAction || provider.refreshAttributes) {
|
||||
await _refreshVisibleAttributes();
|
||||
}
|
||||
} finally {
|
||||
setPendingActions((prev) => {
|
||||
const next = new Map(prev);
|
||||
|
|
@ -658,7 +587,7 @@ export function FormGeneratorTree<T = any>({
|
|||
});
|
||||
}
|
||||
},
|
||||
[_refreshVisibleAttributes],
|
||||
[refreshAfterAction, _refreshVisibleAttributes],
|
||||
);
|
||||
|
||||
const _loadRoot = useCallback(async () => {
|
||||
|
|
@ -681,45 +610,6 @@ export function FormGeneratorTree<T = any>({
|
|||
_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(
|
||||
() => _flatten(nodes, expandedIds, confirmedEmptyFolderIds),
|
||||
[nodes, expandedIds, confirmedEmptyFolderIds],
|
||||
|
|
|
|||
|
|
@ -1106,15 +1106,19 @@ describe('FormGeneratorTree', () => {
|
|||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Always refetch after action (backend-authoritative, spec 2026-05-18)
|
||||
// refreshAfterAction (backend-authoritative mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('refetch after action', () => {
|
||||
describe('refreshAfterAction', () => {
|
||||
it('refetches null + expanded parents after a flag toggle', async () => {
|
||||
const user = userEvent.setup();
|
||||
const provider = _createMockProvider([_ownFolder]);
|
||||
render(
|
||||
<FormGeneratorTree provider={provider} ownership="own" />,
|
||||
<FormGeneratorTree
|
||||
provider={provider}
|
||||
ownership="own"
|
||||
refreshAfterAction
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -1135,6 +1139,28 @@ describe('FormGeneratorTree', () => {
|
|||
expect(newCalls.length).toBeGreaterThan(initialLoadCalls);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { FaFolder, FaFile, FaTrash } from 'react-icons/fa';
|
||||
import type { TreeNodeProvider, TreeNode, Ownership, ScopeValue, TreeBatchAction } from '../types';
|
||||
import api from '../../../../api';
|
||||
import { getUserDataCache } from '../../../../utils/userCache';
|
||||
|
||||
interface FolderData {
|
||||
id: string;
|
||||
|
|
@ -117,7 +116,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
|||
if ((f.parentId ?? null) === null) out.add(f.id);
|
||||
}
|
||||
const paginationParam = JSON.stringify({ filters: { folderId: null }, pageSize: 500 });
|
||||
const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam } });
|
||||
const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam, owner } });
|
||||
const data = filesRes.data;
|
||||
const rawFiles: FileData[] = (data && typeof data === 'object' && 'items' in data)
|
||||
? (Array.isArray(data.items) ? data.items : [])
|
||||
|
|
@ -169,7 +168,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
|||
}
|
||||
const paginationParam = JSON.stringify({ filters, pageSize: 500 });
|
||||
const filesRes = await api.get('/api/files/list', {
|
||||
params: { pagination: paginationParam },
|
||||
params: { pagination: paginationParam, owner },
|
||||
});
|
||||
const data = filesRes.data;
|
||||
let rawFiles: FileData[] = [];
|
||||
|
|
@ -179,10 +178,6 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
|||
rawFiles = data;
|
||||
}
|
||||
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));
|
||||
if (apiParentId === null) {
|
||||
for (const n of fileNodes) n.parentId = synthRootId;
|
||||
|
|
@ -299,6 +294,19 @@ 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[] {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -88,6 +88,16 @@ export interface TreeNodeProvider<T = any> {
|
|||
patchRagIndex?(ids: string[], ragIndexEnabled: boolean): Promise<void>;
|
||||
downloadNode?(node: TreeNode<T>): Promise<void>;
|
||||
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
|
||||
* types into the DataTransfer (e.g. `application/datasource`). The generic
|
||||
* tree always sets `application/tree-items` and `text/plain`; this hook
|
||||
|
|
@ -113,6 +123,14 @@ export interface FormGeneratorTreeProps<T = any> {
|
|||
/** When false, hides checkboxes, multi-select keyboard bindings and the
|
||||
* batch-action toolbar. Default true (backward compatible). */
|
||||
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;
|
||||
/** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */
|
||||
embedMaxHeight?: number;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ interface ChatsTabProps {
|
|||
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
|
||||
onDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||
activeWorkflowId?: string;
|
||||
onCreateNew?: () => void;
|
||||
chatListRefreshKey?: number;
|
||||
onRenameChat?: (chatId: string, newName: string) => void | Promise<void>;
|
||||
onDeleteChat?: (chatId: string) => void | Promise<void>;
|
||||
}
|
||||
|
|
@ -72,7 +72,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
onSelectChat,
|
||||
onDragStart,
|
||||
activeWorkflowId,
|
||||
onCreateNew,
|
||||
chatListRefreshKey,
|
||||
onRenameChat,
|
||||
onDeleteChat,
|
||||
}) => {
|
||||
|
|
@ -82,13 +82,14 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<ChatFilter>('active');
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const groupsRef = useRef(groups);
|
||||
groupsRef.current = groups;
|
||||
|
||||
const _loadChats = useCallback(async (serverSearch?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, unknown> = { includeArchived: true };
|
||||
if (serverSearch) params.search = serverSearch;
|
||||
|
|
@ -140,7 +141,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
} catch (err) {
|
||||
console.error('Failed to load chats:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setHasLoadedOnce(true);
|
||||
}
|
||||
}, [context.instanceId, t]);
|
||||
|
||||
|
|
@ -163,6 +164,12 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
}
|
||||
}, [activeWorkflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatListRefreshKey) {
|
||||
_loadChats();
|
||||
}
|
||||
}, [chatListRefreshKey, _loadChats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingId && renameInputRef.current) {
|
||||
renameInputRef.current.focus();
|
||||
|
|
@ -188,8 +195,18 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
const trimmed = editName.trim();
|
||||
setEditingId(null);
|
||||
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);
|
||||
_loadChats();
|
||||
} catch (err) {
|
||||
console.error('Failed to rename chat:', err);
|
||||
setGroups(prev);
|
||||
}
|
||||
};
|
||||
|
||||
const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => {
|
||||
|
|
@ -201,23 +218,41 @@ 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 prev = groupsRef.current;
|
||||
_setChatStatus(chatId, 'archived');
|
||||
try {
|
||||
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' });
|
||||
_loadChats();
|
||||
} catch (err) {
|
||||
console.error('Failed to archive chat:', err);
|
||||
setGroups(prev);
|
||||
}
|
||||
}, [context.instanceId, _loadChats]);
|
||||
}, [context.instanceId, _setChatStatus]);
|
||||
|
||||
const _restoreChat = useCallback(async (chatId: string) => {
|
||||
const prev = groupsRef.current;
|
||||
_setChatStatus(chatId, 'active');
|
||||
try {
|
||||
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' });
|
||||
_loadChats();
|
||||
} catch (err) {
|
||||
console.error('Failed to restore chat:', err);
|
||||
setGroups(prev);
|
||||
}
|
||||
}, [context.instanceId, _loadChats]);
|
||||
}, [context.instanceId, _setChatStatus]);
|
||||
|
||||
const _isArchived = (chat: ChatItem) => chat.status === 'archived';
|
||||
|
||||
|
|
@ -311,7 +346,17 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
{onDeleteChat && (
|
||||
<button
|
||||
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
|
||||
onClick={async (e) => { e.stopPropagation(); await onDeleteChat(chat.id); _loadChats(); }}
|
||||
onClick={async (e) => {
|
||||
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')}
|
||||
>
|
||||
🗑️
|
||||
|
|
@ -334,8 +379,6 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
return labels[code] || code;
|
||||
};
|
||||
|
||||
if (loading) return <div className={styles.loading}>{t('Chats werden geladen…')}</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.chatsTab}>
|
||||
<div className={styles.toolbar}>
|
||||
|
|
@ -346,11 +389,6 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{onCreateNew && (
|
||||
<button className={styles.createBtn} onClick={() => { onCreateNew(); setTimeout(_loadChats, 500); }} title={t('Neuer Chat')}>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
|
||||
onClick={() => setFlatMode(!flatMode)}
|
||||
|
|
@ -437,7 +475,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
</div>
|
||||
)}
|
||||
|
||||
{_allChats.length === 0 && (
|
||||
{hasLoadedOnce && _allChats.length === 0 && (
|
||||
<div className={styles.emptyState}>
|
||||
{filter === 'archived' ? t('Keine archivierten Chats') : t('Keine aktiven Chats')}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
*
|
||||
* 1. Connection — knowledgeIngestionEnabled master switch + mail/clickup prefs
|
||||
* 2. DataSource RAG-Limits — maxBytes/maxFileSize/maxItems/maxDepth (or clickup variants)
|
||||
* 3. Cost estimate — indicative, non-binding CHF figure
|
||||
* 3. Cost estimate — indicative, non-binding USD figure
|
||||
*
|
||||
* Why a single modal:
|
||||
* - 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' }}>
|
||||
<span style={{ fontSize: 13 }}>{t('Voll-Sync (geschätzt)')}</span>
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>~ {cost.estimatedChf.toFixed(4)} CHF</span>
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>~ {cost.estimatedUsd.toFixed(4)} USD</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#777', marginTop: 4 }}>
|
||||
~ {cost.estimatedTokens.toLocaleString()} {t('Tokens')} · {cost.basis.notes}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,60 @@
|
|||
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) {
|
||||
.fileRow:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
const { showSuccess, showError } = useToast();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
|
||||
const uploadRunIdRef = useRef(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const provider = useMemo(() => createFolderFileProvider(), []);
|
||||
|
|
@ -54,21 +56,41 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
|
||||
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
||||
if (!context.instanceId || uploading) return;
|
||||
uploadRunIdRef.current += 1;
|
||||
const runId = uploadRunIdRef.current;
|
||||
setUploading(true);
|
||||
setUploadProgressPercent(0);
|
||||
try {
|
||||
for (const file of Array.from(fileList)) {
|
||||
const files = Array.from(fileList);
|
||||
const totalFiles = files.length || 1;
|
||||
for (const [index, file] of files.entries()) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('featureInstanceId', context.instanceId);
|
||||
await api.post('/api/files/upload', formData, {
|
||||
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();
|
||||
} catch (err) {
|
||||
console.error('File upload failed:', err);
|
||||
} finally {
|
||||
if (uploadRunIdRef.current === runId) {
|
||||
setUploading(false);
|
||||
// Let 100% render briefly, then reset.
|
||||
window.setTimeout(() => {
|
||||
if (uploadRunIdRef.current === runId) setUploadProgressPercent(0);
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
}, [context.instanceId, uploading, _handleRefresh]);
|
||||
|
||||
|
|
@ -135,6 +157,10 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
|
||||
}, [onSendToChat]);
|
||||
|
||||
const circleRadius = 11;
|
||||
const circleCircumference = 2 * Math.PI * circleRadius;
|
||||
const circleOffset = circleCircumference * (1 - uploadProgressPercent / 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.filesTab}
|
||||
|
|
@ -170,10 +196,26 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
|
||||
className={styles.uploadCircleButton}
|
||||
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
|
||||
onClick={_handleRefresh}
|
||||
|
|
@ -201,6 +243,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
title={t('Eigene')}
|
||||
compact={true}
|
||||
showFilter={true}
|
||||
refreshAfterAction
|
||||
onNodeClick={_handleNodeClickWithImport}
|
||||
onSendToChat={_handleSendToChat}
|
||||
/>
|
||||
|
|
@ -212,6 +255,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
compact={true}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
refreshAfterAction
|
||||
emptyMessage={t('Keine geteilten Dateien')}
|
||||
onNodeClick={_handleNodeClickWithImport}
|
||||
onSendToChat={_handleSendToChat}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@
|
|||
* SourcesTab — UDB tab for personal connections + mandate data.
|
||||
*
|
||||
* Architecture:
|
||||
* - Backend is the single source of truth (`POST /api/udb/tree/children`).
|
||||
* - Backend is the single source of truth (`POST /api/workspace/{instanceId}/tree/children`).
|
||||
* - Tree mechanism: generic `FormGeneratorTree` with a UDB-specific provider.
|
||||
* - Inheritance, mixed-state aggregation and cascade-NULL on patch are
|
||||
* ALL handled by the backend; the frontend never recomputes effective values.
|
||||
* - Every flag toggle: PATCH -> refetch all expanded parents via
|
||||
* loadChildren -> atomic state replace. No optimistic updates.
|
||||
* - Every flag toggle goes through `refreshAfterAction`: PATCH -> refetch all
|
||||
* expanded parents -> atomic state replace. No optimistic updates.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
|
@ -67,6 +67,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context }) => {
|
|||
compact
|
||||
selectable={false}
|
||||
allowCreateFolder={false}
|
||||
refreshAfterAction
|
||||
emptyMessage={t('Keine Datenquellen.')}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,28 +3,19 @@
|
|||
/**
|
||||
* UdbSourcesProvider — TreeNodeProvider for the UDB Sources tab.
|
||||
*
|
||||
* Single responsibility: translate the generic UDB backend tree contract
|
||||
* (POST /api/udb/tree/children -> nodesByParent map) into the generic
|
||||
* TreeNode shape that FormGeneratorTree consumes, and forward flag
|
||||
* mutations to the single generic UDB endpoint
|
||||
* (POST /api/udb/node/{key}/flag/{flag}).
|
||||
* Single responsibility: translate the backend tree contract
|
||||
* (POST /api/workspace/{instanceId}/tree/children → nodesByParent map) into
|
||||
* the generic TreeNode shape that FormGeneratorTree consumes, and forward
|
||||
* flag PATCHes to the existing /api/datasources/{id}/{flag} endpoints.
|
||||
*
|
||||
* No effective-value computation, no inheritance logic, no mixed-state math:
|
||||
* the backend is the single source of truth. The provider only:
|
||||
* 1. caches the most recently loaded backend node payload per id so the
|
||||
* drag/settings handlers have direct access to coordinates,
|
||||
* 1. caches the most recently loaded backend node payload per id, so PATCHes
|
||||
* can resolve the implicit DataSource record (creating it lazily when the
|
||||
* backend reports `canBeAdded=true`),
|
||||
* 2. emits stable display ordering via `displayOrder`,
|
||||
* 3. hides flag affordances on synthetic container nodes (synthRoot,
|
||||
* 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';
|
||||
|
|
@ -146,11 +137,6 @@ function _isSyntheticContainer(kind: UdbBackendKind): boolean {
|
|||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -179,14 +165,7 @@ function _mapBackendNode(
|
|||
// Fields expose ONLY neutralize (mapped to parent table's
|
||||
// neutralizeFields list). Scope and RAG are not field-level concepts.
|
||||
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 {
|
||||
// DataSource family carries the full three-flag set.
|
||||
node.scope = n.effectiveScope as ScopeValue | 'mixed';
|
||||
node.neutralize = n.effectiveNeutralize;
|
||||
if (n.supportsRag) {
|
||||
|
|
@ -222,12 +201,11 @@ export function createUdbSourcesProvider(
|
|||
instanceId: string,
|
||||
onOpenSettings: (dataSourceId: string, label: string) => void,
|
||||
): UdbSourcesProviderHandle {
|
||||
// Per-id cache of the most recent backend payload. Used by the
|
||||
// settings/drag handlers (NOT by the generic flag PATCH path; that
|
||||
// identifies the node purely by its tree key).
|
||||
// Per-id cache of the most recent backend payload. Updated by every
|
||||
// `loadChildren` call. Read by patch/ensureRecord paths.
|
||||
const nodeCache = new Map<string, UdbBackendNode>();
|
||||
|
||||
async function _ensureRecordForSettings(node: UdbBackendNode): Promise<string | null> {
|
||||
async function _ensureRecord(node: UdbBackendNode): Promise<string | null> {
|
||||
if (node.dataSourceId) return node.dataSourceId;
|
||||
try {
|
||||
if (node.kind === 'connection' || node.kind === 'service'
|
||||
|
|
@ -266,13 +244,13 @@ export function createUdbSourcesProvider(
|
|||
return newId;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UdbSourcesProvider] ensureRecordForSettings failed', err);
|
||||
console.error('[UdbSourcesProvider] ensureRecord failed', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _onSettingsClick(node: UdbBackendNode): Promise<void> {
|
||||
const dsId = await _ensureRecordForSettings(node);
|
||||
const dsId = await _ensureRecord(node);
|
||||
if (!dsId) {
|
||||
console.warn('[UdbSourcesProvider] settings click: cannot ensure record', node.key);
|
||||
return;
|
||||
|
|
@ -280,19 +258,79 @@ export function createUdbSourcesProvider(
|
|||
onOpenSettings(dsId, node.label);
|
||||
}
|
||||
|
||||
/** Forward a flag mutation to the generic UDB endpoint. The backend
|
||||
* resolves the node from `nodeKey`, runs the polymorphic `canEdit`
|
||||
* permission check, and applies the cascade-reset. */
|
||||
/** fdsField-specific neutralize: ensure the parent fdsTable record exists,
|
||||
* read its current `neutralizeFields` list, add or remove the field,
|
||||
* PATCH the new list back. Backend treats the FDS-record as the single
|
||||
* 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(
|
||||
ids: string[],
|
||||
flag: 'neutralize' | 'scope' | 'ragIndexEnabled',
|
||||
value: unknown,
|
||||
flag: 'scope' | 'neutralize' | 'rag-index',
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
for (const nodeKey of ids) {
|
||||
for (const id 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 {
|
||||
await api.post(`/api/udb/node/${encodeURIComponent(nodeKey)}/flag/${flag}`, { value });
|
||||
await api.patch(`/api/datasources/${dsId}/${flag}`, body);
|
||||
} catch (err) {
|
||||
console.error('[UdbSourcesProvider] patch failed', { nodeKey, flag, err });
|
||||
console.error('[UdbSourcesProvider] patch failed', { id, flag, err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -302,7 +340,7 @@ export function createUdbSourcesProvider(
|
|||
rootKey: `udb-sources-${instanceId}`,
|
||||
|
||||
async loadChildren(parentId, _ownership) {
|
||||
const res = await api.post(`/api/udb/tree/children`, {
|
||||
const res = await api.post(`/api/workspace/${instanceId}/tree/children`, {
|
||||
parents: [parentId],
|
||||
});
|
||||
const nodesByParent = res.data?.nodesByParent || {};
|
||||
|
|
@ -314,8 +352,8 @@ export function createUdbSourcesProvider(
|
|||
|
||||
canPatchScope(node) {
|
||||
const data = node.data;
|
||||
// Scope only exists on DataSource family; FDS / synthetic containers / fields hide it.
|
||||
return !!data && !_isSyntheticContainer(data.kind) && !_isFdsKind(data.kind);
|
||||
// Field-level scope makes no sense; it's inherited from the parent table.
|
||||
return !!data && !_isSyntheticContainer(data.kind) && data.kind !== 'fdsField';
|
||||
},
|
||||
|
||||
canPatchNeutralize(node) {
|
||||
|
|
@ -325,7 +363,7 @@ export function createUdbSourcesProvider(
|
|||
|
||||
canPatchRagIndex(node) {
|
||||
const data = node.data;
|
||||
// RAG exists at the data-source level (DS root / FDS table+rows), never on fields.
|
||||
// RAG is not a field-level concept either; only the table-record carries it.
|
||||
return !!data && data.supportsRag === true && data.kind !== 'fdsField';
|
||||
},
|
||||
|
||||
|
|
@ -333,15 +371,26 @@ export function createUdbSourcesProvider(
|
|||
// Backend cascades NULL on descendants automatically based on the
|
||||
// existence of explicit child records; the cascadeChildren flag is the
|
||||
// FilesTab convention and is irrelevant here.
|
||||
await _patchFlag(ids, 'scope', scope);
|
||||
await _patchFlag(ids, 'scope', { scope });
|
||||
},
|
||||
|
||||
async patchNeutralize(ids, neutralize) {
|
||||
await _patchFlag(ids, 'neutralize', neutralize);
|
||||
// fdsField nodes don't have their own DB record — they are addressed
|
||||
// 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) {
|
||||
await _patchFlag(ids, 'ragIndexEnabled', ragIndexEnabled);
|
||||
await _patchFlag(ids, 'rag-index', { ragIndexEnabled });
|
||||
},
|
||||
|
||||
customizeDragData(node, dataTransfer) {
|
||||
|
|
@ -375,6 +424,30 @@ 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() {
|
||||
return nodeCache.size;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ interface UnifiedDataBarProps {
|
|||
hideTabs?: UdbTab[];
|
||||
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
|
||||
activeWorkflowId?: string;
|
||||
onCreateNewChat?: () => void;
|
||||
onRenameChat?: (chatId: string, newName: string) => void;
|
||||
chatListRefreshKey?: number;
|
||||
onDeleteChat?: (chatId: string) => void;
|
||||
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||
onFileSelect?: (fileId: string, fileName?: string) => void;
|
||||
|
|
@ -78,8 +78,8 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
|||
hideTabs,
|
||||
onSelectChat,
|
||||
activeWorkflowId,
|
||||
onCreateNewChat,
|
||||
onRenameChat,
|
||||
chatListRefreshKey,
|
||||
onDeleteChat,
|
||||
onChatDragStart,
|
||||
onFileSelect,
|
||||
|
|
@ -122,7 +122,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
|||
onSelectChat={onSelectChat}
|
||||
onDragStart={onChatDragStart}
|
||||
activeWorkflowId={activeWorkflowId}
|
||||
onCreateNew={onCreateNewChat}
|
||||
chatListRefreshKey={chatListRefreshKey}
|
||||
onRenameChat={onRenameChat}
|
||||
onDeleteChat={onDeleteChat}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -60,50 +60,6 @@ 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';
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -112,23 +68,23 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loadChildren -> POST /api/udb/tree/children (feature-agnostic)
|
||||
// loadChildren
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('UdbSourcesProvider.loadChildren', () => {
|
||||
it('calls POST /api/udb/tree/children with parents=[parentId]', async () => {
|
||||
it('calls POST /api/workspace/{instanceId}/tree/children with parents=[parentId]', async () => {
|
||||
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [] } } });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
|
||||
await provider.loadChildren(null, 'own');
|
||||
|
||||
expect(apiMock.post).toHaveBeenCalledWith(
|
||||
`/api/udb/tree/children`,
|
||||
`/api/workspace/${_instanceId}/tree/children`,
|
||||
{ parents: [null] },
|
||||
);
|
||||
});
|
||||
|
||||
it('maps DS-family backend nodes to TreeNode shape with all three flags', async () => {
|
||||
it('maps backend nodes to TreeNode shape with flag-bearer fields', async () => {
|
||||
const conn = _makeBackendNode();
|
||||
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
|
|
@ -138,42 +94,103 @@ describe('UdbSourcesProvider.loadChildren', () => {
|
|||
expect(result).toHaveLength(1);
|
||||
const tn = result[0];
|
||||
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.neutralize).toBe(false);
|
||||
expect(tn.ragIndexEnabled).toBe(false);
|
||||
expect(tn.type).toBe('folder');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it('hides scope/neutralize/ragIndexEnabled on synthetic containers', async () => {
|
||||
const root = _makeSynthRootNode();
|
||||
apiMock.post.mockResolvedValue({ data: { nodesByParent: { __root__: [root] } } });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
|
||||
const [tn] = await provider.loadChildren(null, 'own');
|
||||
expect(tn.scope).toBeUndefined();
|
||||
expect(tn.neutralize).toBeUndefined();
|
||||
expect(tn.ragIndexEnabled).toBeUndefined();
|
||||
const result = await provider.loadChildren(null, 'own');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].scope).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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -182,7 +199,7 @@ describe('UdbSourcesProvider.loadChildren', () => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('UdbSourcesProvider.canPatch*', () => {
|
||||
it('canPatch* all false for synthetic containers', async () => {
|
||||
it('canPatchScope is false for synthetic containers', async () => {
|
||||
apiMock.post.mockResolvedValue({
|
||||
data: { nodesByParent: { __root__: [_makeSynthRootNode()] } },
|
||||
});
|
||||
|
|
@ -193,84 +210,175 @@ describe('UdbSourcesProvider.canPatch*', () => {
|
|||
expect(provider.canPatchRagIndex?.(synthNode)).toBe(false);
|
||||
});
|
||||
|
||||
it('canPatchScope is false for any FDS kind', async () => {
|
||||
it('canPatchRagIndex requires supportsRag=true', async () => {
|
||||
apiMock.post.mockResolvedValue({
|
||||
data: { nodesByParent: { 'feat|m1|trustee|fi1': [_makeFdsTableNode()] } },
|
||||
data: {
|
||||
nodesByParent: {
|
||||
personalRoot: [
|
||||
_makeBackendNode({ key: 'a', supportsRag: true }),
|
||||
_makeBackendNode({ key: 'b', supportsRag: false }),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
const [tbl] = await provider.loadChildren('feat|m1|trustee|fi1', 'own');
|
||||
expect(provider.canPatchScope?.(tbl)).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);
|
||||
const [a, b] = await provider.loadChildren('personalRoot', 'own');
|
||||
expect(provider.canPatchRagIndex?.(a)).toBe(true);
|
||||
expect(provider.canPatchRagIndex?.(b)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// patch flow -> POST /api/udb/node/{key}/flag/{flag}
|
||||
// patch flow: ensureRecord + PATCH
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('UdbSourcesProvider.patchScope', () => {
|
||||
it('POSTs to /api/udb/node/{key}/flag/scope with the new value', async () => {
|
||||
apiMock.post.mockResolvedValue({ data: {} });
|
||||
it('PATCHes existing dataSourceId without creating a new record', async () => {
|
||||
apiMock.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
nodesByParent: {
|
||||
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-existing', canBeAdded: false })],
|
||||
},
|
||||
},
|
||||
});
|
||||
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).toHaveBeenCalledWith(
|
||||
`/api/udb/node/${encodeURIComponent('conn|c1')}/flag/scope`,
|
||||
{ value: 'mandate' },
|
||||
expect(apiMock.patch).toHaveBeenCalledWith(
|
||||
`/api/datasources/ds-existing/scope`,
|
||||
{ scope: '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', () => {
|
||||
it('POSTs to /api/udb/node/{key}/flag/neutralize', async () => {
|
||||
apiMock.post.mockResolvedValue({ data: {} });
|
||||
it('PATCHes /neutralize with the supplied boolean', async () => {
|
||||
apiMock.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
nodesByParent: {
|
||||
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })],
|
||||
},
|
||||
},
|
||||
});
|
||||
apiMock.patch.mockResolvedValue({ data: {} });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
await provider.loadChildren('personalRoot', 'own');
|
||||
|
||||
await provider.patchNeutralize?.(['conn|c1'], true);
|
||||
|
||||
expect(apiMock.post).toHaveBeenCalledWith(
|
||||
`/api/udb/node/${encodeURIComponent('conn|c1')}/flag/neutralize`,
|
||||
{ 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 },
|
||||
expect(apiMock.patch).toHaveBeenCalledWith(
|
||||
`/api/datasources/ds-1/neutralize`,
|
||||
{ neutralize: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UdbSourcesProvider.patchRagIndex', () => {
|
||||
it('POSTs to /api/udb/node/{key}/flag/ragIndexEnabled', async () => {
|
||||
apiMock.post.mockResolvedValue({ data: {} });
|
||||
it('PATCHes /rag-index with the supplied boolean (note dash in URL, camelCase in body)', async () => {
|
||||
apiMock.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
nodesByParent: {
|
||||
personalRoot: [_makeBackendNode({ dataSourceId: 'ds-1', canBeAdded: false })],
|
||||
},
|
||||
},
|
||||
});
|
||||
apiMock.patch.mockResolvedValue({ data: {} });
|
||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||
await provider.loadChildren('personalRoot', 'own');
|
||||
|
||||
await provider.patchRagIndex?.(['conn|c1'], true);
|
||||
|
||||
expect(apiMock.post).toHaveBeenCalledWith(
|
||||
`/api/udb/node/${encodeURIComponent('conn|c1')}/flag/ragIndexEnabled`,
|
||||
{ value: true },
|
||||
expect(apiMock.patch).toHaveBeenCalledWith(
|
||||
`/api/datasources/ds-1/rag-index`,
|
||||
{ ragIndexEnabled: 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export interface PaginationParams {
|
|||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
viewKey?: string;
|
||||
owner?: 'all' | 'me' | 'shared';
|
||||
}
|
||||
|
||||
// Files list hook
|
||||
|
|
@ -150,6 +151,7 @@ export function useUserFiles() {
|
|||
groupField: string;
|
||||
groupDirection?: 'asc' | 'desc';
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
|
||||
owner?: 'all' | 'me' | 'shared';
|
||||
}) => {
|
||||
const levels = base.groupByLevels?.length
|
||||
? base.groupByLevels
|
||||
|
|
@ -164,7 +166,11 @@ export function useUserFiles() {
|
|||
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
||||
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
||||
const { data } = await api.get('/api/files/list', {
|
||||
params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
|
||||
params: {
|
||||
mode: 'groupSummary',
|
||||
pagination: JSON.stringify(pObj),
|
||||
...(base.owner ? { owner: base.owner } : {}),
|
||||
},
|
||||
});
|
||||
return Array.isArray(data?.groups) ? data.groups : [];
|
||||
},
|
||||
|
|
@ -192,7 +198,10 @@ export function useUserFiles() {
|
|||
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
||||
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
||||
const { data } = await api.get('/api/files/list', {
|
||||
params: { pagination: JSON.stringify(pObj) },
|
||||
params: {
|
||||
pagination: JSON.stringify(pObj),
|
||||
...(paginationParams.owner ? { owner: paginationParams.owner } : {}),
|
||||
},
|
||||
});
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
return { items: data.items, pagination: data.pagination };
|
||||
|
|
@ -408,6 +417,7 @@ export function useFileOperations() {
|
|||
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
|
||||
const [editingFiles, setEditingFiles] = useState<Set<string>>(new Set());
|
||||
const [uploadingFile, setUploadingFile] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isLoading] = useState(false);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
|
@ -564,9 +574,11 @@ export function useFileOperations() {
|
|||
file: globalThis.File,
|
||||
workflowId?: string,
|
||||
featureInstanceId?: string,
|
||||
onProgress?: (progress: number) => void,
|
||||
) => {
|
||||
setUploadError(null);
|
||||
setUploadingFile(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
|
||||
|
|
@ -593,7 +605,14 @@ export function useFileOperations() {
|
|||
|
||||
|
||||
// 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;
|
||||
|
||||
// Check if the response indicates a duplicate file
|
||||
|
|
@ -625,6 +644,7 @@ export function useFileOperations() {
|
|||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setUploadingFile(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -749,6 +769,7 @@ export function useFileOperations() {
|
|||
deletingFiles,
|
||||
editingFiles,
|
||||
uploadingFile,
|
||||
uploadProgress,
|
||||
downloadError,
|
||||
deleteError,
|
||||
uploadError,
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ const StorePage: React.FC = () => {
|
|||
)}
|
||||
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
|
||||
<span className={styles.bannerSeparator}>
|
||||
{t('Testphase endet am')}: {new Date(Number(subscriptionInfo.trialEndsAt) * 1000).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })}
|
||||
{t('Testphase endet am')}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
/**
|
||||
* AdminDatabaseHealthPage
|
||||
*
|
||||
* SysAdmin-only page with three tabs:
|
||||
* SysAdmin-only page with two tabs:
|
||||
* 1. Table Statistics — pg_stat data for every table across all databases
|
||||
* 2. Orphan Cleanup — FK orphan detection with per-relation + batch cleanup
|
||||
* 3. Migration — Database backup (export) and restore (import)
|
||||
*
|
||||
* Both Stats/Orphan tabs use FormGeneratorTable with a client-side pagination/sort/filter
|
||||
* Both tabs use FormGeneratorTable with a client-side pagination/sort/filter
|
||||
* adapter (the backend returns all rows at once; the dataset is small enough).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle, FaDownload, FaUpload, FaDatabase, FaInfoCircle, FaCheckCircle } from 'react-icons/fa';
|
||||
import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle, FaDownload } from 'react-icons/fa';
|
||||
import api from '../../api';
|
||||
import styles from './Admin.module.css';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
|
@ -722,937 +721,6 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1671,16 +739,6 @@ export const AdminDatabaseHealthPage: React.FC = () => {
|
|||
label: t('Orphan Cleanup'),
|
||||
content: <OrphansTab />,
|
||||
},
|
||||
{
|
||||
id: 'legacy',
|
||||
label: t('Legacy Cleanup'),
|
||||
content: <LegacyCleanupTab />,
|
||||
},
|
||||
{
|
||||
id: 'migration',
|
||||
label: t('Migration'),
|
||||
content: <MigrationTab />,
|
||||
},
|
||||
], [t]);
|
||||
|
||||
return (
|
||||
|
|
@ -1688,7 +746,7 @@ export const AdminDatabaseHealthPage: React.FC = () => {
|
|||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1>
|
||||
<p className={styles.pageSubtitle}>{t('Tabellenstatistiken, verwaiste Datensaetze und Migration')}</p>
|
||||
<p className={styles.pageSubtitle}>{t('Tabellenstatistiken und verwaiste Datensätze')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,17 @@ interface UserFile {
|
|||
}
|
||||
|
||||
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 = () => {
|
||||
const { t } = useLanguage();
|
||||
|
|
@ -67,14 +78,16 @@ export const FilesPage: React.FC = () => {
|
|||
handleInlineUpdate,
|
||||
deletingFiles,
|
||||
downloadingFiles,
|
||||
uploadingFile,
|
||||
previewingFiles,
|
||||
} = useFileOperations();
|
||||
|
||||
const [editingFile, setEditingFile] = useState<UserFile | null>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [selectedOwnership, setSelectedOwnership] = useState<'own' | 'shared' | null>('own');
|
||||
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
|
||||
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
|
||||
const [isUploadingBatch, setIsUploadingBatch] = useState(false);
|
||||
|
||||
const [treeWidth, setTreeWidth] = useState(300);
|
||||
const [treeVisible, setTreeVisible] = useState(true);
|
||||
|
|
@ -103,14 +116,24 @@ export const FilesPage: React.FC = () => {
|
|||
const _tableRefetch = useCallback(async (params?: any) => {
|
||||
const nextParams = { ...(params || {}) };
|
||||
const nextFilters = { ...(nextParams.filters || {}) };
|
||||
if (viewMode === 'folder' && selectedFolderId) {
|
||||
nextFilters.folderId = selectedFolderId;
|
||||
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
|
||||
const rootSelected = isSyntheticRootFolderId(selectedFolderId);
|
||||
const owner: FileOwnerScope =
|
||||
selectedOwnership === 'own'
|
||||
? 'me'
|
||||
: selectedOwnership === 'shared'
|
||||
? 'shared'
|
||||
: 'all';
|
||||
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
|
||||
nextFilters.folderId = normalizedFolderId;
|
||||
} else {
|
||||
delete nextFilters.folderId;
|
||||
}
|
||||
nextParams.filters = nextFilters;
|
||||
if (owner !== 'all') nextParams.owner = owner;
|
||||
else delete nextParams.owner;
|
||||
await tableRefetch(nextParams);
|
||||
}, [tableRefetch, selectedFolderId, viewMode]);
|
||||
}, [tableRefetch, selectedFolderId, selectedOwnership, viewMode]);
|
||||
|
||||
const fetchGroupSectionSummaries = useCallback(
|
||||
async (base: {
|
||||
|
|
@ -122,12 +145,20 @@ export const FilesPage: React.FC = () => {
|
|||
groupDirection?: 'asc' | 'desc';
|
||||
}) => {
|
||||
const filters = { ...(base.filters || {}) };
|
||||
if (viewMode === 'folder' && selectedFolderId) {
|
||||
filters.folderId = selectedFolderId;
|
||||
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
|
||||
const rootSelected = isSyntheticRootFolderId(selectedFolderId);
|
||||
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
|
||||
filters.folderId = normalizedFolderId;
|
||||
}
|
||||
return fetchGroupSectionSummariesFromHook({ ...base, filters });
|
||||
const owner: FileOwnerScope =
|
||||
selectedOwnership === 'own'
|
||||
? 'me'
|
||||
: selectedOwnership === 'shared'
|
||||
? 'shared'
|
||||
: 'all';
|
||||
return fetchGroupSectionSummariesFromHook({ ...base, filters, owner });
|
||||
},
|
||||
[fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId],
|
||||
[fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId, selectedOwnership],
|
||||
);
|
||||
|
||||
const refetchForSection = useCallback(
|
||||
|
|
@ -137,12 +168,20 @@ export const FilesPage: React.FC = () => {
|
|||
parentColumnFilters?: Record<string, unknown>,
|
||||
) => {
|
||||
const merged = { ...(parentColumnFilters || {}) };
|
||||
if (viewMode === 'folder' && selectedFolderId) {
|
||||
merged.folderId = selectedFolderId;
|
||||
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
|
||||
const rootSelected = isSyntheticRootFolderId(selectedFolderId);
|
||||
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
|
||||
merged.folderId = normalizedFolderId;
|
||||
}
|
||||
return refetchForSectionFromHook(paginationParams, sectionFilter, merged);
|
||||
const owner: FileOwnerScope =
|
||||
selectedOwnership === 'own'
|
||||
? 'me'
|
||||
: selectedOwnership === 'shared'
|
||||
? 'shared'
|
||||
: 'all';
|
||||
return refetchForSectionFromHook({ ...paginationParams, owner }, sectionFilter, merged);
|
||||
},
|
||||
[refetchForSectionFromHook, viewMode, selectedFolderId],
|
||||
[refetchForSectionFromHook, viewMode, selectedFolderId, selectedOwnership],
|
||||
);
|
||||
|
||||
const _refreshAll = useCallback(async () => {
|
||||
|
|
@ -152,14 +191,15 @@ export const FilesPage: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
_tableRefetch({ page: 1, pageSize: 25 });
|
||||
}, [selectedFolderId, viewMode, _tableRefetch]);
|
||||
}, [selectedFolderId, selectedOwnership, viewMode, _tableRefetch]);
|
||||
|
||||
// ── Tree interaction ──────────────────────────────────────────────────
|
||||
const _handleTreeNodeClick = useCallback((node: TreeNode) => {
|
||||
setSelectedOwnership(node.ownership);
|
||||
if (node.type === 'folder') {
|
||||
setSelectedFolderId(node.id);
|
||||
} else if (node.type === 'file') {
|
||||
setSelectedFolderId(node.parentId);
|
||||
setSelectedFolderId(node.parentId ?? null);
|
||||
setHighlightedFileId(node.id);
|
||||
requestAnimationFrame(() => {
|
||||
const row = document.querySelector('tr[data-highlighted="true"]');
|
||||
|
|
@ -264,12 +304,22 @@ export const FilesPage: React.FC = () => {
|
|||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = e.target.files;
|
||||
if (picked && picked.length > 0) {
|
||||
setIsUploadingBatch(true);
|
||||
setUploadProgressPercent(0);
|
||||
try {
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
for (const file of Array.from(picked)) {
|
||||
const result = await handleFileUpload(file);
|
||||
const files = Array.from(picked);
|
||||
const totalFiles = files.length;
|
||||
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++;
|
||||
}
|
||||
setUploadProgressPercent(100);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
await _tableRefetch();
|
||||
setTreeKey(k => k + 1);
|
||||
|
|
@ -283,6 +333,10 @@ export const FilesPage: React.FC = () => {
|
|||
} else if (errorCount > 0) {
|
||||
showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount }));
|
||||
}
|
||||
} finally {
|
||||
setIsUploadingBatch(false);
|
||||
setUploadProgressPercent(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -436,8 +490,39 @@ export const FilesPage: React.FC = () => {
|
|||
<div style={{ flex: 1 }} />
|
||||
|
||||
{canCreate && (
|
||||
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
|
||||
<FaUpload /> {uploadingFile ? t('Wird hochgeladen...') : t('Datei hochladen')}
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,16 +23,12 @@ import { useLanguage } from '../../providers/language/LanguageContext';
|
|||
const _formatCurrency = (amount: number) =>
|
||||
new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount);
|
||||
|
||||
const _formatDate = (value: string | number | null | undefined): string => {
|
||||
if (value == null || value === '') return '—';
|
||||
const _formatDate = (iso: string | null | undefined): string => {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
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' });
|
||||
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
} catch {
|
||||
return String(value);
|
||||
return iso;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -851,6 +851,52 @@
|
|||
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,
|
||||
.responseList {
|
||||
flex: 1;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import styles from './Teamsbot.module.css';
|
|||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Popup } from '../../../components/UiComponents/Popup';
|
||||
|
||||
/**
|
||||
* TeamsbotSessionView - Live session view with real-time transcript and bot responses.
|
||||
|
|
@ -54,6 +55,8 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
const [screenshotsLoading, setScreenshotsLoading] = useState(false);
|
||||
const [screenshotsLoaded, setScreenshotsLoaded] = useState(false);
|
||||
const [screenshotsExpanded, setScreenshotsExpanded] = useState(false);
|
||||
const [transcriptPopupOpen, setTranscriptPopupOpen] = useState(false);
|
||||
const [botResponsesPopupOpen, setBotResponsesPopupOpen] = useState(false);
|
||||
const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{
|
||||
status: string;
|
||||
message?: string;
|
||||
|
|
@ -724,6 +727,64 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
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 (noSessions) return (
|
||||
<div className={styles.emptyState || styles.loading} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '1rem', padding: '3rem' }}>
|
||||
|
|
@ -1132,63 +1193,69 @@ export const TeamsbotSessionView: React.FC = () => {
|
|||
<div className={styles.sessionContent}>
|
||||
{/* Left: Transcript */}
|
||||
<div className={styles.transcriptPanel}>
|
||||
<div className={styles.panelTitleBar}>
|
||||
<h4 className={styles.panelTitle}>
|
||||
{t('Transkript ({count} Segmente)', { count: transcripts.length })}
|
||||
</h4>
|
||||
<div className={styles.transcriptList}>
|
||||
{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')) }}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.panelExpandBtn}
|
||||
onClick={() => setTranscriptPopupOpen(true)}
|
||||
title={t('Vollbild')}
|
||||
aria-label={t('Transkript im Vollbild anzeigen')}
|
||||
>
|
||||
{seg.speaker || t('Unbekannt')}:
|
||||
</span>
|
||||
<span className={styles.transcriptText}>{seg.text}</span>
|
||||
{_renderExpandIcon()}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div ref={transcriptEndRef} />
|
||||
{transcripts.length === 0 && (
|
||||
<div className={styles.emptyState}>{t('Noch kein Transkript vorhanden')}</div>
|
||||
)}
|
||||
<div className={styles.transcriptList}>
|
||||
{_renderTranscriptList(transcriptEndRef)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Bot Responses */}
|
||||
<div className={styles.responsesPanel}>
|
||||
<div className={styles.panelTitleBar}>
|
||||
<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}>
|
||||
{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>
|
||||
)}
|
||||
{_renderBotResponsesList()}
|
||||
</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) */}
|
||||
{session.summary && (
|
||||
<div className={styles.summaryCard}>
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
);
|
||||
const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
|
||||
const [mobileRightOpen, setMobileRightOpen] = useState(false);
|
||||
const [chatListRefreshKey, setChatListRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const _handleResize = () => {
|
||||
|
|
@ -254,6 +255,27 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
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 => ({
|
||||
flex: 1,
|
||||
padding: '6px 0',
|
||||
|
|
@ -356,7 +378,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
onTabChange={setUdbTab}
|
||||
onSelectChat={_handleConversationSelect}
|
||||
activeWorkflowId={workspace.workflowId ?? undefined}
|
||||
onCreateNewChat={workspace.resetToNew}
|
||||
chatListRefreshKey={chatListRefreshKey}
|
||||
onRenameChat={_handleRenameChat}
|
||||
onDeleteChat={_handleDeleteChat}
|
||||
onFileSelect={_handleFileSelect}
|
||||
|
|
@ -408,7 +430,10 @@ 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' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span>
|
||||
<button onClick={() => setLeftCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}>◀</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button onClick={_handleCreateNewChat} style={createChatBtnStyle} title={t('Neuer Chat')}>+</button>
|
||||
<button onClick={() => setLeftCollapsed(true)} style={sidebarHeaderBtnStyle}>◀</button>
|
||||
</div>
|
||||
</div>
|
||||
{_leftPanelBody}
|
||||
</aside>
|
||||
|
|
@ -604,8 +629,11 @@ 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' }}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{_leftPanelBody}
|
||||
</aside>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue