diff --git a/.gitignore b/.gitignore index 74f039c4b9..6adca714b5 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,4 @@ test/metaforge-test-convert/*convertservice # Local assistant memory (never commit) .claude/ .Codex/ +plugins/ diff --git a/configure/etc/conf/i18n/messages_en.properties b/configure/etc/conf/i18n/messages_en.properties index 53352f0c48..ac69282069 100644 --- a/configure/etc/conf/i18n/messages_en.properties +++ b/configure/etc/conf/i18n/messages_en.properties @@ -241,6 +241,8 @@ menu.common.new=New Menu menu.common.i18nKey=I18n Key menu.common.icon=Icon menu.tip.selectType=Please select a type +common.parent=Parent menu +menu.tip.searchIcon=Search icons\nmenu.tip.selectIcon=Select an icon menu.tip.selectParent=Please select a parent menu menu.tip.selectRedirect=Please select a redirect menu ## Snippet i18n @@ -384,7 +386,7 @@ common.uninstallSuccess=Uninstalled successfully common.installTime=Install Time common.installVersion=Install Version common.plugin.version=Plugin Version -common.plugin.systemVersion=Current system version: +common.plugin.systemVersion=Current system version:\u0020 common.plugin.list.name=Plugin List common.plugin.list.description=List of available plugins common.plugin.list.supportVersion=Support Version @@ -516,6 +518,9 @@ role.validate.descriptionSize=Route description must be 3-50 characters long. ## Schedule i18n schedule.common.list=Schedule List schedule.common.history=Schedule History +schedule.common.trigger=Trigger now +schedule.common.triggerSuccess=Task triggered successfully +schedule.common.upcoming=Next 7 executions ## Dashboard i18n dashboard.common.list=Dashboard List dashboard.common.delete=Delete Dashboard @@ -819,7 +824,6 @@ notify.tip.deleted=Notify when datasets are deleted notify.tip.dynamic=Notify when new activities are generated notify.tip.syncData=Notify when data synchronization completes - common.searchPlaceholder=Search datasets, tables, documents... user.tip.modifyUsernamePage=Set a new username to represent your identity across the system user.common.currentUsername=Current Username @@ -945,3 +949,73 @@ dataset.search.fields=Search fields... dataset.adhoc.all=All dataset.adhoc.dimensions=Dimensions dataset.adhoc.measures=Measures +dataTable.search=Search +dataTable.keyword=Keyword +dataTable.keywordPlaceholder=Search by name or code +dataTable.logKeywordPlaceholder=Search device, client or IP +dataTable.codePlaceholder=Enter the exact code +dataTable.all=All +dataTable.enabled=Enabled +dataTable.disabled=Disabled +dataTable.moreFilters=More filters +dataTable.lessFilters=Fewer filters +dataTable.createdFrom=Created from +dataTable.createdTo=Created through +dataTable.invalidDateRange=Start date must not be after end date +dataTable.yes=Yes +dataTable.no=No +dataTable.success=Success +dataTable.failure=Failure +dataTable.systemAccount=System account +dataTable.systemTask=System task +dataTable.menuPath=Menu path +dataTable.sqlContent=SQL content +dataTable.loginResult=Login result +dataTable.loginFrom=Logged in from +dataTable.loginTo=Logged in through +dataTable.queryReport=Query report +dataTable.datasetReport=Dataset report +dataTable.selectDate=Select date +dataTable.today=Today +dataTable.clear=Clear +dataTable.previousMonth=Previous month or year +dataTable.nextMonth=Next month or year +dataTable.stateCREATED=Created +dataTable.stateQUEUE=Queued +dataTable.stateRUNNING=Running +dataTable.stateSUCCESS=Success +dataTable.stateFAILURE=Failure +dataTable.stateTIMEOUT=Timed out +dataTable.stateSTOPPING=Stopping +dataTable.stateSTOPPED=Stopped +dataTable.stateINTERRUPTED=Interrupted +dataTable.modeADHOC=Ad hoc query +dataTable.modeHISTORY=History +dataTable.modeREPORT=Report +dataTable.modeSNIPPET=Snippet +dataTable.modeSYNC=Sync +dataTable.modeDATASET=Dataset +common.columns=Columns +common.download=Export current page +dataTable.available=Available +dataTable.unavailable=Unavailable +role.tip.codePlaceholder=Optional; generated automatically when blank +role.tip.codeHint=Use 1–64 letters, digits, underscores or hyphens; start with a letter or digit. Leave blank to generate a code. +role.tip.codeReadonly=The code cannot be changed after creation +role.tip.codeChecking=Checking code… +role.tip.codeAvailable=Code is available +role.validate.codeFormat=Use 1–64 letters, digits, underscores or hyphens, starting with a letter or digit +role.validate.codeExists=This code already exists; choose another code +role.validate.codeCheckFailed=Could not check the code; please retry +role.validate.identityMismatch=The record does not match the original role; refresh and retry +role.validate.codeConstraint=Could not enforce unique codes; check the database for existing duplicates +role.validate.invalidMenus=Some menus do not exist or cannot be assigned; refresh and retry +role.validate.menuSaveFailed=Could not save the menu assignment; please retry +role.validate.menuLoadFailed=Could not load menus; please retry +role.tip.menuRefreshFailed=Menus were saved, but navigation could not refresh; reload the page +role.tip.expandMenu=Expand $NAME +role.tip.collapseMenu=Collapse $NAME +navigation.dataDevelopment=Data development +navigation.dataAnalysis=Data analysis +navigation.systemManagement=Administration +navigation.accountSettings=Account settings diff --git a/configure/etc/conf/i18n/messages_zh-cn.properties b/configure/etc/conf/i18n/messages_zh-cn.properties index aa7cf3ec74..095e752cd6 100644 --- a/configure/etc/conf/i18n/messages_zh-cn.properties +++ b/configure/etc/conf/i18n/messages_zh-cn.properties @@ -241,6 +241,9 @@ menu.common.new=\u65B0\u83DC\u5355 menu.common.i18nKey=\u56FD\u9645\u5316\u6807\u8BC6 menu.common.icon=\u56FE\u6807 menu.tip.selectType=\u8BF7\u9009\u62E9\u7C7B\u578B +common.parent=上级菜单 +menu.tip.searchIcon=搜索图标 +menu.tip.selectIcon=请选择图标 menu.tip.selectParent=\u8BF7\u9009\u62E9\u7236\u83DC\u5355 menu.tip.selectRedirect=\u8BF7\u9009\u62E9\u91CD\u5B9A\u5411\u83DC\u5355 ## Snippet i18n @@ -384,7 +387,7 @@ common.installVersion=\u5B89\u88C5\u7248\u672C common.installSuccess=\u5B89\u88C5\u6210\u529F common.uninstallSuccess=\u5378\u8F7D\u6210\u529F common.plugin.version=\u63D2\u4EF6\u7248\u672C -common.plugin.systemVersion=\u5F53\u524D\u7CFB\u7EDF\u7248\u672C: +common.plugin.systemVersion=\u5F53\u524D\u7CFB\u7EDF\u7248\u672C:\u0020 common.plugin.list.name=\u63D2\u4EF6\u5217\u8868 common.plugin.list.description=\u53EF\u7528\u7684\u63D2\u4EF6\u5217\u8868 common.plugin.list.supportVersion=\u652F\u6301\u7684\u7248\u672C @@ -516,6 +519,9 @@ role.validate.descriptionSize=\u8DEF\u7531\u63CF\u8FF0\u5FC5\u987B\u57283-50\u4E ## Schedule i18n schedule.common.list=\u8C03\u5EA6\u5217\u8868 schedule.common.history=\u8C03\u5EA6\u5386\u53F2 +schedule.common.trigger=立即执行 +schedule.common.triggerSuccess=任务已触发成功,请稍后查看执行结果 +schedule.common.upcoming=未来 7 次执行时间 ## Dashboard i18n dashboard.common.list=\u4EEA\u8868\u76D8\u5217\u8868 dashboard.common.delete=\u5220\u9664\u4EEA\u8868\u76D8 @@ -524,7 +530,7 @@ dashboard.common.modify=\u4FEE\u6539\u4EEA\u8868\u76D8 dashboard.common.modifyInfo=\u4FEE\u6539\u4EEA\u8868\u76D8 [ $VALUE ] dashboard.common.addReport=\u6DFB\u52A0\u56FE\u8868 dashboard.tip.deleteTip1=\u60A8\u6B63\u5728\u5220\u9664\u4EEA\u8868\u677F\u3002\u6B64\u64CD\u4F5C\u5C06\u6C38\u4E45\u5220\u9664\u4EEA\u8868\u677F\u3002\u5728\u7EE7\u7EED\u64CD\u4F5C\u4E4B\u524D\uFF0C\u8BF7\u52A1\u5FC5\u786E\u8BA4\u60A8\u7684\u64CD\u4F5C\u3002 -dashboard.tip.deleteTip2=\u8B66\u544A\uFF1A\u6B64\u64CD\u4F5C\u65E0\u6CD5\u64A4\u6D88\u3002 +dashboard.tip.deleteTip2=\u8B66\u544A\uFF1A\u6B64\u64CD\u4F5C\u65E0\u6CD5\u64A4\u6D88\u3002\u0020 dashboard.tip.deleteTip3=\u8981\u786E\u8BA4\uFF0C\u8BF7\u5728\u4E0B\u9762\u7684\u6846\u4E2D\u952E\u5165 [ $NAME ] dashboard.tip.publishSuccess=\u4EEA\u8868\u677F [ $VALUE ] \u53D1\u5E03\u6210\u529F dashboard.tip.notFound=\u4EEA\u8868\u677F [ $VALUE ] \u4E0D\u5B58\u5728 @@ -819,7 +825,6 @@ notify.tip.deleted=\u6570\u636E\u96C6\u5220\u9664\u65F6\u53D1\u9001\u901A\u77E5 notify.tip.dynamic=\u4EA7\u751F\u65B0\u52A8\u6001\u65F6\u53D1\u9001\u901A\u77E5 notify.tip.syncData=\u6570\u636E\u540C\u6B65\u5B8C\u6210\u540E\u53D1\u9001\u901A\u77E5 - common.searchPlaceholder=\u641C\u7D22\u6570\u636E\u96C6\u3001\u8868\u3001\u6587\u6863... user.tip.modifyUsernamePage=\u8BBE\u7F6E\u4E00\u4E2A\u65B0\u7684\u7528\u6237\u540D\uFF0C\u7528\u4E8E\u5728\u7CFB\u7EDF\u4E2D\u663E\u793A\u60A8\u7684\u8EAB\u4EFD user.common.currentUsername=\u5F53\u524D\u7528\u6237\u540D @@ -945,3 +950,73 @@ dataset.search.fields=\u641C\u7D22\u5B57\u6BB5... dataset.adhoc.all=\u5168\u90E8 dataset.adhoc.dimensions=\u7EF4\u5EA6 dataset.adhoc.measures=\u6307\u6807 +dataTable.search=\u67E5\u8BE2 +dataTable.keyword=\u5173\u952E\u8BCD +dataTable.keywordPlaceholder=\u6309\u540D\u79F0\u6216\u7F16\u7801\u641C\u7D22 +dataTable.logKeywordPlaceholder=\u641C\u7D22\u8BBE\u5907\u3001\u5BA2\u6237\u7AEF\u6216 IP +dataTable.codePlaceholder=\u8F93\u5165\u5B8C\u6574\u7F16\u7801 +dataTable.all=\u5168\u90E8 +dataTable.enabled=\u542F\u7528 +dataTable.disabled=\u505C\u7528 +dataTable.moreFilters=\u66F4\u591A\u7B5B\u9009 +dataTable.lessFilters=\u6536\u8D77\u7B5B\u9009 +dataTable.createdFrom=\u521B\u5EFA\u65E5\u671F\u8D77 +dataTable.createdTo=\u521B\u5EFA\u65E5\u671F\u6B62 +dataTable.invalidDateRange=\u5F00\u59CB\u65E5\u671F\u4E0D\u80FD\u665A\u4E8E\u7ED3\u675F\u65E5\u671F +dataTable.yes=\u662F +dataTable.no=\u5426 +dataTable.success=\u6210\u529F +dataTable.failure=\u5931\u8D25 +dataTable.systemAccount=\u7CFB\u7EDF\u8D26\u6237 +dataTable.systemTask=\u7CFB\u7EDF\u4EFB\u52A1 +dataTable.menuPath=\u83DC\u5355\u8DEF\u5F84 +dataTable.sqlContent=SQL \u5185\u5BB9 +dataTable.loginResult=\u767B\u5F55\u7ED3\u679C +dataTable.loginFrom=\u767B\u5F55\u65E5\u671F\u8D77 +dataTable.loginTo=\u767B\u5F55\u65E5\u671F\u6B62 +dataTable.queryReport=\u67E5\u8BE2\u62A5\u8868 +dataTable.datasetReport=\u6570\u636E\u96C6\u62A5\u8868 +dataTable.selectDate=\u8BF7\u9009\u62E9\u65E5\u671F +dataTable.today=\u4ECA\u5929 +dataTable.clear=\u6E05\u7A7A +dataTable.previousMonth=\u4E0A\u4E00\u6708\u6216\u5E74 +dataTable.nextMonth=\u4E0B\u4E00\u6708\u6216\u5E74 +dataTable.stateCREATED=\u5DF2\u521B\u5EFA +dataTable.stateQUEUE=\u6392\u961F\u4E2D +dataTable.stateRUNNING=\u8FD0\u884C\u4E2D +dataTable.stateSUCCESS=\u6210\u529F +dataTable.stateFAILURE=\u5931\u8D25 +dataTable.stateTIMEOUT=\u8D85\u65F6 +dataTable.stateSTOPPING=\u505C\u6B62\u4E2D +dataTable.stateSTOPPED=\u5DF2\u505C\u6B62 +dataTable.stateINTERRUPTED=\u5DF2\u4E2D\u65AD +dataTable.modeADHOC=\u5373\u5E2D\u67E5\u8BE2 +dataTable.modeHISTORY=\u5386\u53F2\u67E5\u8BE2 +dataTable.modeREPORT=\u62A5\u8868 +dataTable.modeSNIPPET=\u4EE3\u7801\u7247\u6BB5 +dataTable.modeSYNC=\u540C\u6B65 +dataTable.modeDATASET=\u6570\u636E\u96C6 +common.columns=\u5217\u8BBE\u7F6E +common.download=\u5BFC\u51FA\u5F53\u524D\u9875 +dataTable.available=\u53EF\u7528 +dataTable.unavailable=\u4E0D\u53EF\u7528 +role.tip.codePlaceholder=\u53EF\u9009\uFF0C\u7559\u7A7A\u81EA\u52A8\u751F\u6210 +role.tip.codeHint=1\u201364 \u4F4D\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u4E0B\u5212\u7EBF\u6216\u8FDE\u5B57\u7B26\uFF0C\u4EE5\u5B57\u6BCD\u6216\u6570\u5B57\u5F00\u5934\uFF1B\u7559\u7A7A\u81EA\u52A8\u751F\u6210 +role.tip.codeReadonly=\u7F16\u7801\u521B\u5EFA\u540E\u4E0D\u53EF\u4FEE\u6539 +role.tip.codeChecking=\u6B63\u5728\u6821\u9A8C\u7F16\u7801\u2026 +role.tip.codeAvailable=\u7F16\u7801\u53EF\u7528 +role.validate.codeFormat=\u7F16\u7801\u987B\u4E3A 1\u201364 \u4F4D\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u4E0B\u5212\u7EBF\u6216\u8FDE\u5B57\u7B26\uFF0C\u5E76\u4EE5\u5B57\u6BCD\u6216\u6570\u5B57\u5F00\u5934 +role.validate.codeExists=\u8BE5\u7F16\u7801\u5DF2\u5B58\u5728\uFF0C\u8BF7\u4F7F\u7528\u5176\u4ED6\u7F16\u7801 +role.validate.codeCheckFailed=\u7F16\u7801\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5 +role.validate.identityMismatch=\u6240\u7F16\u8F91\u7684\u8BB0\u5F55\u4E0E\u539F\u8DEF\u7531\u4E0D\u4E00\u81F4\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5 +role.validate.codeConstraint=\u65E0\u6CD5\u4FDD\u8BC1\u7F16\u7801\u552F\u4E00\uFF0C\u8BF7\u5148\u68C0\u67E5\u6570\u636E\u5E93\u4E2D\u7684\u91CD\u590D\u7F16\u7801 +role.validate.invalidMenus=\u90E8\u5206\u83DC\u5355\u4E0D\u5B58\u5728\u6216\u4E0D\u5141\u8BB8\u5206\u914D\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5 +role.validate.menuSaveFailed=\u4FDD\u5B58\u83DC\u5355\u5206\u914D\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5 +role.validate.menuLoadFailed=\u52A0\u8F7D\u83DC\u5355\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5 +role.tip.menuRefreshFailed=\u83DC\u5355\u5DF2\u4FDD\u5B58\uFF0C\u4F46\u5BFC\u822A\u5237\u65B0\u5931\u8D25\uFF0C\u8BF7\u5237\u65B0\u9875\u9762 +role.tip.expandMenu=\u5C55\u5F00 $NAME +role.tip.collapseMenu=\u6536\u8D77 $NAME +navigation.dataDevelopment=\u6570\u636E\u5F00\u53D1 +navigation.dataAnalysis=\u6570\u636E\u5206\u6790 +navigation.systemManagement=\u7CFB\u7EDF\u7BA1\u7406 +navigation.accountSettings=\u8D26\u6237\u8BBE\u7F6E diff --git a/configure/etc/conf/plugins/README.md b/configure/etc/conf/plugins/README.md deleted file mode 100644 index 3bdada240c..0000000000 --- a/configure/etc/conf/plugins/README.md +++ /dev/null @@ -1,12 +0,0 @@ -## Plugin Configure - -> Store some necessary configuration for each plugin, which applies to all related operations of the plugin - -#### Support Types - -- Number -- String -- List -- Boolean -- Array -- Map diff --git a/configure/etc/conf/plugins/alioss.yaml b/configure/etc/conf/plugins/alioss.yaml deleted file mode 100644 index e130f77700..0000000000 --- a/configure/etc/conf/plugins/alioss.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: Alioss -supportTime: '2023-02-23' - -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: https://oss-cn-regison.aliyuncs.com - message: host is a required field, please be sure to enter - - field: username - type: String - required: true - group: authorization - - field: password - type: String - required: true - group: authorization - - field: database - type: String - required: true - value: default - message: database is a required field, please be sure to enter - group: advanced diff --git a/configure/etc/conf/plugins/cassandra.yaml b/configure/etc/conf/plugins/cassandra.yaml deleted file mode 100644 index 7021a82950..0000000000 --- a/configure/etc/conf/plugins/cassandra.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: Apache Cassandra -supportTime: '2023-06-07' - -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 9042 - message: port is a required field, please be sure to enter - - field: database - type: String - required: true - value: datacenter - message: database is a required field, please be sure to enter - group: advanced diff --git a/configure/etc/conf/plugins/ceresdb.yaml b/configure/etc/conf/plugins/ceresdb.yaml deleted file mode 100644 index 78d188e433..0000000000 --- a/configure/etc/conf/plugins/ceresdb.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: CeresDB -supportTime: '2023-04-12' - -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 5440 - message: port is a required field, please be sure to enter diff --git a/configure/etc/conf/plugins/clickhouse.yaml b/configure/etc/conf/plugins/clickhouse.yaml deleted file mode 100644 index f9a29044cb..0000000000 --- a/configure/etc/conf/plugins/clickhouse.yaml +++ /dev/null @@ -1,164 +0,0 @@ -name: ClickHouse -supportTime: '2022-09-22' - -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 9000 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: database - type: String - group: advanced - value: default - required: true - - field: configures - type: Array - value: [ ] - group: custom - -pipelines: - - executor: Seatunnel - type: INPUT - fields: - - field: host - origin: host|port - required: true - - field: username - origin: username - required: true - override: false - - field: password - origin: password - required: false - override: false - - field: database - origin: database - required: true - hidden: false - description: The ClickHouse database - override: true - input: true - width: 300 - type: INPUT - - field: sql - origin: context - required: false - hidden: false - description: The query sql used to search data though Clickhouse server - override: true - input: true - width: 300 - type: TEXT - tooltip: If this value is entered, the default sql statement will be replaced - - field: result_table_name - origin: result_table_name - description: | - When result_table_name is not specified, the data processed by this plugin will not be registered as a data set (dataStream/dataset) that can be directly accessed by other plugins, or called a temporary table (table) ; - When result_table_name is specified, the data processed by this plugin will be registered as a data set (dataStream/dataset) that can be directly accessed by other plugins, or called a temporary table (table) . The data set (dataStream/dataset) registered here can be directly accessed by other plugins by specifying source_table_name . - required: false - hidden: true - input: true - override: true - - executor: Seatunnel - type: OUTPUT - fields: - - field: host - origin: host|port - required: true - - field: username - origin: username - override: false - required: true - - field: password - override: false - required: true - - field: database - origin: database - description: | - The ClickHouse database - override: true - required: true - input: true - - field: table - origin: table - description: | - The table name of the ClickHouse database - override: true - required: true - input: true - - field: fields - origin: fields - description: | - The data field that needs to be output to ClickHouse , if not configured, it will be automatically adapted according to the sink table schema . - - If there are multiple values, each value needs to be one row. - override: true - required: true - input: true - type: TEXT - - field: bulk_size - origin: bulk_size - description: | - The number of rows written through Clickhouse-jdbc each time, the default is 20000 . - override: true - required: false - input: true - value: 20000 - width: 100 - type: NUMBER - - field: split_mode - origin: split_mode - description: | - This mode only support clickhouse table which engine is 'Distributed'.And internal_replication option should be true. They will split distributed table data in seatunnel and perform write directly on each shard. The shard weight define is clickhouse will be counted. - override: true - required: false - input: true - value: false - width: 45 - type: SWITCH - - field: sharding_key - origin: sharding_key - description: | - When use split_mode, which node to send data to is a problem, the default is random selection, but the 'sharding_key' parameter can be used to specify the field for the sharding algorithm. This option only worked when 'split_mode' is true. - override: true - required: false - input: true - width: 100 - type: INPUT - - field: primary_key - origin: primary_key - description: | - Mark the primary key column from clickhouse table, and based on primary key execute INSERT/UPDATE/DELETE to clickhouse table - override: true - required: false - input: true - width: 100 - type: INPUT - - field: support_upsert - origin: support_upsert - description: | - Support upsert row by query primary key - override: true - required: false - input: true - value: false - width: 45 - type: SWITCH diff --git a/configure/etc/conf/plugins/default.yaml b/configure/etc/conf/plugins/default.yaml deleted file mode 100644 index 66a4c7d0f9..0000000000 --- a/configure/etc/conf/plugins/default.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: Default -supportTime: '2022-09-22' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 1 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: database - type: String - message: database is a required field, please be sure to enter - group: advanced - - field: configures - type: Array - value: [ ] - group: custom diff --git a/configure/etc/conf/plugins/dolphindb.yaml b/configure/etc/conf/plugins/dolphindb.yaml deleted file mode 100644 index 28c8c1c9a4..0000000000 --- a/configure/etc/conf/plugins/dolphindb.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: DolphinDB -supportTime: '2025-06-14' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: "127.0.0.1" - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: "8848" - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: configures - type: Array - value: - - field: database - value: default - group: custom diff --git a/configure/etc/conf/plugins/doris.yaml b/configure/etc/conf/plugins/doris.yaml deleted file mode 100644 index 4f778a35c0..0000000000 --- a/configure/etc/conf/plugins/doris.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: Doris -supportTime: '2023-04-19' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 9093 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: database - type: String - required: true - value: default - message: database is a required field, please be sure to enter - group: advanced diff --git a/configure/etc/conf/plugins/duckdb.yaml b/configure/etc/conf/plugins/duckdb.yaml deleted file mode 100644 index 12eddac7ff..0000000000 --- a/configure/etc/conf/plugins/duckdb.yaml +++ /dev/null @@ -1,25 +0,0 @@ -name: DuckDB -supportTime: '2023-02-20' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: "/root" - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 0 - message: port is a required field, please be sure to enter - - field: database - type: String - required: true - value: local - message: database is a required field, please be sure to enter - group: advanced diff --git a/configure/etc/conf/plugins/greptimedb.yaml b/configure/etc/conf/plugins/greptimedb.yaml deleted file mode 100644 index 873eeb4808..0000000000 --- a/configure/etc/conf/plugins/greptimedb.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: GreptimeDB -supportTime: '2023-04-14' - -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 4000 - message: port is a required field, please be sure to enter diff --git a/configure/etc/conf/plugins/h2.yaml b/configure/etc/conf/plugins/h2.yaml deleted file mode 100644 index 29ad8ee0e8..0000000000 --- a/configure/etc/conf/plugins/h2.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: H2 -supportTime: '2023-04-05' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: '-' - disabled: true - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 1 - disabled: true - message: port is a required field, please be sure to enter - - field: username - type: String - required: true - group: authorization - - field: password - type: String - group: authorization - - field: database - type: String - required: true - message: database is a required field, please be sure to enter - group: advanced diff --git a/configure/etc/conf/plugins/hdfs.yaml b/configure/etc/conf/plugins/hdfs.yaml deleted file mode 100644 index d4a67c22bb..0000000000 --- a/configure/etc/conf/plugins/hdfs.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: HDFS -supportTime: '2023-04-27' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: '-' - disabled: true - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 1 - disabled: true - message: port is a required field, please be sure to enter - - field: file - type: File - required: true - value: [] - group: advanced diff --git a/configure/etc/conf/plugins/hive.yaml b/configure/etc/conf/plugins/hive.yaml deleted file mode 100644 index 466623be26..0000000000 --- a/configure/etc/conf/plugins/hive.yaml +++ /dev/null @@ -1,34 +0,0 @@ -name: Apache Hive -supportTime: '2023-09-13' - -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 10000 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: database - type: String - message: database is a required field, please be sure to enter - group: advanced - - field: configures - type: Array - value: [ ] - group: custom diff --git a/configure/etc/conf/plugins/hologres.yaml b/configure/etc/conf/plugins/hologres.yaml deleted file mode 100644 index ca58f0f0c2..0000000000 --- a/configure/etc/conf/plugins/hologres.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: Hologres -supportTime: '2023-04-25' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: hologres-cn-regison.aliyuncs.com - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 80 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: database - type: String - required: true - value: default - message: database is a required field, please be sure to enter - group: advanced diff --git a/configure/etc/conf/plugins/influxdb.yaml b/configure/etc/conf/plugins/influxdb.yaml deleted file mode 100644 index 357bef8060..0000000000 --- a/configure/etc/conf/plugins/influxdb.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: InfluxDB -supportTime: '2024-06-26' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: "127.0.0.1" - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: "8086" - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: configures - type: Array - value: - - field: database - value: default - group: custom diff --git a/configure/etc/conf/plugins/kafka.yaml b/configure/etc/conf/plugins/kafka.yaml deleted file mode 100644 index ed2bde8507..0000000000 --- a/configure/etc/conf/plugins/kafka.yaml +++ /dev/null @@ -1,178 +0,0 @@ -name: Kafka -supportTime: '2023-03-06' - -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1:9092 - message: host is a required field, please be sure to enter - -pipelines: - - executor: Seatunnel - type: INPUT - fields: - - field: bootstrap.servers - origin: host - required: true - - field: topic - description: | - Kafka topic name. If there are multiple topics, use , to split, for example: "tpc1,tpc2". - origin: topic - hidden: false - required: true - override: true - input: true - type: INPUT - - field: pattern - description: | - If pattern is set to true,the regular expression for a pattern of topic names to read from. All topics in clients with names that match the specified regular expression will be subscribed by the consumer. - origin: pattern - required: false - override: true - input: true - value: false - width: 45 - type: SWITCH - - field: group - description: | - Kafka consumer group id, used to distinguish different consumer groups. - origin: consumer.group - required: false - override: true - input: true - value: MetaForge-Pipeline-Group - width: 300 - type: INPUT - - field: checkpoint - description: | - If true the consumer's offset will be periodically committed in the background. - origin: commit_on_checkpoint - required: false - override: true - input: true - value: true - width: 45 - type: SWITCH - - field: format - description: | - Data format. The default format is json. Optional text format. The default field separator is ", ". If you customize the delimiter, add the "field_delimiter" option. - origin: format - required: false - override: true - input: true - value: json - width: 300 - type: INPUT - - field: delimiter - description: | - Customize the field delimiter for data format. - origin: field_delimiter - required: false - override: true - input: true - value: ',' - width: 300 - type: INPUT - - field: start_mode - description: | - The initial consumption pattern of consumers,there are several types: [earliest],[group_offsets],[latest],[specific_offsets],[timestamp] - defaultValues: - - earliest - - group_offsets - - latest - - specific_offsets - - timestamp - origin: start_mode - required: false - override: true - input: true - value: group_offsets - width: 300 - type: SELECT - - executor: Seatunnel - type: OUTPUT - fields: - - field: bootstrap.servers - origin: host - required: true - - field: topic - origin: topic - description: | - Kafka Topic. - required: true - override: true - input: true - - field: semantic - origin: semantic - description: | - Semantics that can be chosen EXACTLY_ONCE/AT_LEAST_ONCE/NON, default NON. - required: false - override: true - input: true - value: NON - type: SELECT - defaultValues: - - EXACTLY_ONCE - - AT_LEAST_ONCE - - NON - - field: partition_key_fields - origin: partition_key_fields - description: | - Configure which fields are used as the key of the kafka message. - - For example, if you want to use value of fields from upstream data as key, you can assign field names to this property. - - If there are multiple values, each value needs to be one row. - required: false - override: true - input: true - type: TEXT - - field: partition - origin: partition - description: | - We can specify the partition, all messages will be sent to this partition. - required: false - override: true - input: true - type: NUMBER - - field: assign_partitions - origin: assign_partitions - description: | - We can decide which partition to send based on the content of the message. The function of this parameter is to distribute information. - required: false - override: true - input: true - type: TEXT - - field: transaction_prefix - origin: transaction_prefix - description: | - If semantic is specified as EXACTLY_ONCE, the producer will write all messages in a Kafka transaction. Kafka distinguishes different transactions by different transactionId. This parameter is prefix of kafka transactionId, make sure different job use different prefix. - required: false - override: true - input: true - type: INPUT - - field: format - description: | - Data format. The default format is json. Optional text format. The default field separator is ", ". If you customize the delimiter, add the "field_delimiter" option. - origin: format - required: false - override: true - input: true - value: json - width: 300 - type: INPUT - - field: delimiter - description: | - Customize the field delimiter for data format. - origin: field_delimiter - required: false - override: true - input: true - value: ',' - width: 300 - type: INPUT diff --git a/configure/etc/conf/plugins/matrixone.yaml b/configure/etc/conf/plugins/matrixone.yaml deleted file mode 100644 index b25c2c89a7..0000000000 --- a/configure/etc/conf/plugins/matrixone.yaml +++ /dev/null @@ -1,113 +0,0 @@ -name: MatrixOne -supportTime: '2024-01-12' - -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 6001 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: ssl - type: Boolean - group: authorization - - field: database - type: String - message: database is a required field, please be sure to enter - group: advanced - - field: configures - type: Array - value: [] - group: custom - -pipelines: - - executor: Seatunnel - type: INPUT - protocol: JDBC - fields: - - field: url - generated: true - override: true - - field: driver - origin: driver - required: true - override: true - input: true - width: 300 - type: INPUT - value: com.microsoft.sqlserver.jdbc.SQLServerDriver - - field: user - origin: username - required: false - override: false - - field: password - origin: password - required: false - override: false - - field: query - origin: context - required: false - hidden: false - description: Query statement - override: true - input: true - width: 300 - type: TEXT - tooltip: If this value is entered, the default sql statement will be replaced - - field: result_table_name - origin: result_table_name - description: | - When result_table_name is not specified, the data processed by this plugin will not be registered as a data set (dataStream/dataset) that can be directly accessed by other plugins, or called a temporary table (table) ; - When result_table_name is specified, the data processed by this plugin will be registered as a data set (dataStream/dataset) that can be directly accessed by other plugins, or called a temporary table (table) . The data set (dataStream/dataset) registered here can be directly accessed by other plugins by specifying source_table_name . - required: false - hidden: true - input: true - override: true - - executor: Seatunnel - type: OUTPUT - protocol: JDBC - fields: - - field: url - generated: true - override: true - - field: driver - origin: driver - required: true - override: true - input: true - width: 300 - type: INPUT - value: com.microsoft.sqlserver.jdbc.SQLServerDriver - - field: user - origin: username - required: false - override: false - - field: password - origin: password - required: false - override: false - - field: query - origin: query - required: true - hidden: false - description: Use this sql write upstream input datas to database. e.g INSERT ... - override: true - input: true - width: 300 - type: TEXT diff --git a/configure/etc/conf/plugins/mongodb.yaml b/configure/etc/conf/plugins/mongodb.yaml deleted file mode 100644 index eeaf3b398b..0000000000 --- a/configure/etc/conf/plugins/mongodb.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: MongoDB Community -supportTime: '2023-05-08' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 27017 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: database - type: String - required: false - value: default - message: database is a required field, please be sure to enter - group: advanced diff --git a/configure/etc/conf/plugins/mysql.yaml b/configure/etc/conf/plugins/mysql.yaml deleted file mode 100644 index feb7474935..0000000000 --- a/configure/etc/conf/plugins/mysql.yaml +++ /dev/null @@ -1,118 +0,0 @@ -name: MySQL - -supportTime: '2022-09-19' - -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 3306 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: ssl - type: Boolean - group: authorization - - field: database - type: String - required: true - value: default - message: database is a required field, please be sure to enter - group: advanced - - field: configures - type: Array - value: - - field: useOldAliasMetadataBehavior - value: true - group: custom - -pipelines: - - executor: Seatunnel - type: INPUT - protocol: JDBC - fields: - - field: url - generated: true - override: true - - field: driver - origin: driver - required: true - override: true - input: true - width: 300 - type: INPUT - value: com.mysql.cj.jdbc.Driver - - field: user - origin: username - required: false - override: false - - field: password - origin: password - required: false - override: false - - field: query - origin: context - required: false - hidden: false - description: Query statement - override: true - input: true - width: 300 - type: TEXT - tooltip: If this value is entered, the default sql statement will be replaced - - field: result_table_name - origin: result_table_name - description: | - When result_table_name is not specified, the data processed by this plugin will not be registered as a data set (dataStream/dataset) that can be directly accessed by other plugins, or called a temporary table (table) ; - When result_table_name is specified, the data processed by this plugin will be registered as a data set (dataStream/dataset) that can be directly accessed by other plugins, or called a temporary table (table) . The data set (dataStream/dataset) registered here can be directly accessed by other plugins by specifying source_table_name . - required: false - hidden: true - input: true - override: true - - executor: Seatunnel - type: OUTPUT - protocol: JDBC - fields: - - field: url - generated: true - override: true - - field: driver - origin: driver - required: true - override: true - input: true - width: 300 - type: INPUT - value: com.mysql.cj.jdbc.Driver - - field: user - origin: username - required: false - override: false - - field: password - origin: password - required: false - override: false - - field: query - origin: query - required: true - hidden: false - description: Use this sql write upstream input datas to database. e.g INSERT ... - override: true - input: true - width: 300 - type: TEXT diff --git a/configure/etc/conf/plugins/oceanbase.yaml b/configure/etc/conf/plugins/oceanbase.yaml deleted file mode 100644 index 81f5318a71..0000000000 --- a/configure/etc/conf/plugins/oceanbase.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: OceanBase -supportTime: '2022-11-30' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 2881 - message: port is a required field, please be sure to enter - - field: username - type: String - required: true - group: authorization - - field: password - type: String - required: true - group: authorization - - field: database - type: String - required: true - value: default - message: database is a required field, please be sure to enter - group: advanced - - field: configures - type: Array - value: - - field: useOldAliasMetadataBehavior - value: true - group: custom diff --git a/configure/etc/conf/plugins/pinot.yaml b/configure/etc/conf/plugins/pinot.yaml deleted file mode 100644 index fc656d558f..0000000000 --- a/configure/etc/conf/plugins/pinot.yaml +++ /dev/null @@ -1,25 +0,0 @@ -name: Pinot -supportTime: '2023-05-06' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 9000 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization diff --git a/configure/etc/conf/plugins/presto.yaml b/configure/etc/conf/plugins/presto.yaml deleted file mode 100644 index 6bee74dfb2..0000000000 --- a/configure/etc/conf/plugins/presto.yaml +++ /dev/null @@ -1,37 +0,0 @@ -name: Presto -supportTime: '2022-09-25' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 8080 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: database - type: String - message: database is a required field, please be sure to enter - group: advanced - - field: catalog - type: String - value: '' - group: advanced - - field: configures - type: Array - value: [ ] - group: custom diff --git a/configure/etc/conf/plugins/questdb.yaml b/configure/etc/conf/plugins/questdb.yaml deleted file mode 100644 index 90abb040bb..0000000000 --- a/configure/etc/conf/plugins/questdb.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: QuestDB -supportTime: '2023-04-17' - -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 9000 - message: port is a required field, please be sure to enter diff --git a/configure/etc/conf/plugins/redis.yaml b/configure/etc/conf/plugins/redis.yaml deleted file mode 100644 index 92ce3bda0e..0000000000 --- a/configure/etc/conf/plugins/redis.yaml +++ /dev/null @@ -1,106 +0,0 @@ -name: Redis -supportTime: '2022-12-01' - -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 6379 - message: port is a required field, please be sure to enter - - field: password - type: String - group: authorization - -pipelines: - - executor: Seatunnel - type: OUTPUT - fields: - - field: host - origin: host - required: true - - field: port - origin: port - required: true - override: false - - field: user - origin: username - required: false - override: false - - field: auth - origin: password - required: false - override: false - - field: key - origin: key - required: true - hidden: false - description: The value of key you want to write to redis. - override: true - input: true - width: 300 - type: INPUT - - field: data_type - origin: data_type - required: true - hidden: false - description: Redis data types - override: true - input: true - width: 300 - type: SELECT - defaultValues: - - key - - hash - - list - - set - - zset - - field: mode - origin: mode - required: false - hidden: false - description: Redis mode - override: true - input: true - width: 300 - type: SELECT - defaultValues: - - single - - cluster - value: single - - field: nodes - origin: nodes - required: false - hidden: false - description: | - Redis nodes information, used in cluster mode - - If there are multiple values, each value needs to be one row. - override: true - input: true - width: 300 - type: TEXT - - field: format - origin: format - required: false - hidden: false - description: | - The format of upstream data - override: true - input: true - width: 300 - type: SELECT - defaultValues: - - json - - text - value: json diff --git a/configure/etc/conf/plugins/scylladb.yaml b/configure/etc/conf/plugins/scylladb.yaml deleted file mode 100644 index ce13515227..0000000000 --- a/configure/etc/conf/plugins/scylladb.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: ScyllaDB -supportTime: '2024-01-12' - -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 9042 - message: port is a required field, please be sure to enter - - field: database - type: String - required: true - value: datacenter - message: database is a required field, please be sure to enter - group: advanced diff --git a/configure/etc/conf/plugins/snowflake.yaml b/configure/etc/conf/plugins/snowflake.yaml deleted file mode 100644 index f378f7deec..0000000000 --- a/configure/etc/conf/plugins/snowflake.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: Snowflake -supportTime: '2023-01-29' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 80 - message: port is a required field, please be sure to enter - - field: username - type: String - required: true - group: authorization - - field: password - type: String - required: true - group: authorization - - field: database - type: String - message: database is a required field, please be sure to enter - group: advanced - - field: configures - type: Array - value: [ ] - group: custom diff --git a/configure/etc/conf/plugins/solr.yaml b/configure/etc/conf/plugins/solr.yaml deleted file mode 100644 index 80a55bbf34..0000000000 --- a/configure/etc/conf/plugins/solr.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: Apache Solr -supportTime: '2024-06-25' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: "127.0.0.1" - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: "8983" - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: database - type: String - required: true - value: "local" - message: database is a required field, please be sure to enter - group: advanced - - field: configures - type: Array - value: [ ] - group: custom diff --git a/configure/etc/conf/plugins/sqlserver.yaml b/configure/etc/conf/plugins/sqlserver.yaml deleted file mode 100644 index bb046efc8d..0000000000 --- a/configure/etc/conf/plugins/sqlserver.yaml +++ /dev/null @@ -1,115 +0,0 @@ -name: Sql Server -supportTime: '2024-01-11' - -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 1433 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: ssl - type: Boolean - group: authorization - - field: database - type: String - message: database is a required field, please be sure to enter - group: advanced - - field: configures - type: Array - value: - - field: trustServerCertificate - value: true - group: custom - -pipelines: - - executor: Seatunnel - type: INPUT - protocol: JDBC - fields: - - field: url - generated: true - override: true - - field: driver - origin: driver - required: true - override: true - input: true - width: 300 - type: INPUT - value: com.microsoft.sqlserver.jdbc.SQLServerDriver - - field: user - origin: username - required: false - override: false - - field: password - origin: password - required: false - override: false - - field: query - origin: context - required: false - hidden: false - description: Query statement - override: true - input: true - width: 300 - type: TEXT - tooltip: If this value is entered, the default sql statement will be replaced - - field: result_table_name - origin: result_table_name - description: | - When result_table_name is not specified, the data processed by this plugin will not be registered as a data set (dataStream/dataset) that can be directly accessed by other plugins, or called a temporary table (table) ; - When result_table_name is specified, the data processed by this plugin will be registered as a data set (dataStream/dataset) that can be directly accessed by other plugins, or called a temporary table (table) . The data set (dataStream/dataset) registered here can be directly accessed by other plugins by specifying source_table_name . - required: false - hidden: true - input: true - override: true - - executor: Seatunnel - type: OUTPUT - protocol: JDBC - fields: - - field: url - generated: true - override: true - - field: driver - origin: driver - required: true - override: true - input: true - width: 300 - type: INPUT - value: com.microsoft.sqlserver.jdbc.SQLServerDriver - - field: user - origin: username - required: false - override: false - - field: password - origin: password - required: false - override: false - - field: query - origin: query - required: true - hidden: false - description: Use this sql write upstream input datas to database. e.g INSERT ... - override: true - input: true - width: 300 - type: TEXT diff --git a/configure/etc/conf/plugins/starrocks.yaml b/configure/etc/conf/plugins/starrocks.yaml deleted file mode 100644 index bddd5c5ad4..0000000000 --- a/configure/etc/conf/plugins/starrocks.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: StarRocks -supportTime: '2023-04-20' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 9030 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: database - type: String - required: true - value: default - message: database is a required field, please be sure to enter - group: advanced diff --git a/configure/etc/conf/plugins/trino.yaml b/configure/etc/conf/plugins/trino.yaml deleted file mode 100644 index e8192d3919..0000000000 --- a/configure/etc/conf/plugins/trino.yaml +++ /dev/null @@ -1,37 +0,0 @@ -name: Trino -supportTime: '2022-09-25' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 8080 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: database - type: String - message: database is a required field, please be sure to enter - group: advanced - - field: catalog - type: String - value: '' - group: advanced - - field: configures - type: Array - value: [ ] - group: custom diff --git a/configure/etc/conf/plugins/ydb.yaml b/configure/etc/conf/plugins/ydb.yaml deleted file mode 100644 index 9aae2e7059..0000000000 --- a/configure/etc/conf/plugins/ydb.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: YDB -supportTime: '2023-01-30' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1 - message: host is a required field, please be sure to enter - - field: port - type: Number - required: true - min: 1 - max: 65535 - value: 2136 - message: port is a required field, please be sure to enter - - field: username - type: String - group: authorization - - field: password - type: String - group: authorization - - field: database - type: String - required: true - value: local - message: database is a required field, please be sure to enter - group: advanced diff --git a/configure/etc/conf/plugins/zookeeper.yaml b/configure/etc/conf/plugins/zookeeper.yaml deleted file mode 100644 index b1727ae29c..0000000000 --- a/configure/etc/conf/plugins/zookeeper.yaml +++ /dev/null @@ -1,12 +0,0 @@ -name: Zookeeper -supportTime: '2023-02-07' -configures: - - field: name - type: String - required: true - message: name is a required field, please be sure to enter - - field: host - type: String - required: true - value: 127.0.0.1:2181 - message: host is a required field, please be sure to enter diff --git a/core/metaforge-common/src/main/java/org/devlive/metaforge/common/enums/ServiceState.java b/core/metaforge-common/src/main/java/org/devlive/metaforge/common/enums/ServiceState.java index 45f4cc8dd3..f8ec1344a1 100644 --- a/core/metaforge-common/src/main/java/org/devlive/metaforge/common/enums/ServiceState.java +++ b/core/metaforge-common/src/main/java/org/devlive/metaforge/common/enums/ServiceState.java @@ -28,6 +28,14 @@ public enum ServiceState USER_NAME_EQUALS(4008, "Cannot be the same as the old user name"), USER_NAME_FORMAT_INVALID(4026, "Invalid username: 4-20 characters, letters, numbers and underscores, cannot start with a digit"), USER_NAME_ALREADY_EXISTS(4027, "The username already exists"), + USER_ROLE_CODE_INVALID(4028, "Invalid role code: use 1-64 letters, digits, underscores or hyphens"), + USER_ROLE_CODE_EXISTS(4029, "The role code already exists"), + USER_ROLE_CODE_IMMUTABLE(4030, "The role code cannot be changed"), + USER_ROLE_ID_MISMATCH(4031, "The role identity does not match the resource being edited"), + USER_ROLE_CODE_CONSTRAINT_FAILED(4032, "Cannot enforce unique role codes; check the database for duplicates"), + USER_ROLE_MENU_INVALID(4033, "Some selected menus do not exist or cannot be assigned"), + USER_ROLE_MENU_SAVE_FAILED(4034, "Could not save the menu assignment"), + USER_ROLE_MENU_READ_FAILED(4035, "Could not load menus"), REQUEST_EXCEPTION(5000, "The request is abnormal"), INVALID_PARAMETER(5001, "Invalid parameter"); diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/Plugin.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/Plugin.java index bdbe3302d8..dc9c714e22 100644 --- a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/Plugin.java +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/Plugin.java @@ -226,11 +226,26 @@ public String getName() * Declare configurable fields exposed by this plugin. Default empty for full * backward compatibility — subclasses (Executor / Scheduler / FS / Notify / ...) override as needed. */ - public java.util.List configures() + public java.util.List runtimeConfigures() { return java.util.Collections.emptyList(); } + /** + * 声明该插件的数据源表单配置字段(字段名 / 类型 / 必填 / 默认值 / 分组 / pipelines 等)。 + * 连接器插件默认返回通用 JDBC 字段(原 default.yaml 语义),转换等其余类型返回 null。 + * 连接器插件应覆写本方法以提供专属配置。 + * + * Declare the source form configuration of this plugin (field names / types / required / + * defaults / groups / pipelines). Connector plugins default to the generic JDBC fields + * (former default.yaml semantics); other plugin types return null. Connector plugins + * should override this method to provide their own configuration. + */ + public org.devlive.metaforge.plugin.configure.PluginConfigure describe() + { + return getType() == PluginType.CONNECTOR ? org.devlive.metaforge.plugin.configure.DefaultJdbcConfigure.INSTANCE : null; + } + public String getVersion() { if (cachedVersion != null) { diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginManager.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginManager.java index c042d11660..cc90161831 100644 --- a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginManager.java +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/PluginManager.java @@ -647,6 +647,7 @@ private void loadPluginFromDirectory(Path pluginDir) .instance(module) .type(module.getType()) .key(module.getKey()) + .configure(module.describe()) .loadTimestamp(System.currentTimeMillis()) .loadTime(DateUtils.formatYMDHMSWithInterval()) .build(); diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/DefaultJdbcConfigure.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/DefaultJdbcConfigure.java new file mode 100644 index 0000000000..fba2b1ee29 --- /dev/null +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/DefaultJdbcConfigure.java @@ -0,0 +1,90 @@ +package org.devlive.metaforge.plugin.configure; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * 复刻原 configure/etc/conf/plugins/default.yaml 的通用 JDBC 连接字段, + * 作为未显式声明 describe() 的连接器插件的默认表单配置。 + * + * Generic JDBC connection fields replicated from the former default.yaml, + * used as the fallback form configuration for connector plugins that do not + * declare their own {@code describe()}. + */ +public final class DefaultJdbcConfigure +{ + public static final PluginConfigure INSTANCE = build(); + + private DefaultJdbcConfigure() {} + + private static PluginConfigure build() + { + return PluginConfigure.of("Default", standardFields(), null); + } + + /** + * 每次调用都返回新的字段实例,避免多个插件共享可变列表。 + * + * Returns fresh field instances on every call so plugins never share + * a mutable configure list. + */ + public static List standardFields() + { + return Arrays.asList( + PluginConfigureField.builder() + .field(PluginConfigureFieldName.name) + .type(PluginConfigureFieldType.String) + .required(true) + .message("name is a required field, please be sure to enter") + .build(), + PluginConfigureField.builder() + .field(PluginConfigureFieldName.host) + .type(PluginConfigureFieldType.String) + .required(true) + .value("127.0.0.1") + .message("host is a required field, please be sure to enter") + .build(), + PluginConfigureField.builder() + .field(PluginConfigureFieldName.port) + .type(PluginConfigureFieldType.Number) + .required(true) + .min(1L) + .max(65535L) + .value(1L) + .message("port is a required field, please be sure to enter") + .build(), + PluginConfigureField.builder() + .field(PluginConfigureFieldName.username) + .type(PluginConfigureFieldType.String) + .group(PluginConfigureFieldGroup.authorization) + .build(), + PluginConfigureField.builder() + .field(PluginConfigureFieldName.password) + .type(PluginConfigureFieldType.String) + .group(PluginConfigureFieldGroup.authorization) + .build(), + PluginConfigureField.builder() + .field(PluginConfigureFieldName.database) + .type(PluginConfigureFieldType.String) + .message("database is a required field, please be sure to enter") + .group(PluginConfigureFieldGroup.advanced) + .build(), + PluginConfigureField.builder() + .field(PluginConfigureFieldName.configures) + .type(PluginConfigureFieldType.Array) + .value(Collections.emptyList()) + .group(PluginConfigureFieldGroup.custom) + .build()); + } + + /** + * 以通用 JDBC 字段创建指定名称的插件配置。 + * + * Creates a plugin configure with the generic JDBC fields under the given name. + */ + public static PluginConfigure of(String name) + { + return PluginConfigure.of(name, standardFields(), null); + } +} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/FieldType.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/FieldType.java similarity index 62% rename from core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/FieldType.java rename to core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/FieldType.java index 1afe6288fb..df89202914 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/FieldType.java +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/FieldType.java @@ -1,4 +1,4 @@ -package org.devlive.metaforge.service.configure; +package org.devlive.metaforge.plugin.configure; public enum FieldType { diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigure.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigure.java new file mode 100644 index 0000000000..6a6e4ab01e --- /dev/null +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigure.java @@ -0,0 +1,64 @@ +package org.devlive.metaforge.plugin.configure; + +import com.fasterxml.jackson.annotation.JsonView; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.devlive.metaforge.common.view.EntityView; + +import java.util.List; +import java.util.Map; + +@Data +@ToString +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SuppressFBWarnings(value = {"EI_EXPOSE_REP2", "EI_EXPOSE_REP2", "EI_EXPOSE_REP", "NP_NULL_PARAM_DEREF_NONVIRTUAL"}, + justification = "I prefer to suppress these FindBugs warnings") +public class PluginConfigure +{ + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + private String name; + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + private java.util.Date supportTime; + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + private List configures; + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + private List pipelines; + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + @Builder.Default + private PluginConfigureCategory category = PluginConfigureCategory.OTHER; + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + @Builder.Default + private Map descriptions = java.util.Collections.emptyMap(); + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + @Builder.Default + private Map> features = java.util.Collections.emptyMap(); + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + @Builder.Default + private Map> scenes = java.util.Collections.emptyMap(); + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + @Builder.Default + private boolean popular = false; + + public static PluginConfigure of(String name, List configures, List pipelines) + { + PluginConfigure configure = new PluginConfigure(); + configure.setName(name); + configure.setConfigures(configures); + configure.setPipelines(pipelines); + return configure; + } +} diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureCategory.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureCategory.java new file mode 100644 index 0000000000..797fd44a7a --- /dev/null +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureCategory.java @@ -0,0 +1,13 @@ +package org.devlive.metaforge.plugin.configure; + +public enum PluginConfigureCategory +{ + RDB, + BIGDATA, + CLOUD, + NOSQL, + FILE, + API, + MQ, + OTHER +} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureExecutor.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureExecutor.java similarity index 52% rename from core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureExecutor.java rename to core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureExecutor.java index 0ffa9d4df6..100e8d8980 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureExecutor.java +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureExecutor.java @@ -1,13 +1,13 @@ -package org.devlive.metaforge.service.configure; +package org.devlive.metaforge.plugin.configure; import com.fasterxml.jackson.annotation.JsonView; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import lombok.ToString; import org.devlive.metaforge.common.view.EntityView; -import org.devlive.metaforge.executor.common.RunProtocol; import java.util.List; @@ -15,18 +15,30 @@ @ToString @NoArgsConstructor @AllArgsConstructor +@Builder @SuppressFBWarnings(value = {"EI_EXPOSE_REP", "EI_EXPOSE_REP2"}) -public class IConfigureExecutor +public class PluginConfigureExecutor { @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) private String executor; @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + @Builder.Default private RunProtocol protocol = RunProtocol.NONE; @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private IConfigurePipelineType type; + private PluginConfigurePipelineType type; @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private List fields; + private List fields; + + public static PluginConfigureExecutor of(String executor, PluginConfigurePipelineType type, RunProtocol protocol, List fields) + { + PluginConfigureExecutor configureExecutor = new PluginConfigureExecutor(); + configureExecutor.setExecutor(executor); + configureExecutor.setType(type); + configureExecutor.setProtocol(protocol == null ? RunProtocol.NONE : protocol); + configureExecutor.setFields(fields); + return configureExecutor; + } } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureExecutorField.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureExecutorField.java similarity index 91% rename from core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureExecutorField.java rename to core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureExecutorField.java index b8be05df9d..648fcb9348 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureExecutorField.java +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureExecutorField.java @@ -1,8 +1,9 @@ -package org.devlive.metaforge.service.configure; +package org.devlive.metaforge.plugin.configure; import com.fasterxml.jackson.annotation.JsonView; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import lombok.ToString; @@ -14,8 +15,9 @@ @ToString @NoArgsConstructor @AllArgsConstructor +@Builder @SuppressFBWarnings(value = {"EI_EXPOSE_REP", "EI_EXPOSE_REP2"}) -public class IConfigureExecutorField +public class PluginConfigureExecutorField { // The field name @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) @@ -32,18 +34,22 @@ public class IConfigureExecutorField // If the flag is true // it means that the field is extracted through user configuration, and the default data will be discarded @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + @Builder.Default private boolean override = false; // Is it an input parameter @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + @Builder.Default private boolean input = false; // Related component width @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + @Builder.Default private int width = 300; // Component type @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + @Builder.Default private FieldType type = FieldType.INPUT; // User-defined prompts @@ -56,6 +62,7 @@ public class IConfigureExecutorField // If this configuration item is true, it will not be displayed on the front end and will be displayed after it is enabled. @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + @Builder.Default private boolean hidden = false; // The description @@ -68,5 +75,6 @@ public class IConfigureExecutorField // If it is auto-generated, the format is:protocol://host:port @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + @Builder.Default private boolean generated = false; } diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureField.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureField.java index 462d09d7a9..3447ceafdf 100644 --- a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureField.java +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureField.java @@ -1,34 +1,61 @@ package org.devlive.metaforge.plugin.configure; +import com.fasterxml.jackson.annotation.JsonView; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; +import org.devlive.metaforge.common.view.EntityView; -/** - * 描述一个插件可配置字段。所有插件类型(Executor / Scheduler / FS / …)共用。 - * - name: 字段 key,序列化到 JSON 配置中的属性名 - * - type: 字段类型,决定 UI 渲染(文本框 / 数字 / 开关 / 密码) - * - defaultValue: 默认值的字符串表示;调用方按 type 解析 - * - description: 字段说明,UI tooltip 用 - * - tunable: true = 普通用户在触发任务时可临时覆盖;false = 仅管理员可在系统配置改 - * - * Generic configurable-field descriptor used by any plugin category. - * - tunable=false fields are administrator-only. - * - tunable=true fields can also be overridden by end users at task invocation time. - */ @Data +@ToString @NoArgsConstructor @AllArgsConstructor +@Builder public class PluginConfigureField { - private String name; - private PluginFieldType type; - private String defaultValue; - private String description; - private boolean tunable; + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + private PluginConfigureFieldName field; // The field name of the tag configuration - public PluginConfigureField(String name, PluginFieldType type, String defaultValue, String description) + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + private PluginConfigureFieldType type; + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + private boolean required; + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + private Long min; + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + private Long max; + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + private String message; + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + private Object value; + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + private boolean disabled; + + @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) + @Builder.Default + private PluginConfigureFieldGroup group = PluginConfigureFieldGroup.configure; + + public static PluginConfigureField of(PluginConfigureFieldName field, PluginConfigureFieldType type, boolean required, Object value, PluginConfigureFieldGroup group) { - this(name, type, defaultValue, description, false); + PluginConfigureField configureField = PluginConfigureField.builder() + .field(field) + .type(type) + .required(required) + .build(); + if (value != null) { + configureField.setValue(value); + } + if (group != null) { + configureField.setGroup(group); + } + return configureField; } } diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureFieldGroup.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureFieldGroup.java new file mode 100644 index 0000000000..1350bc9688 --- /dev/null +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureFieldGroup.java @@ -0,0 +1,9 @@ +package org.devlive.metaforge.plugin.configure; + +public enum PluginConfigureFieldGroup +{ + configure, + authorization, + advanced, + custom +} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureFieldName.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureFieldName.java similarity index 67% rename from core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureFieldName.java rename to core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureFieldName.java index e6d72e05a3..220179c792 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureFieldName.java +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureFieldName.java @@ -1,6 +1,6 @@ -package org.devlive.metaforge.service.configure; +package org.devlive.metaforge.plugin.configure; -public enum IConfigureFieldName +public enum PluginConfigureFieldName { field, value, diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureFieldType.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureFieldType.java new file mode 100644 index 0000000000..61cb91b577 --- /dev/null +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigureFieldType.java @@ -0,0 +1,12 @@ +package org.devlive.metaforge.plugin.configure; + +public enum PluginConfigureFieldType +{ + Number, + String, + List, + Boolean, + Array, + Map, + File +} diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigurePipelineType.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigurePipelineType.java new file mode 100644 index 0000000000..81c0792f0f --- /dev/null +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginConfigurePipelineType.java @@ -0,0 +1,7 @@ +package org.devlive.metaforge.plugin.configure; + +public enum PluginConfigurePipelineType +{ + INPUT, + OUTPUT +} diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RunProtocol.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RunProtocol.java new file mode 100644 index 0000000000..2e22089b00 --- /dev/null +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RunProtocol.java @@ -0,0 +1,8 @@ +package org.devlive.metaforge.plugin.configure; + +public enum RunProtocol +{ + NONE, + JDBC, + NATIVE +} diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureField.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureField.java new file mode 100644 index 0000000000..a688331f4f --- /dev/null +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureField.java @@ -0,0 +1,34 @@ +package org.devlive.metaforge.plugin.configure; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 描述一个插件可配置字段。所有插件类型(Executor / Scheduler / FS / …)共用。 + * - name: 字段 key,序列化到 JSON 配置中的属性名 + * - type: 字段类型,决定 UI 渲染(文本框 / 数字 / 开关 / 密码) + * - defaultValue: 默认值的字符串表示;调用方按 type 解析 + * - description: 字段说明,UI tooltip 用 + * - tunable: true = 普通用户在触发任务时可临时覆盖;false = 仅管理员可在系统配置改 + * + * Generic configurable-field descriptor used by any plugin category. + * - tunable=false fields are administrator-only. + * - tunable=true fields can also be overridden by end users at task invocation time. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RuntimeConfigureField +{ + private String name; + private RuntimeConfigureFieldType type; + private String defaultValue; + private String description; + private boolean tunable; + + public RuntimeConfigureField(String name, RuntimeConfigureFieldType type, String defaultValue, String description) + { + this(name, type, defaultValue, description, false); + } +} diff --git a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginFieldType.java b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureFieldType.java similarity index 92% rename from core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginFieldType.java rename to core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureFieldType.java index 0d97d51430..654f076f1a 100644 --- a/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/PluginFieldType.java +++ b/core/metaforge-plugin/src/main/java/org/devlive/metaforge/plugin/configure/RuntimeConfigureFieldType.java @@ -8,7 +8,7 @@ * Kept minimal on purpose so any plugin layer can declare configurable fields without * pulling in business-side enums. */ -public enum PluginFieldType +public enum RuntimeConfigureFieldType { STRING, NUMBER, diff --git a/core/metaforge-security/src/main/java/org/devlive/metaforge/security/AdminApiRequestMatcher.java b/core/metaforge-security/src/main/java/org/devlive/metaforge/security/AdminApiRequestMatcher.java new file mode 100644 index 0000000000..2ed7fea34d --- /dev/null +++ b/core/metaforge-security/src/main/java/org/devlive/metaforge/security/AdminApiRequestMatcher.java @@ -0,0 +1,43 @@ +package org.devlive.metaforge.security; + +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +import javax.servlet.http.HttpServletRequest; + +import java.util.ArrayList; +import java.util.List; + +/** Keep administrative APIs protected independently of browser URLs and cached menus. */ +public final class AdminApiRequestMatcher + implements RequestMatcher +{ + private final List matchers = new ArrayList<>(); + + public AdminApiRequestMatcher() + { + for (String path : new String[] {"/api/v1/admin/**", "/api/v1/role/**", "/api/v1/menu/**", "/api/v1/schedule/**", "/api/v1/configure/runtime/**"}) { + matchers.add(new AntPathRequestMatcher(path)); + } + add("POST", "/api/v1/user", "/api/v1/user/list", "/api/v1/function", "/api/v1/function/list"); + add("PUT", "/api/v1/user", "/api/v1/user/allocationRole", "/api/v1/function", "/api/v1/function/import"); + add("DELETE", "/api/v1/user", "/api/v1/user/**", "/api/v1/function", "/api/v1/function/**"); + add("GET", "/api/v1/user/info/**"); + } + + private void add(String method, String... paths) + { + for (String path : paths) { + matchers.add(new AntPathRequestMatcher(path, method)); + if (!path.endsWith("/**")) { + matchers.add(new AntPathRequestMatcher(path + "/", method)); + } + } + } + + @Override + public boolean matches(HttpServletRequest request) + { + return matchers.stream().anyMatch(matcher -> matcher.matches(request)); + } +} diff --git a/core/metaforge-security/src/main/java/org/devlive/metaforge/security/SecurityConfigure.java b/core/metaforge-security/src/main/java/org/devlive/metaforge/security/SecurityConfigure.java index 62701b24bf..cbab7b35f6 100644 --- a/core/metaforge-security/src/main/java/org/devlive/metaforge/security/SecurityConfigure.java +++ b/core/metaforge-security/src/main/java/org/devlive/metaforge/security/SecurityConfigure.java @@ -70,6 +70,8 @@ protected void configure(HttpSecurity http) .authorizeRequests() .antMatchers("/", "/**/*.js", "/**/*.css", "/api/auth/**", "/fonts/**", "/static/**", "/h2-console/**", "/api/v1/captcha", "/api/v1/table/dataDownload/**", "/upload/**", "/api/v1/i18n/**", "/api/v1/setup/**") .permitAll() + .requestMatchers(new AdminApiRequestMatcher()) + .hasAuthority("ADMIN") .anyRequest() .authenticated(); // Fixed load denied by X-Frame-Options diff --git a/core/metaforge-server/pom.xml b/core/metaforge-server/pom.xml index 6f13953142..c8d2871a06 100644 --- a/core/metaforge-server/pom.xml +++ b/core/metaforge-server/pom.xml @@ -233,6 +233,13 @@ + + ../../configure/etc/conf/i18n + i18n + + messages_*.properties + + ../metaforge-web/dist resources diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DataSetController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DataSetController.java index 0439995a5d..fe350b8c54 100644 --- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DataSetController.java +++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/DataSetController.java @@ -57,7 +57,7 @@ public CommonResponse syncData(@PathVariable String code, } @GetMapping(value = "syncFields/{code}") - public CommonResponse> syncFields(@PathVariable String code) + public CommonResponse> syncFields(@PathVariable String code) { return service.getSyncFields(code); } diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/I18nController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/I18nController.java index ce54fb0fc5..f188c4ae1e 100644 --- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/I18nController.java +++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/I18nController.java @@ -1,16 +1,23 @@ package org.devlive.metaforge.server.controller; import org.devlive.metaforge.common.response.CommonResponse; +import org.springframework.boot.ApplicationArguments; import org.springframework.core.env.Environment; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.io.File; -import java.io.FileInputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; import java.util.Properties; @RestController @@ -18,31 +25,89 @@ public class I18nController { private final Environment environment; + private final ApplicationArguments applicationArguments; + private final ResourceLoader resourceLoader; - public I18nController(Environment environment) + public I18nController(Environment environment, ApplicationArguments applicationArguments, ResourceLoader resourceLoader) { this.environment = environment; + this.applicationArguments = applicationArguments; + this.resourceLoader = resourceLoader; } @GetMapping("/{locale}") public CommonResponse getLanguagePack(@PathVariable String locale) { try { - String configLocation = environment.getProperty("spring.config.location"); - File i18nFile = new File(configLocation + "/i18n/messages_" + locale.replace("_", "-").toLowerCase() + ".properties"); - + String normalized = locale.replace('_', '-').toLowerCase(Locale.ROOT); + if (normalized.equals("zh")) { + normalized = "zh-cn"; + } + if (!normalized.equals("zh-cn") && !normalized.equals("en")) { + return CommonResponse.failure("Unsupported locale: " + locale); + } + String filename = "messages_" + normalized + ".properties"; Properties props = new Properties(); - if (i18nFile.exists()) { - try (InputStreamReader reader = new InputStreamReader( - new FileInputStream(i18nFile), StandardCharsets.UTF_8)) { - props.load(reader); + load(new ClassPathResource("i18n/" + filename), props); + // Explicit external translations override the bundled defaults. + for (String location : configurationLocations()) { + Resource resource = externalResource(location, filename); + if (resource.exists()) { + load(resource, props); + break; } } - + if (props.isEmpty()) { + return CommonResponse.failure("Language pack not found: " + normalized); + } return CommonResponse.success(props); } catch (Exception e) { return CommonResponse.failure(e.getMessage()); } } + + private List configurationLocations() + { + List locations = new ArrayList<>(); + for (String property : new String[] {"spring.config.additional-location", "spring.config.location"}) { + List values = applicationArguments.getOptionValues(property); + String value = values == null ? environment.getProperty(property) : String.join(",", values); + if (value != null) { + List candidates = new ArrayList<>(); + Collections.addAll(candidates, value.split("[,;]")); + Collections.reverse(candidates); + locations.addAll(candidates); + } + } + return locations; + } + + private Resource externalResource(String location, String filename) + { + String base = location.trim(); + if (base.startsWith("optional:")) { + base = base.substring("optional:".length()); + } + if (!base.startsWith("file:") && !base.startsWith("classpath:")) { + base = new File(base).toURI().toString(); + } + if (base.endsWith(".properties") || base.endsWith(".yaml") || base.endsWith(".yml")) { + base = base.substring(0, base.lastIndexOf('/') + 1); + } + if (!base.endsWith("/")) { + base += "/"; + } + return resourceLoader.getResource(base + "i18n/" + filename); + } + + private void load(Resource resource, Properties properties) + throws Exception + { + if (resource.exists()) { + try (InputStreamReader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8)) { + properties.load(reader); + } + } + } } diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PluginController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PluginController.java index f19028c4ec..e2d4db825f 100644 --- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PluginController.java +++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/PluginController.java @@ -5,9 +5,6 @@ import org.devlive.metaforge.common.response.CommonResponse; import org.devlive.metaforge.plugin.PluginManager; import org.devlive.metaforge.plugin.PluginMetadata; -import org.devlive.metaforge.plugin.PluginType; -import org.devlive.metaforge.service.common.PluginUtils; -import org.springframework.core.env.Environment; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -20,7 +17,6 @@ import java.nio.file.Path; import java.util.List; -import java.util.stream.Collectors; @RestController @RequestMapping(value = "/api/v1/plugin") @@ -28,26 +24,17 @@ public class PluginController { private final PluginManager pluginManager; - private final Environment environment; - public PluginController(PluginManager pluginManager, Environment environment) + public PluginController(PluginManager pluginManager) { this.pluginManager = pluginManager; - this.environment = environment; } @GetMapping public CommonResponse> getPlugins(@RequestParam(value = "hasConfigure", required = false) boolean hasConfigure) { - List plugins = pluginManager.getPluginInfos() - .stream() - .filter(v -> v.getType().equals(PluginType.CONNECTOR)) - .collect(Collectors.toList()); - - if (hasConfigure) { - plugins.forEach(plugin -> plugin.setConfigure(PluginUtils.loadYamlConfigure("JDBC", plugin.getName(), plugin.getName(), environment))); - } - + // Configure 由插件代码内声明,PluginManager 加载时已填充到 PluginMetadata + // Configure is declared in plugin code and populated into PluginMetadata by PluginManager at load time return CommonResponse.success(pluginManager.getPluginInfos()); } diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/RuntimeConfigureController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/RuntimeConfigureController.java index 6399d2f06d..7fb4de3e07 100644 --- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/RuntimeConfigureController.java +++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/RuntimeConfigureController.java @@ -6,7 +6,7 @@ import org.devlive.metaforge.plugin.Plugin; import org.devlive.metaforge.plugin.PluginManager; import org.devlive.metaforge.plugin.PluginType; -import org.devlive.metaforge.plugin.configure.PluginConfigureField; +import org.devlive.metaforge.plugin.configure.RuntimeConfigureField; import org.devlive.metaforge.service.configure.DatasetTargetSchema; import org.devlive.metaforge.service.entity.ConfigureEntity; import org.devlive.metaforge.service.initializer.DataSetConfigure; @@ -56,7 +56,7 @@ public CommonResponse> list(@PathVariable String category) @GetMapping(value = "detail/{category}/{name}") public CommonResponse detail(@PathVariable String category, @PathVariable String name) { - List schema = resolveSchema(category, name); + List schema = resolveSchema(category, name); if (schema == null) { return CommonResponse.failure("No schema for " + category + "/" + name); } @@ -71,10 +71,10 @@ public CommonResponse save( @RequestBody Map configMap) { // 校验:只保留 schema 中存在的 key(防止脏字段写入) - List schema = resolveSchema(category, name); + List schema = resolveSchema(category, name); Map sanitized = new LinkedHashMap<>(); if (schema != null) { - for (PluginConfigureField field : schema) { + for (RuntimeConfigureField field : schema) { if (configMap != null && configMap.containsKey(field.getName())) { sanitized.put(field.getName(), configMap.get(field.getName())); } @@ -95,7 +95,7 @@ public CommonResponse save( return response; } - private List resolveSchema(String category, String name) + private List resolveSchema(String category, String name) { if (RuntimeConfigureService.CATEGORY_EXECUTOR.equals(category)) { return pluginManager.getPluginInfos().stream() @@ -105,7 +105,7 @@ private List resolveSchema(String category, String name) .map(i -> (Plugin) i) .filter(p -> name.equals(p.getName())) .findFirst() - .map(Plugin::configures) + .map(Plugin::runtimeConfigures) .orElse(null); } if (RuntimeConfigureService.CATEGORY_DATASET.equals(category) && DatasetTargetSchema.NAME.equals(name)) { @@ -119,10 +119,10 @@ public static class DetailView { public String category; public String name; - public List schema; + public List schema; public Map values; - public DetailView(String category, String name, List schema, Map values) + public DetailView(String category, String name, List schema, Map values) { this.category = category; this.name = name; diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/admin/RoleController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/admin/RoleController.java index 1faa3b698e..b34f503078 100644 --- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/admin/RoleController.java +++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/admin/RoleController.java @@ -1,20 +1,26 @@ package org.devlive.metaforge.server.controller.admin; -import org.apache.commons.lang3.ObjectUtils; +import com.fasterxml.jackson.annotation.JsonView; +import org.apache.commons.lang3.StringUtils; +import org.devlive.metaforge.common.enums.ServiceState; import org.devlive.metaforge.common.response.CommonResponse; +import org.devlive.metaforge.common.view.EntityView; import org.devlive.metaforge.server.controller.BaseController; -import org.devlive.metaforge.service.entity.MenuEntity; import org.devlive.metaforge.service.entity.RoleEntity; +import org.devlive.metaforge.service.record.TreeRecord; import org.devlive.metaforge.service.repository.RoleRepository; import org.devlive.metaforge.service.service.RoleService; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; import java.util.Set; -import java.util.stream.Collectors; @RestController @RequestMapping(value = "/api/v1/role") @@ -31,23 +37,52 @@ public RoleController(RoleRepository repository, RoleService service) this.service = service; } - @RequestMapping(value = "{id}/menus", method = {RequestMethod.GET, RequestMethod.PUT}) - public CommonResponse getMenusByRoleId(@PathVariable(value = "id") Long id, - @RequestBody(required = false) Set nodes) + @GetMapping("check-code") + public CommonResponse checkCode(@RequestParam("code") String code) { - if (ObjectUtils.isEmpty(nodes)) { - return service.getMenusByRoleId(id); + return service.checkCode(code); + } + + @Override + @PostMapping + @JsonView(EntityView.UserView.class) + public CommonResponse create(@RequestBody RoleEntity configure) + { + return service.createRole(configure); + } + + @PutMapping("{code}") + @JsonView(EntityView.UserView.class) + public CommonResponse updateByCode(@PathVariable("code") String code, @RequestBody RoleEntity configure) + { + return service.updateRole(code, configure); + } + + @Override + @PutMapping + @JsonView(EntityView.UserView.class) + public CommonResponse update(@RequestBody RoleEntity configure) + { + if (configure.getId() != null) { + return repository.findById(configure.getId()) + .map(entity -> service.updateRole(entity.getCode(), configure)) + .orElseGet(() -> CommonResponse.failure(ServiceState.USER_ROLE_NOT_FOUND)); } - else { - return this.repository.findById(id) - .map(item -> { - Set menus = (Set) nodes.stream() - .map(v -> MenuEntity.builder().id(v).build()) - .collect(Collectors.toSet()); - item.setMenus(menus); - return service.saveOrUpdate(repository, item); - }) - .orElse(CommonResponse.failure(String.format("Role [ %s ] not found", id))); + if (StringUtils.isBlank(configure.getCode())) { + return CommonResponse.failure(ServiceState.USER_ROLE_CODE_INVALID); } + return service.updateRole(configure.getCode(), configure); + } + + @GetMapping("{id}/menus") + public CommonResponse> getMenusByRoleId(@PathVariable("id") Long id) + { + return service.getMenusByRoleId(id); + } + + @PutMapping("{id}/menus") + public CommonResponse saveMenusByRoleId(@PathVariable("id") Long id, @RequestBody Set nodes) + { + return service.saveMenusByRoleId(id, nodes); } } diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/admin/ScheduleController.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/admin/ScheduleController.java index ab243c3705..a7aa4539e7 100644 --- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/admin/ScheduleController.java +++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/controller/admin/ScheduleController.java @@ -1,6 +1,7 @@ package org.devlive.metaforge.server.controller.admin; import org.devlive.metaforge.common.response.CommonResponse; +import org.devlive.metaforge.server.runner.ScheduleRunner; import org.devlive.metaforge.service.body.FilterBody; import org.devlive.metaforge.service.entity.PageEntity; import org.devlive.metaforge.service.entity.ScheduledEntity; @@ -13,17 +14,21 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @RestController @RequestMapping(value = {"/api/v1/schedule", "/api/v1/admin/schedule"}) public class ScheduleController { private final ScheduledRepository scheduledRepository; private final ScheduledService scheduledService; + private final ScheduleRunner scheduleRunner; - public ScheduleController(ScheduledRepository scheduledRepository, ScheduledService scheduledService) + public ScheduleController(ScheduledRepository scheduledRepository, ScheduledService scheduledService, ScheduleRunner scheduleRunner) { this.scheduledRepository = scheduledRepository; this.scheduledService = scheduledService; + this.scheduleRunner = scheduleRunner; } @PostMapping(value = "list") @@ -38,4 +43,27 @@ public CommonResponse> getHistory(@PathVariab { return scheduledService.getHistory(id, filter); } + + @PostMapping(value = "{id}/upcoming") + public CommonResponse> getUpcomingTimes(@PathVariable(value = "id") Long id) + { + return scheduledService.getUpcomingTimes(id); + } + + @PostMapping(value = "{id}/trigger") + public CommonResponse trigger(@PathVariable(value = "id") Long id) + { + return scheduledRepository.findById(id) + .map(task -> { + CommonResponse response; + if (scheduleRunner.triggerManually(task)) { + response = CommonResponse.success(true); + } + else { + response = CommonResponse.failure(String.format("Unsupported scheduled type [ %s ]", task.getType())); + } + return response; + }) + .orElseGet(() -> CommonResponse.failure(String.format("Scheduled task [ %s ] not found", id))); + } } diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/NavigationMigrationRunner.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/NavigationMigrationRunner.java new file mode 100644 index 0000000000..e8f0450884 --- /dev/null +++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/NavigationMigrationRunner.java @@ -0,0 +1,41 @@ +package org.devlive.metaforge.server.runner; + +import lombok.extern.slf4j.Slf4j; +import org.devlive.metaforge.server.service.NavigationMigration; +import org.devlive.metaforge.service.initializer.InstallationState; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; + +import java.sql.Connection; + +@Slf4j +@Component +@Order(1) +public class NavigationMigrationRunner + implements CommandLineRunner +{ + private final DataSource dataSource; + private final InstallationState installationState; + + public NavigationMigrationRunner(DataSource dataSource, InstallationState installationState) + { + this.dataSource = dataSource; + this.installationState = installationState; + } + + @Override + public void run(String... args) + throws Exception + { + if (!installationState.isInstalled()) { + log.info("Navigation migration skipped: instance is not installed"); + return; + } + try (Connection connection = dataSource.getConnection()) { + NavigationMigration.apply(connection); + } + } +} diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/RuntimeConfigureSeedRunner.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/RuntimeConfigureSeedRunner.java index ea7d44b3ac..2d3d22cbf7 100644 --- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/RuntimeConfigureSeedRunner.java +++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/RuntimeConfigureSeedRunner.java @@ -5,7 +5,7 @@ import org.devlive.metaforge.plugin.Plugin; import org.devlive.metaforge.plugin.PluginManager; import org.devlive.metaforge.plugin.PluginType; -import org.devlive.metaforge.plugin.configure.PluginConfigureField; +import org.devlive.metaforge.plugin.configure.RuntimeConfigureField; import org.devlive.metaforge.service.initializer.InstallationState; import org.devlive.metaforge.service.service.RuntimeConfigureService; import org.springframework.boot.CommandLineRunner; @@ -15,7 +15,7 @@ import java.util.List; /** - * 启动时把每个 ExecutorPlugin 声明的可配置字段(Plugin.configures())按默认值落到 metaforge_configure。 + * 启动时把每个 ExecutorPlugin 声明的可配置字段(Plugin.runtimeConfigures())按默认值落到 metaforge_configure。 * 已存在的行不动;只为缺失的 (EXECUTOR, name) 做 seed。 * * On startup, iterate ExecutorPlugin instances and seed default configuration rows into @@ -58,7 +58,7 @@ public void run(String... args) return; } Plugin plugin = (Plugin) instance; - List fields = plugin.configures(); + List fields = plugin.runtimeConfigures(); if (fields == null || fields.isEmpty()) { return; } diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/ScheduleRunner.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/ScheduleRunner.java index 68997952ab..c9350a03bd 100644 --- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/ScheduleRunner.java +++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/runner/ScheduleRunner.java @@ -110,6 +110,22 @@ private void scheduleTask(ScheduledEntity task) } } + /** + * Trigger a scheduled task manually + * + * @param task the scheduled task to trigger + * @return true when the task type is supported and submitted, false otherwise + */ + public boolean triggerManually(ScheduledEntity task) + { + Function taskFactory = taskFactories.get(task.getType()); + if (taskFactory == null) { + return false; + } + executorService.submit(taskFactory.apply(task.getName())); + return true; + } + @PreDestroy public void shutdown() { diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/service/NavigationMigration.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/service/NavigationMigration.java new file mode 100644 index 0000000000..208d306181 --- /dev/null +++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/service/NavigationMigration.java @@ -0,0 +1,65 @@ +package org.devlive.metaforge.server.service; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.datasource.init.ScriptUtils; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +/** One-time catalogue upgrade; callers supply a dedicated connection. */ +public final class NavigationMigration +{ + private static final String KEY = "navigation-user-admin-v1"; + + private NavigationMigration() + { + } + + public static void apply(Connection connection) + throws Exception + { + boolean autoCommit = connection.getAutoCommit(); + try { + // MySQL DDL commits implicitly, so prepare the marker before catalogue DML. + connection.setAutoCommit(true); + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE IF NOT EXISTS metaforge_schema_migration (migration_key VARCHAR(100) PRIMARY KEY, applied_at TIMESTAMP NULL DEFAULT NULL)"); + } + try (PreparedStatement insert = connection.prepareStatement("INSERT IGNORE INTO metaforge_schema_migration (migration_key) VALUES (?)")) { + insert.setString(1, KEY); + insert.executeUpdate(); + } + connection.setAutoCommit(false); + try { + boolean applied; + try (PreparedStatement lock = connection.prepareStatement("SELECT applied_at FROM metaforge_schema_migration WHERE migration_key = ? FOR UPDATE")) { + lock.setString(1, KEY); + try (ResultSet state = lock.executeQuery()) { + if (!state.next()) { + throw new SQLException("Navigation migration state is missing"); + } + applied = state.getTimestamp(1) != null; + } + } + if (!applied) { + ScriptUtils.executeSqlScript(connection, new ClassPathResource("schema/navigation-migration.sql")); + try (PreparedStatement update = connection.prepareStatement("UPDATE metaforge_schema_migration SET applied_at = CURRENT_TIMESTAMP WHERE migration_key = ?")) { + update.setString(1, KEY); + update.executeUpdate(); + } + } + connection.commit(); + } + catch (Exception exception) { + connection.rollback(); + throw exception; + } + } + finally { + connection.setAutoCommit(autoCommit); + } + } +} diff --git a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/service/SetupService.java b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/service/SetupService.java index f01ecdbf16..46e6c7caba 100644 --- a/core/metaforge-server/src/main/java/org/devlive/metaforge/server/service/SetupService.java +++ b/core/metaforge-server/src/main/java/org/devlive/metaforge/server/service/SetupService.java @@ -217,6 +217,7 @@ private void installOnActiveDatasource(SetupInstallBody body) try (Connection connection = dataSource.getConnection()) { executeScript(connection, h2 ? "schema/setup-schema-h2.sql" : "schema/setup-schema.sql"); executeScript(connection, "schema/setup-data.sql"); + NavigationMigration.apply(connection); createAdministrator(connection, body); } reloadRuntimeConfigures(); @@ -229,6 +230,7 @@ private void installOnTargetDatasource(SetupInstallBody body) try (Connection connection = openTarget(target)) { executeScript(connection, target.embedded ? "schema/setup-schema-h2.sql" : "schema/setup-schema.sql"); executeScript(connection, "schema/setup-data.sql"); + NavigationMigration.apply(connection); createAdministrator(connection, body); } persistDatasourceOverride(target); diff --git a/core/metaforge-server/src/main/resources/schema/navigation-migration.sql b/core/metaforge-server/src/main/resources/schema/navigation-migration.sql new file mode 100644 index 0000000000..16a5ec268e --- /dev/null +++ b/core/metaforge-server/src/main/resources/schema/navigation-migration.sql @@ -0,0 +1,50 @@ +-- Idempotent navigation catalogue upgrade for MySQL and H2. +-- Menu IDs, role assignments and custom entries are preserved. +UPDATE metaforge_menu SET url = CONCAT('/admin', SUBSTRING(url, 8)) WHERE LOWER(url) = '/system' OR LOWER(url) LIKE '/system/%' OR LOWER(url) LIKE '/system?%' OR LOWER(url) LIKE '/system#%'; +UPDATE metaforge_menu SET url = CONCAT('/account/profile', SUBSTRING(url, 20)) WHERE LOWER(url) = '/admin/user/profile' OR LOWER(url) LIKE '/admin/user/profile/%' OR LOWER(url) LIKE '/admin/user/profile?%' OR LOWER(url) LIKE '/admin/user/profile#%'; +UPDATE metaforge_menu SET url = CONCAT('/account/info', SUBSTRING(url, 17)) WHERE LOWER(url) = '/admin/user/info' OR LOWER(url) LIKE '/admin/user/info/%' OR LOWER(url) LIKE '/admin/user/info?%' OR LOWER(url) LIKE '/admin/user/info#%'; +UPDATE metaforge_menu SET url = CONCAT('/account/log', SUBSTRING(url, 16)) WHERE LOWER(url) = '/admin/user/log' OR LOWER(url) LIKE '/admin/user/log/%' OR LOWER(url) LIKE '/admin/user/log?%' OR LOWER(url) LIKE '/admin/user/log#%'; +UPDATE metaforge_menu SET url = CONCAT('/account/editor', SUBSTRING(url, 19)) WHERE LOWER(url) = '/admin/user/editor' OR LOWER(url) LIKE '/admin/user/editor/%' OR LOWER(url) LIKE '/admin/user/editor?%' OR LOWER(url) LIKE '/admin/user/editor#%'; +UPDATE metaforge_menu SET url = CONCAT('/account/assistant', SUBSTRING(url, 22)) WHERE LOWER(url) = '/admin/user/assistant' OR LOWER(url) LIKE '/admin/user/assistant/%' OR LOWER(url) LIKE '/admin/user/assistant?%' OR LOWER(url) LIKE '/admin/user/assistant#%'; +UPDATE metaforge_menu SET url = CONCAT('/account/username', SUBSTRING(url, 21)) WHERE LOWER(url) = '/admin/user/username' OR LOWER(url) LIKE '/admin/user/username/%' OR LOWER(url) LIKE '/admin/user/username?%' OR LOWER(url) LIKE '/admin/user/username#%'; +UPDATE metaforge_menu SET url = CONCAT('/account/password', SUBSTRING(url, 21)) WHERE LOWER(url) = '/admin/user/password' OR LOWER(url) LIKE '/admin/user/password/%' OR LOWER(url) LIKE '/admin/user/password?%' OR LOWER(url) LIKE '/admin/user/password#%'; +UPDATE metaforge_menu SET url = CONCAT('/account/notify', SUBSTRING(url, 19)) WHERE LOWER(url) = '/admin/user/notify' OR LOWER(url) LIKE '/admin/user/notify/%' OR LOWER(url) LIKE '/admin/user/notify?%' OR LOWER(url) LIKE '/admin/user/notify#%'; +UPDATE metaforge_menu SET url = CONCAT('/query', SUBSTRING(url, 13)) WHERE LOWER(url) = '/admin/query' OR LOWER(url) LIKE '/admin/query/%' OR LOWER(url) LIKE '/admin/query?%' OR LOWER(url) LIKE '/admin/query#%'; +UPDATE metaforge_menu SET url = CONCAT('/source', SUBSTRING(url, 14)) WHERE LOWER(url) = '/admin/source' OR LOWER(url) LIKE '/admin/source/%' OR LOWER(url) LIKE '/admin/source?%' OR LOWER(url) LIKE '/admin/source#%'; +UPDATE metaforge_menu SET url = CONCAT('/dataset', SUBSTRING(url, 15)) WHERE LOWER(url) = '/admin/dataset' OR LOWER(url) LIKE '/admin/dataset/%' OR LOWER(url) LIKE '/admin/dataset?%' OR LOWER(url) LIKE '/admin/dataset#%'; +UPDATE metaforge_menu SET url = CONCAT('/dashboard', SUBSTRING(url, 17)) WHERE LOWER(url) = '/admin/dashboard' OR LOWER(url) LIKE '/admin/dashboard/%' OR LOWER(url) LIKE '/admin/dashboard?%' OR LOWER(url) LIKE '/admin/dashboard#%'; +UPDATE metaforge_menu SET url = CONCAT('/history', SUBSTRING(url, 15)) WHERE LOWER(url) = '/admin/history' OR LOWER(url) LIKE '/admin/history/%' OR LOWER(url) LIKE '/admin/history?%' OR LOWER(url) LIKE '/admin/history#%'; +UPDATE metaforge_menu SET url = CONCAT('/snippet', SUBSTRING(url, 15)) WHERE LOWER(url) = '/admin/snippet' OR LOWER(url) LIKE '/admin/snippet/%' OR LOWER(url) LIKE '/admin/snippet?%' OR LOWER(url) LIKE '/admin/snippet#%'; +UPDATE metaforge_menu SET url = CONCAT('/report', SUBSTRING(url, 14)) WHERE LOWER(url) = '/admin/report' OR LOWER(url) LIKE '/admin/report/%' OR LOWER(url) LIKE '/admin/report?%' OR LOWER(url) LIKE '/admin/report#%'; +UPDATE metaforge_menu SET url = CONCAT('/chat', SUBSTRING(url, 12)) WHERE LOWER(url) = '/admin/chat' OR LOWER(url) LIKE '/admin/chat/%' OR LOWER(url) LIKE '/admin/chat?%' OR LOWER(url) LIKE '/admin/chat#%'; +UPDATE metaforge_menu SET url = CONCAT('/workflow', SUBSTRING(url, 16)) WHERE LOWER(url) = '/admin/workflow' OR LOWER(url) LIKE '/admin/workflow/%' OR LOWER(url) LIKE '/admin/workflow?%' OR LOWER(url) LIKE '/admin/workflow#%'; + +INSERT INTO metaforge_menu (name, code, description, url, sorted, type, parent, active, i18n_key, icon) SELECT '数据开发', 'MANAGEMENT', '数据开发', '/workspace', 2, 'VIEW', 0, 1, 'navigation.dataDevelopment', 'Hammer' WHERE NOT EXISTS (SELECT 1 FROM metaforge_menu WHERE code = 'MANAGEMENT'); +UPDATE metaforge_menu SET name = '数据开发', url = '/workspace', sorted = 2, parent = 0, i18n_key = 'navigation.dataDevelopment', icon = 'Hammer' WHERE code = 'MANAGEMENT'; + +INSERT INTO metaforge_menu (name, code, description, url, sorted, type, parent, active, i18n_key, icon) SELECT '数据分析', 'DATA_ANALYSIS', '数据分析', '/analysis', 3, 'VIEW', 0, 1, 'navigation.dataAnalysis', 'Gauge' WHERE NOT EXISTS (SELECT 1 FROM metaforge_menu WHERE code = 'DATA_ANALYSIS'); +UPDATE metaforge_menu SET name = '数据分析', url = '/analysis', sorted = 3, parent = 0, i18n_key = 'navigation.dataAnalysis', icon = 'Gauge' WHERE code = 'DATA_ANALYSIS'; + +INSERT INTO metaforge_menu (name, code, description, url, sorted, type, parent, active, i18n_key, icon) SELECT '系统管理', 'SYSTEM', '系统管理', '/admin', 5, 'VIEW', 0, 1, 'navigation.systemManagement', 'SafetyCertificate' WHERE NOT EXISTS (SELECT 1 FROM metaforge_menu WHERE code = 'SYSTEM'); +UPDATE metaforge_menu SET name = '系统管理', url = '/admin', sorted = 5, parent = 0, i18n_key = 'navigation.systemManagement', icon = 'SafetyCertificate' WHERE code = 'SYSTEM'; + +UPDATE metaforge_menu SET parent = (SELECT id FROM (SELECT id FROM metaforge_menu WHERE code = 'MANAGEMENT' ORDER BY id LIMIT 1) catalogue_root) WHERE code IN ('QUERY','DATASOURCE','DATASET','SNIPPET','HISTORY','WORKFLOW','b6669c359a7d41e581d906446366daf3'); +UPDATE metaforge_menu SET parent = (SELECT id FROM (SELECT id FROM metaforge_menu WHERE code = 'DATA_ANALYSIS' ORDER BY id LIMIT 1) catalogue_root) WHERE code IN ('DASHBOARD','REPORT'); +UPDATE metaforge_menu SET parent = (SELECT id FROM (SELECT id FROM metaforge_menu WHERE code = 'SYSTEM' ORDER BY id LIMIT 1) catalogue_root) WHERE code IN ('USERS','ROLE','MENU','SCHEDULE','FUNCTION','RUNTIME_CONFIGURE'); +UPDATE metaforge_menu SET sorted = 1 WHERE code = 'HOME'; +UPDATE metaforge_menu SET sorted = 1 WHERE code = 'QUERY'; +UPDATE metaforge_menu SET sorted = 2 WHERE code = 'DATASOURCE'; +UPDATE metaforge_menu SET sorted = 3 WHERE code = 'DATASET'; +UPDATE metaforge_menu SET sorted = 4 WHERE code = 'b6669c359a7d41e581d906446366daf3'; +UPDATE metaforge_menu SET sorted = 4 WHERE code = 'WORKFLOW'; +UPDATE metaforge_menu SET sorted = 5 WHERE code = 'SNIPPET'; +UPDATE metaforge_menu SET sorted = 6 WHERE code = 'HISTORY'; +UPDATE metaforge_menu SET sorted = 1 WHERE code = 'DASHBOARD'; +UPDATE metaforge_menu SET sorted = 2 WHERE code = 'REPORT'; +UPDATE metaforge_menu SET sorted = 4 WHERE code = 'STORE'; +UPDATE metaforge_menu SET sorted = 1 WHERE code = 'USERS'; +UPDATE metaforge_menu SET sorted = 2 WHERE code = 'ROLE'; +UPDATE metaforge_menu SET sorted = 3 WHERE code = 'MENU'; +UPDATE metaforge_menu SET sorted = 4 WHERE code = 'SCHEDULE'; +UPDATE metaforge_menu SET sorted = 5 WHERE code = 'FUNCTION'; +UPDATE metaforge_menu SET sorted = 6 WHERE code = 'RUNTIME_CONFIGURE'; diff --git a/core/metaforge-server/src/main/resources/schema/setup-data.sql b/core/metaforge-server/src/main/resources/schema/setup-data.sql index 338ed92cb9..1c14d2d492 100644 --- a/core/metaforge-server/src/main/resources/schema/setup-data.sql +++ b/core/metaforge-server/src/main/resources/schema/setup-data.sql @@ -1,15 +1,15 @@ INSERT IGNORE INTO `metaforge_function` VALUES (1,'SHOW','SHOW','SHOW 修改','Redis,Zookeeper,Alioss,Kafka,H2,Hdfs,MySQL',NULL,'2024-11-07 21:33:27','2024-11-25 21:43:52','KEYWORD',1,'8daa7c0812b24c2fa295d0b19e0b46a9'),(2,'SELECT','SELECT','SELECT','H2,MySQL,Hdfs,ClickHouse,Presto,Redis',NULL,'2024-11-07 22:11:19','2024-11-07 22:11:19','KEYWORD',1,'1de4c62f66c84f79a1b1687ef727fabd'); -INSERT IGNORE INTO `metaforge_menu` VALUES (1,'全局 - 首页','HOME','全局路由:所有用户都可以访问','/home',NULL,1,'VIEW',0,1,'common.home','Home','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(2,'全局 - 查询','QUERY','全局路由:所有用户都可以访问','/admin/query',NULL,3,'VIEW',0,1,'common.query','SquareChevronRight','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(3,'全局 - 管理主菜单','MANAGEMENT','全局:所有用户都可以访问\n位置:顶部管理主菜单','/admin',NULL,3,'VIEW',0,1,'common.admin','Hammer','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(4,'全局 - 管理 - 数据源','DATASOURCE','全局:所有用户都可以访问\n位置:顶部管理一级子菜单','/admin/source','default',1,'VIEW',3,1,'common.source','List','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(5,'全局 - 管理 - 片段','SNIPPET','全局:所有用户都可以访问\n位置:顶部管理一级子菜单','/admin/snippet',NULL,2,'VIEW',3,1,'common.snippet','SquareDashedBottomCode','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(6,'全局 - 管理 - 查询历史','HISTORY','全局:所有用户都可以访问\n位置:顶部管理一级子菜单','/admin/history',NULL,3,'VIEW',3,1,'common.history','History','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(8,'管理员 - 系统主菜单','SYSTEM','管理员:管理员权限用户可以访问\n位置:顶部管理一级子菜单','/system',NULL,4,'VIEW',0,1,'common.system','ShieldPlus','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(9,'管理员 - 系统 - 函数','FUNCTION','管理员:管理员权限用户可以访问\n位置:顶部管理一级子菜单','/system/function',NULL,6,'VIEW',3,1,'common.function','SquareFunction','2023-07-04 21:47:24','2024-11-25 21:37:49',0,0,NULL),(10,'管理员 - 系统 - 定时任务','SCHEDULE','管理员:管理员权限用户可以访问\n位置:顶部管理一级子菜单','/system/schedule',NULL,2,'VIEW',8,1,'common.schedule','Timer','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(12,'管理员 - 系统 - 权限','ROLE','管理员:管理员权限用户可以访问\n位置:顶部管理一级子菜单','/system/role',NULL,4,'VIEW',8,1,'common.authority','Flag','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(13,'管理员 - 系统 - 菜单','MENU','管理员:管理员权限用户可以访问\n位置:顶部管理一级子菜单','/system/menu',NULL,5,'VIEW',8,1,'common.menu','Menu','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(14,'管理员 - 系统 - 用户','USERS','管理员:管理员权限用户可以访问\n位置:顶部管理一级子菜单','/system/user',NULL,6,'VIEW',8,1,'common.user','User','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,'@/views/pages/system/user/SystemUser.vue'),(15,'全局 - 管理 - 报表','REPORT','全局:所有用户都可以访问\n位置:顶部管理一级子菜单','/admin/report',NULL,6,'VIEW',3,1,'common.report','SquareKanban','2023-12-18 13:37:35',NULL,0,0,NULL),(16,'全局 - 仪表盘','DASHBOARD','全局路由:所有用户都可以访问','/admin/dashboard',NULL,2,'VIEW',0,1,'common.dashboard','Gauge','2023-12-19 10:26:21',NULL,0,0,NULL),(17,'全局 - 数据集','DATASET','全局路由:所有用户都可以访问','/admin/dataset',NULL,3,'VIEW',0,1,'common.dataset','Contrast','2023-12-21 11:32:33','2024-04-05 12:14:02',0,1,NULL),(18,'全局 - 商店','STORE','','/store','',3,'VIEW',0,1,'common.store','Store',NULL,'2024-11-05 21:18:28',0,0,NULL),(20,'测试菜单','10ae626195704b96b353d00aeb6ab1e5','我被修改了,修改了数据','/test','',2,'VIEW',0,1,'common.home','',NULL,'2024-11-25 21:28:13',0,0,NULL),(21,'管理员 - 管理 - 工作流','b6669c359a7d41e581d906446366daf3','','/admin/workflow','default',1,'VIEW',3,1,'common.workflow','Workflow','2024-12-19 18:36:22','2024-12-19 18:36:22',0,1,NULL); +INSERT IGNORE INTO `metaforge_menu` VALUES (1,'全局 - 首页','HOME','全局路由:所有用户都可以访问','/home',NULL,1,'VIEW',0,1,'common.home','Home','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(2,'全局 - 查询','QUERY','全局路由:所有用户都可以访问','/query',NULL,3,'VIEW',0,1,'common.query','SquareChevronRight','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(3,'全局 - 管理主菜单','MANAGEMENT','全局:所有用户都可以访问\n位置:顶部管理主菜单','/admin',NULL,3,'VIEW',0,1,'common.admin','Hammer','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(4,'全局 - 管理 - 数据源','DATASOURCE','全局:所有用户都可以访问\n位置:顶部管理一级子菜单','/source','default',1,'VIEW',3,1,'common.source','List','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(5,'全局 - 管理 - 片段','SNIPPET','全局:所有用户都可以访问\n位置:顶部管理一级子菜单','/snippet',NULL,2,'VIEW',3,1,'common.snippet','SquareDashedBottomCode','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(6,'全局 - 管理 - 查询历史','HISTORY','全局:所有用户都可以访问\n位置:顶部管理一级子菜单','/history',NULL,3,'VIEW',3,1,'common.history','History','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(8,'管理员 - 系统主菜单','SYSTEM','管理员:管理员权限用户可以访问\n位置:顶部管理一级子菜单','/admin',NULL,4,'VIEW',0,1,'common.system','ShieldPlus','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(9,'管理员 - 系统 - 函数','FUNCTION','管理员:管理员权限用户可以访问\n位置:顶部管理一级子菜单','/admin/function',NULL,6,'VIEW',3,1,'common.function','SquareFunction','2023-07-04 21:47:24','2024-11-25 21:37:49',0,0,NULL),(10,'管理员 - 系统 - 定时任务','SCHEDULE','管理员:管理员权限用户可以访问\n位置:顶部管理一级子菜单','/admin/schedule',NULL,2,'VIEW',8,1,'common.schedule','Timer','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(12,'管理员 - 系统 - 权限','ROLE','管理员:管理员权限用户可以访问\n位置:顶部管理一级子菜单','/admin/role',NULL,4,'VIEW',8,1,'common.authority','Flag','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(13,'管理员 - 系统 - 菜单','MENU','管理员:管理员权限用户可以访问\n位置:顶部管理一级子菜单','/admin/menu',NULL,5,'VIEW',8,1,'common.menu','Menu','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,NULL),(14,'管理员 - 系统 - 用户','USERS','管理员:管理员权限用户可以访问\n位置:顶部管理一级子菜单','/admin/user',NULL,6,'VIEW',8,1,'common.user','User','2023-07-04 21:47:24','2023-07-04 21:47:24',0,0,'@/views/pages/system/user/SystemUser.vue'),(15,'全局 - 管理 - 报表','REPORT','全局:所有用户都可以访问\n位置:顶部管理一级子菜单','/report',NULL,6,'VIEW',3,1,'common.report','SquareKanban','2023-12-18 13:37:35',NULL,0,0,NULL),(16,'全局 - 仪表盘','DASHBOARD','全局路由:所有用户都可以访问','/dashboard',NULL,2,'VIEW',0,1,'common.dashboard','Gauge','2023-12-19 10:26:21',NULL,0,0,NULL),(17,'全局 - 数据集','DATASET','全局路由:所有用户都可以访问','/dataset',NULL,3,'VIEW',0,1,'common.dataset','Contrast','2023-12-21 11:32:33','2024-04-05 12:14:02',0,1,NULL),(18,'全局 - 商店','STORE','','/store','',3,'VIEW',0,1,'common.store','Store',NULL,'2024-11-05 21:18:28',0,0,NULL),(20,'测试菜单','10ae626195704b96b353d00aeb6ab1e5','我被修改了,修改了数据','/test','',2,'VIEW',0,1,'common.home','',NULL,'2024-11-25 21:28:13',0,0,NULL),(21,'管理员 - 管理 - 工作流','b6669c359a7d41e581d906446366daf3','','/workflow','default',1,'VIEW',3,1,'common.workflow','Workflow','2024-12-19 18:36:22','2024-12-19 18:36:22',0,1,NULL); INSERT IGNORE INTO `metaforge_role` VALUES (1,'系统管理员','这是管理员路由,可以管理站点所有功能',NULL,1,'ADMIN','2025-02-08 09:26:54'),(2,'普通用户','我是一个普通用户的路由',NULL,1,'USER','2024-11-25 21:32:56'),(3,'Test001','这是一个测试路由,没有任何权限',NULL,1,'ROLE_TEST001',NULL),(4,'测试路由','用于测试功能,修改数据','2024-11-25 20:52:48.00000',1,'cc4','2024-11-26 13:50:55'); INSERT IGNORE INTO `metaforge_role_menu_relation` VALUES ('2','6'),('2','16'),('2','5'),('2','1'),('2','3'),('2','7'),('2','2'),('2','17'),('2','4'),('2','15'),('4','20'),('1','10'),('1','6'),('1','4'),('1','12'),('1','15'),('1','14'),('1','5'),('1','3'),('1','18'),('1','13'),('1','17'),('1','9'),('1','1'),('1','8'),('1','16'),('1','2'),('1','21'),('1','7'); INSERT IGNORE INTO `metaforge_scheduled` VALUES (1,'Synchronize table structure','Synchronize the table structure of the data source library at 1 am every day','0 0 * * * ?',1,1,'2023-07-04 21:47:24','2023-10-10 11:59:29','SOURCE_SYNCHRONIZE',NULL),(2,'Check source available','Check the availability of the data source every 1 hour','0 0 * * * ?',1,1,'2023-08-08 11:54:01','2023-08-09 18:25:26','SOURCE_CHECK',NULL); --- 新菜单:管理员 → 系统 → 运行时配置(/system/configure) +-- 新菜单:管理员 → 系统 → 运行时配置(/admin/configure) -- Admin menu entry for the runtime configuration page. Idempotent insert by `code`. INSERT INTO `metaforge_menu` (`name`, `code`, `description`, `url`, `group_name`, `sorted`, `type`, `parent`, `active`, `i18n_key`, `icon`) SELECT '管理员 - 系统 - 运行时配置', 'RUNTIME_CONFIGURE', '管理员:管理员权限用户可以访问\n位置:顶部管理一级子菜单', - '/system/configure', NULL, 7, 'VIEW', 8, 1, 'configure.runtime.title', 'Settings2' + '/admin/configure', NULL, 7, 'VIEW', 8, 1, 'configure.runtime.title', 'Settings2' WHERE NOT EXISTS (SELECT 1 FROM `metaforge_menu` WHERE `code` = 'RUNTIME_CONFIGURE'); -- 绑定该菜单到 admin 角色(role_id=1)。menu_id 字段为 mediumtext,CAST 后插入。 diff --git a/core/metaforge-server/src/main/resources/schema/setup-schema-h2.sql b/core/metaforge-server/src/main/resources/schema/setup-schema-h2.sql index 32f153e538..0fdaa0cf9d 100644 --- a/core/metaforge-server/src/main/resources/schema/setup-schema-h2.sql +++ b/core/metaforge-server/src/main/resources/schema/setup-schema-h2.sql @@ -244,7 +244,8 @@ CREATE TABLE IF NOT EXISTS `metaforge_role` ( `active` tinyint(1) DEFAULT '1', `code` varchar(100) DEFAULT NULL, `update_time` datetime DEFAULT NULL, -PRIMARY KEY (`id`) +PRIMARY KEY (`id`), + CONSTRAINT `uk_metaforge_role_code` UNIQUE (`code`) ); CREATE TABLE IF NOT EXISTS `metaforge_role_menu_relation` ( `role_id` mediumtext, diff --git a/core/metaforge-server/src/main/resources/schema/setup-schema.sql b/core/metaforge-server/src/main/resources/schema/setup-schema.sql index de5b78e009..7e00775ed1 100644 --- a/core/metaforge-server/src/main/resources/schema/setup-schema.sql +++ b/core/metaforge-server/src/main/resources/schema/setup-schema.sql @@ -244,7 +244,8 @@ CREATE TABLE IF NOT EXISTS `metaforge_role` ( `active` tinyint(1) DEFAULT '1', `code` varchar(100) DEFAULT NULL, `update_time` datetime DEFAULT NULL, - PRIMARY KEY (`id`) + PRIMARY KEY (`id`), + CONSTRAINT `uk_metaforge_role_code` UNIQUE (`code`) ) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; CREATE TABLE IF NOT EXISTS `metaforge_role_menu_relation` ( `role_id` mediumtext, diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/AdminApiSecurityTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/AdminApiSecurityTest.java new file mode 100644 index 0000000000..1d531dbe32 --- /dev/null +++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/AdminApiSecurityTest.java @@ -0,0 +1,145 @@ +package org.devlive.metaforge.server.controller; + +import org.devlive.metaforge.security.AdminApiRequestMatcher; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.mock.web.MockServletContext; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class AdminApiSecurityTest +{ + private AnnotationConfigWebApplicationContext context; + private MockMvc mvc; + + @BeforeEach + public void initialize() + { + context = new AnnotationConfigWebApplicationContext(); + context.setServletContext(new MockServletContext()); + context.register(TestSecurity.class); + context.refresh(); + mvc = MockMvcBuilders.webAppContextSetup(context).addFilters(context.getBean("springSecurityFilterChain", Filter.class)).build(); + } + + @AfterEach + public void close() + { + SecurityContextHolder.clearContext(); + if (context != null) { + context.close(); + } + } + + @Test + public void ordinaryUsersCannotCallAdministrativeApis() + throws Exception + { + for (String path : new String[] {"/api/v1/role/list", "/api/v1/menu/list", "/api/v1/schedule/1/trigger", "/api/v1/admin/schedule/list", "/api/v1/user/list", "/api/v1/function/list"}) { + mvc.perform(request(HttpMethod.POST, path).servletPath(path).header("X-Test-Role", "USER")).andExpect(status().isForbidden()); + mvc.perform(request(HttpMethod.POST, path).servletPath(path).header("X-Test-Role", "ADMIN")).andExpect(status().isOk()); + } + String configure = "/api/v1/configure/runtime/list/EXECUTOR"; + mvc.perform(request(HttpMethod.GET, configure).servletPath(configure).header("X-Test-Role", "USER")).andExpect(status().isForbidden()); + } + + @Test + public void personalEndpointsAndEditorCompletionRemainAvailable() + throws Exception + { + for (String path : new String[] {"/api/v1/user", "/api/v1/user/menus", "/api/v1/user/checkUsername", "/api/v1/function/list/MySQL", "/api/v1/source/admin/count"}) { + mvc.perform(request(HttpMethod.GET, path).servletPath(path).header("X-Test-Role", "USER")).andExpect(status().isOk()); + } + String password = "/api/v1/user/changePassword"; + mvc.perform(request(HttpMethod.PUT, password).servletPath(password).header("X-Test-Role", "USER")).andExpect(status().isOk()); + } + + @Test + public void trailingSlashesCannotBypassAdministrativeWrites() + throws Exception + { + for (String path : new String[] {"/api/v1/user/", "/api/v1/user/allocationRole/", "/api/v1/function/", "/api/v1/function/import/"}) { + mvc.perform(request(HttpMethod.PUT, path).servletPath(path.substring(0, path.length() - 1)).pathInfo("/").header("X-Test-Role", "USER")).andExpect(status().isForbidden()); + } + } + + @Test + public void anonymousAccessRequiresAuthentication() + throws Exception + { + String path = "/api/v1/role/list"; + mvc.perform(request(HttpMethod.POST, path).servletPath(path)).andExpect(status().isUnauthorized()); + } + + @Configuration + @EnableWebSecurity + @EnableWebMvc + public static class TestSecurity + extends WebSecurityConfigurerAdapter + { + @Bean + public Probe probe() + { + return new Probe(); + } + + @Override + protected void configure(HttpSecurity http) + throws Exception + { + http.csrf().disable().exceptionHandling().authenticationEntryPoint((request, response, exception) -> response.sendError(401)).and() + .authorizeRequests().requestMatchers(new AdminApiRequestMatcher()).hasAuthority("ADMIN").anyRequest().authenticated().and() + .addFilterBefore(new OncePerRequestFilter() + { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException + { + String role = request.getHeader("X-Test-Role"); + if (role != null) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test", "", List.of(new SimpleGrantedAuthority(role)))); + } + chain.doFilter(request, response); + } + }, AnonymousAuthenticationFilter.class); + } + } + + @RestController + public static class Probe + { + @RequestMapping("/**") + public String result() + { + return "ok"; + } + } +} diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/I18nControllerTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/I18nControllerTest.java new file mode 100644 index 0000000000..636f92d465 --- /dev/null +++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/I18nControllerTest.java @@ -0,0 +1,112 @@ +package org.devlive.metaforge.server.controller; + +import org.devlive.metaforge.common.response.CommonResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.mock.env.MockEnvironment; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class I18nControllerTest +{ + @TempDir + private Path temporary; + + @Test + public void testBundledPacksWithoutConfigurationLocation() + { + I18nController controller = controller(new MockEnvironment()); + assertEquals("Home", pack(controller, "en").getProperty("common.home")); + assertEquals("首页", pack(controller, "zh_cn").getProperty("common.home")); + assertEquals("首页", pack(controller, "zh-CN").getProperty("common.home")); + } + + @Test + public void testCommandLineDirectoryOverridesBundledPack() + throws Exception + { + writePack(temporary, "Custom home"); + I18nController controller = controller(new MockEnvironment(), "--spring.config.location=" + temporary.toUri()); + Properties pack = pack(controller, "en"); + assertEquals("Custom home", pack.getProperty("common.home")); + assertEquals("Query", pack.getProperty("common.query")); + } + + @Test + public void testCommandLinePropertiesFileAndOptionalPrefix() + throws Exception + { + writePack(temporary, "File configuration"); + Path config = temporary.resolve("application.properties"); + Files.writeString(config, "", StandardCharsets.UTF_8); + I18nController controller = controller(new MockEnvironment(), "--spring.config.location=optional:" + config.toUri()); + assertEquals("File configuration", pack(controller, "en").getProperty("common.home")); + } + + @Test + public void testEnvironmentConfigurationDirectory() + throws Exception + { + writePack(temporary, "Environment configuration"); + MockEnvironment environment = new MockEnvironment().withProperty("spring.config.location", temporary.toString()); + assertEquals("Environment configuration", pack(controller(environment), "en").getProperty("common.home")); + } + + @Test + public void testAdditionalLocationsAndLastLocationTakePrecedence() + throws Exception + { + Path base = temporary.resolve("base"); + Path extra = temporary.resolve("extra"); + Path last = temporary.resolve("last"); + writePack(base, "Base"); + writePack(extra, "Extra"); + writePack(last, "Last"); + I18nController controller = controller(new MockEnvironment(), + "--spring.config.location=" + base.toUri(), + "--spring.config.additional-location=" + extra.toUri() + "," + last.toUri()); + assertEquals("Last", pack(controller, "en").getProperty("common.home")); + } + + @Test + public void testMissingExternalFileFallsBackToBundledPack() + { + I18nController controller = controller(new MockEnvironment(), "--spring.config.location=" + temporary.toUri()); + assertEquals("Home", pack(controller, "en").getProperty("common.home")); + } + + @Test + public void testUnsupportedLocaleReturnsFailure() + { + assertFalse(controller(new MockEnvironment()).getLanguagePack("missing").getStatus()); + } + + private I18nController controller(MockEnvironment environment, String... arguments) + { + return new I18nController(environment, new DefaultApplicationArguments(arguments), new DefaultResourceLoader()); + } + + private Properties pack(I18nController controller, String locale) + { + CommonResponse response = controller.getLanguagePack(locale); + assertTrue(response.getStatus(), String.valueOf(response.getMessage())); + assertTrue(response.getData().size() > 100); + return response.getData(); + } + + private void writePack(Path directory, String home) + throws Exception + { + Path translations = Files.createDirectories(directory.resolve("i18n")); + Files.writeString(translations.resolve("messages_en.properties"), "common.home=" + home + "\n", StandardCharsets.UTF_8); + } +} diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/RoleControllerTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/RoleControllerTest.java new file mode 100644 index 0000000000..4e6324934f --- /dev/null +++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/controller/RoleControllerTest.java @@ -0,0 +1,109 @@ +package org.devlive.metaforge.server.controller; + +import org.devlive.metaforge.common.enums.ServiceState; +import org.devlive.metaforge.common.response.CommonResponse; +import org.devlive.metaforge.server.controller.admin.RoleController; +import org.devlive.metaforge.service.entity.RoleEntity; +import org.devlive.metaforge.service.repository.RoleRepository; +import org.devlive.metaforge.service.service.RoleService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class RoleControllerTest +{ + private final RoleRepository repository = mock(RoleRepository.class); + private final RoleService service = mock(RoleService.class); + private final MockMvc mvc = MockMvcBuilders.standaloneSetup(new RoleController(repository, service)).build(); + + @Test + public void postPreservesTheSpecifiedCode() + throws Exception + { + when(service.createRole(any())).thenReturn(CommonResponse.success(new RoleEntity())); + mvc.perform(post("/api/v1/role").contentType(MediaType.APPLICATION_JSON).content("{\"code\":\"CUSTOM_READ\",\"name\":\"Read role\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.status").value(true)); + ArgumentCaptor configure = ArgumentCaptor.forClass(RoleEntity.class); + verify(service).createRole(configure.capture()); + assertEquals("CUSTOM_READ", configure.getValue().getCode()); + } + + @Test + public void codeCheckUsesAStaticRouteAndReturnsAvailability() + throws Exception + { + when(service.checkCode("CUSTOM_READ")).thenReturn(CommonResponse.success(false)); + mvc.perform(get("/api/v1/role/check-code").param("code", "CUSTOM_READ")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").value(false)); + } + + @Test + public void pathUpdatesKeepTheOriginalResourceIdentity() + throws Exception + { + when(service.updateRole(eq("ORIGINAL"), any())).thenReturn(CommonResponse.failure(ServiceState.USER_ROLE_CODE_IMMUTABLE)); + mvc.perform(put("/api/v1/role/ORIGINAL").contentType(MediaType.APPLICATION_JSON).content("{\"code\":\"OTHER\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(4030)); + verify(service).updateRole(eq("ORIGINAL"), any()); + } + + @Test + public void legacyUpdatesUseTheStoredCodeForTheGivenId() + throws Exception + { + RoleEntity original = new RoleEntity(); + original.setId(1L); + original.setCode("ORIGINAL"); + when(repository.findById(1L)).thenReturn(Optional.of(original)); + when(service.updateRole(eq("ORIGINAL"), any())).thenReturn(CommonResponse.failure(ServiceState.USER_ROLE_CODE_IMMUTABLE)); + mvc.perform(put("/api/v1/role").contentType(MediaType.APPLICATION_JSON).content("{\"id\":1,\"code\":\"OTHER\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(4030)); + } + + @Test + public void emptyMenuPutClearsAssignmentsInsteadOfCallingTheReadEndpoint() + throws Exception + { + when(service.saveMenusByRoleId(1L, Set.of())).thenReturn(CommonResponse.success(true)); + mvc.perform(put("/api/v1/role/1/menus").contentType(MediaType.APPLICATION_JSON).content("[]")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").value(true)); + verify(service).saveMenusByRoleId(1L, Set.of()); + verify(service, never()).getMenusByRoleId(any()); + } + + @Test + public void menuGetUsesTheProjectionReadEndpoint() + throws Exception + { + when(service.getMenusByRoleId(1L)).thenReturn(CommonResponse.success(List.of())); + mvc.perform(get("/api/v1/role/1/menus")).andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + verify(service).getMenusByRoleId(1L); + } + + @Test + public void missingMenuPutBodyIsRejectedWithoutUpdatingAssignments() + throws Exception + { + mvc.perform(put("/api/v1/role/1/menus").contentType(MediaType.APPLICATION_JSON)).andExpect(status().isBadRequest()); + verify(service, never()).saveMenusByRoleId(any(), any()); + } +} diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/MenuAccessRepositoryTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/MenuAccessRepositoryTest.java new file mode 100644 index 0000000000..43db142d1c --- /dev/null +++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/MenuAccessRepositoryTest.java @@ -0,0 +1,207 @@ +package org.devlive.metaforge.server.service; + +import org.devlive.metaforge.service.initializer.RoleCodeConstraint; +import org.devlive.metaforge.service.record.TreeRecord; +import org.devlive.metaforge.service.repository.RoleRepository; +import org.devlive.metaforge.service.repository.UserRepository; +import org.devlive.metaforge.service.repository.jdbc.MenuAccessRepository; +import org.devlive.metaforge.service.security.UserDetailsService; +import org.devlive.metaforge.service.service.impl.RoleServiceImpl; +import org.devlive.metaforge.service.service.impl.UserServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.init.ScriptUtils; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import javax.sql.DataSource; + +import java.sql.Connection; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +public class MenuAccessRepositoryTest +{ + private JdbcTemplate jdbc; + private MenuAccessRepository menus; + + @BeforeEach + public void initialize() + throws Exception + { + DriverManagerDataSource dataSource = new DriverManagerDataSource( + "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", ""); + try (Connection connection = dataSource.getConnection()) { + ScriptUtils.executeSqlScript(connection, new ClassPathResource("schema/setup-schema-h2.sql")); + } + jdbc = new JdbcTemplate(dataSource); + menus = new MenuAccessRepository(dataSource); + jdbc.update("INSERT INTO metaforge_role (id, name, code) VALUES (1, 'First', 'FIRST'), (2, 'Second', 'SECOND'), (3, 'Other', 'OTHER')"); + jdbc.update("INSERT INTO metaforge_user (id, username, code) VALUES (10, 'owner', 'OWNER'), (20, 'other', 'OTHER')"); + jdbc.update("INSERT INTO metaforge_user_role_relation (user_id, role_id) VALUES (10, 1), (10, 2), (20, 3)"); + menu(100, "Admin", 0, 10, "VIEW"); + menu(200, "Home", 0, 0, "VIEW"); + menu(110, "Chosen", 100, 2, "VIEW"); + menu(120, "Sibling", 100, 1, "VIEW"); + menu(111, "Nested", 110, 1, "VIEW"); + menu(300, "Orphan", 9999, 30, "VIEW"); + menu(400, "Cycle one", 401, 40, "VIEW"); + menu(401, "Cycle two", 400, 41, "VIEW"); + menu(500, "Unsupported", 0, 50, null); + jdbc.update("INSERT INTO metaforge_role_menu_relation (role_id, menu_id) VALUES ('1', '100'), ('1', '110'), ('2', '110'), ('2', '200'), ('3', '120')"); + } + + @AfterEach + public void close() + { + SecurityContextHolder.clearContext(); + if (jdbc != null) { + jdbc.execute("SHUTDOWN"); + } + } + + @Test + public void roleTreeLoadsWithoutAnyJpaSessionAndPreservesPartialAssignments() + { + RoleRepository roles = mock(RoleRepository.class); + RoleCodeConstraint constraint = mock(RoleCodeConstraint.class); + RoleServiceImpl service = new RoleServiceImpl(roles, menus, constraint); + List tree = service.getMenusByRoleId(1L).getData(); + assertEquals(200L, tree.get(0).getId()); + TreeRecord admin = find(tree, 100); + assertEquals(List.of(120L, 110L), admin.getChildren().stream().map(TreeRecord::getId).collect(Collectors.toList())); + assertTrue(admin.getSelected()); + assertTrue(find(tree, 110).getSelected()); + assertFalse(find(tree, 120).getSelected()); + assertFalse(find(tree, 111).getSelected()); + assertEquals(8, flatten(tree).size()); + assertTrue(service.saveMenusByRoleId(1L, Set.of(111L)).getStatus()); + assertEquals(Set.of(111L), assigned(1)); + verifyNoInteractions(roles, constraint); + } + + @Test + public void userNavigationUsesSqlUnionsAndCompletesOnlyRequiredAncestors() + { + UserRepository users = mock(UserRepository.class); + RoleRepository roles = mock(RoleRepository.class); + UserServiceImpl service = new UserServiceImpl(users, roles, null, menus, null, null, null, null, null, null, null, null); + UserDetailsService principal = new UserDetailsService(10L, "OWNER", "owner", "", List.of(), null); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(principal, "", principal.getAuthorities())); + List tree = service.getMenus().getData(); + assertEquals(List.of(200L, 100L), tree.stream().map(TreeRecord::getId).collect(Collectors.toList())); + assertEquals(Set.of(100L, 110L, 200L), flatten(tree).stream().map(TreeRecord::getId).collect(Collectors.toSet())); + menus.replaceRoleMenus(1L, Set.of(111L)); + tree = service.getMenus().getData(); + assertEquals(111L, find(tree, 110).getChildren().get(0).getId()); + assertFalse(flatten(tree).stream().anyMatch(menu -> menu.getId() == 120)); + verifyNoInteractions(users, roles); + } + + @Test + public void emptyAssignmentsClearOnlyTheSpecifiedRole() + { + assertEquals(MenuAccessRepository.SaveResult.SAVED, menus.replaceRoleMenus(1L, Set.of())); + assertTrue(assigned(1).isEmpty()); + assertEquals(Set.of(110L, 200L), assigned(2)); + assertTrue(flatten(menus.findRoleMenus(1L).orElseThrow()).stream().noneMatch(TreeRecord::getSelected)); + } + + @Test + public void invalidSelectionsAndMissingRolesLeaveAssignmentsUntouched() + { + assertEquals(MenuAccessRepository.SaveResult.INVALID_MENUS, menus.replaceRoleMenus(1L, Set.of(9999L))); + assertEquals(MenuAccessRepository.SaveResult.INVALID_MENUS, menus.replaceRoleMenus(1L, Set.of(500L))); + assertEquals(MenuAccessRepository.SaveResult.INVALID_MENUS, menus.replaceRoleMenus(1L, new HashSet<>(Arrays.asList(110L, null)))); + assertEquals(MenuAccessRepository.SaveResult.ROLE_NOT_FOUND, menus.replaceRoleMenus(9999L, Set.of())); + assertTrue(menus.findRoleMenus(9999L).isEmpty()); + assertTrue(menus.findUserMenus("MISSING").isEmpty()); + assertEquals(Set.of(100L, 110L), assigned(1)); + } + + @Test + public void failedInsertsRollBackBothDeletesAndPartialInserts() + { + jdbc.execute("ALTER TABLE metaforge_role_menu_relation ADD CONSTRAINT reject_nested CHECK (menu_id <> '111')"); + assertThrows(DataIntegrityViolationException.class, () -> menus.replaceRoleMenus(1L, new LinkedHashSet<>(List.of(120L, 111L)))); + assertEquals(Set.of(100L, 110L), assigned(1)); + assertEquals(Set.of(110L, 200L), assigned(2)); + } + + @Test + public void concurrentReplacementsDoNotMixTwoAssignments() + throws Exception + { + ExecutorService executor = Executors.newFixedThreadPool(2); + CyclicBarrier barrier = new CyclicBarrier(2); + Set first = Set.of(100L, 110L); + Set second = Set.of(200L, 120L); + try { + Future one = executor.submit(() -> { barrier.await(); return menus.replaceRoleMenus(1L, first); }); + Future two = executor.submit(() -> { barrier.await(); return menus.replaceRoleMenus(1L, second); }); + assertEquals(MenuAccessRepository.SaveResult.SAVED, one.get(10, TimeUnit.SECONDS)); + assertEquals(MenuAccessRepository.SaveResult.SAVED, two.get(10, TimeUnit.SECONDS)); + assertTrue(assigned(1).equals(first) || assigned(1).equals(second)); + } + finally { executor.shutdownNow(); } + } + + @Test + public void constructingJdbcAccessDoesNotQueryAnUninstalledDatabase() + { + DataSource dataSource = mock(DataSource.class); + new MenuAccessRepository(dataSource); + verifyNoInteractions(dataSource); + } + + private void menu(long id, String name, long parent, int sorted, String type) + { + jdbc.update("INSERT INTO metaforge_menu (id, name, parent, sorted, type, code, is_new, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + id, name, parent, sorted, type, "MENU_" + id, false, "/test/" + id); + } + + private Set assigned(long role) + { + return new HashSet<>(jdbc.queryForList("SELECT menu_id FROM metaforge_role_menu_relation WHERE role_id = ?", Long.class, Long.toString(role))); + } + + private List flatten(List tree) + { + List all = new ArrayList<>(); + for (TreeRecord node : tree) { + all.add(node); + if (node.getChildren() != null) { + all.addAll(flatten(node.getChildren())); + } + } + return all; + } + + private TreeRecord find(List tree, long id) + { + return flatten(tree).stream().filter(node -> node.getId() == id).findFirst().orElseThrow(); + } +} diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/NavigationMigrationTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/NavigationMigrationTest.java new file mode 100644 index 0000000000..5e1b918e91 --- /dev/null +++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/NavigationMigrationTest.java @@ -0,0 +1,157 @@ +package org.devlive.metaforge.server.service; + +import org.devlive.metaforge.server.runner.NavigationMigrationRunner; +import org.devlive.metaforge.service.common.NavigationPaths; +import org.devlive.metaforge.service.initializer.InstallationState; +import org.devlive.metaforge.service.record.TreeRecord; +import org.devlive.metaforge.service.repository.jdbc.MenuAccessRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.init.ScriptUtils; + +import javax.sql.DataSource; + +import java.sql.Connection; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +public class NavigationMigrationTest +{ + private DriverManagerDataSource dataSource; + private JdbcTemplate jdbc; + + @BeforeEach + public void initialize() + throws Exception + { + dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", ""); + try (Connection connection = dataSource.getConnection()) { + ScriptUtils.executeSqlScript(connection, new ClassPathResource("schema/setup-schema-h2.sql")); + ScriptUtils.executeSqlScript(connection, new ClassPathResource("schema/setup-data.sql")); + } + jdbc = new JdbcTemplate(dataSource); + jdbc.update("INSERT INTO metaforge_user (id, username, code) VALUES (10, 'ordinary', 'ORDINARY'), (11, 'administrator', 'ADMINISTRATOR')"); + jdbc.update("INSERT INTO metaforge_user_role_relation (user_id, role_id) VALUES (10, 2), (11, 1)"); + jdbc.update("UPDATE metaforge_menu SET url = '/admin/query' WHERE code = 'QUERY'"); + jdbc.update("UPDATE metaforge_menu SET url = '/system/role' WHERE code = 'ROLE'"); + jdbc.update("UPDATE metaforge_menu SET url = '/system' WHERE code = 'SYSTEM'"); + jdbc.update("INSERT INTO metaforge_menu (id, name, code, url, type, parent) VALUES (10000, 'Custom entry', 'CUSTOM_ENTRY', '/custom', 'VIEW', 0)"); + } + + @AfterEach + public void close() + { + if (jdbc != null) { + jdbc.execute("SHUTDOWN"); + } + } + + @Test + public void legacyCataloguesAreRegroupedWithoutChangingIdsOrGrants() + throws Exception + { + List> grants = jdbc.queryForList("SELECT * FROM metaforge_role_menu_relation ORDER BY role_id, menu_id"); + NavigationMigrationRunner runner = new NavigationMigrationRunner(dataSource, new InstallationState(dataSource)); + runner.run(); + runner.run(); + assertEquals("/query", url("QUERY")); + assertEquals("/admin/role", url("ROLE")); + assertEquals("/workspace", url("MANAGEMENT")); + assertEquals("/admin", url("SYSTEM")); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM metaforge_menu WHERE code = 'DATA_ANALYSIS'", Integer.class)); + assertEquals(4L, jdbc.queryForObject("SELECT id FROM metaforge_menu WHERE code = 'DATASOURCE'", Long.class)); + assertEquals(3L, jdbc.queryForObject("SELECT parent FROM metaforge_menu WHERE code = 'DATASET'", Long.class)); + assertEquals(8L, jdbc.queryForObject("SELECT parent FROM metaforge_menu WHERE code = 'FUNCTION'", Long.class)); + assertEquals(jdbc.queryForObject("SELECT id FROM metaforge_menu WHERE code = 'DATA_ANALYSIS'", Long.class), jdbc.queryForObject("SELECT parent FROM metaforge_menu WHERE code = 'DASHBOARD'", Long.class)); + assertEquals(grants, jdbc.queryForList("SELECT * FROM metaforge_role_menu_relation ORDER BY role_id, menu_id")); + assertEquals("/custom", url("CUSTOM_ENTRY")); + } + + @Test + public void ordinaryMenusCannotExposeTheAdminAreaEvenIfAnOldGrantExists() + throws Exception + { + jdbc.update("INSERT INTO metaforge_role_menu_relation (role_id, menu_id) VALUES ('2', '12')"); + new NavigationMigrationRunner(dataSource, new InstallationState(dataSource)).run(); + MenuAccessRepository menus = new MenuAccessRepository(dataSource); + List ordinary = flatten(menus.findUserMenus("ORDINARY").orElseThrow()); + assertTrue(ordinary.stream().anyMatch(menu -> "/query".equals(menu.getUrl()))); + assertFalse(ordinary.stream().anyMatch(menu -> NavigationPaths.administrative(menu.getUrl()))); + List admin = flatten(menus.findUserMenus("ADMINISTRATOR").orElseThrow()); + assertTrue(admin.stream().anyMatch(menu -> "/admin/role".equals(menu.getUrl()))); + } + + @Test + public void laterAdministratorCustomizationsSurviveSubsequentRestarts() + throws Exception + { + NavigationMigrationRunner runner = new NavigationMigrationRunner(dataSource, new InstallationState(dataSource)); + runner.run(); + jdbc.update("UPDATE metaforge_menu SET parent = 10000, sorted = 99 WHERE code = 'QUERY'"); + jdbc.update("UPDATE metaforge_menu SET name = 'Team analytics' WHERE code = 'DATA_ANALYSIS'"); + runner.run(); + assertEquals(10000L, jdbc.queryForObject("SELECT parent FROM metaforge_menu WHERE code = 'QUERY'", Long.class)); + assertEquals(99, jdbc.queryForObject("SELECT sorted FROM metaforge_menu WHERE code = 'QUERY'", Integer.class)); + assertEquals("Team analytics", jdbc.queryForObject("SELECT name FROM metaforge_menu WHERE code = 'DATA_ANALYSIS'", String.class)); + } + + @Test + public void migrationFailureRollsBackEarlierUrlChanges() + { + jdbc.execute("ALTER TABLE metaforge_menu ADD CONSTRAINT reject_query CHECK (url <> '/query')"); + assertThrows(Exception.class, () -> new NavigationMigrationRunner(dataSource, new InstallationState(dataSource)).run()); + assertEquals("/system", url("SYSTEM")); + assertEquals("/admin/query", url("QUERY")); + } + + @Test + public void uninstalledInstancesDoNotRunCatalogueQueries() + throws Exception + { + DataSource unavailable = mock(DataSource.class); + new NavigationMigrationRunner(unavailable, mock(InstallationState.class)).run(); + verifyNoInteractions(unavailable); + } + + @Test + public void canonicalPathsPreserveParametersAndRemainStable() + { + assertEquals("/source/Src/d/MyDB", NavigationPaths.canonical("/ADMIN/SOURCE/Src/d/MyDB")); + assertEquals("/account/profile", NavigationPaths.canonical("/admin/user/profile")); + assertEquals("/admin/user", NavigationPaths.canonical("/system/user")); + assertEquals("/query?sql=SELECT#editor", NavigationPaths.canonical("/admin/query?sql=SELECT#editor")); + assertEquals("/admin/role", NavigationPaths.canonical(NavigationPaths.canonical("/system/role"))); + TreeRecord customized = TreeRecord.builder().code("MANAGEMENT").url("/workspace").i18nKey("common.source").build(); + assertEquals("common.source", NavigationPaths.normalize(customized).getI18nKey()); + } + + private String url(String code) + { + return jdbc.queryForObject("SELECT url FROM metaforge_menu WHERE code = ?", String.class, code); + } + + private List flatten(List tree) + { + List result = new ArrayList<>(); + for (TreeRecord record : tree) { + result.add(record); + if (record.getChildren() != null) { + result.addAll(flatten(record.getChildren())); + } + } + return result; + } +} diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/RoleCodeTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/RoleCodeTest.java new file mode 100644 index 0000000000..238cf6d3bf --- /dev/null +++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/RoleCodeTest.java @@ -0,0 +1,175 @@ +package org.devlive.metaforge.server.service; + +import org.devlive.metaforge.common.enums.ServiceState; +import org.devlive.metaforge.common.response.CommonResponse; +import org.devlive.metaforge.service.entity.MenuEntity; +import org.devlive.metaforge.service.entity.RoleEntity; +import org.devlive.metaforge.service.initializer.RoleCodeConstraint; +import org.devlive.metaforge.service.repository.RoleRepository; +import org.devlive.metaforge.service.service.impl.RoleServiceImpl; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import javax.sql.DataSource; + +import java.sql.SQLException; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class RoleCodeTest +{ + private final RoleRepository repository = mock(RoleRepository.class); + private final RoleCodeConstraint constraint = mock(RoleCodeConstraint.class); + private final RoleServiceImpl service = new RoleServiceImpl(repository, null, constraint); + + @Test + public void customCodeIsTrimmedAndSavedAsANewRole() + throws Exception + { + when(repository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0)); + CommonResponse response = service.createRole(role(null, " CUSTOM_READ ")); + assertTrue(response.getStatus()); + assertEquals("CUSTOM_READ", response.getData().getCode()); + assertEquals("Test role", response.getData().getName()); + verify(constraint).ensure(); + } + + @Test + public void blankCodeStillGeneratesAnIdentifier() + { + when(repository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0)); + CommonResponse response = service.createRole(role(null, " ")); + assertTrue(response.getStatus()); + assertTrue(response.getData().getCode().matches("[a-f0-9]{32}")); + } + + @Test + public void invalidDuplicateAndExistingIdentityCannotBeCreated() + { + assertEquals(ServiceState.USER_ROLE_CODE_INVALID.getCode(), service.createRole(role(null, "not valid")).getCode()); + assertEquals(ServiceState.USER_ROLE_CODE_INVALID.getCode(), service.createRole(role(null, "A".repeat(65))).getCode()); + assertEquals(ServiceState.USER_ROLE_ID_MISMATCH.getCode(), service.createRole(role(1L, "EXISTING")).getCode()); + when(repository.existsByCode("TAKEN")).thenReturn(true); + assertEquals(ServiceState.USER_ROLE_CODE_EXISTS.getCode(), service.createRole(role(null, "TAKEN")).getCode()); + verify(repository, never()).saveAndFlush(any()); + verifyNoInteractions(constraint); + } + + @Test + public void checkEndpointReportsAvailabilityAndRejectsInvalidFormats() + { + when(repository.existsByCode("TAKEN")).thenReturn(true); + assertTrue(service.checkCode("AVAILABLE").getData()); + assertFalse(service.checkCode(" TAKEN ").getData()); + assertTrue(service.checkCode("").getData()); + assertEquals(ServiceState.USER_ROLE_CODE_INVALID.getCode(), service.checkCode("bad/code").getCode()); + verifyNoInteractions(constraint); + } + + @Test + public void databaseUniquenessRaceReturnsADuplicateResponse() + { + when(repository.existsByCode("RACE")).thenReturn(false, true); + when(repository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("duplicate code")); + assertEquals(ServiceState.USER_ROLE_CODE_EXISTS.getCode(), service.createRole(role(null, "RACE")).getCode()); + } + + @Test + public void editingRejectsCodeChangesAndMismatchedIdentities() + { + RoleEntity existing = role(1L, "ORIGINAL"); + when(repository.findByCode("ORIGINAL")).thenReturn(Optional.of(existing)); + assertEquals(ServiceState.USER_ROLE_CODE_IMMUTABLE.getCode(), service.updateRole("ORIGINAL", role(1L, "OTHER")).getCode()); + assertEquals(ServiceState.USER_ROLE_ID_MISMATCH.getCode(), service.updateRole("ORIGINAL", role(2L, "ORIGINAL")).getCode()); + assertEquals("ORIGINAL", existing.getCode()); + verify(repository, never()).saveAndFlush(any()); + } + + @Test + public void validEditsPreserveTheCodeAndAssignedMenus() + { + RoleEntity existing = role(1L, "ORIGINAL"); + MenuEntity menu = new MenuEntity(); + menu.setId(10L); + Set menus = Set.of(menu); + existing.setMenus(menus); + when(repository.findByCode("ORIGINAL")).thenReturn(Optional.of(existing)); + when(repository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0)); + RoleEntity update = role(1L, "ORIGINAL"); + update.setName("Updated role"); + CommonResponse response = service.updateRole("ORIGINAL", update); + assertTrue(response.getStatus()); + assertEquals("Updated role", response.getData().getName()); + assertEquals("ORIGINAL", response.getData().getCode()); + assertSame(menus, response.getData().getMenus()); + } + + @Test + public void legacyDatabasesGainAnIdempotentUniqueIndex() + throws Exception + { + for (boolean lowerCase : new boolean[] {true, false}) { + DriverManagerDataSource dataSource = database(lowerCase); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + jdbc.update("INSERT INTO metaforge_role (code) VALUES ('CODE')"); + RoleCodeConstraint guard = new RoleCodeConstraint(dataSource); + guard.ensure(); + guard.ensure(); + assertThrows(DataIntegrityViolationException.class, () -> jdbc.update("INSERT INTO metaforge_role (code) VALUES ('CODE')")); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM metaforge_role", Integer.class)); + jdbc.execute("SHUTDOWN"); + } + } + + @Test + public void existingDuplicatesAreReportedWithoutChangingStoredData() + { + DriverManagerDataSource dataSource = database(true); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + jdbc.update("INSERT INTO metaforge_role (code) VALUES ('DUPLICATE'), ('DUPLICATE')"); + assertThrows(SQLException.class, () -> new RoleCodeConstraint(dataSource).ensure()); + assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM metaforge_role", Integer.class)); + jdbc.execute("SHUTDOWN"); + } + + @Test + public void constructingTheGuardDoesNotQueryAnUninstalledDatabase() + { + DataSource dataSource = mock(DataSource.class); + new RoleCodeConstraint(dataSource); + verifyNoInteractions(dataSource); + } + + private DriverManagerDataSource database(boolean lowerCase) + { + DriverManagerDataSource dataSource = new DriverManagerDataSource( + "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=" + lowerCase, "sa", ""); + new JdbcTemplate(dataSource).execute("CREATE TABLE metaforge_role (id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(100))"); + return dataSource; + } + + private RoleEntity role(Long id, String code) + { + RoleEntity role = new RoleEntity(); + role.setId(id); + role.setCode(code); + role.setName("Test role"); + role.setDescription("Test description"); + return role; + } +} diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/SetupSchemaTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/SetupSchemaTest.java index dc1f6e0dfd..8394b1ef16 100644 --- a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/SetupSchemaTest.java +++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/SetupSchemaTest.java @@ -32,6 +32,7 @@ public void testSetupSchemaSupportsCurrentEntitiesAndRepeatedInitialization() for (int attempt = 0; attempt < 2; attempt++) { ScriptUtils.executeSqlScript(connection, new ClassPathResource("schema/setup-schema-h2.sql")); ScriptUtils.executeSqlScript(connection, new ClassPathResource("schema/setup-data.sql")); + NavigationMigration.apply(connection); } } factory.setDataSource(dataSource); diff --git a/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/TableFilterTest.java b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/TableFilterTest.java new file mode 100644 index 0000000000..2a50c69d25 --- /dev/null +++ b/core/metaforge-server/src/test/java/org/devlive/metaforge/server/service/TableFilterTest.java @@ -0,0 +1,213 @@ +package org.devlive.metaforge.server.service; + +import org.devlive.metaforge.common.enums.State; +import org.devlive.metaforge.service.body.FilterBody; +import org.devlive.metaforge.service.body.FilterCriterionBody; +import org.devlive.metaforge.service.entity.SourceEntity; +import org.devlive.metaforge.service.entity.UserEntity; +import org.devlive.metaforge.service.entity.UserLogEntity; +import org.devlive.metaforge.service.repository.SourceRepository; +import org.devlive.metaforge.service.repository.UserLogRepository; +import org.devlive.metaforge.service.security.UserDetailsService; +import org.devlive.metaforge.service.service.impl.UserLogServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.domain.Page; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.init.ScriptUtils; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import javax.persistence.EntityManager; + +import java.sql.Connection; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class TableFilterTest +{ + private LocalContainerEntityManagerFactoryBean factory; + private EntityManager manager; + private SourceRepository repository; + private UserEntity owner; + private UserEntity other; + + @BeforeEach + public void initialize() + throws Exception + { + DriverManagerDataSource dataSource = new DriverManagerDataSource( + "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", ""); + try (Connection connection = dataSource.getConnection()) { + ScriptUtils.executeSqlScript(connection, new ClassPathResource("schema/setup-schema-h2.sql")); + } + factory = new LocalContainerEntityManagerFactoryBean(); + factory.setDataSource(dataSource); + factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); + factory.setPackagesToScan("org.devlive.metaforge.service.entity"); + factory.afterPropertiesSet(); + manager = factory.getObject().createEntityManager(); + repository = new JpaRepositoryFactory(manager).getRepository(SourceRepository.class); + manager.getTransaction().begin(); + owner = user("owner"); + other = user("other"); + for (int day = 1; day <= 4; day++) { + source("owned-" + day, "Alpha " + day, day % 2 == 1, "2026-10-0" + day + "T12:00:00", owner, false); + } + source("public", "Alpha shared", false, "2026-10-03T23:59:59", other, true); + source("private", "Alpha private", true, "2026-10-03T12:00:00", other, false); + manager.getTransaction().commit(); + manager.clear(); + } + + @AfterEach + public void close() + { + SecurityContextHolder.clearContext(); + if (manager != null) { + if (manager.getTransaction().isActive()) { + manager.getTransaction().rollback(); + } + manager.close(); + } + if (factory != null) { + factory.destroy(); + } + } + + @Test + public void keywordFiltersBeforePaginationAndKeepsPrivateSourcesHidden() + { + FilterBody filter = new FilterBody(); + filter.setKeyword(" aLPHa "); + filter.setPage(2); + filter.setSize(2); + Page page = repository.findFiltered(filter, owner); + assertEquals(5, page.getTotalElements()); + assertEquals(2, page.getNumberOfElements()); + assertTrue(page.getContent().stream().noneMatch(source -> "private".equals(source.getCode()))); + filter.setCode("private"); + assertEquals(0, repository.findFiltered(filter, owner).getTotalElements()); + } + + @Test + public void combinesStatusCodeAndInclusiveDateRange() + { + FilterBody filter = new FilterBody(); + filter.setCreatedFrom("2026-10-02"); + filter.setCreatedTo("2026-10-03"); + assertEquals(3, repository.findFiltered(filter, owner).getTotalElements()); + filter.setActive(false); + assertEquals(2, repository.findFiltered(filter, owner).getTotalElements()); + filter.setCode("public"); + assertEquals(1, repository.findFiltered(filter, owner).getTotalElements()); + } + + @Test + public void treatsLikeWildcardsAndSqlTextAsLiteralSearchTerms() + { + manager.getTransaction().begin(); + source("literal", "literal%_\\value", true, "2026-10-03T12:00:00", owner, false); + source("ordinary", "literal other value", true, "2026-10-03T12:00:00", owner, false); + manager.getTransaction().commit(); + FilterBody filter = new FilterBody(); + filter.setKeyword("%_\\"); + assertEquals(1, repository.findFiltered(filter, owner).getTotalElements()); + filter.setKeyword("' OR 1=1"); + assertEquals(0, repository.findFiltered(filter, owner).getTotalElements()); + } + + @Test + public void emptyCriteriaRetainOriginalVisibilityAndInvalidDatesAreRejected() + { + FilterBody filter = new FilterBody(); + assertEquals(5, repository.findFiltered(filter, owner).getTotalElements()); + filter.setCreatedFrom("2026-10-04"); + filter.setCreatedTo("2026-10-01"); + assertThrows(RuntimeException.class, () -> repository.findFiltered(filter, owner)); + filter.setCreatedFrom("invalid"); + assertThrows(RuntimeException.class, () -> repository.findFiltered(filter, owner)); + } + + @Test + public void businessConditionsUseTypedValuesAndRejectUnsupportedFields() + { + FilterBody filter = new FilterBody(); + filter.setConditions(Map.of("type", new FilterCriterionBody("H2", true), "host", new FilterCriterionBody("local", false))); + assertEquals(5, repository.findFiltered(filter, owner).getTotalElements()); + filter.setConditions(Map.of("available", new FilterCriterionBody("false", true))); + assertEquals(3, repository.findFiltered(filter, owner).getTotalElements()); + filter.setConditions(Map.of("password", new FilterCriterionBody("secret", false))); + assertThrows(RuntimeException.class, () -> repository.findFiltered(filter, owner)); + filter.setConditions(Map.of("available", new FilterCriterionBody("invalid", true))); + assertThrows(RuntimeException.class, () -> repository.findFiltered(filter, owner)); + } + + @Test + public void loginFiltersUseEnumValuesAndPreserveOwnerScope() + { + manager.getTransaction().begin(); + log(owner, State.SUCCESS, "10.0.0.1"); + log(owner, State.FAILURE, "10.0.0.2"); + log(other, State.SUCCESS, "10.0.0.1"); + manager.getTransaction().commit(); + UserDetailsService principal = UserDetailsService.build(owner); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(principal, "", principal.getAuthorities())); + UserLogRepository logs = new JpaRepositoryFactory(manager).getRepository(UserLogRepository.class); + UserLogServiceImpl service = new UserLogServiceImpl(logs); + FilterBody filter = new FilterBody(); + filter.setConditions(Map.of("state", new FilterCriterionBody("SUCCESS", true), "ip", new FilterCriterionBody("10.0.0", false))); + filter.setCreatedFrom("2026-10-03"); + filter.setCreatedTo("2026-10-03"); + assertEquals(1, service.getAllByFilter(filter).getData().getTotal()); + filter.setConditions(Map.of("state", new FilterCriterionBody("INVALID", true))); + assertThrows(RuntimeException.class, () -> service.getAllByFilter(filter)); + } + + private void log(UserEntity user, State state, String ip) + { + UserLogEntity log = new UserLogEntity(); + log.setUser(user); + log.setState(state); + log.setIp(ip); + log.setCreateTime(Timestamp.valueOf("2026-10-03 12:00:00")); + manager.persist(log); + } + + private UserEntity user(String username) + { + UserEntity user = new UserEntity(); + user.setUsername(username); + manager.persist(user); + return user; + } + + private void source(String code, String name, boolean active, String created, UserEntity user, boolean publish) + { + SourceEntity source = new SourceEntity(); + source.setCode(code); + source.setName(name); + source.setType("H2"); + source.setHost("localhost"); + source.setProtocol("JDBC"); + source.setActive(active); + source.setAvailable(active); + source.setCreateTime(Date.from(LocalDateTime.parse(created).atZone(ZoneId.systemDefault()).toInstant())); + source.setUser(user); + source.setPublish(publish); + manager.persist(source); + } +} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/adapter/FilterSpecification.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/adapter/FilterSpecification.java new file mode 100644 index 0000000000..44186141a1 --- /dev/null +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/adapter/FilterSpecification.java @@ -0,0 +1,121 @@ +package org.devlive.metaforge.service.adapter; + +import org.devlive.metaforge.service.body.FilterBody; +import org.devlive.metaforge.service.body.FilterCriterionBody; +import org.springframework.data.jpa.domain.Specification; + +import javax.persistence.criteria.Predicate; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public final class FilterSpecification +{ + private static final Set ALLOWED_FIELDS = Set.of( + "active", "available", "publish", "type", "protocol", "host", "syncMode", "executor", "scheduler", + "system", "description", "isSystem", "expression", "state", "content", "mode", "ip", "device", "client", + "context", "realtime", "group", "url", "plugin"); + + private FilterSpecification() + { + } + + public static Specification of(FilterBody filter) + { + return (root, query, builder) -> { + Set fields = root.getModel().getAttributes().stream() + .map(attribute -> attribute.getName()).collect(Collectors.toSet()); + List predicates = new ArrayList<>(); + if (filter.getKeyword() != null && !filter.getKeyword().trim().isEmpty()) { + String keyword = filter.getKeyword().trim().toLowerCase(Locale.ROOT) + .replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + List matches = new ArrayList<>(); + for (String field : new String[] {"name", "code", "username", "device", "client", "ip"}) { + if (fields.contains(field)) { + matches.add(builder.like(builder.lower(root.get(field)), "%" + keyword + "%", '\\')); + } + } + predicates.add(builder.or(matches.toArray(new Predicate[0]))); + } + if (filter.getActive() != null) { + predicates.add(builder.equal(root.get("active"), filter.getActive())); + } + if (filter.getCode() != null && !filter.getCode().trim().isEmpty()) { + predicates.add(builder.equal(root.get("code"), filter.getCode().trim())); + } + if (filter.getConditions() != null) { + for (Map.Entry entry : filter.getConditions().entrySet()) { + String field = entry.getKey(); + FilterCriterionBody criterion = entry.getValue(); + if (criterion == null || criterion.getValue() == null || criterion.getValue().trim().isEmpty()) { + continue; + } + if (!ALLOWED_FIELDS.contains(field) || !fields.contains(field)) { + throw new IllegalArgumentException("Unsupported filter: " + field); + } + String value = criterion.getValue().trim(); + Class type = root.get(field).getJavaType(); + if (type == Boolean.class || type == boolean.class) { + if (!value.equals("true") && !value.equals("false")) { + throw new IllegalArgumentException("Invalid boolean filter: " + field); + } + predicates.add(builder.equal(root.get(field), Boolean.valueOf(value))); + } + else if (type.isEnum()) { + Object constant = null; + for (Object candidate : type.getEnumConstants()) { + if (((Enum) candidate).name().equals(value)) { + constant = candidate; + } + } + if (constant == null) { + throw new IllegalArgumentException("Invalid enum filter: " + field); + } + predicates.add(builder.equal(root.get(field), constant)); + } + else if (type == String.class) { + if (criterion.isExact()) { + predicates.add(builder.equal(root.get(field), value)); + } + else { + String term = value.toLowerCase(Locale.ROOT).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + predicates.add(builder.like(builder.lower(root.get(field)), "%" + term + "%", '\\')); + } + } + else { + throw new IllegalArgumentException("Unsupported filter type: " + field); + } + } + } + LocalDate from = date(filter.getCreatedFrom()); + LocalDate to = date(filter.getCreatedTo()); + if (from != null && to != null && from.isAfter(to)) { + throw new IllegalArgumentException("Start date must not be after end date"); + } + if (from != null) { + predicates.add(builder.greaterThanOrEqualTo(root.get("createTime"), boundary(from))); + } + if (to != null) { + predicates.add(builder.lessThan(root.get("createTime"), boundary(to.plusDays(1)))); + } + return builder.and(predicates.toArray(new Predicate[0])); + }; + } + + private static LocalDate date(String value) + { + return value == null || value.trim().isEmpty() ? null : LocalDate.parse(value.trim()); + } + + private static Date boundary(LocalDate value) + { + return Date.from(value.atStartOfDay(ZoneId.systemDefault()).toInstant()); + } +} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/body/FilterBody.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/body/FilterBody.java index a476dcc50e..45dd042803 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/body/FilterBody.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/body/FilterBody.java @@ -7,6 +7,7 @@ import lombok.ToString; import java.util.List; +import java.util.Map; @Data @ToString @@ -18,4 +19,17 @@ public class FilterBody private Integer page = 1; private Integer size = 10; private List orders; + private String keyword; + private Boolean active; + private String code; + private String createdFrom; + private String createdTo; + private Map conditions; + + public FilterBody(Integer page, Integer size, List orders) + { + this.page = page; + this.size = size; + this.orders = orders; + } } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/body/FilterCriterionBody.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/body/FilterCriterionBody.java new file mode 100644 index 0000000000..1c05ec2084 --- /dev/null +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/body/FilterCriterionBody.java @@ -0,0 +1,14 @@ +package org.devlive.metaforge.service.body; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class FilterCriterionBody +{ + private String value; + private boolean exact; +} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/body/SourceBody.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/body/SourceBody.java index 9cc5b2999f..76ac92a432 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/body/SourceBody.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/body/SourceBody.java @@ -5,7 +5,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import lombok.ToString; -import org.devlive.metaforge.service.configure.IConfigure; +import org.devlive.metaforge.plugin.configure.PluginConfigure; @Data @ToString @@ -18,5 +18,5 @@ public class SourceBody private String type; private String name; private String version; - private IConfigure configure; + private PluginConfigure configure; } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/common/ConfigureUtils.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/common/ConfigureUtils.java index b1327e6435..2c65dab909 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/common/ConfigureUtils.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/common/ConfigureUtils.java @@ -10,18 +10,19 @@ import org.devlive.metaforge.common.enums.ServiceState; import org.devlive.metaforge.common.utils.BeanToPropertiesUtils; import org.devlive.metaforge.common.utils.JsonUtils; +import org.devlive.metaforge.plugin.Plugin; +import org.devlive.metaforge.plugin.PluginManager; +import org.devlive.metaforge.plugin.configure.FieldType; +import org.devlive.metaforge.plugin.configure.PluginConfigure; +import org.devlive.metaforge.plugin.configure.PluginConfigureExecutor; +import org.devlive.metaforge.plugin.configure.PluginConfigureExecutorField; +import org.devlive.metaforge.plugin.configure.PluginConfigureField; +import org.devlive.metaforge.plugin.configure.PluginConfigureFieldName; +import org.devlive.metaforge.plugin.configure.PluginConfigureFieldType; +import org.devlive.metaforge.plugin.configure.PluginConfigurePipelineType; import org.devlive.metaforge.service.body.PipelineFieldBody; -import org.devlive.metaforge.service.configure.FieldType; -import org.devlive.metaforge.service.configure.IConfigure; -import org.devlive.metaforge.service.configure.IConfigureExecutor; -import org.devlive.metaforge.service.configure.IConfigureExecutorField; -import org.devlive.metaforge.service.configure.IConfigureField; -import org.devlive.metaforge.service.configure.IConfigureFieldName; -import org.devlive.metaforge.service.configure.IConfigureFieldType; -import org.devlive.metaforge.service.configure.IConfigurePipelineType; import org.devlive.metaforge.service.entity.SourceEntity; -import org.devlive.metaforge.spi.model.Configure; -import org.springframework.core.env.Environment; +import org.devlive.metaforge.spi.model.RuntimeConfigure; import java.sql.Timestamp; import java.time.Instant; @@ -43,7 +44,7 @@ public class ConfigureUtils private ConfigureUtils() {} - public static String preparedMessage(List configures) + public static String preparedMessage(List configures) { StringBuffer buffer = new StringBuffer(); buffer.append(ServiceState.PLUGIN_CONFIGURE_REQUIRED.getValue()); @@ -54,33 +55,33 @@ public static String preparedMessage(List configures) return buffer.toString(); } - public static Configure preparedConfigure(List configures) + public static RuntimeConfigure preparedConfigure(List configures) { - Configure configure = new Configure(); + RuntimeConfigure configure = new RuntimeConfigure(); configures.forEach(v -> { switch (v.getField()) { case host: - configure.setHost(ConfigureUtils.getStringValue(configures, IConfigureFieldName.host)); + configure.setHost(ConfigureUtils.getStringValue(configures, PluginConfigureFieldName.host)); break; case port: - configure.setPort(ConfigureUtils.getIntegerValue(configures, IConfigureFieldName.port)); + configure.setPort(ConfigureUtils.getIntegerValue(configures, PluginConfigureFieldName.port)); break; case username: - configure.setUsername(Optional.ofNullable(ConfigureUtils.getStringValue(configures, IConfigureFieldName.username))); + configure.setUsername(Optional.ofNullable(ConfigureUtils.getStringValue(configures, PluginConfigureFieldName.username))); break; case password: - configure.setPassword(Optional.ofNullable(ConfigureUtils.getStringValue(configures, IConfigureFieldName.password))); + configure.setPassword(Optional.ofNullable(ConfigureUtils.getStringValue(configures, PluginConfigureFieldName.password))); break; case database: - String database = ConfigureUtils.getStringValue(configures, IConfigureFieldName.database); + String database = ConfigureUtils.getStringValue(configures, PluginConfigureFieldName.database); Optional _database = StringUtils.isNotEmpty(database) ? Optional.ofNullable(database) : Optional.empty(); configure.setDatabase(_database); break; case ssl: - configure.setSsl(Optional.ofNullable(ConfigureUtils.getBooleanValue(configures, IConfigureFieldName.ssl))); + configure.setSsl(Optional.ofNullable(ConfigureUtils.getBooleanValue(configures, PluginConfigureFieldName.ssl))); break; case configures: - configure.setEnv(Optional.ofNullable(ConfigureUtils.getMapValue(configures, IConfigureFieldName.configures))); + configure.setEnv(Optional.ofNullable(ConfigureUtils.getMapValue(configures, PluginConfigureFieldName.configures))); break; case file: configure.setUsedConfig(true); @@ -91,7 +92,7 @@ public static Configure preparedConfigure(List configures) return configure; } - public static IConfigure preparedConfigure(IConfigure configure, SourceEntity source) + public static PluginConfigure preparedConfigure(PluginConfigure configure, SourceEntity source) { configure.getConfigures().forEach(v -> { switch (v.getField()) { @@ -122,10 +123,13 @@ public static IConfigure preparedConfigure(IConfigure configure, SourceEntity so case configures: List> fields = new ArrayList<>(); if (ObjectUtils.isNotEmpty(source.getConfigures())) { - source.getConfigures().entrySet().forEach(entry -> { + source.getConfigures().forEach((key, value) -> { + if (ObjectUtils.isEmpty(key)) { + return; + } Map map = new LinkedHashMap<>(); - map.put(IConfigureFieldName.field.name(), entry.getKey()); - map.put(IConfigureFieldName.value.name(), entry.getValue()); + map.put(PluginConfigureFieldName.field.name(), key); + map.put(PluginConfigureFieldName.value.name(), value); fields.add(map); }); } @@ -136,38 +140,38 @@ public static IConfigure preparedConfigure(IConfigure configure, SourceEntity so return configure; } - public static SourceEntity preparedSourceEntity(List configures) + public static SourceEntity preparedSourceEntity(List configures) { SourceEntity configure = new SourceEntity(); configures.forEach(v -> { switch (v.getField()) { case name: - configure.setName(ConfigureUtils.getStringValue(configures, IConfigureFieldName.name)); + configure.setName(ConfigureUtils.getStringValue(configures, PluginConfigureFieldName.name)); case host: - configure.setHost(ConfigureUtils.getStringValue(configures, IConfigureFieldName.host)); + configure.setHost(ConfigureUtils.getStringValue(configures, PluginConfigureFieldName.host)); break; case port: - configure.setPort(ConfigureUtils.getIntegerValue(configures, IConfigureFieldName.port)); + configure.setPort(ConfigureUtils.getIntegerValue(configures, PluginConfigureFieldName.port)); break; case username: - configure.setUsername(ConfigureUtils.getStringValue(configures, IConfigureFieldName.username)); + configure.setUsername(ConfigureUtils.getStringValue(configures, PluginConfigureFieldName.username)); break; case password: - configure.setPassword(ConfigureUtils.getStringValue(configures, IConfigureFieldName.password)); + configure.setPassword(ConfigureUtils.getStringValue(configures, PluginConfigureFieldName.password)); break; case database: - String database = ConfigureUtils.getStringValue(configures, IConfigureFieldName.database); + String database = ConfigureUtils.getStringValue(configures, PluginConfigureFieldName.database); configure.setDatabase(database); break; case catalog: - String catalog = ConfigureUtils.getStringValue(configures, IConfigureFieldName.catalog); + String catalog = ConfigureUtils.getStringValue(configures, PluginConfigureFieldName.catalog); configure.setCatalog(catalog); break; case ssl: - configure.setSsl(ConfigureUtils.getBooleanValue(configures, IConfigureFieldName.ssl)); + configure.setSsl(ConfigureUtils.getBooleanValue(configures, PluginConfigureFieldName.ssl)); break; case configures: - configure.setOriginalConfigure(JsonUtils.toJSON(ConfigureUtils.getMapValue(configures, IConfigureFieldName.configures))); + configure.setOriginalConfigure(JsonUtils.toJSON(ConfigureUtils.getMapValue(configures, PluginConfigureFieldName.configures))); break; case file: configure.setUsedConfig(true); @@ -178,12 +182,12 @@ public static SourceEntity preparedSourceEntity(List configures return configure; } - public static List filterNotEmpty(List configures) + public static List filterNotEmpty(List configures) { return configures.stream().filter(v -> !isEmpty(v)).collect(Collectors.toList()); } - public static boolean isEmpty(IConfigureField field) + public static boolean isEmpty(PluginConfigureField field) { boolean flag = true; switch (field.getType()) { @@ -198,39 +202,39 @@ public static boolean isEmpty(IConfigureField field) return flag; } - public static IConfigureField getConfigure(List configures, IConfigureFieldName key) + public static PluginConfigureField getConfigure(List configures, PluginConfigureFieldName key) { - Optional configureFieldOptional = configures.stream().filter(v -> v.getField().equals(key)).findFirst(); + Optional configureFieldOptional = configures.stream().filter(v -> v.getField().equals(key)).findFirst(); return configureFieldOptional.get(); } - public static String getStringValue(List configures, IConfigureFieldName key) + public static String getStringValue(List configures, PluginConfigureFieldName key) { return String.valueOf(getConfigure(configures, key).getValue()); } - public static Integer getIntegerValue(List configures, IConfigureFieldName key) + public static Integer getIntegerValue(List configures, PluginConfigureFieldName key) { return Integer.valueOf(getStringValue(configures, key)); } - public static Long getLongValue(List configures, IConfigureFieldName key) + public static Long getLongValue(List configures, PluginConfigureFieldName key) { return Long.valueOf(getStringValue(configures, key)); } - public static Boolean getBooleanValue(List configures, IConfigureFieldName key) + public static Boolean getBooleanValue(List configures, PluginConfigureFieldName key) { return Boolean.valueOf(getStringValue(configures, key)); } - public static Map getMapValue(List configures, IConfigureFieldName key) + public static Map getMapValue(List configures, PluginConfigureFieldName key) { Map values = new ConcurrentHashMap<>(); - IConfigureField configureField = getConfigure(configures, key); - if (configureField.getType().equals(IConfigureFieldType.Array)) { + PluginConfigureField configureField = getConfigure(configures, key); + if (configureField.getType().equals(PluginConfigureFieldType.Array)) { List> list = (List>) configureField.getValue(); - list.forEach(map -> values.put(String.valueOf(map.get(IConfigureFieldName.field.name())), map.get(IConfigureFieldName.value.name()))); + list.forEach(map -> values.put(String.valueOf(map.get(PluginConfigureFieldName.field.name())), map.get(PluginConfigureFieldName.value.name()))); } else { Preconditions.checkArgument(false, "Not Support type"); @@ -238,6 +242,20 @@ public static Map getMapValue(List configures, return values; } + /** + * Resolves the configure declared inside the plugin code for the given plugin name. + * + * @param pluginManager the plugin manager + * @param type the plugin name (source type) + * @return the plugin configure, or null when the plugin is absent or declares none + */ + private static PluginConfigure resolvePluginConfigure(PluginManager pluginManager, String type) + { + return pluginManager.getPlugin(type) + .map(Plugin::describe) + .orElse(null); + } + /** * Converts the given properties based on the source entity, fields, configure, environment, pipeline type, executor, query, and field body. * @@ -249,17 +267,17 @@ public static Map getMapValue(List configures, * @param fieldBody the field body * @return the merged properties */ - public static Properties convertProperties(SourceEntity entity, Environment environment, - IConfigurePipelineType pipelineType, String executor, String query, PipelineFieldBody fieldBody) + public static Properties convertProperties(SourceEntity entity, PluginManager pluginManager, + PluginConfigurePipelineType pipelineType, String executor, String query, PipelineFieldBody fieldBody) { - IConfigure yamlConfigure = PluginUtils.loadYamlConfigure(entity.getProtocol(), entity.getType(), entity.getType(), environment); - if (ObjectUtils.isEmpty(yamlConfigure.getPipelines())) { + PluginConfigure yamlConfigure = resolvePluginConfigure(pluginManager, entity.getType()); + if (ObjectUtils.isEmpty(yamlConfigure) || ObjectUtils.isEmpty(yamlConfigure.getPipelines())) { String message = String.format("From source [ %s ] is not supported pipeline, type [ %s ]", entity.getId(), entity.getType()); Preconditions.checkArgument(!message.isEmpty(), message); } - List fields = Lists.newArrayList(); - Optional configureExecutor = yamlConfigure.getPipelines() + List fields = Lists.newArrayList(); + Optional configureExecutor = yamlConfigure.getPipelines() .stream() .filter(v -> v.getExecutor().equals(executor) && v.getType().equals(pipelineType)) .findFirst(); @@ -274,7 +292,7 @@ public static Properties convertProperties(SourceEntity entity, Environment envi } Properties originProperties = fieldBody.getConfigures(); - if (pipelineType.equals(IConfigurePipelineType.INPUT)) { + if (pipelineType.equals(PluginConfigurePipelineType.INPUT)) { if (!originProperties.containsKey("context") && query != null) { originProperties.setProperty("context", query); } @@ -282,19 +300,19 @@ public static Properties convertProperties(SourceEntity entity, Environment envi return mergeProperties(entity, fields, originProperties); } - public static PipelineFieldBody convertFieldBody(SourceEntity entity, String executor, IConfigurePipelineType pipelineType, Environment environment, Properties originProperties) + public static PipelineFieldBody convertFieldBody(SourceEntity entity, String executor, PluginConfigurePipelineType pipelineType, PluginManager pluginManager, Properties originProperties) { try { PipelineFieldBody body = new PipelineFieldBody(); body.setProtocol(entity.getProtocol()); - IConfigure yamlConfigure = PluginUtils.loadYamlConfigure(entity.getProtocol(), entity.getType(), entity.getType(), environment); + PluginConfigure yamlConfigure = resolvePluginConfigure(pluginManager, entity.getType()); if (yamlConfigure == null) { - throw new IllegalArgumentException("Failed to load YAML configuration"); + throw new IllegalArgumentException("Failed to load plugin configuration"); } // Pipeline 条目可能缺少 executor/type 字段(如 Doris 的 YAML 配置), // 直接在条目上调用 equals 会抛出 NPE,比较方向必须固定 - IConfigureExecutor matchedExecutor = findPipelineExecutor(yamlConfigure, executor, pipelineType); + PluginConfigureExecutor matchedExecutor = findPipelineExecutor(yamlConfigure, executor, pipelineType); if (matchedExecutor != null) { body.setConfigures(mergeProperties(entity, matchedExecutor.getFields(), originProperties)); } @@ -318,7 +336,7 @@ public static PipelineFieldBody convertFieldBody(SourceEntity entity, String exe * @param pipelineType 管道类型 | Pipeline type * @return 匹配的执行器配置,未命中时返回 null | Matched executor configure, or null */ - static IConfigureExecutor findPipelineExecutor(IConfigure yamlConfigure, String executor, IConfigurePipelineType pipelineType) + static PluginConfigureExecutor findPipelineExecutor(PluginConfigure yamlConfigure, String executor, PluginConfigurePipelineType pipelineType) { return Optional.ofNullable(yamlConfigure.getPipelines()) .orElseGet(ArrayList::new) @@ -337,10 +355,10 @@ static IConfigureExecutor findPipelineExecutor(IConfigure yamlConfigure, String * @param pipelineType the type of pipeline to convert options for * @return a set of strings representing the converted options */ - public static Set convertOptions(SourceEntity entity, Environment environment, String executor, IConfigurePipelineType pipelineType) + public static Set convertOptions(SourceEntity entity, PluginManager pluginManager, String executor, PluginConfigurePipelineType pipelineType) { Set fromOptions = Sets.newHashSet(); - IConfigure yamlConfigure = PluginUtils.loadYamlConfigure(entity.getProtocol(), entity.getType(), entity.getType(), environment); + PluginConfigure yamlConfigure = resolvePluginConfigure(pluginManager, entity.getType()); yamlConfigure.getPipelines() .stream() .filter(v -> v.getExecutor().equals(executor) && v.getType().equals(pipelineType)) @@ -348,7 +366,7 @@ public static Set convertOptions(SourceEntity entity, Environment enviro .ifPresent(iConfigureExecutor -> iConfigureExecutor .getFields() .stream() - .filter(IConfigureExecutorField::isRequired) + .filter(PluginConfigureExecutorField::isRequired) .forEach(v -> fromOptions.add(v.getField()))); return fromOptions; } @@ -361,11 +379,11 @@ public static Set convertOptions(SourceEntity entity, Environment enviro * @param configure the configure properties to be merged with the source entity properties * @return the merged properties */ - private static Properties mergeProperties(SourceEntity entity, List fields, Properties configure) + private static Properties mergeProperties(SourceEntity entity, List fields, Properties configure) { Properties properties = new Properties(); Properties convertBeanProperties = BeanToPropertiesUtils.convertBeanToProperties(entity); - for (IConfigureExecutorField field : fields) { + for (PluginConfigureExecutorField field : fields) { if (field.isOverride()) { setProperty(field, properties, configure); } @@ -383,7 +401,7 @@ private static Properties mergeProperties(SourceEntity entity, List checkField(List fields, Properties configures, String name, String type) + public static List checkField(List fields, Properties configures, String name, String type) { List list = Lists.newArrayList(); fields.stream() - .filter(IConfigureExecutorField::isInput) + .filter(PluginConfigureExecutorField::isInput) .forEach(field -> { if (field.isRequired() && field.isInput() && field.isOverride()) { if (ObjectUtils.isEmpty(configures.get(field.getField()))) { diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/common/NavigationPaths.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/common/NavigationPaths.java new file mode 100644 index 0000000000..7025c445bb --- /dev/null +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/common/NavigationPaths.java @@ -0,0 +1,53 @@ +package org.devlive.metaforge.service.common; + +import org.devlive.metaforge.service.record.TreeRecord; + +import java.util.Locale; +import java.util.regex.Pattern; + +public final class NavigationPaths +{ + private static final Pattern SYSTEM = Pattern.compile("^/system(?=[/?#]|$)", Pattern.CASE_INSENSITIVE); + private static final Pattern ADMIN_ROOT = Pattern.compile("^/admin(?=[/?#]|$)", Pattern.CASE_INSENSITIVE); + private static final Pattern ACCOUNT = Pattern.compile("^/admin/user/(profile|info|log|editor|assistant|username|password|notify)(?=[/?#]|$)", Pattern.CASE_INSENSITIVE); + private static final Pattern BUSINESS = Pattern.compile("^/admin/(query|source|dataset|dashboard|history|snippet|report|chat|workflow)(?=[/?#]|$)", Pattern.CASE_INSENSITIVE); + private static final Pattern ADMIN = Pattern.compile("^/admin/(user|role|menu|schedule|function|configure)(?=[/?#]|$)", Pattern.CASE_INSENSITIVE); + + private NavigationPaths() + { + } + + public static String canonical(String path) + { + if (path == null) { + return null; + } + String result = SYSTEM.matcher(path).replaceFirst("/admin"); + result = ADMIN_ROOT.matcher(result).replaceFirst("/admin"); + result = ACCOUNT.matcher(result).replaceFirst(match -> "/account/" + match.group(1).toLowerCase(Locale.ROOT)); + result = BUSINESS.matcher(result).replaceFirst(match -> "/" + match.group(1).toLowerCase(Locale.ROOT)); + return ADMIN.matcher(result).replaceFirst(match -> "/admin/" + match.group(1).toLowerCase(Locale.ROOT)); + } + + public static boolean administrative(String path) + { + String canonical = canonical(path); + return canonical != null && ADMIN_ROOT.matcher(canonical).find(); + } + + public static TreeRecord normalize(TreeRecord menu) + { + if ("MANAGEMENT".equals(menu.getCode()) && ("/admin".equals(menu.getUrl()) || "common.admin".equals(menu.getI18nKey()))) { + menu.setUrl("/workspace"); + menu.setI18nKey("navigation.dataDevelopment"); + } + else if ("SYSTEM".equals(menu.getCode()) && ("/system".equals(menu.getUrl()) || "common.system".equals(menu.getI18nKey()))) { + menu.setUrl("/admin"); + menu.setI18nKey("navigation.systemManagement"); + } + else { + menu.setUrl(canonical(menu.getUrl())); + } + return menu; + } +} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/common/PluginUtils.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/common/PluginUtils.java index 0d981c4d5f..845a90648d 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/common/PluginUtils.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/common/PluginUtils.java @@ -1,29 +1,15 @@ package org.devlive.metaforge.service.common; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import com.google.common.base.Preconditions; import com.google.inject.Injector; import com.google.inject.Key; import com.google.inject.TypeLiteral; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; -import org.devlive.metaforge.common.utils.JsonUtils; -import org.devlive.metaforge.common.utils.ResourceUtils; -import org.devlive.metaforge.service.configure.IConfigure; import org.devlive.metaforge.spi.PluginService; -import org.springframework.core.env.Environment; -import java.io.File; import java.util.Optional; import java.util.Set; -@Slf4j public class PluginUtils { - private static final ObjectMapper yamlFactory = new ObjectMapper(new YAMLFactory()); - private PluginUtils() {} public static Optional getPluginByName(Injector injector, String pluginName) @@ -41,62 +27,4 @@ public static Optional getPluginByNameAndType(Injector injector, .filter(plugin -> plugin.name().equalsIgnoreCase(pluginName) && plugin.type().name().equalsIgnoreCase(pluginType)) .findFirst(); } - - @Deprecated - public static IConfigure loadConfigure(String type, String plugin, String resource, Environment environment) - { - String root = environment.getProperty("spring.config.location"); - if (!resource.endsWith(".json")) { - resource = resource + ".json"; - } - String path = root + String.format("plugins/%s/%s", type.toLowerCase(), resource.toLowerCase()); - log.info("Load plugin {} type {} resource {} configure file path {}", plugin, type, resource, path); - String json = ResourceUtils.loadResourceToString(path, true); - if (StringUtils.isEmpty(json)) { - log.warn("Plugin {} type {} configuration file {} not found, load default configuration file", plugin, type, resource); - json = ResourceUtils.loadResourceToString(root + "plugins/default.json", true); - } - Preconditions.checkArgument(StringUtils.isNotEmpty(json), "Invalid configuration file, it may be a bug, please submit issues to solve it."); - IConfigure configure = null; - try { - configure = JsonUtils.objectmapper.readValue(json, IConfigure.class); - } - catch (JsonProcessingException e) { - log.error("Format configuration file, it may be a bug, please submit issues to solve it. plugin {} type {} resource {} configure file path {} message ", plugin, type, resource, path, e); - Preconditions.checkArgument(StringUtils.isNotEmpty(json), "Format configuration file, it may be a bug, please submit issues to solve it."); - } - return configure; - } - - public static IConfigure loadYamlConfigure(String type, String plugin, String resource, Environment environment) - { - String root = environment.getProperty("spring.config.location"); - if (resource.contains(" ")) { - resource = resource.split(" ")[0]; - } - if (!resource.endsWith(".yaml")) { - resource = resource + ".yaml"; - } - String path = root + String.format("plugins/%s", resource.toLowerCase()); - log.info("Load plugin {} type {} resource {} configure file path {}", plugin, type, resource, path); - File file = new File(path); - if (!file.exists()) { - log.warn("Plugin {} type {} configuration file {} not found, load default configuration file", plugin, type, resource); - file = new File(root + "plugins/default.yaml"); - } - else { - log.info("Load plugin {} type {} resource {} configure file path {}", plugin, type, resource, path); - } - - IConfigure configure = null; - try { - yamlFactory.findAndRegisterModules(); - configure = yamlFactory.readValue(file, IConfigure.class); - } - catch (Throwable e) { - log.error("Format configuration file, it may be a bug, please submit issues to solve it. plugin {} type {} resource {} configure file path {} message ", plugin, type, resource, path, e); - Preconditions.checkArgument(StringUtils.isNotEmpty(path), "Format configuration file, it may be a bug, please submit issues to solve it."); - } - return configure; - } } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/DatasetTargetSchema.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/DatasetTargetSchema.java index 50cdc1b2ed..045f0bf231 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/DatasetTargetSchema.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/DatasetTargetSchema.java @@ -1,7 +1,7 @@ package org.devlive.metaforge.service.configure; -import org.devlive.metaforge.plugin.configure.PluginConfigureField; -import org.devlive.metaforge.plugin.configure.PluginFieldType; +import org.devlive.metaforge.plugin.configure.RuntimeConfigureField; +import org.devlive.metaforge.plugin.configure.RuntimeConfigureFieldType; import java.util.Arrays; import java.util.List; @@ -19,24 +19,24 @@ private DatasetTargetSchema() {} public static final String NAME = "Default"; - public static List fields() + public static List fields() { return Arrays.asList( - new PluginConfigureField("type", PluginFieldType.STRING, "ClickHouse", + new RuntimeConfigureField("type", RuntimeConfigureFieldType.STRING, "ClickHouse", "Target storage plugin type", false), - new PluginConfigureField("host", PluginFieldType.STRING, "app-clickhouse", + new RuntimeConfigureField("host", RuntimeConfigureFieldType.STRING, "app-clickhouse", "Target host", false), - new PluginConfigureField("port", PluginFieldType.STRING, "8123", + new RuntimeConfigureField("port", RuntimeConfigureFieldType.STRING, "8123", "Target port", false), - new PluginConfigureField("username", PluginFieldType.STRING, "default", + new RuntimeConfigureField("username", RuntimeConfigureFieldType.STRING, "default", "Target username", false), - new PluginConfigureField("password", PluginFieldType.PASSWORD, "", + new RuntimeConfigureField("password", RuntimeConfigureFieldType.PASSWORD, "", "Target password", false), - new PluginConfigureField("database", PluginFieldType.STRING, "metaforge", + new RuntimeConfigureField("database", RuntimeConfigureFieldType.STRING, "metaforge", "Target database", false), - new PluginConfigureField("tableDefaultEngine", PluginFieldType.STRING, "MergeTree", + new RuntimeConfigureField("tableDefaultEngine", RuntimeConfigureFieldType.STRING, "MergeTree", "Default table engine when materializing", false), - new PluginConfigureField("tablePrefix", PluginFieldType.STRING, "metaforge_", + new RuntimeConfigureField("tablePrefix", RuntimeConfigureFieldType.STRING, "metaforge_", "Table name prefix", false) ); } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigure.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigure.java deleted file mode 100644 index d6827d6cd3..0000000000 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigure.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.devlive.metaforge.service.configure; - -import com.fasterxml.jackson.annotation.JsonView; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.ToString; -import org.devlive.metaforge.common.view.EntityView; - -import java.util.Date; -import java.util.List; - -@Data -@ToString -@NoArgsConstructor -@AllArgsConstructor -@SuppressFBWarnings(value = {"EI_EXPOSE_REP2", "EI_EXPOSE_REP2", "EI_EXPOSE_REP", "NP_NULL_PARAM_DEREF_NONVIRTUAL"}, - justification = "I prefer to suppress these FindBugs warnings") -public class IConfigure -{ - @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private String name; - - @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private Date supportTime; - - @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private List configures; - - @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private List pipelines; -} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureField.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureField.java deleted file mode 100644 index 7e26467550..0000000000 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureField.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.devlive.metaforge.service.configure; - -import com.fasterxml.jackson.annotation.JsonView; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.ToString; -import org.devlive.metaforge.common.view.EntityView; - -@Data -@ToString -@NoArgsConstructor -@AllArgsConstructor -public class IConfigureField -{ - @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private IConfigureFieldName field; // The field name of the tag configuration - - @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private IConfigureFieldType type; - - @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private boolean required; - - @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private Long min; - - @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private Long max; - - @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private String message; - - @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private Object value; - - @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private boolean disabled; - - @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private IConfigureFieldGroup group = IConfigureFieldGroup.configure; -} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureFieldGroup.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureFieldGroup.java deleted file mode 100644 index 814df79e6d..0000000000 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureFieldGroup.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.devlive.metaforge.service.configure; - -public enum IConfigureFieldGroup -{ - configure, - authorization, - advanced, - custom -} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureFieldType.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureFieldType.java deleted file mode 100644 index 6a95734c7c..0000000000 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigureFieldType.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.devlive.metaforge.service.configure; - -public enum IConfigureFieldType -{ - Number, - String, - List, - Boolean, - Array, - Map, - File -} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigurePipelineType.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigurePipelineType.java deleted file mode 100644 index db1744b050..0000000000 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/configure/IConfigurePipelineType.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.devlive.metaforge.service.configure; - -public enum IConfigurePipelineType -{ - INPUT, - OUTPUT -} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/ConfigureEntity.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/ConfigureEntity.java index 930f1a8836..fd4062ce82 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/ConfigureEntity.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/ConfigureEntity.java @@ -18,7 +18,7 @@ /** * 通用配置表,承载 EXECUTOR / DATASET 等不同范畴的运行时配置。 - * 字段 schema 由各插件通过 Plugin.configures() 声明;value 为序列化后的 JSON。 + * 字段 schema 由各插件通过 Plugin.runtimeConfigures() 声明;value 为序列化后的 JSON。 * * Generic configuration row keyed by (category, name). * - category: e.g. "EXECUTOR", "DATASET" diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/PluginEntity.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/PluginEntity.java index 13c863f1a5..35e7277a5a 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/PluginEntity.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/PluginEntity.java @@ -3,7 +3,7 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import lombok.Data; import lombok.ToString; -import org.devlive.metaforge.service.configure.IConfigure; +import org.devlive.metaforge.plugin.configure.PluginConfigure; @Data @ToString @@ -13,5 +13,5 @@ public class PluginEntity private String name; private String description; private String type; - private IConfigure configure; + private PluginConfigure configure; } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/RoleEntity.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/RoleEntity.java index c4a1708a1c..786091ab09 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/RoleEntity.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/RoleEntity.java @@ -10,6 +10,7 @@ import lombok.experimental.SuperBuilder; import org.devlive.metaforge.common.view.EntityView; +import javax.persistence.AttributeOverride; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.FetchType; @@ -17,6 +18,7 @@ import javax.persistence.JoinTable; import javax.persistence.ManyToMany; import javax.persistence.Table; +import javax.persistence.UniqueConstraint; import java.util.HashSet; import java.util.Set; @@ -28,7 +30,8 @@ @AllArgsConstructor @EqualsAndHashCode(callSuper = true) @Entity -@Table(name = "metaforge_role") +@Table(name = "metaforge_role", uniqueConstraints = @UniqueConstraint(name = "uk_metaforge_role_code", columnNames = "code")) +@AttributeOverride(name = "code", column = @Column(name = "code", updatable = false)) @SuppressFBWarnings(value = {"EI_EXPOSE_REP", "RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE", "EQ_OVERRIDING_EQUALS_NOT_SYMMETRIC"}, justification = "I prefer to suppress these FindBugs warnings") public class RoleEntity diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/SourceEntity.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/SourceEntity.java index c1517d4d57..f67c24a2a8 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/SourceEntity.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/entity/SourceEntity.java @@ -14,9 +14,9 @@ import org.devlive.metaforge.common.view.EntityView; import org.devlive.metaforge.plugin.Plugin; import org.devlive.metaforge.plugin.PluginManager; -import org.devlive.metaforge.service.configure.IConfigure; -import org.devlive.metaforge.service.configure.IConfigureExecutor; -import org.devlive.metaforge.spi.model.Configure; +import org.devlive.metaforge.plugin.configure.PluginConfigure; +import org.devlive.metaforge.plugin.configure.PluginConfigureExecutor; +import org.devlive.metaforge.spi.model.RuntimeConfigure; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import javax.persistence.CascadeType; @@ -127,11 +127,11 @@ public class SourceEntity @Transient @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private IConfigure schema; + private PluginConfigure schema; @Transient @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) - private List pipelines; + private List pipelines; @Transient @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) @@ -152,7 +152,7 @@ public class SourceEntity @Transient @JsonProperty(value = "configure") - private IConfigure configure; + private PluginConfigure configure; @Transient @JsonView(value = {EntityView.UserView.class, EntityView.AdminView.class}) @@ -174,19 +174,19 @@ public Map getConfigures() return configures; } - public Configure toConfigure(PluginManager pluginManager, Plugin plugin) + public RuntimeConfigure toConfigure(PluginManager pluginManager, Plugin plugin) { return toConfigure("JsonConvert", pluginManager, plugin); } /** - * Converts the current object to a Configure object. + * Converts the current object to a RuntimeConfigure object. * - * @return The Configure object created from the current object. + * @return The RuntimeConfigure object created from the current object. */ - public Configure toConfigure(String format, PluginManager pluginManager, Plugin plugin) + public RuntimeConfigure toConfigure(String format, PluginManager pluginManager, Plugin plugin) { - return Configure.builder() + return RuntimeConfigure.builder() .host(this.getHost()) .port(this.getPort()) .username(Optional.ofNullable(this.getUsername())) diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/initializer/InstallationState.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/initializer/InstallationState.java index 4da72d05be..a00ab37c69 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/initializer/InstallationState.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/initializer/InstallationState.java @@ -1,5 +1,6 @@ package org.devlive.metaforge.service.initializer; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.springframework.stereotype.Component; import javax.sql.DataSource; @@ -20,6 +21,7 @@ public class InstallationState { private final DataSource dataSource; + @SuppressFBWarnings(value = {"EI_EXPOSE_REP2"}, justification = "DataSource is an injected Spring-managed singleton") public InstallationState(DataSource dataSource) { this.dataSource = dataSource; diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/initializer/RoleCodeConstraint.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/initializer/RoleCodeConstraint.java new file mode 100644 index 0000000000..e5426a9c36 --- /dev/null +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/initializer/RoleCodeConstraint.java @@ -0,0 +1,65 @@ +package org.devlive.metaforge.service.initializer; + +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Component +public class RoleCodeConstraint +{ + private final DataSource dataSource; + + public RoleCodeConstraint(DataSource dataSource) + { + this.dataSource = dataSource; + } + + /** Upgrade legacy stores lazily; never query an uninstalled database at startup. */ + public synchronized void ensure() + throws SQLException + { + try (Connection connection = dataSource.getConnection()) { + if (hasUniqueCodeIndex(connection)) { + return; + } + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE UNIQUE INDEX uk_metaforge_role_code ON metaforge_role (code)"); + } + catch (SQLException exception) { + // Another instance may have installed the same constraint concurrently. + if (!hasUniqueCodeIndex(connection)) { + throw exception; + } + } + } + } + + private boolean hasUniqueCodeIndex(Connection connection) + throws SQLException + { + DatabaseMetaData metadata = connection.getMetaData(); + String table = metadata.storesUpperCaseIdentifiers() ? "METAFORGE_ROLE" : "metaforge_role"; + Map> indexes = new HashMap<>(); + try (ResultSet rows = metadata.getIndexInfo(connection.getCatalog(), null, table, true, false)) { + while (rows.next()) { + String index = rows.getString("INDEX_NAME"); + String column = rows.getString("COLUMN_NAME"); + if (index != null && column != null) { + indexes.computeIfAbsent(index, key -> new ArrayList<>()).add(column.toLowerCase(Locale.ROOT)); + } + } + } + return indexes.values().stream().anyMatch(columns -> columns.size() == 1 && columns.contains("code")); + } +} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/BaseRepository.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/BaseRepository.java index 2a8ca96c59..cafde3252e 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/BaseRepository.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/BaseRepository.java @@ -1,19 +1,34 @@ package org.devlive.metaforge.service.repository; +import org.devlive.metaforge.service.adapter.FilterSpecification; +import org.devlive.metaforge.service.adapter.PageRequestAdapter; +import org.devlive.metaforge.service.body.FilterBody; import org.devlive.metaforge.service.entity.UserEntity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.repository.PagingAndSortingRepository; import java.util.Optional; public interface BaseRepository - extends PagingAndSortingRepository + extends PagingAndSortingRepository, JpaSpecificationExecutor { Optional findByCode(String code); void deleteByCode(String code); + default Specification visibleTo(UserEntity user) + { + return (root, query, builder) -> builder.conjunction(); + } + + default Page findFiltered(FilterBody filter, UserEntity user) + { + return findAll(FilterSpecification.of(filter).and(visibleTo(user)), PageRequestAdapter.of(filter)); + } + default Page findAllByUser(UserEntity user, Pageable pageable) { throw new UnsupportedOperationException("findAllByUser not implemented"); diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/DashboardRepository.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/DashboardRepository.java index 770605de4d..aa03ee0047 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/DashboardRepository.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/DashboardRepository.java @@ -4,9 +4,16 @@ import org.devlive.metaforge.service.entity.UserEntity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; public interface DashboardRepository extends BaseRepository { + @Override + default Specification visibleTo(UserEntity user) + { + return (root, query, builder) -> builder.equal(root.get("user"), user); + } + Page findAllByUser(UserEntity user, Pageable pageable); } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/DataSetRepository.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/DataSetRepository.java index 74514b1754..5b78d7dbd7 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/DataSetRepository.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/DataSetRepository.java @@ -5,6 +5,7 @@ import org.devlive.metaforge.service.enums.SyncMode; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.Query; import java.util.Optional; @@ -13,6 +14,12 @@ public interface DataSetRepository extends BaseRepository { + @Override + default Specification visibleTo(UserEntity user) + { + return (root, query, builder) -> builder.equal(root.get("user"), user); + } + Page findAllByUser(UserEntity user, Pageable pageable); Optional findByCode(String code); diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/PluginAuditRepository.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/PluginAuditRepository.java index 636dcf915a..f972eb861f 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/PluginAuditRepository.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/PluginAuditRepository.java @@ -5,6 +5,7 @@ import org.devlive.metaforge.service.entity.UserEntity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -14,6 +15,12 @@ public interface PluginAuditRepository extends BaseRepository { + @Override + default Specification visibleTo(UserEntity user) + { + return (root, query, builder) -> builder.equal(root.get("user"), user); + } + Page findAllByUser(UserEntity user, Pageable pageable); Long countByUser(UserEntity user); diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/ReportRepository.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/ReportRepository.java index f8e8e159ad..388845256e 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/ReportRepository.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/ReportRepository.java @@ -4,9 +4,16 @@ import org.devlive.metaforge.service.entity.UserEntity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; public interface ReportRepository extends BaseRepository { + @Override + default Specification visibleTo(UserEntity user) + { + return (root, query, builder) -> builder.equal(root.get("user"), user); + } + Page findAllByUser(UserEntity user, Pageable pageable); } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/RoleRepository.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/RoleRepository.java index 7e789e6de9..a0947a5b70 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/RoleRepository.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/RoleRepository.java @@ -1,11 +1,14 @@ package org.devlive.metaforge.service.repository; import org.devlive.metaforge.service.entity.RoleEntity; +import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface RoleRepository - extends BaseRepository + extends BaseRepository, JpaRepository { + boolean existsByCode(String code); + Optional findByName(String name); } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/SnippetRepository.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/SnippetRepository.java index 0222b7ec8e..6cb35c439b 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/SnippetRepository.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/SnippetRepository.java @@ -4,9 +4,16 @@ import org.devlive.metaforge.service.entity.UserEntity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; public interface SnippetRepository extends BaseRepository { + @Override + default Specification visibleTo(UserEntity user) + { + return (root, query, builder) -> builder.equal(root.get("user"), user); + } + Page findAllByUser(UserEntity user, Pageable pageable); } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/SourceRepository.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/SourceRepository.java index 34ec9e7f08..46ce74a3e0 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/SourceRepository.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/SourceRepository.java @@ -4,6 +4,7 @@ import org.devlive.metaforge.service.entity.UserEntity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import java.util.List; import java.util.Optional; @@ -11,6 +12,12 @@ public interface SourceRepository extends BaseRepository { + @Override + default Specification visibleTo(UserEntity user) + { + return (root, query, builder) -> builder.or(builder.equal(root.get("user"), user), builder.isTrue(root.get("publish"))); + } + List findAll(); SourceEntity findByName(String name); diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/UserLogRepository.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/UserLogRepository.java index b13c01d79e..fbb9c57d86 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/UserLogRepository.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/UserLogRepository.java @@ -4,10 +4,11 @@ import org.devlive.metaforge.service.entity.UserLogEntity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.repository.PagingAndSortingRepository; public interface UserLogRepository - extends PagingAndSortingRepository + extends PagingAndSortingRepository, JpaSpecificationExecutor { Page findAllByUserOrderByCreateTimeDesc(UserEntity user, Pageable pageable); } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/jdbc/MenuAccessRepository.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/jdbc/MenuAccessRepository.java new file mode 100644 index 0000000000..314f118c29 --- /dev/null +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/repository/jdbc/MenuAccessRepository.java @@ -0,0 +1,179 @@ +package org.devlive.metaforge.service.repository.jdbc; + +import org.devlive.metaforge.service.common.NavigationPaths; +import org.devlive.metaforge.service.record.TreeRecord; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.sql.DataSource; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** Explicit menu projections and atomic relationship updates, independent of ORM sessions. */ +@Repository +public class MenuAccessRepository +{ + public enum SaveResult + { + SAVED, ROLE_NOT_FOUND, INVALID_MENUS + } + + private final NamedParameterJdbcTemplate jdbc; + private final TransactionTemplate transaction; + + public MenuAccessRepository(DataSource dataSource) + { + this.jdbc = new NamedParameterJdbcTemplate(dataSource); + this.transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource)); + } + + public Optional> findRoleMenus(Long roleId) + { + if (roleId == null || roleId <= 0 || jdbc.queryForObject("SELECT COUNT(*) FROM metaforge_role WHERE id = :id", Map.of("id", roleId), Long.class) == 0) { + return Optional.empty(); + } + Set assigned = ids(jdbc.queryForList("SELECT menu_id FROM metaforge_role_menu_relation WHERE role_id = :roleId", + Map.of("roleId", roleId.toString()), String.class)); + return Optional.of(tree(menus(assigned, true))); + } + + public Optional> findUserMenus(String userCode) + { + if (userCode == null) { + return Optional.empty(); + } + List users = jdbc.queryForList("SELECT id FROM metaforge_user WHERE code = :code", Map.of("code", userCode), Long.class); + if (users.size() != 1) { + return Optional.empty(); + } + Set assigned = ids(jdbc.queryForList( + "SELECT DISTINCT rm.menu_id FROM metaforge_role_menu_relation rm " + + "JOIN metaforge_role r ON rm.role_id = CONCAT('', r.id) " + + "JOIN metaforge_user_role_relation ur ON ur.role_id = r.id WHERE ur.user_id = :userId", + Map.of("userId", users.get(0)), String.class)); + boolean administrator = jdbc.queryForObject("SELECT COUNT(*) FROM metaforge_user_role_relation ur JOIN metaforge_role r ON r.id = ur.role_id WHERE ur.user_id = :userId AND r.code = 'ADMIN'", + Map.of("userId", users.get(0)), Long.class) > 0; + List all = menus(assigned, false).stream() + .filter(menu -> administrator || !NavigationPaths.administrative(menu.getUrl())).collect(Collectors.toList()); + Map byId = new LinkedHashMap<>(); + all.forEach(menu -> byId.put(menu.getId(), menu)); + Set included = new HashSet<>(assigned); + for (Long id : assigned) { + TreeRecord current = byId.get(id); + Set visited = new HashSet<>(); + while (current != null && visited.add(current.getId())) { + included.add(current.getId()); + current = byId.get(current.getParent()); + } + } + List visible = new ArrayList<>(); + for (TreeRecord menu : all) { + if (included.contains(menu.getId())) { + menu.setChecked(true); + menu.setSelected(true); + visible.add(menu); + } + } + return Optional.of(tree(visible)); + } + + public SaveResult replaceRoleMenus(Long roleId, Set menuIds) + { + if (roleId == null || roleId <= 0) { + return SaveResult.ROLE_NOT_FOUND; + } + if (menuIds == null || menuIds.stream().anyMatch(id -> id == null || id <= 0)) { + return SaveResult.INVALID_MENUS; + } + return transaction.execute(status -> { + MapSqlParameterSource parameters = new MapSqlParameterSource("id", roleId).addValue("roleId", roleId.toString()); + if (jdbc.queryForList("SELECT id FROM metaforge_role WHERE id = :id FOR UPDATE", parameters, Long.class).isEmpty()) { + return SaveResult.ROLE_NOT_FOUND; + } + if (!menuIds.isEmpty()) { + parameters.addValue("menuIds", menuIds); + List existing = jdbc.queryForList("SELECT id FROM metaforge_menu WHERE type = 'VIEW' AND id IN (:menuIds) ORDER BY id FOR UPDATE", parameters, Long.class); + if (existing.size() != menuIds.size()) { + return SaveResult.INVALID_MENUS; + } + } + jdbc.update("DELETE FROM metaforge_role_menu_relation WHERE role_id = :roleId", parameters); + if (!menuIds.isEmpty()) { + MapSqlParameterSource[] batch = menuIds.stream() + .map(id -> new MapSqlParameterSource("roleId", roleId.toString()).addValue("menuId", id.toString())) + .toArray(MapSqlParameterSource[]::new); + jdbc.batchUpdate("INSERT INTO metaforge_role_menu_relation (role_id, menu_id) VALUES (:roleId, :menuId)", batch); + } + return SaveResult.SAVED; + }); + } + + private List menus(Set assigned, boolean viewsOnly) + { + String sql = "SELECT id, name, url, sorted, code, i18n_key, icon, is_new, description, parent FROM metaforge_menu" + + (viewsOnly ? " WHERE type = 'VIEW'" : "") + " ORDER BY sorted, id"; + return jdbc.query(sql, Map.of(), (row, index) -> NavigationPaths.normalize(TreeRecord.builder() + .id(row.getLong("id")).title(row.getString("name")).url(row.getString("url")) + .sorted(row.getInt("sorted")).code(row.getString("code")).i18nKey(row.getString("i18n_key")) + .icon(row.getString("icon")).isNew(row.getBoolean("is_new")).description(row.getString("description")) + .parent(row.getLong("parent")).checked(assigned.contains(row.getLong("id"))).selected(assigned.contains(row.getLong("id"))) + .build())); + } + + private Set ids(List values) + { + Set ids = new HashSet<>(); + for (String value : values) { + if (value == null) { + continue; + } + try { + ids.add(Long.parseLong(value.trim())); + } + catch (NumberFormatException ignored) { + // Legacy text relationship columns may contain dangling non-numeric values. + } + } + return ids; + } + + private List tree(List menus) + { + Map byId = new LinkedHashMap<>(); + menus.forEach(menu -> byId.put(menu.getId(), menu)); + List roots = new ArrayList<>(); + for (TreeRecord menu : menus) { + TreeRecord parent = byId.get(menu.getParent()); + Set visited = new HashSet<>(); + TreeRecord ancestor = parent; + boolean cyclic = false; + while (ancestor != null) { + if (ancestor.getId().equals(menu.getId()) || !visited.add(ancestor.getId())) { + cyclic = true; + break; + } + ancestor = byId.get(ancestor.getParent()); + } + if (parent == null || cyclic) { + roots.add(menu); + } + else { + if (parent.getChildren() == null) { + parent.setChildren(new ArrayList<>()); + } + parent.getChildren().add(menu); + } + } + return roots; + } +} diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/BaseService.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/BaseService.java index ca04565ca5..a463abbc9f 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/BaseService.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/BaseService.java @@ -7,28 +7,19 @@ import org.devlive.metaforge.common.utils.NullAwareBeanUtils; import org.devlive.metaforge.common.utils.ReflectionUtils; import org.devlive.metaforge.service.SelfException; -import org.devlive.metaforge.service.adapter.PageRequestAdapter; import org.devlive.metaforge.service.body.FilterBody; import org.devlive.metaforge.service.entity.BaseEntity; import org.devlive.metaforge.service.entity.PageEntity; import org.devlive.metaforge.service.entity.UserEntity; import org.devlive.metaforge.service.repository.BaseRepository; import org.devlive.metaforge.service.security.UserDetailsService; -import org.springframework.data.domain.Pageable; public interface BaseService { default CommonResponse> getAll(BaseRepository repository, FilterBody filter) { try { - Pageable pageable = PageRequestAdapter.of(filter); - - try { - return CommonResponse.success(PageEntity.build(repository.findAllByUser(UserDetailsService.getUser(), pageable))); - } - catch (UnsupportedOperationException e) { - return CommonResponse.success(PageEntity.build(repository.findAll(pageable))); - } + return CommonResponse.success(PageEntity.build(repository.findFiltered(filter, UserDetailsService.getUser()))); } catch (Exception e) { return CommonResponse.failure("Failed to fetch data: " + e.getMessage()); diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/DataSetService.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/DataSetService.java index 9498ef36c4..de7080cdf1 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/DataSetService.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/DataSetService.java @@ -33,7 +33,7 @@ public interface DataSetService * 供同步对话框预填表单使用。 * Return the executor's tunable fields with defaultValue pre-populated from current effective config. */ - CommonResponse> getSyncFields(String code); + CommonResponse> getSyncFields(String code); CommonResponse clearData(String code); diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/MenuService.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/MenuService.java index aad054321b..a563fd261f 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/MenuService.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/MenuService.java @@ -1,6 +1,8 @@ package org.devlive.metaforge.service.service; +import org.devlive.metaforge.service.entity.MenuEntity; + public interface MenuService - extends BaseService + extends BaseService { } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/RoleService.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/RoleService.java index 20b737b424..1a8a587678 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/RoleService.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/RoleService.java @@ -5,9 +5,18 @@ import org.devlive.metaforge.service.record.TreeRecord; import java.util.List; +import java.util.Set; public interface RoleService extends BaseService { + CommonResponse checkCode(String code); + + CommonResponse createRole(RoleEntity configure); + + CommonResponse updateRole(String originalCode, RoleEntity configure); + CommonResponse> getMenusByRoleId(Long roleId); + + CommonResponse saveMenusByRoleId(Long roleId, Set menus); } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/RuntimeConfigureService.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/RuntimeConfigureService.java index 09be97e312..728feb822d 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/RuntimeConfigureService.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/RuntimeConfigureService.java @@ -1,7 +1,7 @@ package org.devlive.metaforge.service.service; import org.devlive.metaforge.common.response.CommonResponse; -import org.devlive.metaforge.plugin.configure.PluginConfigureField; +import org.devlive.metaforge.plugin.configure.RuntimeConfigureField; import org.devlive.metaforge.service.entity.ConfigureEntity; import java.util.List; @@ -13,7 +13,7 @@ * 这里管的是 Executor / Dataset Target 真正生效的运行参数。 * * Read / write runtime configuration backed by metaforge_configure. - * Field schema comes from Plugin.configures(); the service merges DB row over schema defaults + * Field schema comes from Plugin.runtimeConfigures(); the service merges DB row over schema defaults * so callers always get a complete value map. */ public interface RuntimeConfigureService @@ -26,7 +26,7 @@ public interface RuntimeConfigureService * - DB 行存在的字段使用 DB 值;缺失字段回落 schema.defaultValue * - 字符串形式返回;调用方根据 schema 类型按需 parse */ - Map getEffective(String category, String name, List fields); + Map getEffective(String category, String name, List fields); /** 不参考 schema 默认值,仅返回 DB 行(可能空 map),用于 UI 展示"用户改过哪些项" */ Map getRaw(String category, String name); @@ -41,5 +41,5 @@ public interface RuntimeConfigureService * 首次启动 seed:当 (category, name) 不存在时,按 schema 默认值生成一条;存在则不动。 * 用于把 plugin 默认值"落地"到 DB,让管理员之后可以从 UI 改。 */ - void seedIfAbsent(String category, String name, List fields, String description); + void seedIfAbsent(String category, String name, List fields, String description); } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/ScheduledService.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/ScheduledService.java index a7307cc436..2df15b8fdc 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/ScheduledService.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/ScheduledService.java @@ -6,8 +6,12 @@ import org.devlive.metaforge.service.entity.ScheduledEntity; import org.devlive.metaforge.service.entity.ScheduledHistoryEntity; +import java.util.List; + public interface ScheduledService extends BaseService { CommonResponse> getHistory(Long id, FilterBody filter); + + CommonResponse> getUpcomingTimes(Long id); } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/DataSetServiceImpl.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/DataSetServiceImpl.java index 128b7486ae..3d6b2c5c59 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/DataSetServiceImpl.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/DataSetServiceImpl.java @@ -25,6 +25,7 @@ import org.devlive.metaforge.plugin.Plugin; import org.devlive.metaforge.plugin.PluginManager; import org.devlive.metaforge.plugin.PluginMetadata; +import org.devlive.metaforge.plugin.configure.PluginConfigurePipelineType; import org.devlive.metaforge.scheduler.SchedulerRequest; import org.devlive.metaforge.scheduler.SchedulerService; import org.devlive.metaforge.service.adapter.PageRequestAdapter; @@ -34,7 +35,6 @@ import org.devlive.metaforge.service.body.adhoc.Adhoc; import org.devlive.metaforge.service.common.ConfigureUtils; import org.devlive.metaforge.service.common.FolderUtils; -import org.devlive.metaforge.service.configure.IConfigurePipelineType; import org.devlive.metaforge.service.entity.DataSetColumnEntity; import org.devlive.metaforge.service.entity.DataSetEntity; import org.devlive.metaforge.service.entity.DatasetHistoryEntity; @@ -64,8 +64,8 @@ import org.devlive.metaforge.spi.PluginService; import org.devlive.metaforge.spi.PluginType; import org.devlive.metaforge.spi.generator.definition.TableDefinition; -import org.devlive.metaforge.spi.model.Configure; import org.devlive.metaforge.spi.model.Response; +import org.devlive.metaforge.spi.model.RuntimeConfigure; import org.devlive.metaforge.sql.EngineType; import org.devlive.metaforge.sql.builder.ColumnBuilder; import org.devlive.metaforge.sql.builder.TableBuilder; @@ -483,9 +483,9 @@ private String getColumnType(ColumnType type) } } - private Configure getConfigure(String database, Plugin plugin) + private RuntimeConfigure getConfigure(String database, Plugin plugin) { - return Configure.builder() + return RuntimeConfigure.builder() .host(initializerConfigure.getDataSetConfigure().getHost()) .port(Integer.valueOf(initializerConfigure.getDataSetConfigure().getPort())) .username(Optional.ofNullable(initializerConfigure.getDataSetConfigure().getUsername())) @@ -608,7 +608,7 @@ private void createTable(DataSetEntity entity) String sql = TableBuilder.Companion.SQL(); log.info("Create table sql \n {} \n on dataset [ {} ]", sql, entity.getName()); - Configure targetConfigure = new Configure(); + RuntimeConfigure targetConfigure = new RuntimeConfigure(); targetConfigure.setHost(initializerConfigure.getDataSetConfigure().getHost()); targetConfigure.setPort(Integer.valueOf(initializerConfigure.getDataSetConfigure().getPort())); targetConfigure.setUsername(Optional.ofNullable(initializerConfigure.getDataSetConfigure().getUsername())); @@ -775,21 +775,21 @@ public CommonResponse syncData(String code, java.util.Map> getSyncFields(String code) + public CommonResponse> getSyncFields(String code) { return repository.findByCode(code) - .>>map(entity -> { + .>>map(entity -> { return pluginManager.getPlugin(entity.getExecutor()) .map(plugin -> { - java.util.List schema = plugin.configures(); + java.util.List schema = plugin.runtimeConfigures(); java.util.Map effective = runtimeConfigureService.getEffective( RuntimeConfigureService.CATEGORY_EXECUTOR, plugin.getName(), schema ); - java.util.List tunable = schema.stream() - .filter(org.devlive.metaforge.plugin.configure.PluginConfigureField::isTunable) - .map(f -> new org.devlive.metaforge.plugin.configure.PluginConfigureField( + java.util.List tunable = schema.stream() + .filter(org.devlive.metaforge.plugin.configure.RuntimeConfigureField::isTunable) + .map(f -> new org.devlive.metaforge.plugin.configure.RuntimeConfigureField( f.getName(), f.getType(), effective.getOrDefault(f.getName(), f.getDefaultValue()), @@ -799,10 +799,10 @@ public CommonResponse CommonResponse.>failure( + .orElseGet(() -> CommonResponse.>failure( String.format("Executor [ %s ] not found", entity.getExecutor()))); }) - .orElseGet(() -> CommonResponse.>failure( + .orElseGet(() -> CommonResponse.>failure( String.format("DataSet [ %s ] not found", code))); } @@ -900,7 +900,7 @@ private void clearData(DataSetEntity entity, java.util.concurrent.ExecutorServic * @param database the database name * @param configure the configuration settings */ - private void flushTableMetadata(DataSetEntity entity, PluginService pluginService, String database, Configure configure, Plugin plugin) + private void flushTableMetadata(DataSetEntity entity, PluginService pluginService, String database, RuntimeConfigure configure, Plugin plugin) { // Get the total number of rows and the total size of the dataset log.info("Get the total number of rows and the total size of the dataset [ {} ]", entity.getName()); @@ -1096,14 +1096,14 @@ private DataSetEntity syncData(DataSetEntity entity, java.util.concurrent.Execut PipelineFieldBody inputFieldBody = ConfigureUtils.convertFieldBody( source, entity.getExecutor(), - IConfigurePipelineType.INPUT, - environment, + PluginConfigurePipelineType.INPUT, + pluginManager, originInputProperties ); Properties inputProperties = ConfigureUtils.convertProperties( - source, environment, - IConfigurePipelineType.INPUT, + source, pluginManager, + PluginConfigurePipelineType.INPUT, entity.getExecutor(), entity.getQuery(), inputFieldBody @@ -1111,12 +1111,12 @@ private DataSetEntity syncData(DataSetEntity entity, java.util.concurrent.Execut Set inputOptions = ConfigureUtils.convertOptions( source, - environment, + pluginManager, entity.getExecutor(), - IConfigurePipelineType.INPUT + PluginConfigurePipelineType.INPUT ); - Configure inputConfigure = source.toConfigure(pluginManager, plugin); + RuntimeConfigure inputConfigure = source.toConfigure(pluginManager, plugin); inputConfigure.setPluginManager(pluginManager); ExecutorConfigure input = new ExecutorConfigure( source.getType(), @@ -1154,15 +1154,15 @@ private DataSetEntity syncData(DataSetEntity entity, java.util.concurrent.Execut PipelineFieldBody outputFieldBody = ConfigureUtils.convertFieldBody( outputSource, entity.getExecutor(), - IConfigurePipelineType.OUTPUT, - environment, + PluginConfigurePipelineType.OUTPUT, + pluginManager, originOutputProperties ); Properties outputProperties = ConfigureUtils.convertProperties( outputSource, - environment, - IConfigurePipelineType.OUTPUT, + pluginManager, + PluginConfigurePipelineType.OUTPUT, entity.getExecutor(), entity.getQuery(), outputFieldBody @@ -1170,9 +1170,9 @@ private DataSetEntity syncData(DataSetEntity entity, java.util.concurrent.Execut Set outputOptions = ConfigureUtils.convertOptions( outputSource, - environment, + pluginManager, entity.getExecutor(), - IConfigurePipelineType.OUTPUT + PluginConfigurePipelineType.OUTPUT ); ExecutorConfigure output = new ExecutorConfigure( @@ -1203,14 +1203,14 @@ private DataSetEntity syncData(DataSetEntity entity, java.util.concurrent.Execut java.util.Map executorCfg = runtimeConfigureService.getEffective( RuntimeConfigureService.CATEGORY_EXECUTOR, executor.getName(), - executor.configures() + executor.runtimeConfigures() ); // 本次同步如有用户临时覆盖:只接受 tunable=true 的字段,其余忽略 java.util.Map overrides = currentSyncOverrides.get(); if (overrides != null && !overrides.isEmpty()) { - java.util.Set tunableKeys = executor.configures().stream() - .filter(org.devlive.metaforge.plugin.configure.PluginConfigureField::isTunable) - .map(org.devlive.metaforge.plugin.configure.PluginConfigureField::getName) + java.util.Set tunableKeys = executor.runtimeConfigures().stream() + .filter(org.devlive.metaforge.plugin.configure.RuntimeConfigureField::isTunable) + .map(org.devlive.metaforge.plugin.configure.RuntimeConfigureField::getName) .collect(java.util.stream.Collectors.toSet()); overrides.forEach((k, v) -> { if (tunableKeys.contains(k) && v != null) { diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/MenuServiceImpl.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/MenuServiceImpl.java index 7fd030644e..a42b1df005 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/MenuServiceImpl.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/MenuServiceImpl.java @@ -1,5 +1,9 @@ package org.devlive.metaforge.service.service.impl; +import org.devlive.metaforge.common.response.CommonResponse; +import org.devlive.metaforge.service.common.NavigationPaths; +import org.devlive.metaforge.service.entity.MenuEntity; +import org.devlive.metaforge.service.repository.BaseRepository; import org.devlive.metaforge.service.repository.admin.MenuRepository; import org.devlive.metaforge.service.service.MenuService; import org.springframework.stereotype.Service; @@ -14,4 +18,11 @@ public MenuServiceImpl(MenuRepository menuRepository) { this.menuRepository = menuRepository; } + + @Override + public CommonResponse saveOrUpdate(BaseRepository repository, MenuEntity configure) + { + configure.setUrl(NavigationPaths.canonical(configure.getUrl())); + return MenuService.super.saveOrUpdate(repository, configure); + } } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/MetadataServiceImpl.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/MetadataServiceImpl.java index 4e1dfe4d5e..5277d96273 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/MetadataServiceImpl.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/MetadataServiceImpl.java @@ -8,8 +8,8 @@ import org.devlive.metaforge.service.service.MetadataService; import org.devlive.metaforge.spi.PluginService; import org.devlive.metaforge.spi.generator.definition.TableDefinition; -import org.devlive.metaforge.spi.model.Configure; import org.devlive.metaforge.spi.model.Response; +import org.devlive.metaforge.spi.model.RuntimeConfigure; import org.springframework.stereotype.Service; @Slf4j @@ -228,7 +228,7 @@ public CommonResponse exportData(String code, String database, String .map(entity -> pluginManager.getPlugin(entity.getType()) .map(plugin -> { PluginService service = plugin.getService(PluginService.class); - Configure conf = entity.toConfigure(pluginManager, plugin); + RuntimeConfigure conf = entity.toConfigure(pluginManager, plugin); conf.setFormat(configure.getFormat()); configure.setDatabase(database); configure.setName(table); diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/ReportServiceImpl.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/ReportServiceImpl.java index e70e21bb8f..a7fd9411f3 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/ReportServiceImpl.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/ReportServiceImpl.java @@ -1,7 +1,6 @@ package org.devlive.metaforge.service.service.impl; import org.devlive.metaforge.common.response.CommonResponse; -import org.devlive.metaforge.service.adapter.PageRequestAdapter; import org.devlive.metaforge.service.body.FilterBody; import org.devlive.metaforge.service.entity.PageEntity; import org.devlive.metaforge.service.entity.ReportEntity; @@ -11,7 +10,6 @@ import org.devlive.metaforge.service.repository.SourceRepository; import org.devlive.metaforge.service.security.UserDetailsService; import org.devlive.metaforge.service.service.ReportService; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @Service @@ -32,8 +30,7 @@ public ReportServiceImpl(ReportRepository repository, SourceRepository sourceRep @Override public CommonResponse> getAll(FilterBody filter) { - Pageable pageable = PageRequestAdapter.of(filter); - return CommonResponse.success(PageEntity.build(repository.findAllByUser(UserDetailsService.getUser(), pageable))); + return CommonResponse.success(PageEntity.build(repository.findFiltered(filter, UserDetailsService.getUser()))); } @Override diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/RoleServiceImpl.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/RoleServiceImpl.java index bdcae5d541..8015f76148 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/RoleServiceImpl.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/RoleServiceImpl.java @@ -1,95 +1,145 @@ package org.devlive.metaforge.service.service.impl; -import org.apache.commons.lang3.ObjectUtils; -import org.devlive.metaforge.common.enums.MenuEnum; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.devlive.metaforge.common.enums.ServiceState; import org.devlive.metaforge.common.response.CommonResponse; -import org.devlive.metaforge.service.entity.MenuEntity; +import org.devlive.metaforge.common.utils.CodeUtils; import org.devlive.metaforge.service.entity.RoleEntity; +import org.devlive.metaforge.service.initializer.RoleCodeConstraint; import org.devlive.metaforge.service.record.TreeRecord; import org.devlive.metaforge.service.repository.RoleRepository; -import org.devlive.metaforge.service.repository.admin.MenuRepository; +import org.devlive.metaforge.service.repository.jdbc.MenuAccessRepository; import org.devlive.metaforge.service.service.RoleService; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.Comparator; +import java.sql.SQLException; import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; -import java.util.stream.StreamSupport; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +@Slf4j @Service public class RoleServiceImpl implements RoleService { + private static final Pattern CODE_PATTERN = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"); + private final RoleRepository repository; - private final MenuRepository menuRepository; + private final MenuAccessRepository menuRepository; + private final RoleCodeConstraint codeConstraint; - public RoleServiceImpl(RoleRepository repository, MenuRepository menuRepository) + public RoleServiceImpl(RoleRepository repository, MenuAccessRepository menuRepository, RoleCodeConstraint codeConstraint) { this.repository = repository; this.menuRepository = menuRepository; + this.codeConstraint = codeConstraint; + } + + @Override + public CommonResponse checkCode(String code) + { + String normalized = StringUtils.trimToNull(code); + if (normalized == null) { + return CommonResponse.success(true); + } + if (!CODE_PATTERN.matcher(normalized).matches()) { + return CommonResponse.failure(ServiceState.USER_ROLE_CODE_INVALID); + } + return CommonResponse.success(!repository.existsByCode(normalized)); + } + + @Override + public CommonResponse createRole(RoleEntity configure) + { + if (configure.getId() != null) { + return CommonResponse.failure(ServiceState.USER_ROLE_ID_MISMATCH); + } + String code = StringUtils.trimToNull(configure.getCode()); + if (code == null) { + code = CodeUtils.generateCode(false); + } + if (!CODE_PATTERN.matcher(code).matches()) { + return CommonResponse.failure(ServiceState.USER_ROLE_CODE_INVALID); + } + if (repository.existsByCode(code)) { + return CommonResponse.failure(ServiceState.USER_ROLE_CODE_EXISTS); + } + try { + codeConstraint.ensure(); + RoleEntity entity = new RoleEntity(); + entity.setCode(code); + entity.setName(configure.getName()); + entity.setDescription(configure.getDescription()); + return CommonResponse.success(repository.saveAndFlush(entity)); + } + catch (DataIntegrityViolationException exception) { + if (repository.existsByCode(code)) { + return CommonResponse.failure(ServiceState.USER_ROLE_CODE_EXISTS); + } + log.error("Could not create role", exception); + return CommonResponse.failure("Could not create role"); + } + catch (SQLException exception) { + log.error("Could not enforce unique role codes", exception); + return CommonResponse.failure(ServiceState.USER_ROLE_CODE_CONSTRAINT_FAILED); + } + } + + @Override + public CommonResponse updateRole(String originalCode, RoleEntity configure) + { + return repository.findByCode(originalCode) + .map(entity -> { + if (configure.getId() != null && !Objects.equals(configure.getId(), entity.getId())) { + return CommonResponse.failure(ServiceState.USER_ROLE_ID_MISMATCH); + } + if (configure.getCode() != null && !Objects.equals(configure.getCode(), entity.getCode())) { + return CommonResponse.failure(ServiceState.USER_ROLE_CODE_IMMUTABLE); + } + if (configure.getName() != null) { + entity.setName(configure.getName()); + } + if (configure.getDescription() != null) { + entity.setDescription(configure.getDescription()); + } + return CommonResponse.success(repository.saveAndFlush(entity)); + }) + .orElseGet(() -> CommonResponse.failure(ServiceState.USER_ROLE_NOT_FOUND)); } @Override public CommonResponse> getMenusByRoleId(Long roleId) { - Optional optionalRole = repository.findById(roleId); - if (!optionalRole.isPresent()) { - return CommonResponse.failure(ServiceState.USER_ROLE_NOT_FOUND); + try { + return menuRepository.findRoleMenus(roleId).map(CommonResponse::success) + .orElseGet(() -> CommonResponse.failure(ServiceState.USER_ROLE_NOT_FOUND)); } - Map treeMap = new ConcurrentHashMap<>(); - // All currently available menus - List activedMenus = menuRepository.findAllByType(MenuEnum.VIEW); - // The current permission has a menu - RoleEntity role = optionalRole.get(); - Map roleMenus = new ConcurrentHashMap<>(); - // Populate own menu - role.getMenus().forEach(menu -> roleMenus.put(menu.getId(), menu)); - // Into the list - List menuList = StreamSupport.stream(activedMenus.spliterator(), false) - .sorted(Comparator.comparing(MenuEntity::getParent)) - .collect(Collectors.toList()); - // Sets the parent menu sort - menuList.forEach(menu -> { - TreeRecord parent = new TreeRecord(); - if (menu.getParent() == 0) { - parent.setId(menu.getId()); - parent.setTitle(menu.getName()); - parent.setUrl(menu.getUrl()); - if (!ObjectUtils.isEmpty(roleMenus.get(menu.getId()))) { - parent.setChecked(Boolean.TRUE); - parent.setSelected(Boolean.TRUE); - } - treeMap.put(menu.getId(), parent); + catch (DataAccessException exception) { + log.error("Could not load role menus", exception); + return CommonResponse.failure(ServiceState.USER_ROLE_MENU_READ_FAILED); + } + } + + @Override + public CommonResponse saveMenusByRoleId(Long roleId, Set menus) + { + try { + MenuAccessRepository.SaveResult result = menuRepository.replaceRoleMenus(roleId, menus); + if (result == MenuAccessRepository.SaveResult.ROLE_NOT_FOUND) { + return CommonResponse.failure(ServiceState.USER_ROLE_NOT_FOUND); } - else { - // Sub menu - TreeRecord temp = treeMap.get(menu.getParent()); - List childrens = temp.getChildren(); - // Automatically sets a collection of submenus if the current submenu does not belong to the main menu - if (ObjectUtils.isEmpty(childrens)) { - childrens = new ArrayList<>(); - } - TreeRecord children = new TreeRecord(); - children.setId(menu.getId()); - children.setTitle(menu.getName()); - children.setUrl(menu.getUrl()); - if (!ObjectUtils.isEmpty(roleMenus.get(menu.getId()))) { - children.setChecked(Boolean.TRUE); - children.setSelected(Boolean.TRUE); - } - childrens.add(children); - temp.setChildren(childrens); - treeMap.put(temp.getId(), temp); + if (result == MenuAccessRepository.SaveResult.INVALID_MENUS) { + return CommonResponse.failure(ServiceState.USER_ROLE_MENU_INVALID); } - }); - // Convert Map data to List - List tree = new ArrayList<>(); - treeMap.keySet().forEach(v -> tree.add(treeMap.get(v))); - return CommonResponse.success(tree); + return CommonResponse.success(true); + } + catch (DataAccessException exception) { + log.error("Could not save role menus", exception); + return CommonResponse.failure(ServiceState.USER_ROLE_MENU_SAVE_FAILED); + } } } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/RuntimeConfigureServiceImpl.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/RuntimeConfigureServiceImpl.java index a498e437eb..4f84ef386e 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/RuntimeConfigureServiceImpl.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/RuntimeConfigureServiceImpl.java @@ -5,7 +5,7 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import lombok.extern.slf4j.Slf4j; import org.devlive.metaforge.common.response.CommonResponse; -import org.devlive.metaforge.plugin.configure.PluginConfigureField; +import org.devlive.metaforge.plugin.configure.RuntimeConfigureField; import org.devlive.metaforge.service.entity.ConfigureEntity; import org.devlive.metaforge.service.repository.ConfigureRepository; import org.devlive.metaforge.service.service.RuntimeConfigureService; @@ -36,12 +36,12 @@ public RuntimeConfigureServiceImpl(ConfigureRepository repository) } @Override - public Map getEffective(String category, String name, List fields) + public Map getEffective(String category, String name, List fields) { Map result = new LinkedHashMap<>(); // 先 fill 默认值,保证调用方拿到的 map 字段齐全 if (fields != null) { - for (PluginConfigureField field : fields) { + for (RuntimeConfigureField field : fields) { if (field.getDefaultValue() != null) { result.put(field.getName(), field.getDefaultValue()); } @@ -89,14 +89,14 @@ public CommonResponse save(String category, String name, Map fields, String description) + public void seedIfAbsent(String category, String name, List fields, String description) { if (repository.existsByCategoryAndName(category, name)) { return; } Map defaults = new LinkedHashMap<>(); if (fields != null) { - for (PluginConfigureField field : fields) { + for (RuntimeConfigureField field : fields) { if (field.getDefaultValue() != null) { defaults.put(field.getName(), field.getDefaultValue()); } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/ScheduledServiceImpl.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/ScheduledServiceImpl.java index d3a48fc69d..68c920b981 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/ScheduledServiceImpl.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/ScheduledServiceImpl.java @@ -7,18 +7,29 @@ import org.devlive.metaforge.service.entity.ScheduledEntity; import org.devlive.metaforge.service.entity.ScheduledHistoryEntity; import org.devlive.metaforge.service.repository.ScheduledHistoryRepository; +import org.devlive.metaforge.service.repository.ScheduledRepository; import org.devlive.metaforge.service.service.ScheduledService; import org.springframework.data.domain.Pageable; +import org.springframework.scheduling.support.CronExpression; import org.springframework.stereotype.Service; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + @Service public class ScheduledServiceImpl implements ScheduledService { + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final ScheduledRepository scheduledRepository; private final ScheduledHistoryRepository historyRepository; - public ScheduledServiceImpl(ScheduledHistoryRepository historyRepository) + public ScheduledServiceImpl(ScheduledRepository scheduledRepository, ScheduledHistoryRepository historyRepository) { + this.scheduledRepository = scheduledRepository; this.historyRepository = historyRepository; } @@ -31,4 +42,31 @@ public CommonResponse> getHistory(Long id, Fi Pageable pageable = PageRequestAdapter.of(filter); return CommonResponse.success(PageEntity.build(historyRepository.findAllByScheduledOrderByIdDesc(entity, pageable))); } + + @Override + public CommonResponse> getUpcomingTimes(Long id) + { + return scheduledRepository.findById(id) + .map(task -> { + CommonResponse> response; + try { + CronExpression cron = CronExpression.parse(task.getExpression()); + ZonedDateTime next = ZonedDateTime.now(); + List times = new ArrayList<>(); + for (int i = 0; i < 7; i++) { + next = cron.next(next); + if (next == null) { + break; + } + times.add(next.format(FORMATTER)); + } + response = CommonResponse.success(times); + } + catch (IllegalArgumentException e) { + response = CommonResponse.failure(String.format("Invalid cron expression [ %s ]", task.getExpression())); + } + return response; + }) + .orElseGet(() -> CommonResponse.failure(String.format("Scheduled task [ %s ] not found", id))); + } } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/SourceServiceImpl.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/SourceServiceImpl.java index 32096cf09b..c16cefdd89 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/SourceServiceImpl.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/SourceServiceImpl.java @@ -10,16 +10,16 @@ import org.apache.commons.lang3.StringUtils; import org.devlive.metaforge.common.enums.ServiceState; import org.devlive.metaforge.common.response.CommonResponse; +import org.devlive.metaforge.plugin.Plugin; import org.devlive.metaforge.plugin.PluginManager; import org.devlive.metaforge.plugin.PluginType; +import org.devlive.metaforge.plugin.configure.PluginConfigure; +import org.devlive.metaforge.plugin.configure.PluginConfigureField; import org.devlive.metaforge.service.adapter.PageRequestAdapter; import org.devlive.metaforge.service.body.FilterBody; import org.devlive.metaforge.service.body.SharedSourceBody; import org.devlive.metaforge.service.body.SourceBody; import org.devlive.metaforge.service.common.ConfigureUtils; -import org.devlive.metaforge.service.common.PluginUtils; -import org.devlive.metaforge.service.configure.IConfigure; -import org.devlive.metaforge.service.configure.IConfigureField; import org.devlive.metaforge.service.entity.PageEntity; import org.devlive.metaforge.service.entity.PluginEntity; import org.devlive.metaforge.service.entity.ScheduledHistoryEntity; @@ -33,8 +33,8 @@ import org.devlive.metaforge.service.service.SourceService; import org.devlive.metaforge.service.wrapper.ResponseWrapper; import org.devlive.metaforge.spi.PluginService; -import org.devlive.metaforge.spi.model.Configure; import org.devlive.metaforge.spi.model.Response; +import org.devlive.metaforge.spi.model.RuntimeConfigure; import org.springframework.core.env.Environment; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -78,16 +78,15 @@ public SourceServiceImpl( public CommonResponse> getAll(BaseRepository repository, FilterBody filter) { UserEntity user = UserDetailsService.getUser(); - Page page = this.sourceRepository.findAllByUserOrPublishIsTrue(user, PageRequestAdapter.of(filter)); + Page page = this.sourceRepository.findFiltered(filter, user); // Populate pipeline configuration information page.getContent().forEach(item -> { - IConfigure fromConfigure = PluginUtils.loadYamlConfigure(item.getType(), item.getType(), item.getType(), environment); - if (fromConfigure != null) { - item.setPipelines(fromConfigure.getPipelines()); - } - pluginManager.getPlugin(item.getType()) .ifPresent(plugin -> { + PluginConfigure pluginConfigure = plugin.describe(); + if (pluginConfigure != null) { + item.setPipelines(pluginConfigure.getPipelines()); + } PluginService service = plugin.getService(PluginService.class); item.setIsSupportMeta(service.isSupportMeta()); }); @@ -120,13 +119,11 @@ public CommonResponse getByCode(BaseRepository { return repository.findByCode(code) .map(entity -> { - SourceBody configure = new SourceBody(); - configure.setId(entity.getId()); - configure.setName(entity.getType()); - configure.setType(entity.getProtocol()); - // Load default configure - IConfigure iConfigure = PluginUtils.loadYamlConfigure(configure.getType(), configure.getName(), configure.getName(), environment); - configure.setConfigure(ConfigureUtils.preparedConfigure(iConfigure, entity)); + // Load plugin configure declared inside the plugin code and fill saved values back + PluginConfigure iConfigure = pluginManager.getPlugin(entity.getType()) + .map(Plugin::describe) + .map(configure -> ConfigureUtils.preparedConfigure(configure, entity)) + .orElse(null); entity.setSchema(iConfigure); return CommonResponse.success(entity); }) @@ -145,7 +142,7 @@ public CommonResponse> getPlugins() entity.setName(plugin.getName()); entity.setDescription(plugin.getName()); entity.setType(plugin.getType().getName()); - entity.setConfigure(PluginUtils.loadYamlConfigure("JDBC", plugin.getName(), plugin.getName(), environment)); + entity.setConfigure((PluginConfigure) plugin.getConfigure()); plugins.add(entity); }); return CommonResponse.success(plugins); @@ -182,15 +179,15 @@ public CommonResponse testConnection(SourceBody configure) return pluginManager.getPlugin(configure.getType()) .>map(plugin -> { // Check configure - IConfigure iConfigure = PluginUtils.loadYamlConfigure(configure.getType(), configure.getType(), configure.getType(), environment); + PluginConfigure iConfigure = plugin.describe(); if (ObjectUtils.isEmpty(iConfigure) || iConfigure.getConfigures().size() != configure.getConfigure().getConfigures().size()) { return CommonResponse.failure(ServiceState.PLUGIN_CONFIGURE_MISMATCH); } // Filter required - List requiredMismatchConfigures = configure.getConfigure().getConfigures() + List requiredMismatchConfigures = configure.getConfigure().getConfigures() .stream() - .filter(IConfigureField::isRequired) + .filter(PluginConfigureField::isRequired) .filter(v -> ObjectUtils.isEmpty(v.getValue())) .collect(Collectors.toList()); if (!requiredMismatchConfigures.isEmpty()) { @@ -199,8 +196,8 @@ public CommonResponse testConnection(SourceBody configure) // The filter parameter value is null data PluginService pluginService = plugin.getService(PluginService.class); - List applyConfigures = ConfigureUtils.filterNotEmpty(configure.getConfigure().getConfigures()); - Configure _configure = ConfigureUtils.preparedConfigure(applyConfigures); + List applyConfigures = ConfigureUtils.filterNotEmpty(configure.getConfigure().getConfigures()); + RuntimeConfigure _configure = ConfigureUtils.preparedConfigure(applyConfigures); _configure.setPluginManager(pluginManager); _configure.setPlugin(plugin); @@ -229,16 +226,16 @@ public CommonResponse saveOrUpdate(BaseRepository>map(plugin -> { // Check configure - IConfigure iConfigure = PluginUtils.loadYamlConfigure(configure.getType(), configure.getType(), configure.getType(), environment); + PluginConfigure iConfigure = plugin.describe(); if (ObjectUtils.isEmpty(iConfigure) || iConfigure.getConfigures().size() != configure.getConfigure().getConfigures().size()) { return CommonResponse.failure(ServiceState.PLUGIN_CONFIGURE_MISMATCH); } // Filter required - List requiredMismatchConfigures = configure.getConfigure() + List requiredMismatchConfigures = configure.getConfigure() .getConfigures() .stream() - .filter(IConfigureField::isRequired) + .filter(PluginConfigureField::isRequired) .filter(v -> ObjectUtils.isEmpty(v.getValue())) .collect(Collectors.toList()); if (!requiredMismatchConfigures.isEmpty()) { @@ -246,7 +243,7 @@ public CommonResponse saveOrUpdate(BaseRepository applyConfigures = ConfigureUtils.filterNotEmpty(configure.getConfigure().getConfigures()); + List applyConfigures = ConfigureUtils.filterNotEmpty(configure.getConfigure().getConfigures()); SourceEntity source = ConfigureUtils.preparedSourceEntity(applyConfigures); source.setProtocol(configure.getType()); source.setType(configure.getName()); diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/UserLogServiceImpl.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/UserLogServiceImpl.java index 65a73c821b..e3941c5a3d 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/UserLogServiceImpl.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/UserLogServiceImpl.java @@ -1,6 +1,7 @@ package org.devlive.metaforge.service.service.impl; import org.devlive.metaforge.common.response.CommonResponse; +import org.devlive.metaforge.service.adapter.FilterSpecification; import org.devlive.metaforge.service.adapter.PageRequestAdapter; import org.devlive.metaforge.service.body.FilterBody; import org.devlive.metaforge.service.entity.PageEntity; @@ -9,7 +10,9 @@ import org.devlive.metaforge.service.repository.UserLogRepository; import org.devlive.metaforge.service.security.UserDetailsService; import org.devlive.metaforge.service.service.UserLogService; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; @Service @@ -27,7 +30,11 @@ public UserLogServiceImpl(UserLogRepository userLogRepository) public CommonResponse> getAllByFilter(FilterBody filter) { Pageable pageable = PageRequestAdapter.of(filter); + if (pageable.getSort().isUnsorted()) { + pageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(Sort.Direction.DESC, "createTime")); + } UserEntity user = UserDetailsService.getUser(); - return CommonResponse.success(PageEntity.build(this.userLogRepository.findAllByUserOrderByCreateTimeDesc(user, pageable))); + return CommonResponse.success(PageEntity.build(this.userLogRepository.findAll( + FilterSpecification.of(filter).and((root, query, builder) -> builder.equal(root.get("user"), user)), pageable))); } } diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/UserServiceImpl.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/UserServiceImpl.java index 99fb542292..bbed31130c 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/UserServiceImpl.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/UserServiceImpl.java @@ -1,6 +1,5 @@ package org.devlive.metaforge.service.service.impl; -import com.google.common.collect.Lists; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.ObjectUtils; @@ -16,13 +15,11 @@ import org.devlive.metaforge.fs.FsResponse; import org.devlive.metaforge.fs.FsService; import org.devlive.metaforge.plugin.PluginManager; -import org.devlive.metaforge.service.adapter.PageRequestAdapter; import org.devlive.metaforge.service.audit.AuditUserLog; import org.devlive.metaforge.service.body.FilterBody; import org.devlive.metaforge.service.body.UploadBody; import org.devlive.metaforge.service.body.UserNameBody; import org.devlive.metaforge.service.body.UserPasswordBody; -import org.devlive.metaforge.service.entity.MenuEntity; import org.devlive.metaforge.service.entity.PageEntity; import org.devlive.metaforge.service.entity.RoleEntity; import org.devlive.metaforge.service.entity.SourceEntity; @@ -36,12 +33,12 @@ import org.devlive.metaforge.service.repository.RoleRepository; import org.devlive.metaforge.service.repository.SourceRepository; import org.devlive.metaforge.service.repository.UserRepository; -import org.devlive.metaforge.service.repository.admin.MenuRepository; +import org.devlive.metaforge.service.repository.jdbc.MenuAccessRepository; import org.devlive.metaforge.service.security.UserDetailsService; import org.devlive.metaforge.service.service.JwtService; import org.devlive.metaforge.service.service.UserService; import org.springframework.core.env.Environment; -import org.springframework.data.domain.Pageable; +import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -61,16 +58,11 @@ import javax.servlet.http.HttpServletRequest; import java.io.IOException; -import java.util.ArrayList; -import java.util.Comparator; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; -import java.util.stream.StreamSupport; @SuppressFBWarnings(value = {"NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE", "EI_EXPOSE_REP2"}) @Service @@ -81,7 +73,7 @@ public class UserServiceImpl private final UserRepository userRepository; private final RoleRepository roleRepository; private final SourceRepository sourceRepository; - private final MenuRepository menuRepository; + private final MenuAccessRepository menuRepository; private final PasswordEncoder encoder; private final AuthenticationManager authenticationManager; private final JwtService jwtService; @@ -91,7 +83,7 @@ public class UserServiceImpl private final PluginManager pluginManager; private final HttpServletRequest request; - public UserServiceImpl(UserRepository userRepository, RoleRepository roleRepository, SourceRepository sourceRepository, MenuRepository menuRepository, PasswordEncoder encoder, AuthenticationManager authenticationManager, JwtService jwtService, RedisTemplate redisTemplate, Environment environment, InitializerConfigure initializer, PluginManager pluginManager, HttpServletRequest request) + public UserServiceImpl(UserRepository userRepository, RoleRepository roleRepository, SourceRepository sourceRepository, MenuAccessRepository menuRepository, PasswordEncoder encoder, AuthenticationManager authenticationManager, JwtService jwtService, RedisTemplate redisTemplate, Environment environment, InitializerConfigure initializer, PluginManager pluginManager, HttpServletRequest request) { this.userRepository = userRepository; this.roleRepository = roleRepository; @@ -286,61 +278,20 @@ public CommonResponse> getSugs(Long id) @Override public CommonResponse> getMenus() { - Map treeMap = new ConcurrentHashMap<>(); - Optional optionalUser = userRepository.findByCode(UserDetailsService.getUser().getCode()); - UserEntity user = optionalUser.get(); - List tree = new ArrayList<>(); - user.getRoles().forEach(role -> { - List menuList = StreamSupport.stream(role.getMenus().spliterator(), false) - .sorted(Comparator.comparing(MenuEntity::getParent)) - .collect(Collectors.toList()); - // Sets the parent menu sort - menuList.forEach(menu -> { - if (menu.getParent() == 0) { - TreeRecord parent = TreeRecord.of(menu, true, true, Lists.newArrayList()); - parent.setNew(menu.isNew()); - treeMap.put(menu.getId(), parent); - } - else { - TreeRecord temp = treeMap.get(menu.getParent()); - if (temp != null) { - List childrens = temp.getChildren(); - if (ObjectUtils.isEmpty(childrens)) { - childrens = Lists.newArrayList(); - } - TreeRecord children = TreeRecord.of(menu, true, true, Lists.newArrayList()); - children.setNew(menu.isNew()); - children.setDescription(menu.getDescription()); - childrens.add(children); - childrens.sort(Comparator.comparing(TreeRecord::getSorted)); - temp.setChildren(childrens); - treeMap.put(temp.getId(), temp); - } - else { - // Handle cases where no parent menu is selected, only child menus are selected - menuRepository.findById(menu.getParent()) - .ifPresent(v -> { - TreeRecord parent = TreeRecord.of(v, true, true, Lists.newArrayList()); - parent.setNew(v.isNew()); - List children = Lists.newArrayList(); - children.add(TreeRecord.of(menu, true, true, Lists.newArrayList())); - parent.setChildren(children); - treeMap.put(v.getId(), parent); - }); - } - } - }); - treeMap.keySet().forEach(v -> tree.add(treeMap.get(v))); - }); - tree.sort(Comparator.comparing(TreeRecord::getSorted)); - return CommonResponse.success(tree); + try { + return menuRepository.findUserMenus(UserDetailsService.getUser().getCode()).map(CommonResponse::success) + .orElseGet(() -> CommonResponse.failure(ServiceState.USER_NOT_FOUND)); + } + catch (DataAccessException exception) { + log.error("Could not load user menus", exception); + return CommonResponse.failure(ServiceState.USER_ROLE_MENU_READ_FAILED); + } } @Override public CommonResponse> getAll(FilterBody filter) { - Pageable pageable = PageRequestAdapter.of(filter); - return CommonResponse.success(PageEntity.build(this.userRepository.findAll(pageable))); + return CommonResponse.success(PageEntity.build(this.userRepository.findFiltered(filter, UserDetailsService.getUser()))); } @Override diff --git a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/WorkflowServiceImpl.java b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/WorkflowServiceImpl.java index ce70963d24..4333afe2de 100644 --- a/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/WorkflowServiceImpl.java +++ b/core/metaforge-service/src/main/java/org/devlive/metaforge/service/service/impl/WorkflowServiceImpl.java @@ -150,7 +150,7 @@ public CommonResponse saveOrUpdate(BaseRepository executorCfg = runtimeConfigureService.getEffective( RuntimeConfigureService.CATEGORY_EXECUTOR, plugin.getName(), - plugin.configures() + plugin.runtimeConfigures() ); log.debug("Executor home directory: {}", executorCfg.get("home")); diff --git a/core/metaforge-service/src/test/java/org/devlive/metaforge/service/common/ConfigureUtilsTest.java b/core/metaforge-service/src/test/java/org/devlive/metaforge/service/common/ConfigureUtilsTest.java index 12bac88c9c..14924f7f64 100644 --- a/core/metaforge-service/src/test/java/org/devlive/metaforge/service/common/ConfigureUtilsTest.java +++ b/core/metaforge-service/src/test/java/org/devlive/metaforge/service/common/ConfigureUtilsTest.java @@ -1,8 +1,8 @@ package org.devlive.metaforge.service.common; -import org.devlive.metaforge.service.configure.IConfigure; -import org.devlive.metaforge.service.configure.IConfigureExecutor; -import org.devlive.metaforge.service.configure.IConfigurePipelineType; +import org.devlive.metaforge.plugin.configure.PluginConfigure; +import org.devlive.metaforge.plugin.configure.PluginConfigureExecutor; +import org.devlive.metaforge.plugin.configure.PluginConfigurePipelineType; import org.junit.Assert; import org.junit.Test; @@ -15,50 +15,50 @@ public class ConfigureUtilsTest public void testFindPipelineExecutorSkipsIncompleteEntries() { // 复现 issue #953:Doris 的 YAML 中存在缺少 executor/type 的 pipeline 条目 - IConfigure configure = new IConfigure(); - List pipelines = new ArrayList<>(); - pipelines.add(new IConfigureExecutor()); + PluginConfigure configure = new PluginConfigure(); + List pipelines = new ArrayList<>(); + pipelines.add(new PluginConfigureExecutor()); configure.setPipelines(pipelines); - Assert.assertNull(ConfigureUtils.findPipelineExecutor(configure, "TRUNCATE", IConfigurePipelineType.INPUT)); + Assert.assertNull(ConfigureUtils.findPipelineExecutor(configure, "TRUNCATE", PluginConfigurePipelineType.INPUT)); } @Test public void testFindPipelineExecutorMatches() { - IConfigure configure = new IConfigure(); - IConfigureExecutor matched = new IConfigureExecutor(); + PluginConfigure configure = new PluginConfigure(); + PluginConfigureExecutor matched = new PluginConfigureExecutor(); matched.setExecutor("TRUNCATE"); - matched.setType(IConfigurePipelineType.INPUT); - List pipelines = new ArrayList<>(); - pipelines.add(new IConfigureExecutor()); + matched.setType(PluginConfigurePipelineType.INPUT); + List pipelines = new ArrayList<>(); + pipelines.add(new PluginConfigureExecutor()); pipelines.add(matched); configure.setPipelines(pipelines); - Assert.assertSame(matched, ConfigureUtils.findPipelineExecutor(configure, "TRUNCATE", IConfigurePipelineType.INPUT)); + Assert.assertSame(matched, ConfigureUtils.findPipelineExecutor(configure, "TRUNCATE", PluginConfigurePipelineType.INPUT)); } @Test public void testFindPipelineExecutorWithNullPipelines() { - IConfigure configure = new IConfigure(); + PluginConfigure configure = new PluginConfigure(); - Assert.assertNull(ConfigureUtils.findPipelineExecutor(configure, "TRUNCATE", IConfigurePipelineType.INPUT)); + Assert.assertNull(ConfigureUtils.findPipelineExecutor(configure, "TRUNCATE", PluginConfigurePipelineType.INPUT)); } @Test public void testFindPipelineExecutorWithNullArguments() { - IConfigure configure = new IConfigure(); - IConfigureExecutor matched = new IConfigureExecutor(); + PluginConfigure configure = new PluginConfigure(); + PluginConfigureExecutor matched = new PluginConfigureExecutor(); matched.setExecutor("TRUNCATE"); - matched.setType(IConfigurePipelineType.INPUT); - List pipelines = new ArrayList<>(); + matched.setType(PluginConfigurePipelineType.INPUT); + List pipelines = new ArrayList<>(); pipelines.add(matched); configure.setPipelines(pipelines); // 请求参数为 null 时也不能抛 NPE Assert.assertNull(ConfigureUtils.findPipelineExecutor(configure, null, null)); - Assert.assertSame(matched, ConfigureUtils.findPipelineExecutor(configure, "TRUNCATE", IConfigurePipelineType.INPUT)); + Assert.assertSame(matched, ConfigureUtils.findPipelineExecutor(configure, "TRUNCATE", PluginConfigurePipelineType.INPUT)); } } diff --git a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/PluginService.java b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/PluginService.java index 1808ac85f0..12c639c4a3 100644 --- a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/PluginService.java +++ b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/PluginService.java @@ -32,9 +32,9 @@ import org.devlive.metaforge.spi.generator.table.SelectTable; import org.devlive.metaforge.spi.generator.table.TruncateTable; import org.devlive.metaforge.spi.generator.table.UpdateTable; -import org.devlive.metaforge.spi.model.Configure; import org.devlive.metaforge.spi.model.Pagination; import org.devlive.metaforge.spi.model.Response; +import org.devlive.metaforge.spi.model.RuntimeConfigure; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -93,7 +93,7 @@ default Boolean isSupportMeta() return false; } - default void connect(Configure configure) + default void connect(RuntimeConfigure configure) { Response response = new Response(); try { @@ -116,7 +116,7 @@ default void connect(Configure configure) * @param configure 配置信息 | Configuration information * @return 驱动路径 | Driver path */ - default String url(Configure configure) + default String url(RuntimeConfigure configure) { if (configure.getUrl().isPresent()) { return configure.getUrl().get(); @@ -184,7 +184,7 @@ else if (type().equals(PluginType.NATIVE)) { return response; } - default Response execute(Configure configure, String content) + default Response execute(RuntimeConfigure configure, String content) { this.connect(configure); return this.execute(content); @@ -205,7 +205,7 @@ default boolean supportsStreaming() * Stream rows out of the source one at a time. The implementation must NOT materialize * the full result set in heap. */ - default void executeStream(Configure configure, String content, int fetchSize, RowCallback callback) + default void executeStream(RuntimeConfigure configure, String content, int fetchSize, RowCallback callback) { if (type().equals(PluginType.JDBC)) { JdbcStreamAdapter.executeStream(this, configure, content, fetchSize, callback); @@ -221,7 +221,7 @@ default void executeStream(Configure configure, String content, int fetchSize, R * and on close(). Must be closed by the caller. */ default BatchWriter openBatchWriter( - Configure configure, + RuntimeConfigure configure, String database, String table, java.util.List columns, @@ -275,7 +275,7 @@ default Response getDataTypes() * @param configure 配置信息 | Configuration information * @return 数据库列表 | Database list */ - default Response getDatabases(Configure configure) + default Response getDatabases(RuntimeConfigure configure) { String sql = "SELECT\n" + " SCHEMA_NAME AS object_name,\n" + @@ -297,7 +297,7 @@ default Response getDatabases(Configure configure) * @param configure 配置信息 | Configuration information * @return 数据库列表 | Database list */ - default Response metadataDatabases(Configure configure) + default Response metadataDatabases(RuntimeConfigure configure) { Response response = new Response(); response.setHeaders(Lists.newArrayList("object_name", "object_type", "object_comment")); @@ -341,7 +341,7 @@ default Response metadataDatabases(Configure configure) * @param configure 配置信息 | Configuration information * @return 原生 JDBC 连接 | Native JDBC connection */ - private java.sql.Connection openJdbcConnection(Configure configure) + private java.sql.Connection openJdbcConnection(RuntimeConfigure configure) { this.connect(configure); Connection connection = local.get(); @@ -359,7 +359,7 @@ private java.sql.Connection openJdbcConnection(Configure configure) * @param database 数据库 | Database * @return 数据表结构 | Table structure */ - default Response getTables(Configure configure, String database) + default Response getTables(RuntimeConfigure configure, String database) { String sql = "SELECT\n" + " CASE\n" + @@ -433,7 +433,7 @@ default Response getTables(Configure configure, String database) * @param database 数据库 | Database * @return 数据表列表 | Table list */ - default Response metadataTables(Configure configure, String database) + default Response metadataTables(RuntimeConfigure configure, String database) { Response response = new Response(); response.setHeaders(Lists.newArrayList("type_name", "object_name", "object_comment")); @@ -473,7 +473,7 @@ default Response metadataTables(Configure configure, String database) * @param table 数据表 | Table * @return 数据列结构 | Column structure */ - default Response getColumns(Configure configure, String database, String table) + default Response getColumns(RuntimeConfigure configure, String database, String table) { String sql = "SELECT detail.*\n" + "FROM (\n" + @@ -593,7 +593,7 @@ default Response getColumns(Configure configure, String database, String table) * @param table 数据表 | Table * @return 数据列结构 | Column structure */ - default Response metadataColumns(Configure configure, String database, String table) + default Response metadataColumns(RuntimeConfigure configure, String database, String table) { Response response = new Response(); response.setHeaders(Lists.newArrayList( @@ -655,7 +655,7 @@ default Response metadataColumns(Configure configure, String database, String ta return response; } - default Response getPrimaryKeys(Configure configure, String database, String table) + default Response getPrimaryKeys(RuntimeConfigure configure, String database, String table) { String sql = "SELECT \n" + " COLUMN_NAME as object_name,\n" + @@ -686,7 +686,7 @@ default Response getPrimaryKeys(Configure configure, String database, String tab * @param database 数据库 | Database * @return 数据库信息 | Database information */ - default Response getDatabase(Configure configure, String database) + default Response getDatabase(RuntimeConfigure configure, String database) { String sql = "SELECT \n" + " s.SCHEMA_NAME as object_name,\n" + @@ -749,7 +749,7 @@ default Response getDatabase(Configure configure, String database) * @param table 表 | Table * @return 表信息 | Table information */ - default Response getTable(Configure configure, String database, String table) + default Response getTable(RuntimeConfigure configure, String database, String table) { String sql = "WITH object_info AS (\n" + " -- 获取表和视图类型\n" + @@ -854,7 +854,7 @@ default Response getTable(Configure configure, String database, String table) ); } - default Response getTableStatement(Configure configure, TableDefinition definition) + default Response getTableStatement(RuntimeConfigure configure, TableDefinition definition) { String sql = "WITH index_list AS (\n" + " SELECT\n" + @@ -969,7 +969,7 @@ default Response getTableStatement(Configure configure, TableDefinition definiti ); } - default Response getSuggests(Configure configure, String keyword) + default Response getSuggests(RuntimeConfigure configure, String keyword) { String sql = "(\n" + " -- 数据库\n" + @@ -1077,7 +1077,7 @@ default Response getSuggests(Configure configure, String keyword) * @param definition 表配置定义 | Table configuration definition * @return 执行结果 | Execution result */ - default Response changeAutoIncrement(Configure configure, TableDefinition definition) + default Response changeAutoIncrement(RuntimeConfigure configure, TableDefinition definition) { String sql = AlterTable.create(definition.getDatabase(), definition.getName()) .autoIncrement(definition.getAutoIncrement()) @@ -1094,7 +1094,7 @@ default Response changeAutoIncrement(Configure configure, TableDefinition defini * @param definition 表配置定义 | Table configuration definition * @return 执行结果 | Execution result */ - default Response createTable(Configure configure, TableDefinition definition) + default Response createTable(RuntimeConfigure configure, TableDefinition definition) { AbstractTable tableDefinition = CreateTable.create(definition.getDatabase(), definition.getName()) .comment(definition.getComment()) @@ -1125,7 +1125,7 @@ default Response createTable(Configure configure, TableDefinition definition) * @param definition 表配置定义 | Table configuration definition * @return 执行结果 | Execution result */ - default Response dropTable(Configure configure, TableDefinition definition) + default Response dropTable(RuntimeConfigure configure, TableDefinition definition) { DropTable tableDefinition = DropTable.create(definition.getDatabase(), definition.getName()) .ifExists(); @@ -1141,7 +1141,7 @@ default Response dropTable(Configure configure, TableDefinition definition) * @param definition 表配置定义 | Table configuration definition * @return 执行结果 | Execution result */ - default Response truncateTable(Configure configure, TableDefinition definition) + default Response truncateTable(RuntimeConfigure configure, TableDefinition definition) { TruncateTable tableDefinition = TruncateTable.create(definition.getDatabase(), definition.getName()); @@ -1156,7 +1156,7 @@ default Response truncateTable(Configure configure, TableDefinition definition) * @param definition 表配置定义 | Table configuration definition * @return 执行结果 | Execution result */ - default Response queryTable(Configure configure, TableDefinition definition) + default Response queryTable(RuntimeConfigure configure, TableDefinition definition) { SelectTable tableDefinition = SelectTable.create(definition.getDatabase(), definition.getName()) .limit(definition.getPagination().getSize()) @@ -1187,7 +1187,7 @@ default Response queryTable(Configure configure, TableDefinition definition) * @param definition 表配置定义 | Table configuration definition * @return 执行结果 | Execution result */ - default Response deleteData(Configure configure, TableDefinition definition) + default Response deleteData(RuntimeConfigure configure, TableDefinition definition) { Set set = Sets.newHashSet(); @@ -1212,7 +1212,7 @@ default Response deleteData(Configure configure, TableDefinition definition) * @param definition 表配置定义 | Table configuration definition * @return 执行结果 | Execution result */ - default Response createColumn(Configure configure, TableDefinition definition) + default Response createColumn(RuntimeConfigure configure, TableDefinition definition) { AlterTable tableDefinition = AlterTable.create(definition.getDatabase(), definition.getName()); @@ -1233,7 +1233,7 @@ default Response createColumn(Configure configure, TableDefinition definition) * @param definition 表配置定义 | Table configuration definition * @return 执行结果 | Execution result */ - default Response dropColumn(Configure configure, TableDefinition definition) + default Response dropColumn(RuntimeConfigure configure, TableDefinition definition) { AlterTable tableDefinition = AlterTable.create(definition.getDatabase(), definition.getName()); @@ -1254,7 +1254,7 @@ default Response dropColumn(Configure configure, TableDefinition definition) * @param definition 表配置定义 | Table configuration definition * @return 执行结果 | Execution result */ - default Response getColumn(Configure configure, TableDefinition definition) + default Response getColumn(RuntimeConfigure configure, TableDefinition definition) { Optional column = definition.getColumns().stream().findAny(); @@ -1387,7 +1387,7 @@ default Response getColumn(Configure configure, TableDefinition definition) * @param definition 表配置定义 | Table configuration definition * @return 执行结果 | Execution result */ - default Response changeColumn(Configure configure, TableDefinition definition) + default Response changeColumn(RuntimeConfigure configure, TableDefinition definition) { AlterTable tableDefinition = AlterTable.create(definition.getDatabase(), definition.getName()); @@ -1408,7 +1408,7 @@ default Response changeColumn(Configure configure, TableDefinition definition) * @param definition 表配置定义 | Table configuration definition * @return 执行结果 | Execution result */ - default Response insertData(Configure configure, TableDefinition definition) + default Response insertData(RuntimeConfigure configure, TableDefinition definition) { InsertTable tableDefinition = InsertTable.create(definition.getDatabase(), definition.getName()) .addValues(definition.getRows()) @@ -1429,7 +1429,7 @@ default Response insertData(Configure configure, TableDefinition definition) * @param definition 表配置定义 | Table configuration definition * @return 执行结果 | Execution result */ - default Response updateData(Configure configure, TableDefinition definition) + default Response updateData(RuntimeConfigure configure, TableDefinition definition) { UpdateTable tableDefinition = UpdateTable.create(definition.getDatabase(), definition.getName()) .addPrimaryKeys(this.formatPrimaryKeys(configure, definition.getDatabase(), definition.getName())); @@ -1446,7 +1446,7 @@ default Response updateData(Configure configure, TableDefinition definition) ); } - default Response getResponse(String sql, Configure configure, BaseDefinition definition) + default Response getResponse(String sql, RuntimeConfigure configure, BaseDefinition definition) { Response response; if (definition.isPreview()) { @@ -1466,7 +1466,7 @@ default Response getResponse(String sql, Configure configure, BaseDefinition def return response; } - private Response getResponse(String sql, Configure configure, TableDefinition definition, Pagination pagination) + private Response getResponse(String sql, RuntimeConfigure configure, TableDefinition definition, Pagination pagination) { Response response = this.getResponse(sql, configure, definition); response.setPagination(pagination); @@ -1500,7 +1500,7 @@ default CreateColumn getCreateColumn(ColumnDefinition col) * @param definition 表配置定义 | Table configuration definition * @return 分页 | Pagination */ - default Pagination getPagination(Configure configure, TableDefinition definition) + default Pagination getPagination(RuntimeConfigure configure, TableDefinition definition) { try { /* @@ -1531,7 +1531,7 @@ default Pagination getPagination(Configure configure, TableDefinition definition } } - default Pagination formatPagination(Configure configure, String sql, TableDefinition definition) + default Pagination formatPagination(RuntimeConfigure configure, String sql, TableDefinition definition) { Response response = this.getResponse( sql.replace("{0}", definition.getDatabase()) @@ -1553,7 +1553,7 @@ default Pagination formatPagination(Configure configure, String sql, TableDefini .endIndex(node.get("object_end_index").asInt()); } - default List formatPrimaryKeys(Configure configure, String database, String table) + default List formatPrimaryKeys(RuntimeConfigure configure, String database, String table) { List columns = Lists.newArrayList(); diff --git a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/adapter/JdbcAdapter.java b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/adapter/JdbcAdapter.java index b6d2e19cea..581aed9e47 100644 --- a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/adapter/JdbcAdapter.java +++ b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/adapter/JdbcAdapter.java @@ -5,8 +5,8 @@ import org.devlive.metaforge.spi.column.Column; import org.devlive.metaforge.spi.column.JdbcColumn; import org.devlive.metaforge.spi.connection.JdbcConnection; -import org.devlive.metaforge.spi.model.Configure; import org.devlive.metaforge.spi.model.Response; +import org.devlive.metaforge.spi.model.RuntimeConfigure; import org.devlive.metaforge.spi.model.Time; import java.sql.Connection; @@ -39,7 +39,7 @@ public Response handlerExecute(String content) processorTime.setStart(new Date().getTime()); Response response = jdbcConnection.getResponse(); Connection connection = (Connection) jdbcConnection.getConnection(); - Configure configure = jdbcConnection.getConfigure(); + RuntimeConfigure configure = jdbcConnection.getConfigure(); log.debug("Execute sql \n{}", content); if (response.getIsConnected()) { try (Statement statement = connection.createStatement()) { diff --git a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/adapter/JdbcStreamAdapter.java b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/adapter/JdbcStreamAdapter.java index 045d87df60..cf30a3eac6 100644 --- a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/adapter/JdbcStreamAdapter.java +++ b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/adapter/JdbcStreamAdapter.java @@ -4,8 +4,8 @@ import lombok.extern.slf4j.Slf4j; import org.devlive.metaforge.spi.PluginService; import org.devlive.metaforge.spi.connection.JdbcConnection; -import org.devlive.metaforge.spi.model.Configure; import org.devlive.metaforge.spi.model.Response; +import org.devlive.metaforge.spi.model.RuntimeConfigure; import java.sql.Connection; import java.sql.PreparedStatement; @@ -28,7 +28,7 @@ private JdbcStreamAdapter() {} public static void executeStream( PluginService plugin, - Configure configure, + RuntimeConfigure configure, String sql, int fetchSize, RowCallback callback) @@ -101,7 +101,7 @@ public static void executeStream( public static BatchWriter openBatchWriter( PluginService plugin, - Configure configure, + RuntimeConfigure configure, String database, String table, List columns, @@ -121,7 +121,7 @@ public static BatchWriter openBatchWriter( return new JdbcBatchWriter(jdbcConnection, connection, database, table, columns, effectiveBatchSize); } - private static JdbcConnection openConnection(PluginService plugin, Configure configure) + private static JdbcConnection openConnection(PluginService plugin, RuntimeConfigure configure) { Response response = new Response(); configure.setDriver(plugin.driver()); @@ -130,7 +130,7 @@ private static JdbcConnection openConnection(PluginService plugin, Configure con return new JdbcConnection(configure, response); } - private static void applyFetchSize(Statement statement, int fetchSize, Configure configure) + private static void applyFetchSize(Statement statement, int fetchSize, RuntimeConfigure configure) { try { String type = configure.getType(); diff --git a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/connection/Connection.java b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/connection/Connection.java index 1e9d78ed61..09c40a51ee 100644 --- a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/connection/Connection.java +++ b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/connection/Connection.java @@ -1,8 +1,8 @@ package org.devlive.metaforge.spi.connection; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import org.devlive.metaforge.spi.model.Configure; import org.devlive.metaforge.spi.model.Response; +import org.devlive.metaforge.spi.model.RuntimeConfigure; import org.devlive.metaforge.spi.model.Time; import java.util.Date; @@ -10,11 +10,11 @@ @SuppressFBWarnings(value = {"EI_EXPOSE_REP", "EI_EXPOSE_REP2"}) public abstract class Connection { - protected final Configure configure; + protected final RuntimeConfigure configure; protected final Response response; protected Object connection; - public Connection(Configure configure, Response response) + public Connection(RuntimeConfigure configure, Response response) { this.configure = configure; this.response = response; @@ -37,7 +37,7 @@ public Response getResponse() return this.response; } - public Configure getConfigure() + public RuntimeConfigure getConfigure() { return this.configure; } diff --git a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/connection/HttpConfigure.java b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/connection/HttpConfigure.java index 2b755159f9..261fd95d0b 100644 --- a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/connection/HttpConfigure.java +++ b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/connection/HttpConfigure.java @@ -6,7 +6,7 @@ import lombok.ToString; import okhttp3.MediaType; import org.devlive.metaforge.spi.connection.http.HttpMethod; -import org.devlive.metaforge.spi.model.Configure; +import org.devlive.metaforge.spi.model.RuntimeConfigure; import java.util.Map; @@ -15,7 +15,7 @@ @EqualsAndHashCode(callSuper = false) @SuppressFBWarnings(value = {"EQ_OVERRIDING_EQUALS_NOT_SYMMETRIC", "EI_EXPOSE_REP", "EI_EXPOSE_REP2"}) public class HttpConfigure - extends Configure + extends RuntimeConfigure { private String protocol = "http"; private String path; diff --git a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/connection/JdbcConnection.java b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/connection/JdbcConnection.java index 313b822eef..c0bae39b56 100644 --- a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/connection/JdbcConnection.java +++ b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/connection/JdbcConnection.java @@ -4,8 +4,8 @@ import org.apache.commons.lang3.ObjectUtils; import org.devlive.metaforge.plugin.PluginContextManager; import org.devlive.metaforge.plugin.loader.PluginClassLoader; -import org.devlive.metaforge.spi.model.Configure; import org.devlive.metaforge.spi.model.Response; +import org.devlive.metaforge.spi.model.RuntimeConfigure; import java.sql.Connection; import java.sql.Driver; @@ -21,7 +21,7 @@ public class JdbcConnection { private java.sql.Connection jdbcConnection; - public JdbcConnection(Configure configure, Response response) + public JdbcConnection(RuntimeConfigure configure, Response response) { super(configure, response); } diff --git a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/model/Configure.java b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/model/RuntimeConfigure.java similarity index 94% rename from core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/model/Configure.java rename to core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/model/RuntimeConfigure.java index fabcad33ee..821dbcb7ad 100644 --- a/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/model/Configure.java +++ b/core/metaforge-spi/src/main/java/org/devlive/metaforge/spi/model/RuntimeConfigure.java @@ -20,7 +20,7 @@ @AllArgsConstructor @SuppressFBWarnings(value = {"EI_EXPOSE_REP", "EI_EXPOSE_REP2", "RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE", "CT_CONSTRUCTOR_THROW", "NP_NONNULL_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR"}) -public class Configure +public class RuntimeConfigure implements Cloneable { @NonNull @@ -68,10 +68,10 @@ public class Configure private Boolean isAppendChar = Boolean.TRUE; @Override - public Configure clone() + public RuntimeConfigure clone() { try { - return (Configure) super.clone(); + return (RuntimeConfigure) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionError(); diff --git a/core/metaforge-spi/src/test/java/org/devlive/metaforge/spi/PluginServiceTest.java b/core/metaforge-spi/src/test/java/org/devlive/metaforge/spi/PluginServiceTest.java index b69bf7d62f..7b1d64a00d 100644 --- a/core/metaforge-spi/src/test/java/org/devlive/metaforge/spi/PluginServiceTest.java +++ b/core/metaforge-spi/src/test/java/org/devlive/metaforge/spi/PluginServiceTest.java @@ -1,7 +1,7 @@ package org.devlive.metaforge.spi; -import org.devlive.metaforge.spi.model.Configure; import org.devlive.metaforge.spi.model.Response; +import org.devlive.metaforge.spi.model.RuntimeConfigure; import org.junit.Assert; import org.junit.Test; @@ -11,9 +11,9 @@ public class PluginServiceTest { - private Configure configure() + private RuntimeConfigure configure() { - Configure configure = new Configure(); + RuntimeConfigure configure = new RuntimeConfigure(); configure.setType("mysql"); configure.setHost("127.0.0.1"); configure.setPort(3306); @@ -32,7 +32,7 @@ public void testBuildUrl() @Test public void testBuildUrlWithSsl() { - Configure configure = configure(); + RuntimeConfigure configure = configure(); configure.setSsl(Optional.of(true)); Assert.assertEquals("jdbc:mysql://127.0.0.1:3306/test?ssl=true", new PluginService() { @@ -42,7 +42,7 @@ public void testBuildUrlWithSsl() @Test public void testBuildUrlWithEnv() { - Configure configure = configure(); + RuntimeConfigure configure = configure(); Map env = new HashMap<>(); env.put("serverTimezone", "Asia/Shanghai"); configure.setEnv(Optional.of(env)); @@ -55,7 +55,7 @@ public void testBuildUrlWithEnv() public void testBuildUrlRejectsH2InitInjectionViaDatabase() { // issue #966 的 H2 RCE 载荷:通过 database 注入 INIT 参数 - Configure configure = configure(); + RuntimeConfigure configure = configure(); configure.setDatabase(Optional.of("testdb;INIT=CREATE ALIAS EXEC AS 'void cmd_exec(String cmd) throws java.lang.Exception {Runtime.getRuntime().exec(cmd)\\;}'\\;CALL EXEC ('touch /tmp/pwned')\\;")); new PluginService() { @@ -65,7 +65,7 @@ public void testBuildUrlRejectsH2InitInjectionViaDatabase() @Test(expected = IllegalArgumentException.class) public void testBuildUrlRejectsInjectionViaHost() { - Configure configure = configure(); + RuntimeConfigure configure = configure(); configure.setHost("127.0.0.1?allowLoadLocalInfile=true"); new PluginService() { @@ -76,7 +76,7 @@ public void testBuildUrlRejectsInjectionViaHost() public void testBuildUrlRejectsForbiddenEnvParameter() { // issue #966 的 MySQL 任意文件读取载荷:allowLoadLocalInfile 参数 - Configure configure = configure(); + RuntimeConfigure configure = configure(); Map env = new HashMap<>(); env.put("allow_load_local_infile", "true"); configure.setEnv(Optional.of(env)); @@ -95,7 +95,7 @@ public void testGetDatabasesPassesThroughSuccessfulResponse() PluginService service = new PluginService() { @Override - public Response execute(Configure configure, String content) + public Response execute(RuntimeConfigure configure, String content) { return expected; } @@ -110,7 +110,7 @@ public void testGetDatabasesFallsBackWhenSqlFails() PluginService service = new PluginService() { @Override - public Response execute(Configure configure, String content) + public Response execute(RuntimeConfigure configure, String content) { return Response.builder() .isConnected(Boolean.TRUE) @@ -137,7 +137,7 @@ public void testGetDatabasesKeepsResponseWhenConnectionFailed() PluginService service = new PluginService() { @Override - public Response execute(Configure configure, String content) + public Response execute(RuntimeConfigure configure, String content) { return expected; } @@ -151,7 +151,7 @@ public void testGetTablesFallsBackWhenSqlFails() PluginService service = new PluginService() { @Override - public Response execute(Configure configure, String content) + public Response execute(RuntimeConfigure configure, String content) { return Response.builder() .isConnected(Boolean.TRUE) @@ -171,7 +171,7 @@ public void testGetColumnsFallsBackWhenSqlFails() PluginService service = new PluginService() { @Override - public Response execute(Configure configure, String content) + public Response execute(RuntimeConfigure configure, String content) { return Response.builder() .isConnected(Boolean.TRUE) diff --git a/core/metaforge-ui/package.json b/core/metaforge-ui/package.json index 15be4b6cc5..9a8f8cc1fc 100644 --- a/core/metaforge-ui/package.json +++ b/core/metaforge-ui/package.json @@ -14,6 +14,7 @@ }, "scripts": { "dev": "vite", + "test": "node --test tests/*.test.mjs", "build": "vue-tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/core/metaforge-ui/playground/App.vue b/core/metaforge-ui/playground/App.vue index 978e517da3..fe426d0514 100644 --- a/core/metaforge-ui/playground/App.vue +++ b/core/metaforge-ui/playground/App.vue @@ -17,21 +17,40 @@ import { MfCheckbox, MfConfigProvider, MfDivider, + MfDatePicker, MfInput, + MfMenu, + MfMenuItem, MfModal, MfRadio, MfRadioGroup, MfSelect, MfSpace, MfSpinner, + MfSubMenu, MfSwitch, MfTag, + MfTableToolbar, MfTextarea, MfTooltip, } from '../src/index' const mode = ref('light') +const menuMode = ref<'horizontal' | 'inline'>('horizontal') +const menuSelectedKeys = ref<(string | number)[]>(['/home']) +const menuClickCount = ref(0) +const navigation = [ + { key: '/home', label: '首页' }, + { key: '/dashboard', label: '仪表盘' }, + { key: '/dataset', label: '数据集' }, + { key: '/store', label: '商店' }, + { key: '/query', label: '查询' }, +] const primary = ref('#2563eb') +const toolbarKeyword = ref('') +const toolbarCode = ref('') +const toolbarDate = ref('') +const toolbarSearches = ref(0) const presets = { light: mfLightPreset, @@ -115,6 +134,47 @@ function simulateLoading(): void {
+
+

数据列表功能区

+ + + + + + +

查询次数:{{ toolbarSearches }}

+
+ +
+ + 顶部导航 + 侧边导航 + + + + {{ item.label }} + + + 禁用链接 + + + 数据源 + 工作流 + + + 用户 + 运行时配置 + + +

触发次数:{{ menuClickCount }}

+
+
Primary @@ -210,6 +270,14 @@ function simulateLoading(): void { +
+
+ 表格滚动容器边缘提示 + + 分配菜单 + +
+
Open modal diff --git a/core/metaforge-ui/src/components/MfCheckbox.vue b/core/metaforge-ui/src/components/MfCheckbox.vue index 48c3d492e7..a995ca4843 100644 --- a/core/metaforge-ui/src/components/MfCheckbox.vue +++ b/core/metaforge-ui/src/components/MfCheckbox.vue @@ -70,6 +70,8 @@ function toggle(): void { type="checkbox" class="sr-only" :checked="checked" + :indeterminate="indeterminate" + :aria-checked="indeterminate ? 'mixed' : checked" :disabled="disabled" @change="toggle" > diff --git a/core/metaforge-ui/src/components/MfDatePicker.vue b/core/metaforge-ui/src/components/MfDatePicker.vue new file mode 100644 index 0000000000..09d3ec7c21 --- /dev/null +++ b/core/metaforge-ui/src/components/MfDatePicker.vue @@ -0,0 +1,144 @@ + + + diff --git a/core/metaforge-ui/src/components/MfDropdown.vue b/core/metaforge-ui/src/components/MfDropdown.vue index 25faf49f5e..1b1e80b67a 100644 --- a/core/metaforge-ui/src/components/MfDropdown.vue +++ b/core/metaforge-ui/src/components/MfDropdown.vue @@ -1,54 +1,65 @@ diff --git a/core/metaforge-ui/src/components/MfMenu.vue b/core/metaforge-ui/src/components/MfMenu.vue index f5c79c5711..36504091f2 100644 --- a/core/metaforge-ui/src/components/MfMenu.vue +++ b/core/metaforge-ui/src/components/MfMenu.vue @@ -44,9 +44,9 @@ const horizontal = computed(() => props.mode === 'horizontal') const rootClass = computed(() => clsx( 'flex text-sm select-none', - horizontal - ? 'flex-row items-center' - : clsx('flex-col', props.collapsed && 'items-center'), + horizontal.value + ? 'flex-row items-center gap-1.5' + : clsx('flex-col gap-1', props.collapsed && 'items-center'), )) diff --git a/core/metaforge-ui/src/components/MfMenuItem.vue b/core/metaforge-ui/src/components/MfMenuItem.vue index 84057a0e38..547aa13027 100644 --- a/core/metaforge-ui/src/components/MfMenuItem.vue +++ b/core/metaforge-ui/src/components/MfMenuItem.vue @@ -23,6 +23,13 @@ const subKey = inject<{ key: string | number } | null>('mfSubMenu', null) const itemKey = computed(() => props.key_) const selected = computed(() => menu?.selectedKeys.value.includes(itemKey.value) ?? false) +function onClickCapture(event: MouseEvent): void { + if (props.disabled) { + event.preventDefault() + event.stopImmediatePropagation() + } +} + function onClick(): void { if (props.disabled || !menu) return const keyPath = subKey ? [itemKey.value, subKey.key] : [itemKey.value] @@ -34,11 +41,11 @@ const horizontal = computed(() => menu?.mode.value === 'horizontal') const itemClass = computed(() => clsx( 'relative flex cursor-pointer items-center gap-2.5 rounded-mf-md px-3 py-2 font-medium', 'transition-[color,background-color] duration-(--mf-duration-fast) ease-(--mf-easing)', - horizontal ? '' : 'w-full', + horizontal.value ? '' : 'w-full', props.danger ? 'text-danger' : 'text-content-secondary', - props.disabled && 'cursor-not-allowed opacity-40', - !props.disabled && !selected && 'hover:bg-surface-hover hover:text-content', - selected && clsx('bg-surface-active', props.danger ? 'text-danger' : 'text-primary'), + props.disabled && 'cursor-not-allowed opacity-40 [&_a]:cursor-not-allowed', + !props.disabled && !selected.value && 'hover:bg-surface-hover hover:text-content', + selected.value && clsx('bg-surface-active', props.danger ? 'text-danger' : 'text-primary'), )) @@ -46,10 +53,13 @@ const itemClass = computed(() => clsx(
  • - +
  • diff --git a/core/metaforge-ui/src/components/MfModal.vue b/core/metaforge-ui/src/components/MfModal.vue index abad5096a5..c2b48a6317 100644 --- a/core/metaforge-ui/src/components/MfModal.vue +++ b/core/metaforge-ui/src/components/MfModal.vue @@ -65,9 +65,9 @@ const style = computed(() => ({ maxWidth: props.width })) role="dialog" aria-modal="true" :style="style" - class="relative w-full rounded-mf-lg bg-surface shadow-mf-lg" + class="relative flex max-h-full w-full flex-col overflow-hidden rounded-mf-lg bg-surface shadow-mf-lg" > -
    +

    {{ title }}

    @@ -82,10 +82,10 @@ const style = computed(() => ({ maxWidth: props.width }))
    -
    +
    -
    +
    diff --git a/core/metaforge-ui/src/components/MfPagination.vue b/core/metaforge-ui/src/components/MfPagination.vue index 1345845f3b..2a8e9d3031 100644 --- a/core/metaforge-ui/src/components/MfPagination.vue +++ b/core/metaforge-ui/src/components/MfPagination.vue @@ -1,6 +1,7 @@ diff --git a/core/metaforge-ui/src/components/MfSelect.vue b/core/metaforge-ui/src/components/MfSelect.vue index 037c18d399..2f93053c31 100644 --- a/core/metaforge-ui/src/components/MfSelect.vue +++ b/core/metaforge-ui/src/components/MfSelect.vue @@ -1,7 +1,8 @@ diff --git a/core/metaforge-ui/src/components/MfSubMenu.vue b/core/metaforge-ui/src/components/MfSubMenu.vue index 766e6635fe..28f2b26fd1 100644 --- a/core/metaforge-ui/src/components/MfSubMenu.vue +++ b/core/metaforge-ui/src/components/MfSubMenu.vue @@ -1,6 +1,7 @@ + + diff --git a/core/metaforge-ui/src/components/MfTooltip.vue b/core/metaforge-ui/src/components/MfTooltip.vue index d16e98c254..3af267f2ea 100644 --- a/core/metaforge-ui/src/components/MfTooltip.vue +++ b/core/metaforge-ui/src/components/MfTooltip.vue @@ -1,6 +1,7 @@ diff --git a/core/metaforge-ui/src/components/calendar.ts b/core/metaforge-ui/src/components/calendar.ts new file mode 100644 index 0000000000..a292b0bded --- /dev/null +++ b/core/metaforge-ui/src/components/calendar.ts @@ -0,0 +1,22 @@ +export function dateKey(date: Date): string { + return `${String(date.getFullYear()).padStart(4, '0')}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +export function parseDate(value: string): Date | undefined { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined + const [year, month, day] = value.split('-').map(Number) + if (year < 1000 || year > 9999) return undefined + const date = new Date(year, month - 1, day, 12) + return dateKey(date) === value ? date : undefined +} + +export function monthDays(date: Date): Date[] { + const first = new Date(date.getFullYear(), date.getMonth(), 1, 12) + const offset = (first.getDay() + 6) % 7 + return Array.from({ length: 42 }, (_, index) => new Date(first.getFullYear(), first.getMonth(), 1 - offset + index, 12)) +} + +export function moveMonth(date: Date, offset: number): Date { + const last = new Date(date.getFullYear(), date.getMonth() + offset + 1, 0, 12).getDate() + return new Date(date.getFullYear(), date.getMonth() + offset, Math.min(date.getDate(), last), 12) +} diff --git a/core/metaforge-ui/src/composables/useDismissableLayer.ts b/core/metaforge-ui/src/composables/useDismissableLayer.ts new file mode 100644 index 0000000000..6e69c8d7fb --- /dev/null +++ b/core/metaforge-ui/src/composables/useDismissableLayer.ts @@ -0,0 +1,98 @@ +import { onBeforeUnmount, onMounted, watch, type Ref } from 'vue' + +interface DismissableLayer { + contains: (event: Event) => boolean + dismiss: () => void +} + +type LayerDocument = Pick + +interface LayerRegistry { + layers: DismissableLayer[] + pointerdown: (event: Event) => void + keydown: (event: Event) => void +} + +const registries = new WeakMap() + +function removeLayer(document: LayerDocument, registry: LayerRegistry, layer: DismissableLayer): void { + const index = registry.layers.indexOf(layer) + if (index < 0) return + registry.layers.splice(index, 1) + if (registry.layers.length === 0) { + document.removeEventListener('pointerdown', registry.pointerdown, true) + document.removeEventListener('keydown', registry.keydown, true) + registries.delete(document) + } +} + +function dismissLayer(document: LayerDocument, registry: LayerRegistry, layer: DismissableLayer): void { + removeLayer(document, registry, layer) + layer.dismiss() +} + +/** One listener pair per document; Escape dismisses only the topmost popup. */ +export function registerDismissableLayer(document: LayerDocument, layer: DismissableLayer): () => void { + let registry = registries.get(document) + if (!registry) { + const created: LayerRegistry = { + layers: [], + pointerdown(event) { + for (const active of [...created.layers].reverse()) { + if (created.layers.includes(active) && !active.contains(event)) { + dismissLayer(document, created, active) + } + } + }, + keydown(event) { + if ((event as KeyboardEvent).key !== 'Escape' || event.defaultPrevented) return + const active = created.layers[created.layers.length - 1] + if (!active) return + event.preventDefault() + // A Select inside a drawer closes before the drawer itself. + event.stopPropagation() + dismissLayer(document, created, active) + }, + } + registry = created + registries.set(document, registry) + document.addEventListener('pointerdown', registry.pointerdown, true) + document.addEventListener('keydown', registry.keydown, true) + } + const active = { ...layer } + const current = registry + current.layers.push(active) + return () => removeLayer(document, current, active) +} + +export function useDismissableLayer( + root: Ref, + active: Readonly>, + dismiss: () => void, + /** Extra elements (e.g. a teleported overlay) that belong to this layer. */ + extra: ReadonlyArray> = [], +): void { + let unregister: (() => void) | undefined + let stop: (() => void) | undefined + onMounted(() => { + stop = watch(active, value => { + unregister?.() + unregister = undefined + if (value) { + const document = root.value?.ownerDocument ?? globalThis.document + unregister = registerDismissableLayer(document, { + contains(event) { + const elements = [root.value, ...extra.map(item => item.value)] + .filter((element): element is HTMLElement => !!element) + return elements.some(element => event.composedPath().includes(element) || element.contains(event.target as Node | null)) + }, + dismiss, + }) + } + }, { immediate: true, flush: 'sync' }) + }) + onBeforeUnmount(() => { + stop?.() + unregister?.() + }) +} diff --git a/core/metaforge-ui/src/composables/useOverlayPosition.ts b/core/metaforge-ui/src/composables/useOverlayPosition.ts new file mode 100644 index 0000000000..63e2c2ebde --- /dev/null +++ b/core/metaforge-ui/src/composables/useOverlayPosition.ts @@ -0,0 +1,104 @@ +import { nextTick, onBeforeUnmount, ref, watch, type Ref } from 'vue' + +export type OverlayPlacement = 'bottomLeft' | 'bottom' | 'bottomRight' | 'topLeft' | 'top' | 'topRight' + +interface OverlayPositionOptions { + /** Trigger element the overlay is anchored to. */ + root: Ref + open: Readonly> + placement: () => OverlayPlacement + /** Distance between trigger and overlay in pixels. */ + gap?: number + /** Opt in to measured viewport clamping and vertical flipping. */ + overlay?: Ref + maxWidth?: number +} + +/** + * Pins a teleported overlay to the trigger's viewport rect with + * `position: fixed`, so popups escape `overflow: hidden/auto` ancestors + * (tables, cards) instead of being clipped or forcing scrollbars. + */ +export function useOverlayPosition(options: OverlayPositionOptions): Ref> { + const style = ref>({}) + let observer: ResizeObserver | undefined + let revision = 0 + + function update(): void { + const trigger = options.root.value + if (!trigger) return + const rect = trigger.getBoundingClientRect() + const gap = options.gap ?? 6 + const center = rect.left + rect.width / 2 + if (options.overlay) { + const margin = 8 + const viewportWidth = document.documentElement?.clientWidth || window.innerWidth + const viewportHeight = document.documentElement?.clientHeight || window.innerHeight + const limits = { + maxWidth: `${Math.max(0, Math.min(options.maxWidth ?? viewportWidth, viewportWidth - margin * 2))}px`, + maxHeight: `${Math.max(0, viewportHeight - margin * 2)}px`, + } + const overlay = options.overlay.value + if (!overlay) { + style.value = { ...limits, top: '0px', left: '0px', visibility: 'hidden' } + return + } + const bounds = overlay.getBoundingClientRect() + const placement = options.placement() + const above = rect.top - gap - bounds.height + const below = rect.bottom + gap + let top = placement.startsWith('top') ? above : below + if (placement.startsWith('top') && above < margin && below + bounds.height <= viewportHeight - margin) top = below + else if (!placement.startsWith('top') && below + bounds.height > viewportHeight - margin && above >= margin) top = above + const left = placement.endsWith('Left') ? rect.left + : placement.endsWith('Right') ? rect.right - bounds.width : center - bounds.width / 2 + style.value = { + ...limits, + left: `${Math.max(margin, Math.min(left, viewportWidth - bounds.width - margin))}px`, + top: `${Math.max(margin, Math.min(top, viewportHeight - bounds.height - margin))}px`, + } + return + } + const topEdge = window.innerHeight - rect.top + gap + style.value = { + bottomLeft: { top: `${rect.bottom + gap}px`, left: `${rect.left}px` }, + bottom: { top: `${rect.bottom + gap}px`, left: `${center}px`, transform: 'translateX(-50%)' }, + bottomRight: { top: `${rect.bottom + gap}px`, left: `${rect.right}px`, transform: 'translateX(-100%)' }, + topLeft: { bottom: `${topEdge}px`, left: `${rect.left}px` }, + top: { bottom: `${topEdge}px`, left: `${center}px`, transform: 'translateX(-50%)' }, + topRight: { bottom: `${topEdge}px`, left: `${rect.right}px`, transform: 'translateX(-100%)' }, + }[options.placement()] + } + + function start(): void { + const current = ++revision + update() + window.addEventListener('resize', update) + // Capture phase catches scrolls from any ancestor container while open. + document.addEventListener('scroll', update, true) + if (options.overlay) { + void nextTick(() => { + if (current !== revision || !options.open.value) return + update() + if (typeof ResizeObserver !== 'undefined' && options.overlay?.value) { + observer = new ResizeObserver(update) + observer.observe(options.overlay.value) + if (options.root.value) observer.observe(options.root.value) + } + }) + } + } + + function stop(): void { + revision++ + observer?.disconnect() + observer = undefined + window.removeEventListener('resize', update) + document.removeEventListener('scroll', update, true) + } + + watch(options.open, value => (value ? start() : stop())) + onBeforeUnmount(stop) + + return style +} diff --git a/core/metaforge-ui/src/index.ts b/core/metaforge-ui/src/index.ts index 4b8dc9b6b7..fffc43b5db 100644 --- a/core/metaforge-ui/src/index.ts +++ b/core/metaforge-ui/src/index.ts @@ -10,6 +10,7 @@ import MfCheckbox from './components/MfCheckbox.vue' import MfCheckboxGroup from './components/MfCheckboxGroup.vue' import MfConfigProvider from './components/MfConfigProvider.vue' import MfDivider from './components/MfDivider.vue' +import MfDatePicker from './components/MfDatePicker.vue' import MfDrawer from './components/MfDrawer.vue' import MfDropdown from './components/MfDropdown.vue' import MfForm from './components/MfForm.vue' @@ -32,6 +33,7 @@ import MfSubMenu from './components/MfSubMenu.vue' import MfSwitch from './components/MfSwitch.vue' import MfTabPane from './components/MfTabPane.vue' import MfTable from './components/MfTable.vue' +import MfTableToolbar from './components/MfTableToolbar.vue' import MfTabs from './components/MfTabs.vue' import MfTag from './components/MfTag.vue' import MfTextarea from './components/MfTextarea.vue' @@ -53,6 +55,7 @@ export const components = { MfCheckboxGroup, MfConfigProvider, MfDivider, + MfDatePicker, MfDrawer, MfDropdown, MfForm, @@ -75,6 +78,7 @@ export const components = { MfSwitch, MfTabPane, MfTable, + MfTableToolbar, MfTabs, MfTag, MfTextarea, @@ -107,6 +111,7 @@ export { MfCheckboxGroup, MfConfigProvider, MfDivider, + MfDatePicker, MfDrawer, MfDropdown, MfForm, @@ -129,6 +134,7 @@ export { MfSwitch, MfTabPane, MfTable, + MfTableToolbar, MfTabs, MfTag, MfTextarea, @@ -139,5 +145,6 @@ export { export * from './theme/theme' export * from './theme/message' +export { useDismissableLayer } from './composables/useDismissableLayer' export default { install, version } diff --git a/core/metaforge-ui/tests/component.mjs b/core/metaforge-ui/tests/component.mjs new file mode 100644 index 0000000000..e3daf76588 --- /dev/null +++ b/core/metaforge-ui/tests/component.mjs @@ -0,0 +1,45 @@ +import { readFile } from 'node:fs/promises' +import { extname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { compileScript, parse } from 'vue/compiler-sfc' +import ts from 'typescript' + +const modules = new Map() + +async function moduleUrl(url) { + const filename = fileURLToPath(url) + if (!modules.has(filename)) { + modules.set(filename, (async () => { + let source = await readFile(url, 'utf8') + if (filename.endsWith('.vue')) { + const { descriptor } = parse(source, { filename }) + source = compileScript(descriptor, { id: filename, inlineTemplate: true }).content + } + const { outputText } = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ESNext }, + }) + const imports = [...outputText.matchAll(/from (['"])([^'"]+)\1/g)] + const resolved = new Map() + for (const [, , specifier] of imports) { + if (resolved.has(specifier)) continue + const target = specifier.startsWith('.') + ? await moduleUrl(new URL(extname(specifier) ? specifier : `${specifier}.ts`, url)) + : specifier === 'vue' + ? new URL('./vue-runtime.mjs', import.meta.url).href + : import.meta.resolve(specifier) + resolved.set(specifier, target) + } + const code = outputText.replace(/from (['"])([^'"]+)\1/g, (_match, _quote, specifier) => `from ${JSON.stringify(resolved.get(specifier))}`) + return `data:text/javascript;base64,${Buffer.from(code).toString('base64')}` + })()) + } + return modules.get(filename) +} + +export async function sourceModule(path) { + return import(await moduleUrl(new URL(path, import.meta.url))) +} + +export async function component(name) { + return (await sourceModule(`../src/components/${name}.vue`)).default +} diff --git a/core/metaforge-ui/tests/date-picker.test.mjs b/core/metaforge-ui/tests/date-picker.test.mjs new file mode 100644 index 0000000000..c523251d07 --- /dev/null +++ b/core/metaforge-ui/tests/date-picker.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { h, nextTick, ref } from 'vue' +import { component, sourceModule } from './component.mjs' +import { click, keydown, mountHost, pointerDown } from './interaction-host.mjs' + +const Picker = await component('MfDatePicker') +const { dateKey, monthDays, moveMonth, parseDate } = await sourceModule('../src/components/calendar.ts') + +test('calendar uses valid local dates, Monday weeks and leap-year month boundaries', () => { + assert.equal(dateKey(parseDate('2024-02-29')), '2024-02-29') + for (const invalid of ['2023-02-29', '2026-13-01', '2026-04-31', 'bad']) assert.equal(parseDate(invalid), undefined) + const days = monthDays(parseDate('2024-02-15')) + assert.equal(days.length, 42) + assert.equal(days[0].getDay(), 1) + assert.equal(dateKey(moveMonth(parseDate('2024-01-31'), 1)), '2024-02-29') + assert.equal(dateKey(moveMonth(parseDate('2026-01-31'), -1)), '2025-12-31') +}) + +test('custom calendar selects, clears, and restores trigger focus without a native date input', async () => { + const value = ref('2026-02-10') + const host = mountHost(() => h(Picker, { modelValue: value.value, 'onUpdate:modelValue': date => { value.value = date } })) + try { + const trigger = () => host.find(node => node.tag === 'button' && node.props['aria-haspopup'] === 'dialog') + await click(host.document, trigger()) + assert.ok(host.find(node => node.props.role === 'grid')) + assert.ok(!host.find(node => node.tag === 'input')) + await click(host.document, host.find(node => node.props['data-date'] === '2026-02-15')) + assert.equal(value.value, '2026-02-15') + assert.ok(!host.find(node => node.props.role === 'dialog')) + assert.equal(host.document.activeElement, trigger()) + await click(host.document, trigger()) + await click(host.document, host.find(node => node.tag === 'button' && node.textContent.trim() === 'Clear')) + assert.equal(value.value, '') + } + finally { host.unmount() } +}) + +test('calendar respects date limits and supports arrows, Escape and outside dismissal', async () => { + const host = mountHost(h(Picker, { modelValue: '2026-02-10', min: '2026-02-10', max: '2026-02-20' })) + try { + const trigger = () => host.find(node => node.props['aria-haspopup'] === 'dialog') + await click(host.document, trigger()) + assert.equal(host.find(node => node.props['data-date'] === '2026-02-09').disabled, true) + await keydown(host.document, host.find(node => node.props['data-date'] === '2026-02-10'), 'ArrowRight') + await nextTick() + assert.equal(host.document.activeElement.props['data-date'], '2026-02-11') + await keydown(host.document, host.document.activeElement, 'Escape') + assert.ok(!host.find(node => node.props.role === 'dialog')) + assert.equal(host.document.activeElement, trigger()) + await click(host.document, trigger()) + pointerDown(host.document, host.container) + await nextTick() + assert.ok(!host.find(node => node.props.role === 'dialog')) + } + finally { host.unmount() } + assert.equal(host.document.count('pointerdown'), 0) + assert.equal(host.document.count('scroll'), 0) +}) diff --git a/core/metaforge-ui/tests/dismissable-layer.test.mjs b/core/metaforge-ui/tests/dismissable-layer.test.mjs new file mode 100644 index 0000000000..96fcf2c4bf --- /dev/null +++ b/core/metaforge-ui/tests/dismissable-layer.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { sourceModule } from './component.mjs' +import { InteractionEvent, TestDocument } from './interaction-host.mjs' + +const { registerDismissableLayer } = await sourceModule('../src/composables/useDismissableLayer.ts') + +function layer(document, root, close) { + return registerDismissableLayer(document, { contains: event => event.composedPath().includes(root), dismiss: close }) +} + +test('outside pointerdown dismisses a layer without preventing the clicked control', () => { + const doc = new TestDocument() + let dismissed = 0 + let otherHandler = 0 + layer(doc, {}, () => dismissed++) + doc.addEventListener('pointerdown', () => otherHandler++) + const event = new InteractionEvent('pointerdown', [{}, doc], { pointerType: 'touch' }) + doc.dispatchEvent(event) + assert.equal(dismissed, 1) + assert.equal(otherHandler, 1) + assert.equal(event.defaultPrevented, false) + assert.equal(event.propagationStopped, false) + assert.equal(doc.count('keydown'), 0) +}) + +test('inside pointerdown includes composed paths and leaves the layer open', () => { + const doc = new TestDocument() + const root = {} + let dismissed = 0 + const unregister = layer(doc, root, () => dismissed++) + doc.dispatchEvent(new InteractionEvent('pointerdown', [{}, root, doc])) + assert.equal(dismissed, 0) + unregister() +}) + +test('Escape closes the topmost layer first and blocks parent dismissal', () => { + const doc = new TestDocument() + const order = [] + layer(doc, {}, () => order.push('parent')) + layer(doc, {}, () => order.push('child')) + const event = new InteractionEvent('keydown', [doc], { key: 'Escape' }) + doc.dispatchEvent(event) + assert.deepEqual(order, ['child']) + assert.equal(event.defaultPrevented, true) + assert.equal(event.propagationStopped, true) + assert.equal(doc.count('keydown'), 1) + doc.dispatchEvent(new InteractionEvent('keydown', [doc], { key: 'Escape' })) + assert.deepEqual(order, ['child', 'parent']) + assert.equal(doc.count('keydown'), 0) +}) + +test('clicking a parent popup outside its child closes only the child', () => { + const doc = new TestDocument() + const parent = {} + const closed = [] + const unregister = layer(doc, parent, () => closed.push('parent')) + layer(doc, {}, () => closed.push('child')) + doc.dispatchEvent(new InteractionEvent('pointerdown', [parent, doc])) + assert.deepEqual(closed, ['child']) + unregister() +}) + +test('clicking outside nested layers closes all of them from the inside out', () => { + const doc = new TestDocument() + const order = [] + layer(doc, {}, () => order.push('parent')) + layer(doc, {}, () => order.push('child')) + doc.dispatchEvent(new InteractionEvent('pointerdown', [{}, doc])) + assert.deepEqual(order, ['child', 'parent']) + assert.equal(doc.count('pointerdown'), 0) +}) + +test('unregister removes listeners and stale cleanup cannot remove a reopened layer', () => { + const doc = new TestDocument() + let closed = 0 + const old = layer(doc, {}, () => closed++) + old() + assert.equal(doc.count('pointerdown'), 0) + const current = layer(doc, {}, () => closed++) + old() + assert.equal(doc.count('pointerdown'), 1) + doc.dispatchEvent(new InteractionEvent('pointerdown', [{}, doc])) + assert.equal(closed, 1) + current() +}) + +test('separate documents have separate layer stacks', () => { + const first = new TestDocument() + const second = new TestDocument() + const closed = [] + layer(first, {}, () => closed.push('first')) + const unregister = layer(second, {}, () => closed.push('second')) + first.dispatchEvent(new InteractionEvent('pointerdown', [{}, first])) + assert.deepEqual(closed, ['first']) + unregister() +}) + +test('unrelated and already-handled keys do not dismiss popups', () => { + const doc = new TestDocument() + let closed = 0 + const unregister = layer(doc, {}, () => closed++) + doc.dispatchEvent(new InteractionEvent('keydown', [doc], { key: 'Enter' })) + const event = new InteractionEvent('keydown', [doc], { key: 'Escape' }) + event.preventDefault() + doc.dispatchEvent(event) + assert.equal(closed, 0) + unregister() +}) diff --git a/core/metaforge-ui/tests/interaction-host.mjs b/core/metaforge-ui/tests/interaction-host.mjs new file mode 100644 index 0000000000..c8f4cfcf83 --- /dev/null +++ b/core/metaforge-ui/tests/interaction-host.mjs @@ -0,0 +1,163 @@ +import { createRenderer, nextTick } from 'vue' + +export class TestDocument extends EventTarget { + listeners = new Map() + constructor() { + super() + this.defaultView = Object.assign(new EventTarget(), { innerHeight: 900, innerWidth: 1280 }) + this.body = new HostNode('body', this) + } + addEventListener(type, listener, options) { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()) + this.listeners.get(type).add(listener) + super.addEventListener(type, listener, options) + } + removeEventListener(type, listener, options) { + this.listeners.get(type)?.delete(listener) + super.removeEventListener(type, listener, options) + } + count(type) { return this.listeners.get(type)?.size || 0 } +} + +export class InteractionEvent extends Event { + propagationStopped = false + immediateStopped = false + constructor(type, path, options = {}) { + super(type, { bubbles: true, cancelable: true }) + this.path = path + Object.assign(this, options) + } + get target() { return this.path[0] } + composedPath() { return this.path } + stopPropagation() { super.stopPropagation(); this.propagationStopped = true } + stopImmediatePropagation() { super.stopImmediatePropagation(); this.immediateStopped = true; this.propagationStopped = true } +} + +class HostNode extends EventTarget { + constructor(tag, document, text = '') { + super() + this.tag = tag + this.tagName = tag.toUpperCase() + this.ownerDocument = document + this.children = [] + this.props = {} + this.style = { display: '' } + this.text = text + this.parent = null + } + contains(node) { + for (let current = node; current; current = current.parent) if (current === this) return true + return false + } + get textContent() { return this.tag === '#comment' ? '' : this.text + this.children.map(child => child.textContent).join('') } + getBoundingClientRect() { return { top: 0, left: 0, bottom: 36, right: 200, width: 200, height: 36 } } + focus() { this.ownerDocument.activeElement = this } +} + +function remove(node) { + if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1) + node.parent = null +} + +export function mountHost(vnode, document = new TestDocument(), configure = () => {}) { + const previousDocument = globalThis.document + const previousWindow = globalThis.window + globalThis.document = document + globalThis.window = document.defaultView + const renderer = createRenderer({ + querySelector: selector => selector === 'body' ? document.body : null, + createElement: tag => new HostNode(tag, document), + createText: text => new HostNode('#text', document, text), + createComment: text => new HostNode('#comment', document, text), + insert(node, parent, anchor = null) { + remove(node) + node.parent = parent + const index = anchor ? parent.children.indexOf(anchor) : -1 + parent.children.splice(index < 0 ? parent.children.length : index, 0, node) + }, + remove, + setText: (node, text) => { node.text = text }, + setElementText(node, text) { node.text = text; node.children.forEach(child => { child.parent = null }); node.children = [] }, + parentNode: node => node.parent, + nextSibling: node => node.parent?.children[node.parent.children.indexOf(node) + 1] || null, + patchProp(node, key, _previous, value) { + node.props[key] = value + if (['value', 'type', 'disabled'].includes(key)) node[key] = value + if (key === 'style' && value && typeof value === 'object') Object.assign(node.style, value) + }, + }) + const container = new HostNode('test-root', document) + const app = renderer.createApp({ render: () => typeof vnode === 'function' ? vnode() : vnode }) + configure(app) + app.mount(container) + function nodes() { + const result = [] + const visit = node => { result.push(node); node.children.forEach(visit) } + visit(container) + visit(document.body) + return result + } + return { + document, + container, + find: predicate => nodes().find(predicate), + all: predicate => nodes().filter(predicate), + unmount() { + try { app.unmount() } + finally { + globalThis.document = previousDocument + globalThis.window = previousWindow + } + }, + } +} + +function pathTo(node, document) { + const path = [] + for (let current = node; current; current = current.parent) path.push(current) + return [...path, document] +} + +function invoke(handler, event) { + for (const callback of Array.isArray(handler) ? handler : [handler]) { + if (event.immediateStopped) break + callback?.(event) + } +} + +export function pointerDown(document, node) { + const event = new InteractionEvent('pointerdown', pathTo(node, document)) + document.dispatchEvent(event) + return event +} + +export async function click(document, node, type = 'click') { + pointerDown(document, node) + const event = new InteractionEvent(type, pathTo(node, document)) + const property = type === 'auxclick' ? 'onAuxclick' : 'onClick' + for (const current of [...event.path].reverse()) { + invoke(current.props?.[`${property}Capture`], event) + if (event.propagationStopped) break + } + if (!event.propagationStopped) { + for (const current of event.path) { + invoke(current.props?.[property], event) + if (event.propagationStopped) break + } + } + await nextTick() + return event +} + +export async function keydown(document, node, key) { + const event = new InteractionEvent('keydown', pathTo(node, document), { key }) + document.dispatchEvent(event) + if (!event.propagationStopped) { + for (const current of event.path) { + invoke(current.props?.onKeydown, event) + if (event.propagationStopped) break + } + } + await nextTick() + return event +} diff --git a/core/metaforge-ui/tests/menu.test.mjs b/core/metaforge-ui/tests/menu.test.mjs new file mode 100644 index 0000000000..3eeacae9eb --- /dev/null +++ b/core/metaforge-ui/tests/menu.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createSSRApp, h } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { component } from './component.mjs' + +const Menu = await component('MfMenu') +const MenuItem = await component('MfMenuItem') +const items = ['/home', '/admin/dashboard', '/admin/dataset', '/store', '/admin/query'] + +async function renderMenu(selectedKeys = [], mode = 'horizontal') { + const app = createSSRApp({ + render: () => h(Menu, { selectedKeys, mode }, { + default: () => items.map(key => h(MenuItem, { key, key_: key }, () => key)), + }), + }) + const html = await renderToString(app) + const entries = [...html.matchAll(/]*>/g)].map(([tag]) => ({ + classes: (tag.match(/class="([^"]*)"/)?.[1] || '').split(/\s+/), + selected: tag.includes('aria-selected="true"'), + })) + const classes = (html.match(/]*class="([^"]*)"/)?.[1] || '').split(/\s+/) + return { classes, entries } +} + +test('only the selected item receives the active background and text', async () => { + const { entries } = await renderMenu(['/home']) + assert.equal(entries.length, items.length) + for (const [index, entry] of entries.entries()) { + assert.equal(entry.selected, index === 0) + assert.equal(entry.classes.includes('bg-surface-active'), entry.selected) + assert.equal(entry.classes.includes('text-primary'), entry.selected) + assert.equal(entry.classes.includes('hover:bg-surface-hover'), !entry.selected) + } +}) + +test('menus with no selected keys keep every item neutral', async () => { + const { entries } = await renderMenu() + assert.ok(entries.every(entry => !entry.selected && !entry.classes.includes('bg-surface-active'))) +}) + +test('changing selection removes the previous highlight', async () => { + for (const key of items) { + const { entries } = await renderMenu([key]) + assert.deepEqual(entries.map(entry => entry.classes.includes('bg-surface-active')), items.map(item => item === key)) + } +}) + +test('root menu direction follows horizontal, vertical and inline modes', async () => { + for (const mode of ['horizontal', 'vertical', 'inline']) { + const { classes } = await renderMenu([], mode) + assert.equal(classes.includes('flex-row'), mode === 'horizontal') + assert.equal(classes.includes('flex-col'), mode !== 'horizontal') + } +}) + +test('vertical and inline menu items span the menu width', async () => { + for (const mode of ['horizontal', 'vertical', 'inline']) { + const { entries } = await renderMenu([], mode) + assert.ok(entries.every(entry => entry.classes.includes('w-full') === (mode !== 'horizontal'))) + } +}) + +test('an item without a menu context is not highlighted', async () => { + const html = await renderToString(createSSRApp({ render: () => h(MenuItem, {}, () => 'Action') })) + assert.ok(html.includes('aria-selected="false"')) + assert.ok(!html.includes('bg-surface-active')) +}) + +test('navigation items preserve native link destinations and new-tab attributes', async () => { + const html = await renderToString(createSSRApp({ + render: () => h(MenuItem, {}, () => h('a', { href: '/destination', target: '_blank', rel: 'noopener' }, 'Destination')), + })) + assert.ok(html.includes('href="/destination"')) + assert.ok(html.includes('target="_blank"')) + assert.ok(html.includes('rel="noopener"')) +}) + +test('disabled menu items expose their disabled state to assistive technology', async () => { + const html = await renderToString(createSSRApp({ + render: () => h(MenuItem, { disabled: true }, () => h('a', { href: '/disabled' }, 'Disabled')), + })) + assert.ok(html.includes('aria-disabled="true"')) +}) diff --git a/core/metaforge-ui/tests/popup-interaction.test.mjs b/core/metaforge-ui/tests/popup-interaction.test.mjs new file mode 100644 index 0000000000..3cad32f015 --- /dev/null +++ b/core/metaforge-ui/tests/popup-interaction.test.mjs @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { h, nextTick, ref } from 'vue' +import { component, sourceModule } from './component.mjs' +import { click, InteractionEvent, keydown, mountHost, pointerDown, TestDocument } from './interaction-host.mjs' + +const Dropdown = await component('MfDropdown') +const Popover = await component('MfPopover') +const Select = await component('MfSelect') +const Menu = await component('MfMenu') +const MenuItem = await component('MfMenuItem') +const SubMenu = await component('MfSubMenu') +const { useDismissableLayer } = await sourceModule('../src/composables/useDismissableLayer.ts') + +test('click dropdown closes outside and unregisters on unmount', async () => { + const host = mountHost(h(Dropdown, { trigger: 'click' }, { + default: () => h('button', 'Open'), + overlay: () => h('div', { 'data-panel': 'dropdown' }, 'Choices'), + })) + try { + await click(host.document, host.find(node => node.tag === 'button')) + assert.ok(host.find(node => node.props['data-panel'] === 'dropdown')) + pointerDown(host.document, host.container) + await nextTick() + assert.ok(!host.find(node => node.props['data-panel'] === 'dropdown')) + assert.equal(host.document.count('pointerdown'), 0) + await click(host.document, host.find(node => node.tag === 'button')) + assert.equal(host.document.count('keydown'), 1) + } + finally { host.unmount() } + assert.equal(host.document.count('keydown'), 0) +}) + +test('an outside control that stops click propagation still dismisses the dropdown and acts once', async () => { + let actions = 0 + const host = mountHost(h('div', [ + h(Dropdown, { trigger: 'click' }, { + default: () => h('button', { 'data-trigger': true }, 'Open'), + overlay: () => h('div', { 'data-panel': true }, 'Choices'), + }), + h('button', { 'data-outside': true, onClick: event => { event.stopPropagation(); actions++ } }, 'Outside'), + ])) + try { + await click(host.document, host.find(node => node.props['data-trigger'])) + assert.ok(host.find(node => node.props['data-panel'])) + await click(host.document, host.find(node => node.props['data-outside'])) + assert.ok(!host.find(node => node.props['data-panel'])) + assert.equal(actions, 1) + } + finally { host.unmount() } +}) + +test('a conditionally rendered context menu can dismiss and reopen without leaking listeners', async () => { + const document = new TestDocument() + const previousDocument = globalThis.document + globalThis.document = document + const visible = ref(false) + const ContextMenu = { + setup() { + const root = ref() + useDismissableLayer(root, visible, () => { visible.value = false }) + return () => visible.value ? h('div', { ref: root, 'data-panel': true }, 'Menu') : null + }, + } + const host = mountHost(h(ContextMenu), document) + try { + visible.value = true + await nextTick() + pointerDown(document, host.find(node => node.props['data-panel'])) + assert.equal(visible.value, true) + await keydown(document, host.container, 'Escape') + assert.equal(visible.value, false) + visible.value = true + await nextTick() + assert.equal(document.count('pointerdown'), 1) + pointerDown(document, host.container) + await nextTick() + assert.equal(visible.value, false) + assert.equal(document.count('pointerdown'), 0) + } + finally { host.unmount(); globalThis.document = previousDocument } +}) + +test('click popover keeps inside interactions open but closes outside or on Escape', async () => { + const host = mountHost(h(Popover, { trigger: 'click' }, { + default: () => h('button', { 'data-trigger': true }, 'Open'), + content: () => h('button', { 'data-inside': true }, 'Inside'), + })) + try { + await click(host.document, host.find(node => node.props['data-trigger'])) + await click(host.document, host.find(node => node.props['data-inside'])) + assert.ok(host.find(node => node.props.role === 'tooltip')) + await keydown(host.document, host.find(node => node.props['data-inside']), 'Escape') + assert.ok(!host.find(node => node.props.role === 'tooltip')) + await click(host.document, host.find(node => node.props['data-trigger'])) + pointerDown(host.document, host.container) + await nextTick() + assert.ok(!host.find(node => node.props.role === 'tooltip')) + } + finally { host.unmount() } +}) + +test('Select closes outside without changing the selection', async () => { + const model = ref('one') + const host = mountHost(() => h(Select, { + modelValue: model.value, + options: [{ label: 'One', value: 'one' }, { label: 'Two', value: 'two' }], + 'onUpdate:modelValue': value => { model.value = value }, + })) + try { + await click(host.document, host.find(node => node.props.class?.includes('mf-invalidable'))) + assert.ok(host.find(node => node.props.role === 'listbox')) + pointerDown(host.document, host.container) + await nextTick() + assert.ok(!host.find(node => node.props.role === 'listbox')) + assert.equal(model.value, 'one') + } + finally { host.unmount() } +}) + +test('Escape closes a nested Select before reaching its drawer-like parent', async () => { + let parentEscapes = 0 + const host = mountHost(h('div', { onKeydown: event => { if (event.key === 'Escape') parentEscapes++ } }, [ + h(Select, { options: [{ label: 'One', value: 'one' }] }), + ])) + try { + const trigger = host.find(node => node.props.class?.includes('mf-invalidable')) + await click(host.document, trigger) + const first = await keydown(host.document, trigger, 'Escape') + assert.ok(!host.find(node => node.props.role === 'listbox')) + assert.equal(first.defaultPrevented, true) + assert.equal(parentEscapes, 0) + await keydown(host.document, trigger, 'Escape') + assert.equal(parentEscapes, 1) + } + finally { host.unmount() } +}) + +test('Select search is reset when dismissed and can be reopened', async () => { + const originalFrame = globalThis.requestAnimationFrame + globalThis.requestAnimationFrame = callback => { queueMicrotask(() => callback(0)); return 1 } + const host = mountHost(h(Select, { searchable: true, options: [{ label: 'One', value: 'one' }, { label: 'Two', value: 'two' }] })) + try { + await click(host.document, host.find(node => node.props.class?.includes('mf-invalidable'))) + const input = host.find(node => node.tag === 'input') + input.value = 'tw' + input.dispatchEvent(new InteractionEvent('input', [input])) + await nextTick() + assert.equal(host.all(node => node.props.role === 'option').length, 1) + await keydown(host.document, input, 'Escape') + await click(host.document, host.find(node => node.props.class?.includes('mf-invalidable'))) + assert.equal(host.find(node => node.tag === 'input').value, '') + assert.equal(host.all(node => node.props.role === 'option').length, 2) + } + finally { host.unmount(); globalThis.requestAnimationFrame = originalFrame } +}) + +test('horizontal submenu closes outside while inline expansion stays open', async () => { + const mode = ref('horizontal') + const host = mountHost(() => h(Menu, { mode: mode.value }, { + default: () => h(SubMenu, { key_: 'tools', title: 'Tools' }, () => h(MenuItem, { key_: 'leaf' }, () => 'Leaf')), + })) + try { + const title = () => host.find(node => node.tag === 'div' && node.props.onClick) + await click(host.document, title()) + assert.equal(host.find(node => node.props.class?.includes('absolute')).style.display, '') + pointerDown(host.document, host.container) + await nextTick() + assert.equal(host.find(node => node.props.class?.includes('absolute')).style.display, 'none') + mode.value = 'inline' + await nextTick() + await click(host.document, title()) + const children = host.find(node => node.props.class?.includes('border-l')) + assert.equal(children.style.display, '') + pointerDown(host.document, host.container) + await nextTick() + assert.equal(children.style.display, '') + assert.equal(host.document.count('pointerdown'), 0) + } + finally { host.unmount() } +}) + +test('menu activation reaches the link and emits only once', async () => { + let linkClicks = 0 + let activations = 0 + const host = mountHost(h(Menu, { onClick: () => activations++ }, { + default: () => h(MenuItem, { key_: 'entry' }, () => h('a', { href: '/entry', onClick: () => linkClicks++ }, 'Entry')), + })) + try { + await click(host.document, host.find(node => node.tag === 'a')) + assert.equal(linkClicks, 1) + assert.equal(activations, 1) + } + finally { host.unmount() } +}) + +test('disabled menu items block links, parent actions and auxiliary clicks', async () => { + let actions = 0 + const host = mountHost(h(Menu, { onClick: () => actions++ }, { + default: () => h(MenuItem, { key_: 'entry', disabled: true, onClick: () => actions++ }, () => h('a', { href: '/entry', onClick: () => actions++ }, 'Entry')), + })) + try { + const link = host.find(node => node.tag === 'a') + assert.equal((await click(host.document, link)).defaultPrevented, true) + assert.equal((await click(host.document, link, 'auxclick')).defaultPrevented, true) + assert.equal((await click(host.document, host.find(node => node.tag === 'li'))).defaultPrevented, true) + assert.equal(actions, 0) + } + finally { host.unmount() } +}) diff --git a/core/metaforge-ui/tests/space.test.mjs b/core/metaforge-ui/tests/space.test.mjs new file mode 100644 index 0000000000..0e685863af --- /dev/null +++ b/core/metaforge-ui/tests/space.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createSSRApp, h } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { component } from './component.mjs' + +const Space = await component('MfSpace') + +function renderSpace(props = {}) { + return renderToString(createSSRApp({ render: () => h(Space, props, () => [h('span', 'First'), h('span', 'Second')]) })) +} + +test('numeric size creates the requested pixel gap', async () => { + assert.match(await renderSpace({ size: 16 }), /style="[^"]*gap:16px/) +}) + +test('zero is accepted as an explicit gap', async () => { + assert.match(await renderSpace({ size: 0 }), /style="[^"]*gap:0px/) +}) + +test('wrap false keeps horizontal content on one line', async () => { + const html = await renderSpace({ wrap: false }) + assert.ok(html.includes('flex-nowrap')) + assert.ok(!html.includes('flex-wrap')) +}) + +test('default horizontal content can still wrap', async () => { + assert.ok((await renderSpace()).includes('flex-wrap')) +}) + +test('numeric spacing also applies to vertical content', async () => { + const html = await renderSpace({ direction: 'vertical', size: 16, fill: true }) + assert.ok(html.includes('flex-col')) + assert.ok(html.includes('w-full')) + assert.match(html, /gap:16px/) +}) diff --git a/core/metaforge-ui/tests/table-toolbar.test.mjs b/core/metaforge-ui/tests/table-toolbar.test.mjs new file mode 100644 index 0000000000..624976c522 --- /dev/null +++ b/core/metaforge-ui/tests/table-toolbar.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { h, nextTick, ref } from 'vue' +import { component } from './component.mjs' +import { click, InteractionEvent, mountHost } from './interaction-host.mjs' + +const Toolbar = await component('MfTableToolbar') + +test('more filters toggle in place and retain entered values without submitting', async () => { + const value = ref('saved code') + let queries = 0 + const host = mountHost(h(Toolbar, { onSearch: () => queries++ }, { + filter: () => h('input', { 'data-basic': true }), + 'advanced-filter': () => h('input', { value: value.value, 'data-extra': true }), + })) + try { + const toggle = () => host.find(node => node.tag === 'button' && node.props['aria-expanded'] !== undefined) + const field = host.find(node => node.props['data-extra']) + assert.equal(toggle().props['aria-expanded'], false) + assert.equal(toggle().props.type, 'button') + assert.equal(field.parent.style.display, 'none') + await click(host.document, toggle()) + assert.equal(toggle().props['aria-expanded'], true) + assert.equal(field.parent.style.display, '') + value.value = 'new code' + await nextTick() + await click(host.document, toggle()) + await click(host.document, toggle()) + assert.equal(host.find(node => node.props['data-extra']), field) + assert.equal(field.value, 'new code') + assert.equal(queries, 0) + } + finally { host.unmount() } +}) + +test('form submission and reset emit their own action exactly once', async () => { + let searches = 0 + let resets = 0 + const host = mountHost(h(Toolbar, { onSearch: () => searches++, onReset: () => resets++ }, { + filter: () => h('input'), + })) + try { + const form = host.find(node => node.tag === 'form') + const event = new InteractionEvent('submit', [form]) + form.props.onSubmit(event) + assert.equal(event.defaultPrevented, true) + assert.equal(searches, 1) + const reset = host.find(node => node.tag === 'button' && node.textContent.trim() === 'Reset') + assert.equal(reset.props.type, 'button') + await click(host.document, reset) + assert.equal(searches, 1) + assert.equal(resets, 1) + assert.ok(!host.find(node => node.props['aria-expanded'] !== undefined)) + } + finally { host.unmount() } +}) + +test('loading prevents repeated search and reset while tools and business actions stay available', () => { + const host = mountHost(h(Toolbar, { loading: true }, { + filter: () => h('input'), + actions: () => h('button', { 'data-create': true }, 'Create'), + tools: () => h('button', { 'data-columns': true }, 'Columns'), + })) + try { + assert.equal(host.find(node => node.tag === 'button' && node.props.type === 'submit').disabled, true) + assert.equal(host.find(node => node.tag === 'button' && node.textContent.trim() === 'Reset').disabled, true) + assert.ok(host.find(node => node.props['data-create'])) + assert.ok(host.find(node => node.props['data-columns'])) + } + finally { host.unmount() } +}) diff --git a/core/metaforge-ui/tests/tooltip.test.mjs b/core/metaforge-ui/tests/tooltip.test.mjs new file mode 100644 index 0000000000..5ba717c115 --- /dev/null +++ b/core/metaforge-ui/tests/tooltip.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { h, nextTick, ref } from 'vue' +import { component } from './component.mjs' +import { InteractionEvent, keydown, mountHost } from './interaction-host.mjs' + +const Tooltip = await component('MfTooltip') +const settle = async () => { await nextTick(); await nextTick() } +const trigger = host => host.find(node => node.props.onMouseenter) +const panel = host => host.find(node => node.props.role === 'tooltip') + +test('tooltip text is mounted outside the table scroll container only while shown', async () => { + const host = mountHost(h('div', { 'data-scroll': true }, [h(Tooltip, { content: 'Assign menus to the system administrator' }, () => h('button', 'Assign'))])) + try { + const scroll = host.find(node => node.props['data-scroll']) + assert.ok(!panel(host)) + trigger(host).props.onMouseenter() + await settle() + assert.ok(host.document.body.contains(panel(host))) + assert.equal(scroll.contains(panel(host)), false) + assert.ok(panel(host).textContent.includes('Assign menus')) + trigger(host).props.onMouseleave() + await settle() + assert.ok(!panel(host)) + assert.equal(host.document.count('scroll'), 0) + assert.equal(host.document.count('keydown'), 0) + } + finally { host.unmount() } +}) + +test('tooltip flips vertically and clamps horizontally at viewport edges', async () => { + for (const placement of ['top', 'bottom']) { + const host = mountHost(h(Tooltip, { content: 'Long tooltip', placement }, () => h('button', 'Action'))) + try { + host.document.defaultView.innerWidth = 337 + host.document.defaultView.innerHeight = 257 + host.document.documentElement = { clientWidth: 320, clientHeight: 240 } + const top = placement === 'top' ? 2 : 200 + trigger(host).getBoundingClientRect = () => ({ left: 280, right: 312, top, bottom: top + 32, width: 32, height: 32 }) + trigger(host).props.onMouseenter() + await settle() + const overlay = panel(host) + const left = Number.parseFloat(overlay.style.left) + const y = Number.parseFloat(overlay.style.top) + assert.ok(left >= 8) + assert.ok(left + overlay.getBoundingClientRect().width <= 312) + assert.ok(y >= 8 && y + overlay.getBoundingClientRect().height <= 232) + assert.equal(y, placement === 'top' ? 40 : 158) + } + finally { host.unmount() } + } +}) + +test('keyboard focus keeps the tooltip open until Escape or focus leaves the trigger', async () => { + const host = mountHost(h(Tooltip, { content: 'Help' }, () => h('button', 'Action'))) + try { + const root = trigger(host) + root.props.onFocusin() + root.props.onMouseenter() + await settle() + root.props.onMouseleave() + await settle() + assert.ok(panel(host)) + await keydown(host.document, host.find(node => node.tag === 'button'), 'Escape') + assert.ok(!panel(host)) + root.props.onFocusout(new InteractionEvent('focusout', [root], { relatedTarget: host.container })) + root.props.onFocusin() + await settle() + assert.ok(panel(host)) + root.props.onFocusout(new InteractionEvent('focusout', [root], { relatedTarget: host.container })) + await settle() + assert.ok(!panel(host)) + } + finally { host.unmount() } +}) + +test('empty content creates no invisible overflowing panel', async () => { + const content = ref('') + const host = mountHost(() => h(Tooltip, { content: content.value }, () => h('button', 'Action'))) + try { + trigger(host).props.onMouseenter() + await settle() + assert.ok(!panel(host)) + assert.equal(host.document.count('scroll'), 0) + content.value = 'Now available' + await settle() + assert.ok(panel(host)) + content.value = '' + await settle() + assert.ok(!panel(host)) + } + finally { host.unmount() } +}) + +test('size changes and scrolling update position, and unmount removes observers and listeners', async () => { + const previousObserver = globalThis.ResizeObserver + const observers = [] + globalThis.ResizeObserver = class { + constructor(callback) { this.callback = callback; this.targets = []; this.disconnected = false; observers.push(this) } + observe(target) { this.targets.push(target) } + disconnect() { this.disconnected = true } + } + const host = mountHost(h(Tooltip, { content: 'Resize me' }, () => h('button', 'Action'))) + try { + trigger(host).props.onMouseenter() + await settle() + assert.equal(observers.length, 1) + assert.equal(observers[0].targets.length, 2) + host.document.defaultView.innerWidth = 320 + panel(host).getBoundingClientRect = () => ({ left: 0, right: 304, top: 0, bottom: 80, width: 304, height: 80 }) + observers[0].callback() + await settle() + assert.equal(panel(host).style.left, '8px') + trigger(host).getBoundingClientRect = () => ({ left: 20, right: 52, top: 150, bottom: 182, width: 32, height: 32 }) + host.document.dispatchEvent(new Event('scroll')) + await settle() + assert.equal(panel(host).style.top, '64px') + } + finally { host.unmount(); globalThis.ResizeObserver = previousObserver } + assert.equal(observers[0].disconnected, true) + assert.equal(host.document.count('scroll'), 0) + assert.equal(host.document.count('pointerdown'), 0) +}) diff --git a/core/metaforge-ui/tests/vue-runtime.mjs b/core/metaforge-ui/tests/vue-runtime.mjs new file mode 100644 index 0000000000..8ee346cbc8 --- /dev/null +++ b/core/metaforge-ui/tests/vue-runtime.mjs @@ -0,0 +1,10 @@ +export * from 'vue' + +// Interaction tests exercise real Vue state/lifecycle without a browser or +// animation clock. Only the visual transition wrapper is replaced. +export const Transition = { + inheritAttrs: false, + setup(_props, { slots }) { + return () => slots.default?.() + }, +} diff --git a/core/metaforge-web/package.json b/core/metaforge-web/package.json index f153701ecd..90c79000dc 100644 --- a/core/metaforge-web/package.json +++ b/core/metaforge-web/package.json @@ -6,15 +6,16 @@ "type": "module", "scripts": { "dev": "vite", + "test": "node --test tests/*.test.mjs", "build": "vue-tsc --skipLibCheck && vite build", "preview": "vite preview" }, "dependencies": { - "@ant-design/icons-vue": "^7.0.1", "@antv/x6": "^2.18.1", "@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/vue-fontawesome": "^3.0.6", + "@lucide/vue": "^1.62.0", "@visactor/vchart": "^1.10.0", "@visactor/vtable": "^0.21.2", "@vue-flow/background": "^1.3.0", @@ -61,4 +62,4 @@ "engines": { "node": ">=20.0.0" } -} \ No newline at end of file +} diff --git a/core/metaforge-web/src/config/menu-icons.ts b/core/metaforge-web/src/config/menu-icons.ts new file mode 100644 index 0000000000..232b7662cf --- /dev/null +++ b/core/metaforge-web/src/config/menu-icons.ts @@ -0,0 +1,94 @@ +import * as LucideIcons from '@lucide/vue' +import { + CircleCheck, + CircleHelp, + CircleX, + Clock, + Columns3, + Eraser, + Info, + LayoutDashboard, + LayoutGrid, + Layers, + MoveVertical, + Network, + PanelLeftClose, + PanelLeftOpen, + Play, + RefreshCw, + RotateCw, + Server, + Settings, + SquareFunction, + SquareX, + Boxes, + FileCode2, + ArrowUpNarrowWide, + Rows3, + Type, + TriangleAlert, + Zap, + ArrowUpToLine +} from '@lucide/vue' + +/** + * 历史兼容:lucide 迁移前,存量菜单 icon 字段保存的是 antd 图标名(去掉 Outlined 后缀)。 + * 这里把这些旧名称映射到最接近的 lucide 图标,保证升级部署的存量菜单仍可渲染; + * 与 lucide 导出同名的旧名称(Table、History、User、Delete、Edit 等)无需映射, + * 已由下方全量注册覆盖。 + */ +const LEGACY_ANTD_ALIASES: Record = { + Appstore: LayoutGrid, + CaretRight: Play, + CheckCircle: CircleCheck, + Clear: Eraser, + ClockCircle: Clock, + CloseCircle: CircleX, + CloseSquare: SquareX, + CloudServer: Server, + Cluster: Network, + ColumnHeight: MoveVertical, + Dashboard: LayoutDashboard, + DeploymentUnit: Boxes, + FontSize: Type, + Function: SquareFunction, + InfoCircle: Info, + InsertRowRight: Columns3, + MenuFold: PanelLeftClose, + MenuUnfold: PanelLeftOpen, + Partition: Layers, + QuestionCircle: CircleHelp, + Reload: RotateCw, + Setting: Settings, + Snippets: FileCode2, + SortAscending: ArrowUpNarrowWide, + SplitCells: Rows3, + Sync: RefreshCw, + Thunderbolt: Zap, + VerticalAlignTop: ArrowUpToLine, + Warning: TriangleAlert +} + +/** + * 菜单图标注册表:key 为存储在菜单 icon 字段的图标名,value 为渲染组件。 + * 图标名统一使用 lucide 命名(种子数据见 metaforge-server/schema/setup-data.sql), + * 全量收录 @lucide/vue 导出的图标组件,供菜单图标选择器搜索与渲染。 + */ + +// @lucide/vue 的辅助导出(非图标组件),不参与注册。 +const NON_ICON_EXPORTS = new Set(['icons', 'createLucideIcon']) + +export const MENU_ICONS: Record = {} + +for (const [name, component] of Object.entries(LEGACY_ANTD_ALIASES)) { + MENU_ICONS[name] = component +} + +for (const [name, component] of Object.entries(LucideIcons)) { + if (NON_ICON_EXPORTS.has(name)) { + continue + } + if (!MENU_ICONS[name]) { + MENU_ICONS[name] = component + } +} diff --git a/core/metaforge-web/src/i18n/I18n.ts b/core/metaforge-web/src/i18n/I18n.ts index 25bb724869..5b054ffed9 100644 --- a/core/metaforge-web/src/i18n/I18n.ts +++ b/core/metaforge-web/src/i18n/I18n.ts @@ -1,12 +1,13 @@ import { createI18n } from 'vue-i18n' import { inject, provide, ref } from 'vue' import I18nService from '@/services/i18n' +import { createLocaleLoader, normalizeLocale } from './locale-loader' // 默认语言配置 // Default locale -const DEFAULT_LOCALE = 'zh_cn' +const DEFAULT_LOCALE = 'zh-cn' const DEFAULT_MESSAGES = { - zh_cn: { + 'zh-cn': { // 基础的回退翻译 // Basic fallback translation common: { @@ -164,8 +165,6 @@ const DEFAULT_MESSAGES = { } } -const language = (navigator.language || 'en').toLocaleLowerCase() - const i18n = createI18n({ fallbackLocale: DEFAULT_LOCALE, globalInjection: true, @@ -174,10 +173,17 @@ const i18n = createI18n({ fallbackWarn: false, silentTranslationWarn: true, missingWarn: false, - locale: localStorage.getItem('locale') || language.split('-')[0] || DEFAULT_LOCALE, + flatJson: true, + locale: normalizeLocale(localStorage.getItem('locale') || navigator.language || DEFAULT_LOCALE), messages: DEFAULT_MESSAGES }) +const ensureLocaleLoaded = createLocaleLoader( + locale => I18nService.getLocale(locale), + (locale, messages) => i18n.global.mergeLocaleMessage(locale, messages), +) +let localeRequest = 0 + export const I18N_KEY = Symbol('i18n-handler') export const loadingState = ref(false) @@ -195,62 +201,29 @@ export function provideI18nHandler() } const loadLocale = async (locale: string) => { - // 如果请求的语言包已经加载过,直接使用 - // If the requested language package has been loaded, use it directly - // @ts-ignore - if (i18n.global.availableLocales.includes(locale) && - Object.keys(i18n.global.getLocaleMessage(locale)).length > 1) { - // @ts-ignore - i18n.global.locale.value = locale - localStorage.setItem('locale', locale) - return true - } - + const request = ++localeRequest resetState() loadingState.value = true loadingText.value = i18n.global.t('common.loading') try { - const response = await I18nService.getLocale(locale) - if (response.status && response.data && Object.keys(response.data).length > 0) { - i18n.global.setLocaleMessage(locale, { - ...DEFAULT_MESSAGES[DEFAULT_LOCALE], - ...response.data - }) - // @ts-ignore - i18n.global.locale.value = locale - localStorage.setItem('locale', locale) - resetState() - return true - } - else { - throw new Error('Invalid locale data received') - } + const normalized = await ensureLocaleLoaded(locale) + if (request !== localeRequest) return false + i18n.global.locale.value = normalized + localStorage.setItem('locale', normalized) + resetState() + return true } catch (error) { + if (request !== localeRequest) return false errorState.value = true errorMessage.value = error instanceof Error ? error.message : i18n.global.t('common.loadingFailed') - // 如果加载失败,确保至少有基础翻译可用 - // Ensure that at least the basic translation is available - // @ts-ignore - if (!i18n.global.availableLocales.includes(locale)) { - i18n.global.setLocaleMessage(locale, DEFAULT_MESSAGES[DEFAULT_LOCALE]) - } - - // 如果当前语言加载失败,尝试回退到默认语言 - // If the current language fails to load, try to fall back to the default language - if (locale !== DEFAULT_LOCALE && - !i18n.global.availableLocales.includes(i18n.global.locale.value)) { - i18n.global.locale.value = DEFAULT_LOCALE - localStorage.setItem('locale', DEFAULT_LOCALE) - } - console.error('Failed to load locale:', error) return false } finally { - loadingState.value = false + if (request === localeRequest) loadingState.value = false } } diff --git a/core/metaforge-web/src/i18n/locale-loader.ts b/core/metaforge-web/src/i18n/locale-loader.ts new file mode 100644 index 0000000000..8dd4351087 --- /dev/null +++ b/core/metaforge-web/src/i18n/locale-loader.ts @@ -0,0 +1,46 @@ +export type SupportedLocale = 'zh-cn' | 'en' +export type LocaleMessages = Record + +interface LocaleResponse { + status: boolean + data?: unknown + message?: unknown +} + +export function normalizeLocale(locale: string): SupportedLocale { + const normalized = locale.toLowerCase().replace(/_/g, '-') + return normalized === 'zh' || normalized.startsWith('zh-') ? 'zh-cn' : 'en' +} + +/** Only successful server responses count as loaded language packs. */ +export function createLocaleLoader( + fetchLocale: (locale: SupportedLocale) => Promise, + applyMessages: (locale: SupportedLocale, messages: LocaleMessages) => void, +) { + const loaded = new Set() + const pending = new Map>() + + return async (locale: string): Promise => { + const normalized = normalizeLocale(locale) + if (loaded.has(normalized)) return normalized + + let request = pending.get(normalized) + if (!request) { + request = Promise.resolve() + .then(() => fetchLocale(normalized)) + .then(response => { + const messages = response.data + if (!response.status || !messages || typeof messages !== 'object' + || Array.isArray(messages) || Object.keys(messages).length === 0) { + throw new Error(!response.status && typeof response.message === 'string' ? response.message : 'Invalid locale data received') + } + applyMessages(normalized, messages as LocaleMessages) + loaded.add(normalized) + }) + .finally(() => pending.delete(normalized)) + pending.set(normalized, request) + } + await request + return normalized + } +} diff --git a/core/metaforge-web/src/model/filter.ts b/core/metaforge-web/src/model/filter.ts index 34b2ab4307..eaf64e9740 100644 --- a/core/metaforge-web/src/model/filter.ts +++ b/core/metaforge-web/src/model/filter.ts @@ -1,7 +1,29 @@ import { OrderModel } from '@/model/order' +export interface TableFilters +{ + keyword: string + active?: boolean + code: string + createdFrom: string + createdTo: string + conditions: Record +} + +export function emptyTableFilters(): TableFilters +{ + return { keyword: '', active: undefined, code: '', createdFrom: '', createdTo: '', conditions: {} } +} + export class FilterModel + implements TableFilters { + keyword = '' + active: boolean | undefined = undefined + code = '' + createdFrom = '' + createdTo = '' + conditions: TableFilters['conditions'] = {} page = 1 size = 10 orders: Array | undefined diff --git a/core/metaforge-web/src/model/role.ts b/core/metaforge-web/src/model/role.ts index 8abe588ded..d1289674da 100644 --- a/core/metaforge-web/src/model/role.ts +++ b/core/metaforge-web/src/model/role.ts @@ -14,6 +14,7 @@ export class RoleRequest { return { id: undefined, + code: undefined, name: undefined, description: undefined } diff --git a/core/metaforge-web/src/model/user/response/auth.ts b/core/metaforge-web/src/model/user/response/auth.ts index 762e4fbc03..448fc702ea 100644 --- a/core/metaforge-web/src/model/user/response/auth.ts +++ b/core/metaforge-web/src/model/user/response/auth.ts @@ -4,6 +4,6 @@ export interface AuthResponse type: string id: number username: string - roles: [] + roles: string[] avatar: string } diff --git a/core/metaforge-web/src/router/default.ts b/core/metaforge-web/src/router/default.ts index 5a920c3611..c451640d1b 100644 --- a/core/metaforge-web/src/router/default.ts +++ b/core/metaforge-web/src/router/default.ts @@ -1,147 +1,21 @@ +import type { Router } from 'vue-router' import LayoutContainer from '@/views/layouts/common/LayoutContainer.vue' import ProfileContainer from '@/views/layouts/profile/LayoutContainer.vue' -import { TokenUtils } from '@/utils/token' import MetadataContainer from '@/views/layouts/metadata/MetadataContainer.vue' +import { accountPages, businessPages, canonicalPagePath } from './navigation' -/** - * Create a default router - * - * @param {any} router - the router object - * @return {void} - */ -const createDefaultRouter = (router: any) => { - // Only when the token is valid, the redirection route will be built - if (TokenUtils.getAuthUser() !== undefined) { - const indexRouter = { - path: '/', - redirect: '/home', - component: LayoutContainer, - children: [ - { - name: 'home', - path: 'home', - component: () => import('@/views/pages/default/dashboard/DashboardHome.vue') - } - ] - } - router.addRoute(indexRouter) - - const storeRouter = { - path: '/', - meta: { - title: 'common.store', - isRoot: true - }, - component: LayoutContainer, - redirect: '/store', - children: [ - { - name: 'store', - path: 'store', - meta: { - title: 'common.store', - isRoot: false - }, - component: () => import('@/views/pages/store/StoreHome.vue') - } - ] - } - router.addRoute(storeRouter) - } - - createSystemRouter(router) - createAdminRouter(router) - createProfileRouter(router) -} - -/** - * Creates a system router and adds it to the provided router. - * - * @param {any} router - The router instance to which the system router will be added. - * @return {void} - */ -const createSystemRouter = (router: any) => { - const newRouter = { - path: '/system', - meta: { - title: 'common.system', - isRoot: true - }, - component: LayoutContainer, - children: [ - { - name: 'user', - path: 'user', - meta: { - title: 'common.user', - isRoot: false - }, - component: () => import('@/views/pages/system/user/UserHome.vue') - }, - { - name: 'role', - path: 'role', - meta: { - title: 'common.role', - isRoot: false - }, - component: () => import('@/views/pages/system/role/RoleHome.vue') - }, - { - name: 'schedule', - path: 'schedule', - meta: { - title: 'common.schedule', - isRoot: false - }, - component: () => import('@/views/pages/system/schedule/ScheduleHome.vue') - }, - { - name: 'function', - path: 'function', - meta: { - title: 'common.function', - isRoot: false - }, - component: () => import('@/views/pages/system/function/FunctionHome.vue') - }, - { - name: 'menu', - path: 'menu', - meta: { - title: 'common.menu', - isRoot: false - }, - component: () => import('@/views/pages/system/menu/MenuHome.vue') - }, - { - name: 'configure', - path: 'configure', - meta: { - title: 'configure.runtime.title', - isRoot: false - }, - component: () => import('@/views/pages/system/configure/ConfigureHome.vue') - } - ] - } - router.addRoute(newRouter) -} - -/** - * Creates an admin router and adds it to the given router. - * - * @param {any} router - The router to which the admin router will be added. - */ -const createAdminRouter = (router: any) => { - const newRouter = { - path: '/admin', - meta: { - title: 'common.admin', - isRoot: true - }, +const createDefaultRouter = (router: Router) => { + router.addRoute({ + name: 'app', + path: '/', + redirect: '/home', component: LayoutContainer, + meta: { requiresAuth: true }, children: [ + { name: 'home', path: 'home', component: () => import('@/views/pages/default/dashboard/DashboardHome.vue') }, + { name: 'store', path: 'store', meta: { title: 'common.store' }, component: () => import('@/views/pages/store/StoreHome.vue') }, + { name: 'workspace', path: 'workspace', redirect: '/query' }, + { name: 'analysis', path: 'analysis', redirect: '/dashboard' }, { name: 'dashboard', path: 'dashboard', @@ -178,7 +52,7 @@ const createAdminRouter = (router: any) => { }, { name: 'queryHistory', - path: 'query/:type?/:code?', + path: 'query/:type/:code?', meta: { title: 'common.query', isRoot: false @@ -196,7 +70,6 @@ const createAdminRouter = (router: any) => { }, { path: 'dataset/info/:code?', - layout: LayoutContainer, meta: { title: 'common.dataset', isRoot: false @@ -213,7 +86,6 @@ const createAdminRouter = (router: any) => { }, { path: 'dataset/adhoc/:code', - layout: LayoutContainer, meta: { title: 'common.dataset', isRoot: false @@ -222,7 +94,6 @@ const createAdminRouter = (router: any) => { }, { path: 'dataset/adhoc/:code/:id', - layout: LayoutContainer, meta: { title: 'common.dataset', isRoot: false @@ -349,19 +220,70 @@ const createAdminRouter = (router: any) => { component: () => import('@/views/pages/admin/wofkflow/WorkflowInfo.vue') } ] - } - router.addRoute(newRouter) -} - -const createProfileRouter = (router: any) => { - const newRouter = { - path: '/admin/user', - meta: { - title: 'user.common.setting', - isRoot: true - }, - redirect: '/admin/user/profile', - component: ProfileContainer, + }) + router.addRoute({ + name: 'administration', path: '/admin', redirect: '/admin/user', component: LayoutContainer, + meta: { requiresAuth: true, requiresAdmin: true, title: 'navigation.systemManagement' }, + children: [ + { + name: 'user', + path: 'user', + meta: { + title: 'common.user', + isRoot: false + }, + component: () => import('@/views/pages/system/user/UserHome.vue') + }, + { + name: 'role', + path: 'role', + meta: { + title: 'common.role', + isRoot: false + }, + component: () => import('@/views/pages/system/role/RoleHome.vue') + }, + { + name: 'schedule', + path: 'schedule', + meta: { + title: 'common.schedule', + isRoot: false + }, + component: () => import('@/views/pages/system/schedule/ScheduleHome.vue') + }, + { + name: 'function', + path: 'function', + meta: { + title: 'common.function', + isRoot: false + }, + component: () => import('@/views/pages/system/function/FunctionHome.vue') + }, + { + name: 'menu', + path: 'menu', + meta: { + title: 'common.menu', + isRoot: false + }, + component: () => import('@/views/pages/system/menu/MenuHome.vue') + }, + { + name: 'configure', + path: 'configure', + meta: { + title: 'configure.runtime.title', + isRoot: false + }, + component: () => import('@/views/pages/system/configure/ConfigureHome.vue') + } + ] + }) + router.addRoute({ + name: 'account', path: '/account', redirect: '/account/profile', component: ProfileContainer, + meta: { requiresAuth: true, title: 'navigation.accountSettings' }, children: [ { name: 'info', @@ -418,7 +340,7 @@ const createProfileRouter = (router: any) => { component: () => import('@/views/pages/admin/user/security/UsernameHome.vue') }, { - username: 'password', + name: 'password', path: 'password', meta: { title: 'user.common.modifyPassword', @@ -427,7 +349,7 @@ const createProfileRouter = (router: any) => { component: () => import('@/views/pages/admin/user/security/PasswordHome.vue') }, { - username: 'notify', + name: 'notify', path: 'notify', meta: { title: 'user.common.notify', @@ -436,10 +358,11 @@ const createProfileRouter = (router: any) => { component: () => import('@/views/pages/admin/user/profile/NotifyHome.vue') } ] + }) + const legacyPaths = ['/system/:legacyPath(.*)*', ...businessPages.map(page => `/admin/${page}/:legacyPath(.*)*`), ...accountPages.map(page => `/admin/user/${page}/:legacyPath(.*)*`)] + for (const path of legacyPaths) { + router.addRoute({ path, redirect: to => ({ path: canonicalPagePath(to.path), query: to.query, hash: to.hash, replace: true }) }) } - router.addRoute(newRouter) } -export { - createDefaultRouter -} +export { createDefaultRouter } diff --git a/core/metaforge-web/src/router/index.ts b/core/metaforge-web/src/router/index.ts index 5fb85c9e95..8d4341c0d1 100644 --- a/core/metaforge-web/src/router/index.ts +++ b/core/metaforge-web/src/router/index.ts @@ -4,6 +4,8 @@ import { createDefaultRouter } from '@/router/default' import { createHttpRoute } from '@/router/http' import { createSetupRoute } from '@/router/setup' import SetupService from '@/services/setup' +import { TokenUtils } from '@/utils/token' +import { pageAccess } from './navigation' const routes: Array = [] @@ -49,7 +51,9 @@ router.beforeEach(async (_to, _from, _next) => { _next('/auth/signin') } else { - _next() + const access = pageAccess(_to, TokenUtils.getAuthUser()) + if (access === true) _next() + else _next(access) } }) diff --git a/core/metaforge-web/src/router/navigation.ts b/core/metaforge-web/src/router/navigation.ts new file mode 100644 index 0000000000..5adc5f99e9 --- /dev/null +++ b/core/metaforge-web/src/router/navigation.ts @@ -0,0 +1,47 @@ +export const businessPages = ['query', 'source', 'dataset', 'dashboard', 'history', 'snippet', 'report', 'chat', 'workflow'] as const +export const accountPages = ['profile', 'info', 'log', 'editor', 'assistant', 'username', 'password', 'notify'] as const + +export function canonicalPagePath(path: string): string { + let result = path.replace(/^\/system(?=[/?#]|$)/i, '/admin') + result = result.replace(/^\/admin(?=[/?#]|$)/i, '/admin') + result = result.replace(/^\/admin\/user\/(profile|info|log|editor|assistant|username|password|notify)(?=[/?#]|$)/i, (_match, page) => `/account/${page.toLowerCase()}`) + result = result.replace(/^\/admin\/(query|source|dataset|dashboard|history|snippet|report|chat|workflow)(?=[/?#]|$)/i, (_match, page) => `/${page.toLowerCase()}`) + return result.replace(/^\/admin\/(user|role|menu|schedule|function|configure)(?=[/?#]|$)/i, (_match, page) => `/admin/${page.toLowerCase()}`) +} + +export function isAdministrator(user: { roles?: unknown } | null | undefined): boolean { + return Array.isArray(user?.roles) && user.roles.some(role => role === 'ADMIN' || role?.code === 'ADMIN') +} + +export function pageAccess(to: { fullPath: string; meta: Record }, user: { token?: unknown; roles?: unknown } | null | undefined) { + if (to.meta.requiresAuth && !(typeof user?.token === 'string' && user.token.length > 0)) { + return { path: '/auth/signin', query: { redirect: to.fullPath } } + } + if (to.meta.requiresAdmin && !isAdministrator(user)) return { path: '/common/403' } + return true +} + +export function signInTarget(value: unknown): string { + return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//') && !value.includes('\\') + && !/[\u0000-\u001f\u007f]/.test(value) && !/^\/(auth|setup)(?:[/?#]|$)/i.test(value) ? canonicalPagePath(value) : '/home' +} + +/** Normalize stale menu caches until the current catalogue is fetched from the server. */ +export function normalizeNavigation(menus: unknown, user: { roles?: unknown } | null | undefined): any[] { + if (!Array.isArray(menus)) return [] + return menus.flatMap(menu => { + if (!menu || typeof menu !== 'object') return [] + const original = typeof menu.url === 'string' ? menu.url : '' + const originalChildren = Array.isArray(menu.children) ? menu.children : [] + const development = (menu.code === 'MANAGEMENT' && (original === '/admin' || menu.i18nKey === 'common.admin')) || (!menu.code && original === '/admin' && originalChildren.some((child: any) => /^\/admin\/(query|source|dataset|workflow|snippet|history|report)(?:\/|$)/i.test(child?.url))) + const url = development ? '/workspace' : canonicalPagePath(original) + if (/^\/admin(?:[/?#]|$)/.test(url) && !isAdministrator(user)) return [] + const children = originalChildren.length ? normalizeNavigation(originalChildren, user) : undefined + if (originalChildren.length && children?.length === 0) return [] + const legacySystem = menu.code === 'SYSTEM' && (original === '/system' || menu.i18nKey === 'common.system') + const result = { ...menu, url, ...(development ? { i18nKey: 'navigation.dataDevelopment' } : legacySystem ? { i18nKey: 'navigation.systemManagement' } : {}) } + if (children?.length) result.children = children + else delete result.children + return [result] + }) +} diff --git a/core/metaforge-web/src/services/role.ts b/core/metaforge-web/src/services/role.ts index 3b57b2fd84..1d4bdf6a2c 100644 --- a/core/metaforge-web/src/services/role.ts +++ b/core/metaforge-web/src/services/role.ts @@ -1,6 +1,8 @@ import { BaseService } from '@/services/base' import { ResponseModel } from '@/model/response' import { HttpUtils } from '@/utils/http' +import type { RoleModel } from '@/model/role' +import { roleSaveTarget } from '@/utils/role-code' const DEFAULT_PATH = '/api/v1/role' @@ -12,6 +14,20 @@ class RoleService super(DEFAULT_PATH) } + checkCode(code: string): Promise + { + return new HttpUtils().get(`${DEFAULT_PATH}/check-code`, { code }) + } + + saveOrUpdate(configure: RoleModel, originalCode?: string): Promise + { + const target = roleSaveTarget(configure, originalCode) + const http = new HttpUtils() + return target.method === 'post' + ? http.post(DEFAULT_PATH, configure) + : http.put(`${DEFAULT_PATH}${target.suffix}`, configure) + } + getAllMenuById(id: number): Promise { return new HttpUtils().get(`${DEFAULT_PATH}/${id}/menus`) diff --git a/core/metaforge-web/src/services/schedule.ts b/core/metaforge-web/src/services/schedule.ts index e9043d11ad..ce53ff0b04 100644 --- a/core/metaforge-web/src/services/schedule.ts +++ b/core/metaforge-web/src/services/schedule.ts @@ -24,6 +24,28 @@ class ScheduleService { return new HttpUtils().post(`${DEFAULT_PATH}/${id}/history`, filter) } + + /** + * Trigger the scheduled task manually. + * + * @param {number} id - the ID of the schedule + * @return {Promise} the response model promise + */ + trigger(id: number): Promise + { + return new HttpUtils().post(`${DEFAULT_PATH}/${id}/trigger`, {}) + } + + /** + * Get the upcoming execution times of the schedule. + * + * @param {number} id - the ID of the schedule + * @return {Promise} the response model promise + */ + getUpcomingTimes(id: number): Promise + { + return new HttpUtils().post(`${DEFAULT_PATH}/${id}/upcoming`, {}) + } } export default new ScheduleService() diff --git a/core/metaforge-web/src/stores/user.ts b/core/metaforge-web/src/stores/user.ts index 0e2b7cfdba..94880f50d0 100644 --- a/core/metaforge-web/src/stores/user.ts +++ b/core/metaforge-web/src/stores/user.ts @@ -4,11 +4,12 @@ import type { AuthResponse } from '@/model/user/response/auth' import { TokenUtils } from '@/utils/token' import Common from '@/utils/common' import UserService from '@/services/user' +import { normalizeNavigation } from '@/router/navigation' export const useUserStore = defineStore('user', () => { // 初始化用户数据 const userInfo = ref(TokenUtils.getAuthUser() || {} as any) - const menu = ref(TokenUtils.getUserMenu() || []) + const menu = ref(normalizeNavigation(TokenUtils.getUserMenu(), TokenUtils.getAuthUser())) const isLoggedIn = computed(() => { return !!userInfo.value && Object.keys(userInfo.value).length > 0 @@ -29,8 +30,16 @@ export const useUserStore = defineStore('user', () => { // 更新菜单 const updateMenu = (newMenu: any) => { - menu.value = newMenu - localStorage.setItem(Common.menu, JSON.stringify(newMenu)) + menu.value = normalizeNavigation(newMenu, TokenUtils.getAuthUser()) + localStorage.setItem(Common.menu, JSON.stringify(menu.value)) + } + + const fetchMenus = async () => { + try { + const response = await UserService.getMenus() + if (response.status && Array.isArray(response.data)) updateMenu(response.data) + } + catch (error) { console.error('Failed to refresh navigation:', error) } } // 登出 @@ -48,6 +57,7 @@ export const useUserStore = defineStore('user', () => { isLoggedIn, fetchUserInfo, updateMenu, + fetchMenus, logout } -}) \ No newline at end of file +}) diff --git a/core/metaforge-web/src/style.css b/core/metaforge-web/src/style.css index 157dd99b43..a3737494c5 100644 --- a/core/metaforge-web/src/style.css +++ b/core/metaforge-web/src/style.css @@ -26,8 +26,15 @@ } -/* 容器:接近全宽 + 固定侧边距(原型比例),替代 1400px 居中限宽 */ +/* 页面容器铺满可用宽度,左右保留一致的内容边距。 */ html .container { - max-width: 1520px; + width: 100%; + max-width: none; padding-inline: 40px; } + +@media (max-width: 767px) { + html .container { + padding-inline: 16px; + } +} diff --git a/core/metaforge-web/src/utils/role-code.ts b/core/metaforge-web/src/utils/role-code.ts new file mode 100644 index 0000000000..8d61ab1b85 --- /dev/null +++ b/core/metaforge-web/src/utils/role-code.ts @@ -0,0 +1,17 @@ +export function normalizeRoleCode(code?: string): string { + return (code || '').trim() +} + +export function validRoleCode(code?: string): boolean { + const value = normalizeRoleCode(code) + return !value || /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value) +} + +export function roleSaveTarget(role: { id?: number; code?: string }, originalCode?: string): { method: 'post' | 'put'; suffix: string } { + if (originalCode) return { method: 'put', suffix: `/${encodeURIComponent(originalCode)}` } + return { method: role.id && role.id > 0 ? 'put' : 'post', suffix: '' } +} + +export function roleErrorKey(code: number): string | undefined { + return ({ 4028: 'role.validate.codeFormat', 4029: 'role.validate.codeExists', 4030: 'role.tip.codeReadonly', 4031: 'role.validate.identityMismatch', 4032: 'role.validate.codeConstraint', 4033: 'role.validate.invalidMenus', 4034: 'role.validate.menuSaveFailed', 4035: 'role.validate.menuLoadFailed' } as Record)[code] +} diff --git a/core/metaforge-web/src/views/auth/signin/AuthSignin.vue b/core/metaforge-web/src/views/auth/signin/AuthSignin.vue index d586206d96..b9a2dfcbef 100644 --- a/core/metaforge-web/src/views/auth/signin/AuthSignin.vue +++ b/core/metaforge-web/src/views/auth/signin/AuthSignin.vue @@ -101,7 +101,7 @@ import UserService from '@/services/user' import CaptchaService from '@/services/captcha' import CommonUtils from '@/utils/common' import router from '@/router' -import { createDefaultRouter } from '@/router/default' +import { signInTarget } from '@/router/navigation' import BaseLayout from '@/views/layouts/base/BaseLayout.vue' interface Props @@ -152,14 +152,14 @@ const onSubmit = async () => { if (loginResponse.status) { localStorage.setItem(CommonUtils.token, JSON.stringify(loginResponse.data)) + userStore.userInfo = loginResponse.data // 获取用户信息和菜单 const menuResponse = await UserService.getMenus() if (menuResponse.status) { userStore.updateMenu(menuResponse.data) // 更新路由并跳转 - createDefaultRouter(router) - router.push('/home') + await router.replace(signInTarget(router.currentRoute.value.query.redirect)) } else { if (!menuResponse.status) { diff --git a/core/metaforge-web/src/views/components/editor/builder/DataBuilderEditor.vue b/core/metaforge-web/src/views/components/editor/builder/DataBuilderEditor.vue index 19d3477da0..ea6d60c4b9 100644 --- a/core/metaforge-web/src/views/components/editor/builder/DataBuilderEditor.vue +++ b/core/metaforge-web/src/views/components/editor/builder/DataBuilderEditor.vue @@ -13,7 +13,7 @@ class="flex items-center gap-2 px-2 py-1.5 mb-1 rounded border bg-white cursor-grab hover:border-[var(--dc-primary)] hover:shadow-sm text-sm" draggable="true" @dragstart="onPanelDragStart($event, child)"> - + {{ child.label }} @@ -118,7 +118,7 @@ + + diff --git a/core/metaforge-web/src/views/components/table/tableFilters.ts b/core/metaforge-web/src/views/components/table/tableFilters.ts new file mode 100644 index 0000000000..4411552d19 --- /dev/null +++ b/core/metaforge-web/src/views/components/table/tableFilters.ts @@ -0,0 +1,51 @@ +import { computed } from 'vue' +import { useI18n } from 'vue-i18n' + +export type TableResource = 'source' | 'dataset' | 'user' | 'role' | 'menu' | 'function' | 'workflow' | 'history' | 'log' | 'snippet' | 'report' | 'schedule' + +export interface TableFilterField +{ + key: string + label: string + type: 'text' | 'select' | 'date' + advanced?: boolean + placeholder?: string + options?: { value: string; label: string }[] +} + +export function createTableFilterFields(resource: TableResource, t: (key: string) => string): TableFilterField[] +{ + const text = (key: string, label: string, advanced = false): TableFilterField => ({ key, label: t(label), type: 'text', advanced }) + const select = (key: string, label: string, values: [string, string][], advanced = false): TableFilterField => ({ + key, label: t(label), type: 'select', advanced, + options: [{ value: '', label: t('dataTable.all') }, ...values.map(([value, title]) => ({ value, label: t(title) }))] + }) + const boolean = (key: string, label: string, advanced = false) => select(key, label, [['true', 'dataTable.yes'], ['false', 'dataTable.no']], advanced) + const active = (advanced = false) => select('active', 'common.active', [['true', 'dataTable.enabled'], ['false', 'dataTable.disabled']], advanced) + const dates = (login = false): TableFilterField[] => [ + { key: 'createdFrom', label: t(login ? 'dataTable.loginFrom' : 'dataTable.createdFrom'), type: 'date', advanced: true }, + { key: 'createdTo', label: t(login ? 'dataTable.loginTo' : 'dataTable.createdTo'), type: 'date', advanced: true } + ] + const keyword = text('keyword', resource === 'user' ? 'common.username' : 'common.name') + const result = [['SUCCESS', 'dataTable.success'], ['FAILURE', 'dataTable.failure']] as [string, string][] + switch (resource) { + case 'source': return [keyword, select('available', 'common.available', [['true', 'dataTable.available'], ['false', 'dataTable.unavailable']]), text('type', 'source.common.type', true), text('host', 'common.host', true), text('protocol', 'common.protocol', true), boolean('publish', 'common.public', true), ...dates()] + case 'dataset': return [keyword, select('syncMode', 'dataset.common.syncMode', [['MANUAL', 'dataset.common.syncModeManual'], ['TIMING', 'dataset.common.syncModeTiming'], ['OUT_SYNC', 'dataset.common.syncModeOutSync']]), text('executor', 'common.executor', true), text('scheduler', 'common.scheduler', true), ...dates()] + case 'user': return [keyword, boolean('system', 'dataTable.systemAccount'), active(true), ...dates()] + case 'role': return [keyword, text('code', 'common.code'), text('description', 'common.description', true), ...dates()] + case 'menu': return [keyword, text('group', 'common.group'), active(true), text('url', 'dataTable.menuPath', true), ...dates()] + case 'function': return [keyword, select('type', 'common.type', [['KEYWORD', 'function.common.keyword'], ['OPERATOR', 'function.common.operator'], ['FUNCTION', 'function.common.function']]), text('plugin', 'common.plugin', true), text('content', 'common.content', true), ...dates()] + case 'workflow': return [keyword, select('state', 'common.state', ['CREATED', 'QUEUE', 'RUNNING', 'SUCCESS', 'FAILURE', 'TIMEOUT', 'STOPPING', 'STOPPED', 'INTERRUPTED'].map(value => [value, `dataTable.state${value}`])), text('executor', 'common.executor', true), ...dates()] + case 'history': return [text('content', 'dataTable.sqlContent'), select('state', 'common.state', result), select('mode', 'common.from', ['ADHOC', 'HISTORY', 'REPORT', 'SNIPPET', 'SYNC', 'DATASET'].map(value => [value, `dataTable.mode${value}`]), true), ...dates()] + case 'log': return [text('ip', 'common.ip'), select('state', 'dataTable.loginResult', result), text('device', 'common.device', true), text('client', 'common.client', true), ...dates(true)] + case 'snippet': return [keyword, text('context', 'dataTable.sqlContent'), text('description', 'common.description', true), ...dates()] + case 'report': return [keyword, select('type', 'common.type', [['QUERY', 'dataTable.queryReport'], ['DATASET', 'dataTable.datasetReport']]), boolean('realtime', 'common.realtime', true), ...dates()] + case 'schedule': return [keyword, active(), boolean('isSystem', 'dataTable.systemTask', true), text('expression', 'common.expression', true), ...dates()] + } +} + +export function useTableFilters(resource: TableResource) +{ + const { t } = useI18n() + return computed(() => createTableFilterFields(resource, t)) +} diff --git a/core/metaforge-web/src/views/components/tree/MetadataTree.vue b/core/metaforge-web/src/views/components/tree/MetadataTree.vue index c5679403e4..4bd1eed2aa 100644 --- a/core/metaforge-web/src/views/components/tree/MetadataTree.vue +++ b/core/metaforge-web/src/views/components/tree/MetadataTree.vue @@ -11,11 +11,11 @@ @select="onSelect"> @@ -29,7 +29,7 @@ import { MfMessage } from 'metaforge-ui' import { StructureEnum, StructureModel } from '@/model/structure' import MetadataService from '@/services/metadata.ts' import { ObjectUtils } from '@/utils/object' -import { DatabaseOutlined, InsertRowRightOutlined, TableOutlined } from '@ant-design/icons-vue' +import { Columns3, Database, Table } from '@lucide/vue' defineOptions({ name: 'MetadataTree' }) diff --git a/core/metaforge-web/src/views/components/visual/VisualEditor.vue b/core/metaforge-web/src/views/components/visual/VisualEditor.vue index 3256bcbc5e..0f731f0335 100644 --- a/core/metaforge-web/src/views/components/visual/VisualEditor.vue +++ b/core/metaforge-web/src/views/components/visual/VisualEditor.vue @@ -38,7 +38,7 @@ - + @@ -251,7 +251,7 @@ import VisualFunnel from '@/views/components/visual/components/VisualFunnel.vue' import VisualGauge from '@/views/components/visual/components/VisualGauge.vue' import { createdConfigure } from '@/views/components/visual/Utils.ts' import VisualRose from '@/views/components/visual/components/VisualRose.vue' -import { TableOutlined } from '@ant-design/icons-vue' +import { Table } from '@lucide/vue' defineOptions({ name: 'VisualEditor' }) diff --git a/core/metaforge-web/src/views/layouts/base/BaseLayout.vue b/core/metaforge-web/src/views/layouts/base/BaseLayout.vue index 25b0e38013..ac44fe5c57 100644 --- a/core/metaforge-web/src/views/layouts/base/BaseLayout.vue +++ b/core/metaforge-web/src/views/layouts/base/BaseLayout.vue @@ -22,6 +22,7 @@ diff --git a/core/metaforge-web/src/views/layouts/profile/LayoutContainer.vue b/core/metaforge-web/src/views/layouts/profile/LayoutContainer.vue index 739977f3a7..c06b5db867 100644 --- a/core/metaforge-web/src/views/layouts/profile/LayoutContainer.vue +++ b/core/metaforge-web/src/views/layouts/profile/LayoutContainer.vue @@ -1,18 +1,19 @@ @@ -119,7 +119,7 @@ import { toNumber } from 'lodash' import { cn } from '@/lib/utils.ts' import { MessageModel } from '@/model/message.ts' import MessageService from '@/services/message.ts' -import { PlusOutlined, SendOutlined } from '@ant-design/icons-vue' +import { Plus, Send } from '@lucide/vue' defineOptions({ name: 'ChatHome' }) diff --git a/core/metaforge-web/src/views/pages/admin/dashboard/DashboardHome.vue b/core/metaforge-web/src/views/pages/admin/dashboard/DashboardHome.vue index 6e7d4d2fa6..7ff10275ff 100644 --- a/core/metaforge-web/src/views/pages/admin/dashboard/DashboardHome.vue +++ b/core/metaforge-web/src/views/pages/admin/dashboard/DashboardHome.vue @@ -2,9 +2,9 @@