This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git
The following commit(s) were added to refs/heads/main by this push:
new f1543613a feat:support comlexity data scope (#8593)
f1543613a is described below
commit f1543613a9673acb64d7b4da549a10131ae275a6
Author: Warren Chen <[email protected]>
AuthorDate: Fri Sep 26 19:58:09 2025 +0800
feat:support comlexity data scope (#8593)
* feat:support comlexicy data scope
* fix: scopedata
* fix: unit test
---
backend/plugins/q_dev/api/init.go | 14 +
backend/plugins/q_dev/api/s3_slice_api.go | 121 +++++++
backend/plugins/q_dev/impl/impl.go | 15 +-
backend/plugins/q_dev/impl/impl_test.go | 2 +-
.../20250926_add_s3_slice_table.go} | 29 +-
.../{register.go => archived/s3_slice.go} | 24 +-
.../q_dev/models/migrationscripts/register.go | 1 +
backend/plugins/q_dev/models/s3_slice.go | 231 ++++++++++++
config-ui/src/plugins/register/q-dev/config.tsx | 9 +
.../src/plugins/register/q-dev/data-scope.tsx | 400 +++++++++++++++++++++
10 files changed, 821 insertions(+), 25 deletions(-)
diff --git a/backend/plugins/q_dev/api/init.go
b/backend/plugins/q_dev/api/init.go
index 7a16d5f0c..3bb67450b 100644
--- a/backend/plugins/q_dev/api/init.go
+++ b/backend/plugins/q_dev/api/init.go
@@ -21,12 +21,15 @@ import (
"github.com/apache/incubator-devlake/core/context"
"github.com/apache/incubator-devlake/core/plugin"
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/helpers/srvhelper"
+ "github.com/apache/incubator-devlake/plugins/q_dev/models"
"github.com/go-playground/validator/v10"
)
var vld *validator.Validate
var connectionHelper *api.ConnectionApiHelper
var basicRes context.BasicRes
+var dsHelper *api.DsHelper[models.QDevConnection, models.QDevS3Slice,
srvhelper.NoScopeConfig]
func Init(br context.BasicRes, p plugin.PluginMeta) {
basicRes = br
@@ -36,4 +39,15 @@ func Init(br context.BasicRes, p plugin.PluginMeta) {
vld,
p.Name(),
)
+
+ dsHelper = api.NewDataSourceHelper[
+ models.QDevConnection, models.QDevS3Slice,
srvhelper.NoScopeConfig,
+ ](
+ basicRes,
+ p.Name(),
+ []string{"prefix", "basePath", "name"},
+ func(c models.QDevConnection) models.QDevConnection { return
c.Sanitize() },
+ func(s models.QDevS3Slice) models.QDevS3Slice { return
s.Sanitize() },
+ nil,
+ )
}
diff --git a/backend/plugins/q_dev/api/s3_slice_api.go
b/backend/plugins/q_dev/api/s3_slice_api.go
new file mode 100644
index 000000000..737081302
--- /dev/null
+++ b/backend/plugins/q_dev/api/s3_slice_api.go
@@ -0,0 +1,121 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+ "github.com/apache/incubator-devlake/helpers/srvhelper"
+ "github.com/apache/incubator-devlake/plugins/q_dev/models"
+)
+
+type PutScopesReqBody = helper.PutScopesReqBody[models.QDevS3Slice]
+type ScopeDetail = srvhelper.ScopeDetail[models.QDevS3Slice,
srvhelper.NoScopeConfig]
+
+// PutScopes create or update Q Developer scopes (S3 prefixes)
+// @Summary create or update Q Developer scopes
+// @Description Create or update Q Developer scopes
+// @Tags plugins/q_dev
+// @Accept application/json
+// @Param connectionId path int true "connection ID"
+// @Param scope body PutScopesReqBody true "json"
+// @Success 200 {object} []models.QDevS3Slice
+// @Failure 400 {object} shared.ApiBody "Bad Request"
+// @Failure 500 {object} shared.ApiBody "Internal Error"
+// @Router /plugins/q_dev/connections/{connectionId}/scopes [PUT]
+func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput,
errors.Error) {
+ return dsHelper.ScopeApi.PutMultiple(input)
+}
+
+// GetScopeList returns Q Developer scopes
+// @Summary get Q Developer scopes
+// @Description get Q Developer scopes
+// @Tags plugins/q_dev
+// @Param connectionId path int true "connection ID"
+// @Param pageSize query int false "page size"
+// @Param page query int false "page number"
+// @Param blueprints query bool false "include blueprint references"
+// @Success 200 {object} []ScopeDetail
+// @Failure 400 {object} shared.ApiBody "Bad Request"
+// @Failure 500 {object} shared.ApiBody "Internal Error"
+// @Router /plugins/q_dev/connections/{connectionId}/scopes [GET]
+func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput,
errors.Error) {
+ return dsHelper.ScopeApi.GetPage(input)
+}
+
+// GetScope returns a single scope record
+// @Summary get a Q Developer scope
+// @Description get a Q Developer scope
+// @Tags plugins/q_dev
+// @Param connectionId path int true "connection ID"
+// @Param scopeId path string true "scope id"
+// @Param blueprints query bool false "include blueprint references"
+// @Success 200 {object} ScopeDetail
+// @Failure 400 {object} shared.ApiBody "Bad Request"
+// @Failure 500 {object} shared.ApiBody "Internal Error"
+// @Router /plugins/q_dev/connections/{connectionId}/scopes/{scopeId} [GET]
+func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput,
errors.Error) {
+ return dsHelper.ScopeApi.GetScopeDetail(input)
+}
+
+// PatchScope updates a scope record
+// @Summary patch a Q Developer scope
+// @Description patch a Q Developer scope
+// @Tags plugins/q_dev
+// @Accept application/json
+// @Param connectionId path int true "connection ID"
+// @Param scopeId path string true "scope id"
+// @Param scope body models.QDevS3Slice true "json"
+// @Success 200 {object} models.QDevS3Slice
+// @Failure 400 {object} shared.ApiBody "Bad Request"
+// @Failure 500 {object} shared.ApiBody "Internal Error"
+// @Router /plugins/q_dev/connections/{connectionId}/scopes/{scopeId} [PATCH]
+func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput,
errors.Error) {
+ return dsHelper.ScopeApi.Patch(input)
+}
+
+// DeleteScope removes a scope and optionally associated data.
+// @Summary delete a Q Developer scope
+// @Description delete Q Developer scope data
+// @Tags plugins/q_dev
+// @Param connectionId path int true "connection ID"
+// @Param scopeId path string true "scope id"
+// @Param delete_data_only query bool false "Only delete scope data"
+// @Success 200
+// @Failure 400 {object} shared.ApiBody "Bad Request"
+// @Failure 409 {object} srvhelper.DsRefs "References exist to this scope"
+// @Failure 500 {object} shared.ApiBody "Internal Error"
+// @Router /plugins/q_dev/connections/{connectionId}/scopes/{scopeId} [DELETE]
+func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput,
errors.Error) {
+ return dsHelper.ScopeApi.Delete(input)
+}
+
+// GetScopeLatestSyncState returns scope sync state info
+// @Summary latest sync state for a Q Developer scope
+// @Description get latest sync state for a Q Developer scope
+// @Tags plugins/q_dev
+// @Param connectionId path int true "connection ID"
+// @Param scopeId path string true "scope id"
+// @Success 200
+// @Failure 400 {object} shared.ApiBody "Bad Request"
+// @Failure 500 {object} shared.ApiBody "Internal Error"
+// @Router
/plugins/q_dev/connections/{connectionId}/scopes/{scopeId}/latest-sync-state
[GET]
+func GetScopeLatestSyncState(input *plugin.ApiResourceInput)
(*plugin.ApiResourceOutput, errors.Error) {
+ return dsHelper.ScopeApi.GetScopeLatestSyncState(input)
+}
diff --git a/backend/plugins/q_dev/impl/impl.go
b/backend/plugins/q_dev/impl/impl.go
index b2568334b..9c3824924 100644
--- a/backend/plugins/q_dev/impl/impl.go
+++ b/backend/plugins/q_dev/impl/impl.go
@@ -54,6 +54,7 @@ func (p QDev) GetTablesInfo() []dal.Tabler {
&models.QDevConnection{},
&models.QDevUserData{},
&models.QDevS3FileMeta{},
+ &models.QDevS3Slice{},
}
}
@@ -70,7 +71,7 @@ func (p QDev) Connection() dal.Tabler {
}
func (p QDev) Scope() plugin.ToolLayerScope {
- return nil
+ return &models.QDevS3Slice{}
}
func (p QDev) ScopeConfig() dal.Tabler {
@@ -146,6 +147,18 @@ func (p QDev) ApiResources()
map[string]map[string]plugin.ApiResourceHandler {
"connections/:connectionId/test": {
"POST": api.TestExistingConnection,
},
+ "connections/:connectionId/scopes": {
+ "GET": api.GetScopeList,
+ "PUT": api.PutScopes,
+ },
+ "connections/:connectionId/scopes/:scopeId": {
+ "GET": api.GetScope,
+ "PATCH": api.PatchScope,
+ "DELETE": api.DeleteScope,
+ },
+ "connections/:connectionId/scopes/:scopeId/latest-sync-state": {
+ "GET": api.GetScopeLatestSyncState,
+ },
}
}
diff --git a/backend/plugins/q_dev/impl/impl_test.go
b/backend/plugins/q_dev/impl/impl_test.go
index 7c02ef488..97dea86e6 100644
--- a/backend/plugins/q_dev/impl/impl_test.go
+++ b/backend/plugins/q_dev/impl/impl_test.go
@@ -34,7 +34,7 @@ func TestQDev_BasicPluginMethods(t *testing.T) {
// Test table info
tables := plugin.GetTablesInfo()
- assert.Len(t, tables, 3)
+ assert.Len(t, tables, 4)
// Test subtask metas
subtasks := plugin.SubTaskMetas()
diff --git a/backend/plugins/q_dev/api/init.go
b/backend/plugins/q_dev/models/migrationscripts/20250926_add_s3_slice_table.go
similarity index 60%
copy from backend/plugins/q_dev/api/init.go
copy to
backend/plugins/q_dev/models/migrationscripts/20250926_add_s3_slice_table.go
index 7a16d5f0c..19fe467b1 100644
--- a/backend/plugins/q_dev/api/init.go
+++
b/backend/plugins/q_dev/models/migrationscripts/20250926_add_s3_slice_table.go
@@ -15,25 +15,28 @@ See the License for the specific language governing
permissions and
limitations under the License.
*/
-package api
+package migrationscripts
import (
"github.com/apache/incubator-devlake/core/context"
- "github.com/apache/incubator-devlake/core/plugin"
- "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
- "github.com/go-playground/validator/v10"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/helpers/migrationhelper"
+
"github.com/apache/incubator-devlake/plugins/q_dev/models/migrationscripts/archived"
)
-var vld *validator.Validate
-var connectionHelper *api.ConnectionApiHelper
-var basicRes context.BasicRes
+type addS3SliceTable struct{}
-func Init(br context.BasicRes, p plugin.PluginMeta) {
- basicRes = br
- vld = validator.New()
- connectionHelper = api.NewConnectionHelper(
+func (*addS3SliceTable) Up(basicRes context.BasicRes) errors.Error {
+ return migrationhelper.AutoMigrateTables(
basicRes,
- vld,
- p.Name(),
+ &archived.QDevS3Slice{},
)
}
+
+func (*addS3SliceTable) Version() uint64 {
+ return 20250926
+}
+
+func (*addS3SliceTable) Name() string {
+ return "Add S3 slice table for QDev plugin"
+}
diff --git a/backend/plugins/q_dev/models/migrationscripts/register.go
b/backend/plugins/q_dev/models/migrationscripts/archived/s3_slice.go
similarity index 61%
copy from backend/plugins/q_dev/models/migrationscripts/register.go
copy to backend/plugins/q_dev/models/migrationscripts/archived/s3_slice.go
index 85a74690e..77c7dec0d 100644
--- a/backend/plugins/q_dev/models/migrationscripts/register.go
+++ b/backend/plugins/q_dev/models/migrationscripts/archived/s3_slice.go
@@ -15,18 +15,22 @@ See the License for the specific language governing
permissions and
limitations under the License.
*/
-package migrationscripts
+package archived
import (
- "github.com/apache/incubator-devlake/core/plugin"
+
"github.com/apache/incubator-devlake/core/models/migrationscripts/archived"
)
-// All return all migration scripts
-func All() []plugin.MigrationScript {
- return []plugin.MigrationScript{
- new(initTables),
- new(modifyFileMetaTable),
- new(addDisplayNameFields),
- new(addMissingMetrics),
- }
+type QDevS3Slice struct {
+ archived.NoPKModel
+ ConnectionId uint64 `gorm:"primaryKey"`
+ Id string `gorm:"primaryKey;type:varchar(512)"`
+ Prefix string `gorm:"type:varchar(512);not null"`
+ BasePath string `gorm:"type:varchar(512)"`
+ Year int `gorm:"not null"`
+ Month *int
+}
+
+func (QDevS3Slice) TableName() string {
+ return "_tool_q_dev_s3_slices"
}
diff --git a/backend/plugins/q_dev/models/migrationscripts/register.go
b/backend/plugins/q_dev/models/migrationscripts/register.go
index 85a74690e..dfff79fae 100644
--- a/backend/plugins/q_dev/models/migrationscripts/register.go
+++ b/backend/plugins/q_dev/models/migrationscripts/register.go
@@ -28,5 +28,6 @@ func All() []plugin.MigrationScript {
new(modifyFileMetaTable),
new(addDisplayNameFields),
new(addMissingMetrics),
+ new(addS3SliceTable),
}
}
diff --git a/backend/plugins/q_dev/models/s3_slice.go
b/backend/plugins/q_dev/models/s3_slice.go
new file mode 100644
index 000000000..c844d1634
--- /dev/null
+++ b/backend/plugins/q_dev/models/s3_slice.go
@@ -0,0 +1,231 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package models
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/apache/incubator-devlake/core/models/common"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "gorm.io/gorm"
+)
+
+// QDevS3Slice describes a time-sliced S3 prefix to collect from.
+type QDevS3Slice struct {
+ common.Scope `mapstructure:",squash"`
+ Id string `json:"id" mapstructure:"id"
gorm:"primaryKey;type:varchar(512)"`
+ Prefix string `json:"prefix" mapstructure:"prefix"
gorm:"type:varchar(512);not null"`
+ BasePath string `json:"basePath" mapstructure:"basePath"
gorm:"type:varchar(512)"`
+ Year int `json:"year" mapstructure:"year" gorm:"not null"`
+ Month *int `json:"month,omitempty" mapstructure:"month"`
+
+ Name string `json:"name" mapstructure:"name" gorm:"-"`
+ FullName string `json:"fullName" mapstructure:"fullName" gorm:"-"`
+}
+
+func (QDevS3Slice) TableName() string {
+ return "_tool_q_dev_s3_slices"
+}
+
+// BeforeSave ensures derived fields stay in sync before persisting.
+func (s *QDevS3Slice) BeforeSave(_ *gorm.DB) error {
+ return s.normalize(true)
+}
+
+// AfterFind fills derived fields for API responses.
+func (s *QDevS3Slice) AfterFind(_ *gorm.DB) error {
+ return s.normalize(false)
+}
+
+// normalize trims inputs, derives prefix/id/name fields, and optionally
validates.
+func (s *QDevS3Slice) normalize(strict bool) error {
+ if s == nil {
+ return nil
+ }
+
+ s.BasePath = cleanPath(s.BasePath)
+ s.Prefix = cleanPath(selectNonEmpty(s.Prefix, s.Id))
+
+ if s.Year <= 0 {
+ if err := s.deriveYearAndMonthFromPrefix(); err != nil &&
strict {
+ return err
+ }
+ }
+
+ if s.Year <= 0 {
+ if strict {
+ return fmt.Errorf("year is required for QDev S3 slice")
+ }
+ }
+
+ if s.Month != nil {
+ if *s.Month < 1 || *s.Month > 12 {
+ return fmt.Errorf("month must be between 1 and 12")
+ }
+ }
+
+ if s.Prefix == "" {
+ s.Prefix = buildPrefix(s.BasePath, s.Year, s.Month)
+ }
+
+ prefix := buildPrefix(s.BasePath, s.Year, s.Month)
+ if prefix != "" {
+ s.Prefix = prefix
+ }
+
+ if s.Id == "" {
+ s.Id = s.Prefix
+ }
+
+ if s.Month != nil {
+ s.Name = fmt.Sprintf("%04d-%02d", s.Year, *s.Month)
+ } else if s.Year > 0 {
+ s.Name = fmt.Sprintf("%04d", s.Year)
+ }
+
+ if s.FullName == "" {
+ s.FullName = s.Prefix
+ }
+
+ return nil
+}
+
+func (s *QDevS3Slice) deriveYearAndMonthFromPrefix() error {
+ if s == nil {
+ return nil
+ }
+ segments := splitPath(s.Prefix)
+ if len(segments) == 0 {
+ return fmt.Errorf("prefix is empty")
+ }
+ last := segments[len(segments)-1]
+ if len(last) == 2 {
+ if month, err := strconv.Atoi(last); err == nil {
+ s.Month = ptr(month)
+ if len(segments) >= 2 {
+ yearSegment := segments[len(segments)-2]
+ year, yearErr := strconv.Atoi(yearSegment)
+ if yearErr != nil {
+ return yearErr
+ }
+ s.Year = year
+ base := segments[:len(segments)-2]
+ s.BasePath = strings.Join(base, "/")
+ return nil
+ }
+ }
+ }
+ if year, err := strconv.Atoi(last); err == nil {
+ s.Year = year
+ base := segments[:len(segments)-1]
+ s.BasePath = strings.Join(base, "/")
+ s.Month = nil
+ return nil
+ }
+ return fmt.Errorf("unable to derive year/month from prefix %q",
s.Prefix)
+}
+
+func (s QDevS3Slice) ScopeId() string {
+ return s.Id
+}
+
+func (s QDevS3Slice) ScopeName() string {
+ if s.Name != "" {
+ return s.Name
+ }
+ if s.Month != nil {
+ return fmt.Sprintf("%04d-%02d", s.Year, *s.Month)
+ }
+ if s.Year > 0 {
+ return fmt.Sprintf("%04d", s.Year)
+ }
+ return s.Prefix
+}
+
+func (s QDevS3Slice) ScopeFullName() string {
+ if s.FullName != "" {
+ return s.FullName
+ }
+ return s.Prefix
+}
+
+func (s QDevS3Slice) ScopeParams() interface{} {
+ return &QDevS3SliceParams{
+ ConnectionId: s.ConnectionId,
+ Prefix: s.Prefix,
+ }
+}
+
+// Sanitize returns a copy ready for JSON serialization.
+func (s QDevS3Slice) Sanitize() QDevS3Slice {
+ _ = s.normalize(false)
+ return s
+}
+
+type QDevS3SliceParams struct {
+ ConnectionId uint64 `json:"connectionId"`
+ Prefix string `json:"prefix"`
+}
+
+var _ plugin.ToolLayerScope = (*QDevS3Slice)(nil)
+
+func buildPrefix(basePath string, year int, month *int) string {
+ parts := splitPath(basePath)
+ if year > 0 {
+ parts = append(parts, fmt.Sprintf("%04d", year))
+ }
+ if month != nil {
+ parts = append(parts, fmt.Sprintf("%02d", *month))
+ }
+ return strings.Join(parts, "/")
+}
+
+func splitPath(value string) []string {
+ if value == "" {
+ return nil
+ }
+ chunks := strings.Split(value, "/")
+ result := make([]string, 0, len(chunks))
+ for _, chunk := range chunks {
+ trimmed := strings.TrimSpace(chunk)
+ if trimmed == "" {
+ continue
+ }
+ result = append(result, trimmed)
+ }
+ return result
+}
+
+func cleanPath(value string) string {
+ return strings.Join(splitPath(value), "/")
+}
+
+func selectNonEmpty(values ...string) string {
+ for _, v := range values {
+ if strings.TrimSpace(v) != "" {
+ return strings.TrimSpace(v)
+ }
+ }
+ return ""
+}
+
+func ptr[T any](value T) *T {
+ return &value
+}
diff --git a/config-ui/src/plugins/register/q-dev/config.tsx
b/config-ui/src/plugins/register/q-dev/config.tsx
index 3c0f71781..cc1e89511 100644
--- a/config-ui/src/plugins/register/q-dev/config.tsx
+++ b/config-ui/src/plugins/register/q-dev/config.tsx
@@ -20,6 +20,7 @@ import { IPluginConfig } from '@/types';
import Icon from './assets/icon.svg?react';
import { AwsCredentials, IdentityCenterConfig, S3Config } from
'./connection-fields';
+import { QDevDataScope } from './data-scope';
export const QDevConfig: IPluginConfig = {
plugin: 'q_dev',
@@ -78,6 +79,14 @@ export const QDevConfig: IPluginConfig = {
},
dataScope: {
title: 'S3 Prefixes',
+ render: ({ connectionId, disabledItems, selectedItems,
onChangeSelectedItems }) => (
+ <QDevDataScope
+ connectionId={connectionId}
+ disabledItems={disabledItems}
+ selectedItems={selectedItems as any}
+ onChangeSelectedItems={onChangeSelectedItems}
+ />
+ ),
},
scopeConfig: {
entities: ['CROSS'],
diff --git a/config-ui/src/plugins/register/q-dev/data-scope.tsx
b/config-ui/src/plugins/register/q-dev/data-scope.tsx
new file mode 100644
index 000000000..97d641201
--- /dev/null
+++ b/config-ui/src/plugins/register/q-dev/data-scope.tsx
@@ -0,0 +1,400 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { useEffect, useMemo } from 'react';
+import { Button, Checkbox, Flex, Form, Input, InputNumber, Segmented, Table,
Tooltip, Typography } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { DeleteOutlined } from '@ant-design/icons';
+
+interface ScopeData {
+ prefix?: string;
+ year?: number;
+ month?: number | null;
+ basePath?: string;
+}
+
+interface ScopeItem {
+ id: string;
+ name: string;
+ fullName: string;
+ data?: ScopeData;
+}
+
+interface Props {
+ connectionId: ID;
+ disabledItems?: Array<{ id: ID }>;
+ selectedItems: ScopeItem[];
+ onChangeSelectedItems: (items: ScopeItem[]) => void;
+}
+
+const CURRENT_YEAR = new Date().getUTCFullYear();
+const MONTHS = Array.from({ length: 12 }, (_, idx) => idx + 1);
+const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug',
'Sep', 'Oct', 'Nov', 'Dec'];
+
+const DEFAULT_BASE_PATH = 'user-report/AWSLogs';
+
+const ensureLeadingZero = (value: number) => value.toString().padStart(2, '0');
+
+const normalizeBasePath = (value: string) => value.trim().replace(/^\/+/,
'').replace(/\/+$/, '');
+
+const trimTrailingSlashes = (value: string) => value.replace(/\/+$/, '');
+
+const extractScopeMeta = (item: ScopeItem) => {
+ const data = item.data ?? {};
+ const rawPrefix = data.prefix ?? item.fullName ?? item.id;
+ const prefix = typeof rawPrefix === 'string' ?
trimTrailingSlashes(rawPrefix) : '';
+ const segments = prefix ? prefix.split('/').filter(Boolean) : [];
+
+ let month = data.month ?? null;
+ if (month === undefined || month === null) {
+ const last = segments[segments.length - 1];
+ if (last && /^(0[1-9]|1[0-2])$/.test(last)) {
+ month = Number(last);
+ } else {
+ month = null;
+ }
+ }
+
+ let year = data.year;
+ if (year === undefined || year === null) {
+ const idx = month ? segments.length - 2 : segments.length - 1;
+ const candidate = idx >= 0 ? segments[idx] : undefined;
+ if (candidate && /^\d{4}$/.test(candidate)) {
+ year = Number(candidate);
+ } else {
+ year = undefined;
+ }
+ }
+
+ let baseSegments: string[];
+ if (segments.length === 0) {
+ baseSegments = [];
+ } else if (month) {
+ baseSegments = segments.slice(0, Math.max(segments.length - 2, 0));
+ } else {
+ baseSegments = segments.slice(0, Math.max(segments.length - 1, 0));
+ }
+
+ const basePath = normalizeBasePath(data.basePath ?? (baseSegments.length ?
baseSegments.join('/') : ''));
+
+ return {
+ basePath,
+ year: typeof year === 'number' ? year : null,
+ month,
+ prefix,
+ };
+};
+
+const deriveBasePathFromSelection = (items: ScopeItem[]) => {
+ for (const item of items) {
+ const meta = extractScopeMeta(item);
+ if (meta.basePath !== undefined) {
+ return meta.basePath;
+ }
+ }
+ return undefined;
+};
+
+const buildPrefix = (basePath: string, year: number, month: number | null) => {
+ const segments = [] as string[];
+ const sanitizedBase = normalizeBasePath(basePath);
+ if (sanitizedBase) {
+ segments.push(sanitizedBase);
+ }
+ segments.push(String(year));
+ if (month !== null && month !== undefined) {
+ segments.push(ensureLeadingZero(month));
+ }
+ return segments.join('/');
+};
+
+const createScopeItem = (basePath: string, year: number, month: number |
null): ScopeItem => {
+ const sanitizedBase = normalizeBasePath(basePath);
+ const prefix = buildPrefix(sanitizedBase, year, month);
+ const isFullYear = month === null;
+ const name = isFullYear
+ ? `${year} (Full Year)`
+ : `${year}-${ensureLeadingZero(month as number)} (${MONTH_LABELS[(month as
number) - 1]})`;
+
+ return {
+ id: prefix,
+ name,
+ fullName: prefix,
+ data: {
+ basePath: sanitizedBase,
+ prefix,
+ year,
+ month,
+ },
+ };
+};
+
+const formatScopeLabel = (item: ScopeItem) => {
+ const meta = extractScopeMeta(item);
+ if (!meta.year) {
+ return item.name;
+ }
+
+ if (meta.month) {
+ const monthLabel = MONTH_LABELS[meta.month - 1] ??
ensureLeadingZero(meta.month);
+ return `${meta.year}-${ensureLeadingZero(meta.month)} (${monthLabel})`;
+ }
+
+ return `${meta.year} (Full Year)`;
+};
+
+const MONTH_OPTIONS = MONTHS.map((value) => ({
+ label: `${MONTH_LABELS[value - 1]} (${ensureLeadingZero(value)})`,
+ value,
+}));
+
+type FormValues = {
+ basePath: string;
+ year: number;
+ mode: 'year' | 'months';
+ months?: number[];
+};
+
+export const QDevDataScope = ({ connectionId: _connectionId, disabledItems,
selectedItems, onChangeSelectedItems }: Props) => {
+ const [form] = Form.useForm<FormValues>();
+
+ const disabledIds = useMemo(() => new Set(disabledItems?.map((it) =>
String(it.id)) ?? []), [disabledItems]);
+
+ const derivedBasePath = useMemo(
+ () => deriveBasePathFromSelection(selectedItems) ?? DEFAULT_BASE_PATH,
+ [selectedItems],
+ );
+
+ useEffect(() => {
+ if (!form.isFieldsTouched(['basePath'])) {
+ form.setFieldsValue({ basePath: derivedBasePath });
+ }
+ }, [derivedBasePath, form]);
+
+ useEffect(() => {
+ form.setFieldsValue({ mode: 'year', year: form.getFieldValue('year') ??
CURRENT_YEAR });
+ }, [form]);
+
+ const handleAdd = async () => {
+ const { basePath, year, mode, months = [] } = await form.validateFields();
+
+ const normalizedBase = normalizeBasePath(basePath ?? '');
+ const normalizedYear = Number(year);
+ if (!normalizedYear || Number.isNaN(normalizedYear)) {
+ return;
+ }
+
+ const currentIds = new Set(selectedItems.map((item) => item.id));
+ const hasFullYear = selectedItems.some((item) => {
+ const meta = extractScopeMeta(item);
+ return (
+ meta.basePath === normalizedBase &&
+ meta.year === normalizedYear &&
+ (meta.month === null || meta.month === undefined)
+ );
+ });
+
+ const additions: ScopeItem[] = [];
+
+ if (mode === 'year') {
+ if (hasFullYear) {
+ return;
+ }
+
+ const hasMonths = selectedItems.some((item) => {
+ const meta = extractScopeMeta(item);
+ return meta.basePath === normalizedBase && meta.year ===
normalizedYear && meta.month !== null;
+ });
+
+ if (hasMonths) {
+ return;
+ }
+
+ const item = createScopeItem(normalizedBase, normalizedYear, null);
+ if (!currentIds.has(item.id) && !disabledIds.has(item.id)) {
+ additions.push(item);
+ }
+ } else {
+ if (hasFullYear) {
+ return;
+ }
+
+ const uniqueMonths = Array.from(new Set(months)).map((m) =>
Number(m)).filter((m) => !Number.isNaN(m));
+ uniqueMonths.sort((a, b) => a - b);
+
+ uniqueMonths.forEach((month) => {
+ if (month < 1 || month > 12) {
+ return;
+ }
+
+ const item = createScopeItem(normalizedBase, normalizedYear, month);
+ if (currentIds.has(item.id) || disabledIds.has(item.id)) {
+ return;
+ }
+ additions.push(item);
+ });
+ }
+
+ if (!additions.length) {
+ return;
+ }
+
+ const next = [...selectedItems, ...additions];
+ next.sort((a, b) => a.id.localeCompare(b.id));
+ onChangeSelectedItems(next);
+
+ if (mode === 'months') {
+ form.setFieldsValue({ months: [] });
+ }
+ };
+
+ const handleRemove = (id: string) => {
+ onChangeSelectedItems(selectedItems.filter((item) => item.id !== id));
+ };
+
+ const columns: ColumnsType<ScopeItem> = [
+ {
+ title: 'Time Range',
+ dataIndex: 'id',
+ key: 'name',
+ render: (_: unknown, item) => formatScopeLabel(item),
+ },
+ {
+ title: 'S3 Prefix',
+ dataIndex: 'id',
+ key: 'prefix',
+ render: (_: unknown, item) => {
+ const meta = extractScopeMeta(item);
+ return <Typography.Text code>{meta.prefix}</Typography.Text>;
+ },
+ },
+ {
+ title: 'Base Path',
+ dataIndex: 'id',
+ key: 'basePath',
+ render: (_: unknown, item) => {
+ const meta = extractScopeMeta(item);
+ return meta.basePath ?
<Typography.Text>{meta.basePath}</Typography.Text> : <Typography.Text
type="secondary">(bucket root)</Typography.Text>;
+ },
+ },
+ {
+ title: '',
+ dataIndex: 'id',
+ key: 'action',
+ width: 80,
+ align: 'center',
+ render: (id: string) => (
+ <Tooltip title={disabledIds.has(id) ? 'Scope is used by existing
blueprint' : 'Remove'}>
+ <Button
+ type="text"
+ danger
+ icon={<DeleteOutlined />}
+ disabled={disabledIds.has(id)}
+ onClick={() => handleRemove(id)}
+ />
+ </Tooltip>
+ ),
+ },
+ ];
+
+ return (
+ <Flex vertical gap="middle">
+ <Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
+ Pick which year and month prefixes DevLake should collect from your Q
Developer S3 bucket. Leave empty to
+ collect all available data.
+ </Typography.Paragraph>
+
+ <Form
+ form={form}
+ layout="inline"
+ initialValues={{
+ basePath: derivedBasePath,
+ year: CURRENT_YEAR,
+ mode: 'year',
+ months: [],
+ }}
+ onFinish={handleAdd}
+ style={{ rowGap: 16 }}
+ >
+ <Form.Item
+ label="Base Path"
+ name="basePath"
+ style={{ flex: 1 }}
+ tooltip="Common prefix in S3 between the bucket root and the year
directory"
+ >
+ <Input placeholder="user-report/AWSLogs/.../us-east-1" />
+ </Form.Item>
+
+ <Form.Item
+ label="Year"
+ name="year"
+ rules={[{ required: true, message: 'Enter year' }]}
+ style={{ width: 160 }}
+ >
+ <InputNumber min={2000} max={2100} style={{ width: '100%' }} />
+ </Form.Item>
+
+ <Form.Item name="mode" style={{ width: 180 }}>
+ <Segmented
+ options={[
+ { label: 'Full Year', value: 'year' },
+ { label: 'Specific Months', value: 'months' },
+ ]}
+ />
+ </Form.Item>
+
+ <Form.Item noStyle shouldUpdate>
+ {({ getFieldValue }) =>
+ getFieldValue('mode') === 'months' ? (
+ <Form.Item
+ name="months"
+ rules={[{ required: true, message: 'Select at least one month'
}]}
+ style={{ minWidth: 260 }}
+ >
+ <Checkbox.Group
+ options={MONTH_OPTIONS}
+ style={{ display: 'grid', gridTemplateColumns: 'repeat(4,
minmax(60px, 1fr))', gap: 8 }}
+ />
+ </Form.Item>
+ ) : null
+ }
+ </Form.Item>
+ <Form.Item>
+ <Button type="primary" htmlType="submit">
+ Add Scope
+ </Button>
+ </Form.Item>
+ </Form>
+
+ <Table
+ size="middle"
+ rowKey="id"
+ columns={columns}
+ dataSource={selectedItems}
+ pagination={false}
+ locale={{ emptyText: 'No scope selected yet.' }}
+ />
+
+ {selectedItems.length > 0 && (
+ <Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
+ These selections will be stored as S3 prefixes and used during data
collection.
+ </Typography.Paragraph>
+ )}
+ </Flex>
+ );
+};