Compare commits
79 Commits
main
...
build/plug
| Author | SHA1 | Date |
|---|---|---|
|
|
a0c0d0fcd5 | 9 months ago |
|
|
6d826d4dc6 | 9 months ago |
|
|
7be548a694 | 9 months ago |
|
|
3a9ef9fd47 | 9 months ago |
|
|
0b41b84adf | 9 months ago |
|
|
6c515bf9c3 | 9 months ago |
|
|
a9a6e773a1 | 9 months ago |
|
|
ebec1cf2d8 | 9 months ago |
|
|
ed5a5962d2 | 9 months ago |
|
|
6e8a3c8021 | 9 months ago |
|
|
93890e6658 | 9 months ago |
|
|
c62e8bf71e | 9 months ago |
|
|
2d8f6f4a48 | 9 months ago |
|
|
2d960ce401 | 9 months ago |
|
|
a519a7c50c | 9 months ago |
|
|
5e7a7cc0c7 | 9 months ago |
|
|
6a29b9f766 | 9 months ago |
|
|
1016678ea4 | 9 months ago |
|
|
a2f64e23c9 | 9 months ago |
|
|
5d722c19a7 | 9 months ago |
|
|
ad40295b75 | 9 months ago |
|
|
22d0fadcd0 | 9 months ago |
|
|
31976996f8 | 9 months ago |
|
|
c919823000 | 9 months ago |
|
|
23a9ad23ae | 9 months ago |
|
|
5b0bbe7a3b | 9 months ago |
|
|
2523f5870a | 9 months ago |
|
|
23a5dc3e32 | 9 months ago |
|
|
40feb607c1 | 9 months ago |
|
|
74d61fda2a | 9 months ago |
|
|
2c795ec301 | 9 months ago |
|
|
bcfbeee333 | 9 months ago |
|
|
6674d7fc18 | 9 months ago |
|
|
f373e3df99 | 9 months ago |
|
|
60bce19696 | 9 months ago |
|
|
c2520f7cb4 | 9 months ago |
|
|
8b62e5520a | 9 months ago |
|
|
71b3d6ad9c | 9 months ago |
|
|
fccb00c281 | 9 months ago |
|
|
769b43cc3b | 9 months ago |
|
|
7608eb1049 | 9 months ago |
|
|
95ce7b6f47 | 9 months ago |
|
|
784a236280 | 9 months ago |
|
|
1e0426ca6f | 9 months ago |
|
|
fd7396d8f9 | 9 months ago |
|
|
a0af33e945 | 9 months ago |
|
|
8d8220b06c | 9 months ago |
|
|
0625d6a361 | 9 months ago |
|
|
63a1a1077e | 9 months ago |
|
|
0af646d947 | 9 months ago |
|
|
07c99745fa | 9 months ago |
|
|
afd0d31354 | 9 months ago |
|
|
18bbf1165d | 9 months ago |
|
|
5f17edc77f | 9 months ago |
|
|
836027cb33 | 9 months ago |
|
|
f3cbfe2223 | 9 months ago |
|
|
bc1e4c88e0 | 9 months ago |
|
|
d114485abd | 9 months ago |
|
|
3e8a4a66fe | 9 months ago |
|
|
4c583f3d9a | 9 months ago |
|
|
52b845a5bb | 9 months ago |
|
|
38d1c85c57 | 9 months ago |
|
|
c43d992f2b | 9 months ago |
|
|
1ff5969b92 | 9 months ago |
|
|
93a560ee54 | 9 months ago |
|
|
2f241d932c | 9 months ago |
|
|
a0804786fd | 9 months ago |
|
|
c6fa8102eb | 9 months ago |
|
|
7ec5816513 | 9 months ago |
|
|
825fbcc6f8 | 9 months ago |
|
|
ccef71626d | 9 months ago |
|
|
29cac85b12 | 9 months ago |
|
|
8b290ac7a1 | 9 months ago |
|
|
01cdffaa08 | 9 months ago |
|
|
3061280f7a | 9 months ago |
|
|
bc75d810c4 | 9 months ago |
|
|
dc5e974a78 | 9 months ago |
|
|
baff25c160 | 9 months ago |
|
|
42b6524954 | 10 months ago |
@ -0,0 +1,41 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 16081485540c
|
||||||
|
Revises: d28f2004b072
|
||||||
|
Create Date: 2025-05-15 16:35:39.113777
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import models as models
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '16081485540c'
|
||||||
|
down_revision = '58eb7bdb93fe'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('tenant_plugin_auto_upgrade_strategies',
|
||||||
|
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
|
||||||
|
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
|
||||||
|
sa.Column('strategy_setting', sa.String(length=16), server_default='fix_only', nullable=False),
|
||||||
|
sa.Column('upgrade_time_of_day', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('upgrade_mode', sa.String(length=16), server_default='exclude', nullable=False),
|
||||||
|
sa.Column('exclude_plugins', sa.ARRAY(sa.String(length=255)), nullable=False),
|
||||||
|
sa.Column('include_plugins', sa.ARRAY(sa.String(length=255)), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='tenant_plugin_auto_upgrade_strategy_pkey'),
|
||||||
|
sa.UniqueConstraint('tenant_id', name='unique_tenant_plugin_auto_upgrade_strategy')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('tenant_plugin_auto_upgrade_strategies')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
import app
|
||||||
|
from extensions.ext_database import db
|
||||||
|
from models.account import TenantPluginAutoUpgradeStrategy
|
||||||
|
from tasks.process_tenant_plugin_autoupgrade_check_task import process_tenant_plugin_autoupgrade_check_task
|
||||||
|
|
||||||
|
AUTO_UPGRADE_MINIMAL_CHECKING_INTERVAL = 15 * 60 # 15 minutes
|
||||||
|
|
||||||
|
|
||||||
|
@app.celery.task(queue="plugin")
|
||||||
|
def check_upgradable_plugin_task():
|
||||||
|
click.echo(click.style("Start check upgradable plugin.", fg="green"))
|
||||||
|
start_at = time.perf_counter()
|
||||||
|
|
||||||
|
now_seconds_of_day = time.time() % 86400 - 30 # we assume the tz is UTC
|
||||||
|
click.echo(click.style("Now seconds of day: {}".format(now_seconds_of_day), fg="green"))
|
||||||
|
|
||||||
|
strategies = (
|
||||||
|
db.session.query(TenantPluginAutoUpgradeStrategy)
|
||||||
|
.filter(
|
||||||
|
TenantPluginAutoUpgradeStrategy.upgrade_time_of_day >= now_seconds_of_day,
|
||||||
|
TenantPluginAutoUpgradeStrategy.upgrade_time_of_day
|
||||||
|
< now_seconds_of_day + AUTO_UPGRADE_MINIMAL_CHECKING_INTERVAL,
|
||||||
|
TenantPluginAutoUpgradeStrategy.strategy_setting
|
||||||
|
!= TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for strategy in strategies:
|
||||||
|
process_tenant_plugin_autoupgrade_check_task.delay(
|
||||||
|
strategy.tenant_id,
|
||||||
|
strategy.strategy_setting,
|
||||||
|
strategy.upgrade_time_of_day,
|
||||||
|
strategy.upgrade_mode,
|
||||||
|
strategy.exclude_plugins,
|
||||||
|
strategy.include_plugins,
|
||||||
|
)
|
||||||
|
|
||||||
|
end_at = time.perf_counter()
|
||||||
|
click.echo(
|
||||||
|
click.style(
|
||||||
|
"Checked upgradable plugin success latency: {}".format(end_at - start_at),
|
||||||
|
fg="green",
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from extensions.ext_database import db
|
||||||
|
from models.account import TenantPluginAutoUpgradeStrategy
|
||||||
|
|
||||||
|
|
||||||
|
class PluginAutoUpgradeService:
|
||||||
|
@staticmethod
|
||||||
|
def get_strategy(tenant_id: str) -> TenantPluginAutoUpgradeStrategy | None:
|
||||||
|
with Session(db.engine) as session:
|
||||||
|
return (
|
||||||
|
session.query(TenantPluginAutoUpgradeStrategy)
|
||||||
|
.filter(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def change_strategy(
|
||||||
|
tenant_id: str,
|
||||||
|
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting,
|
||||||
|
upgrade_time_of_day: int,
|
||||||
|
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
|
||||||
|
exclude_plugins: list[str],
|
||||||
|
include_plugins: list[str],
|
||||||
|
) -> bool:
|
||||||
|
with Session(db.engine) as session:
|
||||||
|
exist_strategy = (
|
||||||
|
session.query(TenantPluginAutoUpgradeStrategy)
|
||||||
|
.filter(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not exist_strategy:
|
||||||
|
strategy = TenantPluginAutoUpgradeStrategy(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
strategy_setting=strategy_setting,
|
||||||
|
upgrade_time_of_day=upgrade_time_of_day,
|
||||||
|
upgrade_mode=upgrade_mode,
|
||||||
|
exclude_plugins=exclude_plugins,
|
||||||
|
include_plugins=include_plugins,
|
||||||
|
)
|
||||||
|
session.add(strategy)
|
||||||
|
else:
|
||||||
|
exist_strategy.strategy_setting = strategy_setting
|
||||||
|
exist_strategy.upgrade_time_of_day = upgrade_time_of_day
|
||||||
|
exist_strategy.upgrade_mode = upgrade_mode
|
||||||
|
exist_strategy.exclude_plugins = exclude_plugins
|
||||||
|
exist_strategy.include_plugins = include_plugins
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def exclude_plugin(tenant_id: str, plugin_id: str) -> bool:
|
||||||
|
with Session(db.engine) as session:
|
||||||
|
exist_strategy = (
|
||||||
|
session.query(TenantPluginAutoUpgradeStrategy)
|
||||||
|
.filter(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not exist_strategy:
|
||||||
|
# create for this tenant
|
||||||
|
PluginAutoUpgradeService.change_strategy(
|
||||||
|
tenant_id,
|
||||||
|
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
|
||||||
|
0,
|
||||||
|
TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||||
|
[plugin_id],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE:
|
||||||
|
if plugin_id not in exist_strategy.exclude_plugins:
|
||||||
|
new_exclude_plugins = exist_strategy.exclude_plugins.copy()
|
||||||
|
new_exclude_plugins.append(plugin_id)
|
||||||
|
exist_strategy.exclude_plugins = new_exclude_plugins
|
||||||
|
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL:
|
||||||
|
if plugin_id in exist_strategy.include_plugins:
|
||||||
|
new_include_plugins = exist_strategy.include_plugins.copy()
|
||||||
|
new_include_plugins.remove(plugin_id)
|
||||||
|
exist_strategy.include_plugins = new_include_plugins
|
||||||
|
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
|
||||||
|
exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
|
||||||
|
exist_strategy.exclude_plugins = [plugin_id]
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
@ -0,0 +1,166 @@
|
|||||||
|
import traceback
|
||||||
|
import typing
|
||||||
|
|
||||||
|
import click
|
||||||
|
from celery import shared_task # type: ignore
|
||||||
|
|
||||||
|
from core.helper import marketplace
|
||||||
|
from core.helper.marketplace import MarketplacePluginDeclaration
|
||||||
|
from core.plugin.entities.plugin import PluginInstallationSource
|
||||||
|
from core.plugin.impl.plugin import PluginInstaller
|
||||||
|
from models.account import TenantPluginAutoUpgradeStrategy
|
||||||
|
|
||||||
|
RETRY_TIMES_OF_ONE_PLUGIN_IN_ONE_TENANT = 3
|
||||||
|
|
||||||
|
|
||||||
|
cached_plugin_manifests: dict[str, typing.Union[MarketplacePluginDeclaration, None]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def marketplace_batch_fetch_plugin_manifests(
|
||||||
|
plugin_ids_plain_list: list[str],
|
||||||
|
) -> list[MarketplacePluginDeclaration]:
|
||||||
|
global cached_plugin_manifests
|
||||||
|
# return marketplace.batch_fetch_plugin_manifests(plugin_ids_plain_list)
|
||||||
|
not_included_plugin_ids = [
|
||||||
|
plugin_id for plugin_id in plugin_ids_plain_list if plugin_id not in cached_plugin_manifests
|
||||||
|
]
|
||||||
|
if not_included_plugin_ids:
|
||||||
|
manifests = marketplace.batch_fetch_plugin_manifests_ignore_deserialization_error(not_included_plugin_ids)
|
||||||
|
for manifest in manifests:
|
||||||
|
cached_plugin_manifests[manifest.plugin_id] = manifest
|
||||||
|
|
||||||
|
if (
|
||||||
|
len(manifests) == 0
|
||||||
|
): # this indicates that the plugin not found in marketplace, should set None in cache to prevent future check
|
||||||
|
for plugin_id in not_included_plugin_ids:
|
||||||
|
cached_plugin_manifests[plugin_id] = None
|
||||||
|
|
||||||
|
result: list[MarketplacePluginDeclaration] = []
|
||||||
|
for plugin_id in plugin_ids_plain_list:
|
||||||
|
final_manifest = cached_plugin_manifests.get(plugin_id)
|
||||||
|
if final_manifest is not None:
|
||||||
|
result.append(final_manifest)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(queue="plugin")
|
||||||
|
def process_tenant_plugin_autoupgrade_check_task(
|
||||||
|
tenant_id: str,
|
||||||
|
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting,
|
||||||
|
upgrade_time_of_day: int,
|
||||||
|
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
|
||||||
|
exclude_plugins: list[str],
|
||||||
|
include_plugins: list[str],
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
manager = PluginInstaller()
|
||||||
|
|
||||||
|
click.echo(
|
||||||
|
click.style(
|
||||||
|
"Checking upgradable plugin for tenant: {}".format(tenant_id),
|
||||||
|
fg="green",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED:
|
||||||
|
return
|
||||||
|
|
||||||
|
# get plugin_ids to check
|
||||||
|
plugin_ids: list[tuple[str, str, str]] = [] # plugin_id, version, unique_identifier
|
||||||
|
click.echo(click.style("Upgrade mode: {}".format(upgrade_mode), fg="green"))
|
||||||
|
|
||||||
|
if upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL and include_plugins:
|
||||||
|
all_plugins = manager.list_plugins(tenant_id)
|
||||||
|
|
||||||
|
for plugin in all_plugins:
|
||||||
|
if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id in include_plugins:
|
||||||
|
plugin_ids.append(
|
||||||
|
(
|
||||||
|
plugin.plugin_id,
|
||||||
|
plugin.version,
|
||||||
|
plugin.plugin_unique_identifier,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE:
|
||||||
|
# get all plugins and remove excluded plugins
|
||||||
|
all_plugins = manager.list_plugins(tenant_id)
|
||||||
|
plugin_ids = [
|
||||||
|
(plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier)
|
||||||
|
for plugin in all_plugins
|
||||||
|
if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id not in exclude_plugins
|
||||||
|
]
|
||||||
|
elif upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
|
||||||
|
all_plugins = manager.list_plugins(tenant_id)
|
||||||
|
plugin_ids = [
|
||||||
|
(plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier)
|
||||||
|
for plugin in all_plugins
|
||||||
|
if plugin.source == PluginInstallationSource.Marketplace
|
||||||
|
]
|
||||||
|
|
||||||
|
if not plugin_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
plugin_ids_plain_list = [plugin_id for plugin_id, _, _ in plugin_ids]
|
||||||
|
|
||||||
|
manifests = marketplace_batch_fetch_plugin_manifests(plugin_ids_plain_list)
|
||||||
|
|
||||||
|
if not manifests:
|
||||||
|
return
|
||||||
|
|
||||||
|
for manifest in manifests:
|
||||||
|
for plugin_id, version, original_unique_identifier in plugin_ids:
|
||||||
|
if manifest.plugin_id != plugin_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_version = version
|
||||||
|
latest_version = manifest.latest_version
|
||||||
|
|
||||||
|
def fix_only_checker(latest_version, current_version):
|
||||||
|
latest_version_tuple = tuple(int(val) for val in latest_version.split("."))
|
||||||
|
current_version_tuple = tuple(int(val) for val in current_version.split("."))
|
||||||
|
|
||||||
|
if (
|
||||||
|
latest_version_tuple[0] == current_version_tuple[0]
|
||||||
|
and latest_version_tuple[1] == current_version_tuple[1]
|
||||||
|
):
|
||||||
|
return latest_version_tuple[2] != current_version_tuple[2]
|
||||||
|
return False
|
||||||
|
|
||||||
|
version_checker = {
|
||||||
|
TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST: lambda latest_version,
|
||||||
|
current_version: latest_version != current_version,
|
||||||
|
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY: fix_only_checker,
|
||||||
|
}
|
||||||
|
|
||||||
|
if version_checker[strategy_setting](latest_version, current_version):
|
||||||
|
# execute upgrade
|
||||||
|
new_unique_identifier = manifest.latest_package_identifier
|
||||||
|
|
||||||
|
marketplace.record_install_plugin_event(new_unique_identifier)
|
||||||
|
click.echo(
|
||||||
|
click.style(
|
||||||
|
"Upgrade plugin: {} -> {}".format(original_unique_identifier, new_unique_identifier),
|
||||||
|
fg="green",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task_start_resp = manager.upgrade_plugin(
|
||||||
|
tenant_id,
|
||||||
|
original_unique_identifier,
|
||||||
|
new_unique_identifier,
|
||||||
|
PluginInstallationSource.Marketplace,
|
||||||
|
{
|
||||||
|
"plugin_unique_identifier": new_unique_identifier,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(click.style("Error when upgrading plugin: {}".format(e), fg="red"))
|
||||||
|
traceback.print_exc()
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(click.style("Error when checking upgradable plugin: {}".format(e), fg="red"))
|
||||||
|
traceback.print_exc()
|
||||||
|
return
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M28.0049 16C28.0049 20.4183 24.4231 24 20.0049 24C15.5866 24 12.0049 20.4183 12.0049 16C12.0049 11.5817 15.5866 8 20.0049 8C24.4231 8 28.0049 11.5817 28.0049 16Z" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.00488 16H6.67155" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.00488 9.33334H8.00488" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.00488 22.6667H8.00488" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M26 22L29.3333 25.3333" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 823 B |
@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z" fill="black"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.3308 16H14.2915L13.6249 13.9476H10.3761L9.70846 16H7.66918L10.7759 7H13.2281L16.3308 16ZM10.8595 12.4622H13.1435L12.0378 9.05639H11.9673L10.8595 12.4622Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 772 B |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"icon": {
|
||||||
|
"type": "element",
|
||||||
|
"isRootNode": true,
|
||||||
|
"name": "svg",
|
||||||
|
"attributes": {
|
||||||
|
"width": "32",
|
||||||
|
"height": "32",
|
||||||
|
"viewBox": "0 0 32 32",
|
||||||
|
"fill": "none",
|
||||||
|
"xmlns": "http://www.w3.org/2000/svg"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "element",
|
||||||
|
"name": "path",
|
||||||
|
"attributes": {
|
||||||
|
"d": "M28.0049 16C28.0049 20.4183 24.4231 24 20.0049 24C15.5866 24 12.0049 20.4183 12.0049 16C12.0049 11.5817 15.5866 8 20.0049 8C24.4231 8 28.0049 11.5817 28.0049 16Z",
|
||||||
|
"stroke": "currentColor",
|
||||||
|
"stroke-width": "2",
|
||||||
|
"stroke-linecap": "round",
|
||||||
|
"stroke-linejoin": "round"
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "element",
|
||||||
|
"name": "path",
|
||||||
|
"attributes": {
|
||||||
|
"d": "M4.00488 16H6.67155",
|
||||||
|
"stroke": "currentColor",
|
||||||
|
"stroke-width": "2",
|
||||||
|
"stroke-linecap": "round",
|
||||||
|
"stroke-linejoin": "round"
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "element",
|
||||||
|
"name": "path",
|
||||||
|
"attributes": {
|
||||||
|
"d": "M4.00488 9.33334H8.00488",
|
||||||
|
"stroke": "currentColor",
|
||||||
|
"stroke-width": "2",
|
||||||
|
"stroke-linecap": "round",
|
||||||
|
"stroke-linejoin": "round"
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "element",
|
||||||
|
"name": "path",
|
||||||
|
"attributes": {
|
||||||
|
"d": "M4.00488 22.6667H8.00488",
|
||||||
|
"stroke": "currentColor",
|
||||||
|
"stroke-width": "2",
|
||||||
|
"stroke-linecap": "round",
|
||||||
|
"stroke-linejoin": "round"
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "element",
|
||||||
|
"name": "path",
|
||||||
|
"attributes": {
|
||||||
|
"d": "M26 22L29.3333 25.3333",
|
||||||
|
"stroke": "currentColor",
|
||||||
|
"stroke-width": "2",
|
||||||
|
"stroke-linecap": "round",
|
||||||
|
"stroke-linejoin": "round"
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"name": "SearchMenu"
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
// GENERATE BY script
|
||||||
|
// DON NOT EDIT IT MANUALLY
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import data from './SearchMenu.json'
|
||||||
|
import IconBase from '@/app/components/base/icons/IconBase'
|
||||||
|
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||||
|
|
||||||
|
const Icon = (
|
||||||
|
{
|
||||||
|
ref,
|
||||||
|
...props
|
||||||
|
}: React.SVGProps<SVGSVGElement> & {
|
||||||
|
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||||
|
},
|
||||||
|
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||||
|
|
||||||
|
Icon.displayName = 'SearchMenu'
|
||||||
|
|
||||||
|
export default Icon
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"icon": {
|
||||||
|
"type": "element",
|
||||||
|
"isRootNode": true,
|
||||||
|
"name": "svg",
|
||||||
|
"attributes": {
|
||||||
|
"width": "24",
|
||||||
|
"height": "24",
|
||||||
|
"viewBox": "0 0 24 24",
|
||||||
|
"fill": "none",
|
||||||
|
"xmlns": "http://www.w3.org/2000/svg"
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "element",
|
||||||
|
"name": "path",
|
||||||
|
"attributes": {
|
||||||
|
"d": "M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z",
|
||||||
|
"fill": "currentColor"
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "element",
|
||||||
|
"name": "path",
|
||||||
|
"attributes": {
|
||||||
|
"fill-rule": "evenodd",
|
||||||
|
"clip-rule": "evenodd",
|
||||||
|
"d": "M16.3308 16H14.2915L13.6249 13.9476H10.3761L9.70846 16H7.66918L10.7759 7H13.2281L16.3308 16ZM10.8595 12.4622H13.1435L12.0378 9.05639H11.9673L10.8595 12.4622Z",
|
||||||
|
"fill": "currentColor"
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"name": "AutoUpdateLine"
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
// GENERATE BY script
|
||||||
|
// DON NOT EDIT IT MANUALLY
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import data from './AutoUpdateLine.json'
|
||||||
|
import IconBase from '@/app/components/base/icons/IconBase'
|
||||||
|
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||||
|
|
||||||
|
const Icon = (
|
||||||
|
{
|
||||||
|
ref,
|
||||||
|
...props
|
||||||
|
}: React.SVGProps<SVGSVGElement> & {
|
||||||
|
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||||
|
},
|
||||||
|
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||||
|
|
||||||
|
Icon.displayName = 'AutoUpdateLine'
|
||||||
|
|
||||||
|
export default Icon
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { default as AutoUpdateLine } from './AutoUpdateLine'
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import type { AutoUpdateConfig } from './types'
|
||||||
|
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from './types'
|
||||||
|
export const defaultValue: AutoUpdateConfig = {
|
||||||
|
strategy_setting: AUTO_UPDATE_STRATEGY.disabled,
|
||||||
|
upgrade_time_of_day: 0,
|
||||||
|
upgrade_mode: AUTO_UPDATE_MODE.update_all,
|
||||||
|
exclude_plugins: [],
|
||||||
|
include_plugins: [],
|
||||||
|
}
|
||||||
@ -0,0 +1,185 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import React, { useCallback, useMemo } from 'react'
|
||||||
|
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY, type AutoUpdateConfig } from './types'
|
||||||
|
import Label from '../label'
|
||||||
|
import StrategyPicker from './strategy-picker'
|
||||||
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
|
||||||
|
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
|
||||||
|
import PluginsPicker from './plugins-picker'
|
||||||
|
import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds, dayjsToTimeOfDay, timeOfDayToDayjs } from './utils'
|
||||||
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import type { TriggerParams } from '@/app/components/base/date-and-time-picker/types'
|
||||||
|
import { RiTimeLine } from '@remixicon/react'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs'
|
||||||
|
import { useModalContextSelector } from '@/context/modal-context'
|
||||||
|
|
||||||
|
const i18nPrefix = 'plugin.autoUpdate'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
payload: AutoUpdateConfig
|
||||||
|
onChange: (payload: AutoUpdateConfig) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingTimeZone: FC<{
|
||||||
|
children?: React.ReactNode
|
||||||
|
}> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
|
||||||
|
return (
|
||||||
|
<span className='body-xs-regular cursor-pointer text-text-accent' onClick={() => setShowAccountSettingModal({ payload: 'language' })} >{children}</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const AutoUpdateSetting: FC<Props> = ({
|
||||||
|
payload,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { userProfile: { timezone } } = useAppContext()
|
||||||
|
|
||||||
|
const {
|
||||||
|
strategy_setting,
|
||||||
|
upgrade_time_of_day,
|
||||||
|
upgrade_mode,
|
||||||
|
exclude_plugins,
|
||||||
|
include_plugins,
|
||||||
|
} = payload
|
||||||
|
|
||||||
|
const minuteFilter = useCallback((minutes: string[]) => {
|
||||||
|
return minutes.filter((m) => {
|
||||||
|
const time = Number.parseInt(m, 10)
|
||||||
|
return time % 15 === 0
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
const strategyDescription = useMemo(() => {
|
||||||
|
switch (strategy_setting) {
|
||||||
|
case AUTO_UPDATE_STRATEGY.fixOnly:
|
||||||
|
return t(`${i18nPrefix}.strategy.fixOnly.selectedDescription`)
|
||||||
|
case AUTO_UPDATE_STRATEGY.latest:
|
||||||
|
return t(`${i18nPrefix}.strategy.latest.selectedDescription`)
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}, [strategy_setting, t])
|
||||||
|
|
||||||
|
const plugins = useMemo(() => {
|
||||||
|
switch (upgrade_mode) {
|
||||||
|
case AUTO_UPDATE_MODE.partial:
|
||||||
|
return include_plugins
|
||||||
|
case AUTO_UPDATE_MODE.exclude:
|
||||||
|
return exclude_plugins
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}, [upgrade_mode, exclude_plugins, include_plugins])
|
||||||
|
|
||||||
|
const handlePluginsChange = useCallback((newPlugins: string[]) => {
|
||||||
|
if (upgrade_mode === AUTO_UPDATE_MODE.partial) {
|
||||||
|
onChange({
|
||||||
|
...payload,
|
||||||
|
include_plugins: newPlugins,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else if (upgrade_mode === AUTO_UPDATE_MODE.exclude) {
|
||||||
|
onChange({
|
||||||
|
...payload,
|
||||||
|
exclude_plugins: newPlugins,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [payload, upgrade_mode, onChange])
|
||||||
|
const handleChange = useCallback((key: keyof AutoUpdateConfig) => {
|
||||||
|
return (value: AutoUpdateConfig[keyof AutoUpdateConfig]) => {
|
||||||
|
onChange({
|
||||||
|
...payload,
|
||||||
|
[key]: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [payload, onChange])
|
||||||
|
|
||||||
|
const renderTimePickerTrigger = useCallback(({ inputElem, onClick, isOpen }: TriggerParams) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='group float-right flex h-8 w-[160px] cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2 hover:bg-state-base-hover-alt'
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className='flex w-0 grow items-center gap-x-1'>
|
||||||
|
<RiTimeLine className={cn(
|
||||||
|
'h-4 w-4 shrink-0 text-text-tertiary',
|
||||||
|
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
|
||||||
|
)} />
|
||||||
|
{inputElem}
|
||||||
|
</div>
|
||||||
|
<div className='system-sm-regular text-text-tertiary'>{convertTimezoneToOffsetStr(timezone)}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [timezone])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='self-stretch px-6'>
|
||||||
|
<div className='my-3 flex items-center'>
|
||||||
|
<div className='system-xs-medium-uppercase text-text-tertiary'>{t(`${i18nPrefix}.updateSettings`)}</div>
|
||||||
|
<div className='ml-2 h-px grow bg-divider-subtle'></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<Label label={t(`${i18nPrefix}.automaticUpdates`)} description={strategyDescription} />
|
||||||
|
<StrategyPicker value={strategy_setting} onChange={handleChange('strategy_setting')} />
|
||||||
|
</div>
|
||||||
|
{strategy_setting !== AUTO_UPDATE_STRATEGY.disabled && (
|
||||||
|
<>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<Label label={t(`${i18nPrefix}.updateTime`)} />
|
||||||
|
<div className='flex flex-col justify-start'>
|
||||||
|
<TimePicker
|
||||||
|
value={timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(upgrade_time_of_day, timezone!))}
|
||||||
|
timezone={timezone}
|
||||||
|
onChange={v => handleChange('upgrade_time_of_day')(convertLocalSecondsToUTCDaySeconds(dayjsToTimeOfDay(v), timezone!))}
|
||||||
|
onClear={() => handleChange('upgrade_time_of_day')(convertLocalSecondsToUTCDaySeconds(0, timezone!))}
|
||||||
|
popupClassName='z-[99]'
|
||||||
|
title={t(`${i18nPrefix}.updateTime`)}
|
||||||
|
minuteFilter={minuteFilter}
|
||||||
|
renderTrigger={renderTimePickerTrigger}
|
||||||
|
/>
|
||||||
|
<div className='body-xs-regular mt-1 text-right text-text-tertiary'>
|
||||||
|
<Trans
|
||||||
|
i18nKey={`${i18nPrefix}.changeTimezone`}
|
||||||
|
components={{
|
||||||
|
setTimezone: <SettingTimeZone />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label label={t(`${i18nPrefix}.specifyPluginsToUpdate`)} />
|
||||||
|
<div className='mt-1 flex w-full items-start justify-between gap-2'>
|
||||||
|
{[AUTO_UPDATE_MODE.update_all, AUTO_UPDATE_MODE.exclude, AUTO_UPDATE_MODE.partial].map(option => (
|
||||||
|
<OptionCard
|
||||||
|
key={option}
|
||||||
|
title={t(`${i18nPrefix}.upgradeMode.${option}`)}
|
||||||
|
onSelect={() => handleChange('upgrade_mode')(option)}
|
||||||
|
selected={upgrade_mode === option}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{upgrade_mode !== AUTO_UPDATE_MODE.update_all && (
|
||||||
|
<PluginsPicker
|
||||||
|
value={plugins}
|
||||||
|
onChange={handlePluginsChange}
|
||||||
|
updateMode={upgrade_mode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default React.memo(AutoUpdateSetting)
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import React from 'react'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import { Group } from '@/app/components/base/icons/src/vender/other'
|
||||||
|
import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className: string
|
||||||
|
noPlugins?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const NoDataPlaceholder: FC<Props> = ({
|
||||||
|
className,
|
||||||
|
noPlugins,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const icon = noPlugins ? (<Group className='size-6 text-text-quaternary' />) : (<SearchMenu className='size-8 text-text-tertiary' />)
|
||||||
|
const text = t(`plugin.autoUpdate.noPluginPlaceholder.${noPlugins ? 'noInstalled' : 'noFound'}`)
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center justify-center', className)}>
|
||||||
|
<div className='flex flex-col items-center'>
|
||||||
|
{icon}
|
||||||
|
<div className='system-sm-regular mt-2 text-text-tertiary'>{text}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(NoDataPlaceholder)
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import React from 'react'
|
||||||
|
import { AUTO_UPDATE_MODE } from './types'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
updateMode: AUTO_UPDATE_MODE
|
||||||
|
}
|
||||||
|
|
||||||
|
const NoPluginSelected: FC<Props> = ({
|
||||||
|
updateMode,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const text = `${t(`plugin.autoUpdate.upgradeModePlaceholder.${updateMode === AUTO_UPDATE_MODE.partial ? 'partial' : 'exclude'}`)}`
|
||||||
|
return (
|
||||||
|
<div className='system-xs-regular rounded-[10px] border border-[divider-subtle] bg-background-section p-3 text-center text-text-tertiary'>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default React.memo(NoPluginSelected)
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import React from 'react'
|
||||||
|
import NoPluginSelected from './no-plugin-selected'
|
||||||
|
import { AUTO_UPDATE_MODE } from './types'
|
||||||
|
import PluginsSelected from './plugins-selected'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
import { RiAddLine } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useBoolean } from 'ahooks'
|
||||||
|
import ToolPicker from './tool-picker'
|
||||||
|
|
||||||
|
const i18nPrefix = 'plugin.autoUpdate'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
updateMode: AUTO_UPDATE_MODE
|
||||||
|
value: string[] // plugin ids
|
||||||
|
onChange: (value: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PluginsPicker: FC<Props> = ({
|
||||||
|
updateMode,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const hasSelected = value.length > 0
|
||||||
|
const isExcludeMode = updateMode === AUTO_UPDATE_MODE.exclude
|
||||||
|
const handleClear = () => {
|
||||||
|
onChange([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const [isShowToolPicker, {
|
||||||
|
set: setToolPicker,
|
||||||
|
}] = useBoolean(false)
|
||||||
|
return (
|
||||||
|
<div className='mt-2 rounded-[10px] bg-background-section-burn p-2.5'>
|
||||||
|
{hasSelected ? (
|
||||||
|
<div className='flex justify-between text-text-tertiary'>
|
||||||
|
<div className='system-xs-medium'>{t(`${i18nPrefix}.${isExcludeMode ? 'excludeUpdate' : 'partialUPdate'}`, { num: value.length })}</div>
|
||||||
|
<div className='system-xs-medium cursor-pointer' onClick={handleClear}>{t(`${i18nPrefix}.operation.clearAll`)}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<NoPluginSelected updateMode={updateMode} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSelected && (
|
||||||
|
<PluginsSelected
|
||||||
|
className='mt-2'
|
||||||
|
plugins={value}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ToolPicker
|
||||||
|
trigger={
|
||||||
|
<Button className='mt-2 w-[412px]' size='small' variant='secondary-accent'>
|
||||||
|
<RiAddLine className='size-3.5' />
|
||||||
|
{t(`${i18nPrefix}.operation.select`)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
isShow={isShowToolPicker}
|
||||||
|
onShowChange={setToolPicker}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default React.memo(PluginsPicker)
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import React from 'react'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||||
|
import Icon from '@/app/components/plugins/card/base/card-icon'
|
||||||
|
|
||||||
|
const MAX_DISPLAY_COUNT = 14
|
||||||
|
type Props = {
|
||||||
|
className?: string
|
||||||
|
plugins: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const PluginsSelected: FC<Props> = ({
|
||||||
|
className,
|
||||||
|
plugins,
|
||||||
|
}) => {
|
||||||
|
const isShowAll = plugins.length < MAX_DISPLAY_COUNT
|
||||||
|
const displayPlugins = plugins.slice(0, MAX_DISPLAY_COUNT)
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center space-x-1', className)}>
|
||||||
|
{displayPlugins.map(plugin => (
|
||||||
|
<Icon key={plugin} size='tiny' src={`${MARKETPLACE_API_PREFIX}/plugins/${plugin}/icon`} />
|
||||||
|
))}
|
||||||
|
{!isShowAll && <div className='system-xs-medium text-text-tertiary'>+{plugins.length - MAX_DISPLAY_COUNT}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default React.memo(PluginsSelected)
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
RiArrowDownSLine,
|
||||||
|
RiCheckLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { AUTO_UPDATE_STRATEGY } from './types'
|
||||||
|
import {
|
||||||
|
PortalToFollowElem,
|
||||||
|
PortalToFollowElemContent,
|
||||||
|
PortalToFollowElemTrigger,
|
||||||
|
} from '@/app/components/base/portal-to-follow-elem'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
const i18nPrefix = 'plugin.autoUpdate.strategy'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: AUTO_UPDATE_STRATEGY
|
||||||
|
onChange: (value: AUTO_UPDATE_STRATEGY) => void
|
||||||
|
}
|
||||||
|
const StrategyPicker = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
value: AUTO_UPDATE_STRATEGY.disabled,
|
||||||
|
label: t(`${i18nPrefix}.disabled.name`),
|
||||||
|
description: t(`${i18nPrefix}.disabled.description`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: AUTO_UPDATE_STRATEGY.fixOnly,
|
||||||
|
label: t(`${i18nPrefix}.fixOnly.name`),
|
||||||
|
description: t(`${i18nPrefix}.fixOnly.description`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: AUTO_UPDATE_STRATEGY.latest,
|
||||||
|
label: t(`${i18nPrefix}.latest.name`),
|
||||||
|
description: t(`${i18nPrefix}.latest.description`),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const selectedOption = options.find(option => option.value === value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalToFollowElem
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
placement='top-end'
|
||||||
|
offset={4}
|
||||||
|
>
|
||||||
|
<PortalToFollowElemTrigger onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.nativeEvent.stopImmediatePropagation()
|
||||||
|
setOpen(v => !v)
|
||||||
|
}}>
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
>
|
||||||
|
{selectedOption?.label}
|
||||||
|
<RiArrowDownSLine className='h-3.5 w-3.5' />
|
||||||
|
</Button>
|
||||||
|
</PortalToFollowElemTrigger>
|
||||||
|
<PortalToFollowElemContent className='z-[99]'>
|
||||||
|
<div className='w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'>
|
||||||
|
{
|
||||||
|
options.map(option => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className='flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.nativeEvent.stopImmediatePropagation()
|
||||||
|
onChange(option.value)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='mr-1 w-4 shrink-0'>
|
||||||
|
{
|
||||||
|
value === option.value && (
|
||||||
|
<RiCheckLine className='h-4 w-4 text-text-accent' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className='grow'>
|
||||||
|
<div className='system-sm-semibold mb-0.5 text-text-secondary'>{option.label}</div>
|
||||||
|
<div className='system-xs-regular text-text-tertiary'>{option.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</PortalToFollowElemContent>
|
||||||
|
</PortalToFollowElem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StrategyPicker
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import React from 'react'
|
||||||
|
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||||
|
import Icon from '@/app/components/plugins/card/base/card-icon'
|
||||||
|
import { renderI18nObject } from '@/i18n'
|
||||||
|
import { useGetLanguage } from '@/context/i18n'
|
||||||
|
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||||
|
import Checkbox from '@/app/components/base/checkbox'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
payload: PluginDetail
|
||||||
|
isChecked?: boolean
|
||||||
|
onCheckChange: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToolItem: FC<Props> = ({
|
||||||
|
payload,
|
||||||
|
isChecked,
|
||||||
|
onCheckChange,
|
||||||
|
}) => {
|
||||||
|
const language = useGetLanguage()
|
||||||
|
|
||||||
|
const { plugin_id, declaration } = payload
|
||||||
|
const { label, author: org } = declaration
|
||||||
|
return (
|
||||||
|
<div className='p-1'>
|
||||||
|
<div
|
||||||
|
className='flex w-full select-none items-center rounded-lg pr-2 hover:bg-state-base-hover'
|
||||||
|
>
|
||||||
|
<div className='flex h-8 grow items-center space-x-2 pl-3 pr-2'>
|
||||||
|
<Icon size='tiny' src={`${MARKETPLACE_API_PREFIX}/plugins/${plugin_id}/icon`} />
|
||||||
|
<div className='system-sm-medium max-w-[150px] shrink-0 truncate text-text-primary'>{renderI18nObject(label, language)}</div>
|
||||||
|
<div className='system-xs-regular max-w-[150px] shrink-0 truncate text-text-quaternary'>{org}</div>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
checked={isChecked}
|
||||||
|
onCheck={onCheckChange}
|
||||||
|
className='shrink-0'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default React.memo(ToolItem)
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import React, { useCallback, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
PortalToFollowElem,
|
||||||
|
PortalToFollowElemContent,
|
||||||
|
PortalToFollowElemTrigger,
|
||||||
|
} from '@/app/components/base/portal-to-follow-elem'
|
||||||
|
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||||
|
import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/plugin-type-switch'
|
||||||
|
import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import ToolItem from './tool-item'
|
||||||
|
import Loading from '@/app/components/base/loading'
|
||||||
|
import NoDataPlaceholder from './no-data-placeholder'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
trigger: React.ReactNode
|
||||||
|
value: string[]
|
||||||
|
onChange: (value: string[]) => void
|
||||||
|
isShow: boolean
|
||||||
|
onShowChange: (isShow: boolean) => void
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToolPicker: FC<Props> = ({
|
||||||
|
trigger,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
isShow,
|
||||||
|
onShowChange,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toggleShowPopup = useCallback(() => {
|
||||||
|
onShowChange(!isShow)
|
||||||
|
}, [onShowChange, isShow])
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
key: PLUGIN_TYPE_SEARCH_MAP.all,
|
||||||
|
name: t('plugin.category.all'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: PLUGIN_TYPE_SEARCH_MAP.model,
|
||||||
|
name: t('plugin.category.models'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||||
|
name: t('plugin.category.tools'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: PLUGIN_TYPE_SEARCH_MAP.agent,
|
||||||
|
name: t('plugin.category.agents'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: PLUGIN_TYPE_SEARCH_MAP.extension,
|
||||||
|
name: t('plugin.category.extensions'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: PLUGIN_TYPE_SEARCH_MAP.bundle,
|
||||||
|
name: t('plugin.category.bundles'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [tags, setTags] = useState<string[]>([])
|
||||||
|
const { data, isLoading } = useInstalledPluginList()
|
||||||
|
const filteredList = useMemo(() => {
|
||||||
|
const list = data ? data.plugins : []
|
||||||
|
return list.filter((plugin) => {
|
||||||
|
return (
|
||||||
|
(pluginType === PLUGIN_TYPE_SEARCH_MAP.all || plugin.declaration.category === pluginType)
|
||||||
|
&& (tags.length === 0 || tags.some(tag => plugin.declaration.tags.includes(tag)))
|
||||||
|
&& (query === '' || plugin.plugin_id.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [data, pluginType, query, tags])
|
||||||
|
const handleCheckChange = useCallback((pluginId: string) => {
|
||||||
|
return () => {
|
||||||
|
const newValue = value.includes(pluginId)
|
||||||
|
? value.filter(id => id !== pluginId)
|
||||||
|
: [...value, pluginId]
|
||||||
|
onChange(newValue)
|
||||||
|
}
|
||||||
|
}, [onChange, value])
|
||||||
|
|
||||||
|
const listContent = (
|
||||||
|
<div className='max-h-[396px] overflow-y-auto'>
|
||||||
|
{filteredList.map(item => (
|
||||||
|
<ToolItem
|
||||||
|
key={item.plugin_id}
|
||||||
|
payload={item}
|
||||||
|
isChecked={value.includes(item.plugin_id)}
|
||||||
|
onCheckChange={handleCheckChange(item.plugin_id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const loadingContent = (
|
||||||
|
<div className='flex h-[396px] items-center justify-center'>
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const noData = (
|
||||||
|
<NoDataPlaceholder className='h-[396px]' noPlugins={!query} />
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PortalToFollowElem
|
||||||
|
placement='top'
|
||||||
|
offset={0}
|
||||||
|
open={isShow}
|
||||||
|
onOpenChange={onShowChange}
|
||||||
|
>
|
||||||
|
<PortalToFollowElemTrigger
|
||||||
|
onClick={toggleShowPopup}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</PortalToFollowElemTrigger>
|
||||||
|
<PortalToFollowElemContent className='z-[1000]'>
|
||||||
|
<div className={cn('relative min-h-20 w-[436px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-2 shadow-lg backdrop-blur-sm')}>
|
||||||
|
<div className='p-2 pb-1'>
|
||||||
|
<SearchBox
|
||||||
|
search={query}
|
||||||
|
onSearchChange={setQuery}
|
||||||
|
tags={tags}
|
||||||
|
onTagsChange={setTags}
|
||||||
|
size='small'
|
||||||
|
placeholder={t('plugin.searchTools')!}
|
||||||
|
inputClassName='w-full'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between border-b-[0.5px] border-divider-subtle bg-background-default-hover px-3 shadow-xs'>
|
||||||
|
<div className='flex h-8 items-center space-x-1'>
|
||||||
|
{
|
||||||
|
tabs.map(tab => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
|
||||||
|
'text-xs font-medium text-text-secondary',
|
||||||
|
pluginType === tab.key && 'bg-state-base-hover-alt',
|
||||||
|
)}
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setPluginType(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isLoading && filteredList.length > 0 && listContent}
|
||||||
|
{!isLoading && filteredList.length === 0 && noData}
|
||||||
|
{isLoading && loadingContent}
|
||||||
|
</div>
|
||||||
|
</PortalToFollowElemContent>
|
||||||
|
</PortalToFollowElem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(ToolPicker)
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
export enum AUTO_UPDATE_STRATEGY {
|
||||||
|
fixOnly = 'fix_only',
|
||||||
|
disabled = 'disabled',
|
||||||
|
latest = 'latest',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AUTO_UPDATE_MODE {
|
||||||
|
partial = 'partial',
|
||||||
|
exclude = 'exclude',
|
||||||
|
update_all = 'all',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutoUpdateConfig = {
|
||||||
|
strategy_setting: AUTO_UPDATE_STRATEGY
|
||||||
|
upgrade_time_of_day: number
|
||||||
|
upgrade_mode: AUTO_UPDATE_MODE
|
||||||
|
exclude_plugins: string[]
|
||||||
|
include_plugins: string[]
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds } from './utils'
|
||||||
|
|
||||||
|
describe('convertLocalSecondsToUTCDaySeconds', () => {
|
||||||
|
it('should convert local seconds to UTC day seconds correctly', () => {
|
||||||
|
const localTimezone = 'Asia/Shanghai'
|
||||||
|
const utcSeconds = convertLocalSecondsToUTCDaySeconds(0, localTimezone)
|
||||||
|
expect(utcSeconds).toBe((24 - 8) * 3600)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should convert local seconds to UTC day seconds for a specific time', () => {
|
||||||
|
const localTimezone = 'Asia/Shanghai'
|
||||||
|
expect(convertUTCDaySecondsToLocalSeconds(convertLocalSecondsToUTCDaySeconds(0, localTimezone), localTimezone)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import type { Dayjs } from 'dayjs'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import utc from 'dayjs/plugin/utc'
|
||||||
|
import timezone from 'dayjs/plugin/timezone'
|
||||||
|
|
||||||
|
dayjs.extend(utc)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
|
||||||
|
export const timeOfDayToDayjs = (timeOfDay: number): Dayjs => {
|
||||||
|
const hours = Math.floor(timeOfDay / 3600)
|
||||||
|
const minutes = (timeOfDay - hours * 3600) / 60
|
||||||
|
const res = dayjs().startOf('day').hour(hours).minute(minutes)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export const convertLocalSecondsToUTCDaySeconds = (secondsInDay: number, localTimezone: string): number => {
|
||||||
|
const localDayStart = dayjs().tz(localTimezone).startOf('day')
|
||||||
|
const localTargetTime = localDayStart.add(secondsInDay, 'second')
|
||||||
|
const utcTargetTime = localTargetTime.utc()
|
||||||
|
const utcDayStart = utcTargetTime.startOf('day')
|
||||||
|
const secondsFromUTCMidnight = utcTargetTime.diff(utcDayStart, 'second')
|
||||||
|
return secondsFromUTCMidnight
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dayjsToTimeOfDay = (date?: Dayjs): number => {
|
||||||
|
if (!date) return 0
|
||||||
|
return date.hour() * 3600 + date.minute() * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
export const convertUTCDaySecondsToLocalSeconds = (utcDaySeconds: number, localTimezone: string): number => {
|
||||||
|
const utcDayStart = dayjs().utc().startOf('day')
|
||||||
|
const utcTargetTime = utcDayStart.add(utcDaySeconds, 'second')
|
||||||
|
const localTargetTime = utcTargetTime.tz(localTimezone)
|
||||||
|
const localDayStart = localTargetTime.startOf('day')
|
||||||
|
const secondsInLocalDay = localTargetTime.diff(localDayStart, 'second')
|
||||||
|
return secondsInLocalDay
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import React from 'react'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Label: FC<Props> = ({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={cn('flex h-6 items-center', description && 'h-4')}>
|
||||||
|
<span className='system-sm-semibold text-text-secondary'>{label}</span>
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<div className='body-xs-regular mt-1 text-text-tertiary'>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default React.memo(Label)
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Button from '@/app/components/base/button'
|
||||||
|
|
||||||
|
const i18nPrefix = 'plugin.autoUpdate.pluginDowngradeWarning'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onCancel: () => void
|
||||||
|
onJustDowngrade: () => void
|
||||||
|
onExcludeAndDowngrade: () => void
|
||||||
|
}
|
||||||
|
const DowngradeWarningModal = ({
|
||||||
|
onCancel,
|
||||||
|
onJustDowngrade,
|
||||||
|
onExcludeAndDowngrade,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col items-start gap-2 self-stretch'>
|
||||||
|
<div className='title-2xl-semi-bold text-text-primary'>{t(`${i18nPrefix}.title`)}</div>
|
||||||
|
<div className='system-md-regular text-text-secondary'>
|
||||||
|
{t(`${i18nPrefix}.description`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='mt-9 flex items-start justify-end space-x-2 self-stretch'>
|
||||||
|
<Button variant='secondary' onClick={() => onCancel()}>{t('app.newApp.Cancel')}</Button>
|
||||||
|
<Button variant='secondary' destructive onClick={onJustDowngrade}>{t(`${i18nPrefix}.downgrade`)}</Button>
|
||||||
|
<Button variant='primary' onClick={onExcludeAndDowngrade}>{t(`${i18nPrefix}.exclude`)}</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DowngradeWarningModal
|
||||||
Loading…
Reference in New Issue