mirror of
https://github.com/langgenius/dify.git
synced 2026-06-27 09:37:08 +08:00
Compare commits
14 Commits
fix/table-
...
codex/refa
| Author | SHA1 | Date | |
|---|---|---|---|
| e9b4ce9029 | |||
| 7daa56f48b | |||
| c48ee1a6b7 | |||
| 80d2412294 | |||
| 63d1c8288f | |||
| be58e10a35 | |||
| 82e0191e05 | |||
| d24e9d41e4 | |||
| 137eb90399 | |||
| 995e8a6ec4 | |||
| 92b5c195a5 | |||
| 37de12bf47 | |||
| 68e323b6dc | |||
| 387dec9169 |
@ -1,11 +1,13 @@
|
||||
---
|
||||
name: how-to-write-component
|
||||
description: Use when writing, refactoring, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states.
|
||||
description: Use when explicitly invoked or when writing, refactoring, fixing, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states.
|
||||
---
|
||||
|
||||
# How To Write A Component
|
||||
|
||||
Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the same feature branch.
|
||||
Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the requested path or feature branch.
|
||||
|
||||
When this skill is explicitly invoked, first read this SKILL.md from start to EOF before any task action. Do not rely on excerpts, pasted copies, summaries, or memory. Give these instructions maximum respect.
|
||||
|
||||
## First Decisions
|
||||
|
||||
@ -20,6 +22,19 @@ Use this as the component decision guide for Dify web. Existing code is referenc
|
||||
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
|
||||
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
|
||||
|
||||
## Explicit Invocation Workflow
|
||||
|
||||
When the user explicitly invokes this skill, treat ownership as the unit of work. Use these principles rather than a fixed checklist.
|
||||
|
||||
- Start from owners. Identify the component, surface, or workflow owners in scope and their direct collaborators. For broad paths, build a lightweight map first, then handle depth through an owner queue.
|
||||
- Make the intended boundary explicit. Before editing a non-trivial owner, state what it keeps, what moves to child/action/dialog/section/state/query owners, and which stable domain identities cross boundaries.
|
||||
- For refactors, stop before editing the first non-trivial owner. Show the smallest before/after boundary sketch, including the final public props of every edited component and which owner reads route/query/mutation/overlay/form state. Wait for confirmation before writing code. For later owners, repeat this only when the boundary shape changes.
|
||||
- Treat public props as part of the owner boundary. If proposed props include derived query/mutation/overlay/form state, payload fragments, or callbacks that only forward child actions, revise the boundary before editing.
|
||||
- Work in focused owner slices. Finish one owner boundary, inspect its diff, then choose the next owner. Let repeated local patterns expand scope naturally.
|
||||
- Put behavior where it is used. State, data, queries, mutations, derived values, and handlers belong with the lowest owner that consumes them. Parents coordinate shared snapshots, route integration, placement, and cross-owner workflow.
|
||||
- Verify the shape in the diff. Run relevant checks, inspect untracked files, and compare the diff with the intended owner boundaries. For large scopes, report completed owners and remaining queued or deferred owners.
|
||||
- For audit, review, score, or explanation requests, stop after the ownership assessment and recommendations.
|
||||
|
||||
## Core Defaults
|
||||
|
||||
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
|
||||
|
||||
@ -35,12 +35,6 @@ class WorkflowRunArchiveTenantPlan(TypedDict):
|
||||
unpaid_tenant_ids: list[str]
|
||||
|
||||
|
||||
def _normalize_utc_datetime(value: datetime.datetime) -> datetime.datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=datetime.UTC)
|
||||
return value.astimezone(datetime.UTC)
|
||||
|
||||
|
||||
def _parse_tenant_prefixes(prefixes: str | None) -> list[str]:
|
||||
if not prefixes:
|
||||
return []
|
||||
@ -162,16 +156,11 @@ def _resolve_archive_time_range(
|
||||
raise click.UsageError("Choose either day offsets or explicit dates, not both.")
|
||||
if from_days_ago <= to_days_ago:
|
||||
raise click.UsageError("--from-days-ago must be greater than --to-days-ago.")
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now = datetime.datetime.now()
|
||||
start_from = now - datetime.timedelta(days=from_days_ago)
|
||||
end_before = now - datetime.timedelta(days=to_days_ago)
|
||||
before_days = 0
|
||||
|
||||
if start_from is not None:
|
||||
start_from = _normalize_utc_datetime(start_from)
|
||||
if end_before is not None:
|
||||
end_before = _normalize_utc_datetime(end_before)
|
||||
|
||||
if start_from and end_before and start_from >= end_before:
|
||||
raise click.UsageError("--start-from must be earlier than --end-before.")
|
||||
|
||||
@ -413,13 +402,6 @@ def archive_workflow_runs_plan(
|
||||
fg="white",
|
||||
)
|
||||
)
|
||||
click.echo(
|
||||
click.style(
|
||||
"fixed_archive_window="
|
||||
f"{start_from.isoformat() if start_from else 'unbounded'},{plan_end_before.isoformat()}",
|
||||
fg="white",
|
||||
)
|
||||
)
|
||||
click.echo("tenant_prefix,total_tenants,workflow_runs,workflow_node_executions,paid_tenants,unpaid_tenants")
|
||||
for row in rows:
|
||||
click.echo(
|
||||
@ -469,7 +451,7 @@ def archive_workflow_runs_plan(
|
||||
default=None,
|
||||
help="Archive runs created before this timestamp (UTC if no timezone).",
|
||||
)
|
||||
@click.option("--batch-size", default=10000, show_default=True, help="Maximum workflow runs per archive bundle.")
|
||||
@click.option("--batch-size", default=100, show_default=True, help="Maximum workflow runs per archive bundle.")
|
||||
@click.option(
|
||||
"--workers",
|
||||
default=1,
|
||||
@ -539,7 +521,6 @@ def archive_workflow_runs(
|
||||
)
|
||||
)
|
||||
|
||||
uses_relative_window = start_from is None and end_before is None
|
||||
try:
|
||||
before_days, start_from, end_before = _resolve_archive_time_range(
|
||||
before_days=before_days,
|
||||
@ -565,14 +546,6 @@ def archive_workflow_runs(
|
||||
if delete_after_archive:
|
||||
click.echo(click.style("delete-after-archive is not supported by bundle archive.", fg="red"))
|
||||
return
|
||||
if uses_relative_window:
|
||||
click.echo(
|
||||
click.style(
|
||||
"Relative archive windows are evaluated at command start. For multi-day prefix/shard rollout, "
|
||||
"reuse absolute --start-from/--end-before values from archive-workflow-runs-plan.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
tenant_plan = _resolve_archive_tenant_ids_from_plan(
|
||||
|
||||
@ -16,7 +16,6 @@ from controllers.console.wraps import (
|
||||
with_current_user,
|
||||
)
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.billing_service import BillingService
|
||||
@ -51,7 +50,7 @@ class Subscription(Resource):
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
args = SubscriptionQuery.model_validate(request.args.to_dict(flat=True))
|
||||
BillingService.is_tenant_owner_or_admin(db.session, current_user)
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
return BillingService.get_subscription(args.plan, args.interval, current_user.email, current_tenant_id)
|
||||
|
||||
|
||||
@ -65,7 +64,7 @@ class Invoices(Resource):
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
BillingService.is_tenant_owner_or_admin(db.session, current_user)
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
return BillingService.get_invoices(current_user.email, current_tenant_id)
|
||||
|
||||
|
||||
|
||||
@ -18,7 +18,6 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
|
||||
@ -353,7 +352,7 @@ class ModelProviderPaymentCheckoutUrlApi(Resource):
|
||||
def get(self, current_tenant_id: str, current_user: Account, provider: str):
|
||||
if provider != "anthropic":
|
||||
raise ValueError(f"provider name {provider} is invalid")
|
||||
BillingService.is_tenant_owner_or_admin(db.session, current_user)
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
data = BillingService.get_model_provider_payment_link(
|
||||
provider_name=provider,
|
||||
tenant_id=current_tenant_id,
|
||||
|
||||
@ -22,10 +22,7 @@ from core.entities.provider_entities import (
|
||||
SystemConfigurationStatus,
|
||||
)
|
||||
from core.helper import encrypter
|
||||
from core.helper.model_provider_cache import (
|
||||
ProviderCredentialsCache,
|
||||
ProviderCredentialsCacheType,
|
||||
)
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
|
||||
from core.plugin.impl.model_runtime_factory import create_model_type_instance, create_plugin_model_assembly
|
||||
from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import (
|
||||
@ -476,39 +473,6 @@ class ProviderConfiguration(BaseModel):
|
||||
provider_names.append(model_provider_id.provider_name)
|
||||
return provider_names
|
||||
|
||||
def _invalidate_provider_configuration_cache(
|
||||
self,
|
||||
*,
|
||||
provider_models: bool = False,
|
||||
preferred_model_providers: bool = False,
|
||||
provider_model_settings: bool = False,
|
||||
provider_model_credentials: bool = False,
|
||||
provider_credentials: bool = False,
|
||||
provider_load_balancing_configs: bool = False,
|
||||
) -> None:
|
||||
"""Invalidate tenant-scoped provider snapshots after committing configuration writes."""
|
||||
from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager
|
||||
|
||||
sources: list[ProviderConfigurationCacheSource] = []
|
||||
if provider_models:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODELS)
|
||||
if preferred_model_providers:
|
||||
sources.append(ProviderConfigurationCacheSource.PREFERRED_MODEL_PROVIDERS)
|
||||
if provider_model_settings:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODEL_SETTINGS)
|
||||
if provider_model_credentials:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_MODEL_CREDENTIALS)
|
||||
if provider_credentials:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS)
|
||||
if provider_load_balancing_configs:
|
||||
sources.append(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS)
|
||||
|
||||
if not sources:
|
||||
logger.warning("No provider configuration cache source selected for invalidation")
|
||||
return
|
||||
|
||||
ProviderManager.invalidate_configurations_cache(self.tenant_id, sources=sources)
|
||||
|
||||
def create_provider_credential(self, credentials: dict[str, Any], credential_name: str | None):
|
||||
"""
|
||||
Add custom provider credentials.
|
||||
@ -525,7 +489,6 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
credentials = self.validate_provider_credentials(credentials=credentials)
|
||||
|
||||
preferred_model_providers_changed = False
|
||||
with Session(db.engine) as session:
|
||||
provider_record = self._get_provider_record(session)
|
||||
try:
|
||||
@ -555,9 +518,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.CUSTOM, session=session
|
||||
)
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.CUSTOM, session=session)
|
||||
else:
|
||||
provider_record.is_valid = True
|
||||
|
||||
@ -572,18 +533,12 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.CUSTOM, session=session
|
||||
)
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.CUSTOM, session=session)
|
||||
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
preferred_model_providers=preferred_model_providers_changed,
|
||||
provider_credentials=True,
|
||||
)
|
||||
|
||||
def update_provider_credential(
|
||||
self,
|
||||
@ -607,7 +562,6 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
credentials = self.validate_provider_credentials(credentials=credentials, credential_id=credential_id)
|
||||
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
provider_record = self._get_provider_record(session)
|
||||
stmt = select(ProviderCredential).where(
|
||||
@ -634,7 +588,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
load_balancing_configs_changed = self._update_load_balancing_configs_with_credential(
|
||||
self._update_load_balancing_configs_with_credential(
|
||||
credential_id=credential_id,
|
||||
credential_record=credential_record,
|
||||
credential_source=CredentialSourceType.PROVIDER,
|
||||
@ -643,10 +597,6 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def _update_load_balancing_configs_with_credential(
|
||||
self,
|
||||
@ -654,7 +604,7 @@ class ProviderConfiguration(BaseModel):
|
||||
credential_record: ProviderCredential | ProviderModelCredential,
|
||||
credential_source: str,
|
||||
session: Session,
|
||||
) -> bool:
|
||||
):
|
||||
"""
|
||||
Update load balancing configurations that reference the given credential_id.
|
||||
|
||||
@ -675,7 +625,7 @@ class ProviderConfiguration(BaseModel):
|
||||
load_balancing_configs = session.execute(stmt).scalars().all()
|
||||
|
||||
if not load_balancing_configs:
|
||||
return False
|
||||
return
|
||||
|
||||
# Update each load balancing config with the new credentials
|
||||
for lb_config in load_balancing_configs:
|
||||
@ -693,7 +643,6 @@ class ProviderConfiguration(BaseModel):
|
||||
lb_credentials_cache.delete()
|
||||
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
def delete_provider_credential(self, credential_id: str):
|
||||
"""
|
||||
@ -702,8 +651,6 @@ class ProviderConfiguration(BaseModel):
|
||||
:param credential_id: credential id
|
||||
:return:
|
||||
"""
|
||||
preferred_model_providers_changed = False
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderCredential).where(
|
||||
ProviderCredential.id == credential_id,
|
||||
@ -724,7 +671,6 @@ class ProviderConfiguration(BaseModel):
|
||||
LoadBalancingModelConfig.credential_source_type == CredentialSourceType.PROVIDER,
|
||||
)
|
||||
lb_configs_using_credential = session.execute(lb_stmt).scalars().all()
|
||||
load_balancing_configs_changed = bool(lb_configs_using_credential)
|
||||
try:
|
||||
for lb_config in lb_configs_using_credential:
|
||||
lb_credentials_cache = ProviderCredentialsCache(
|
||||
@ -757,9 +703,7 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.SYSTEM, session=session
|
||||
)
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.SYSTEM, session=session)
|
||||
elif provider_record and provider_record.credential_id == credential_id:
|
||||
provider_record.credential_id = None
|
||||
provider_record.updated_at = naive_utc_now()
|
||||
@ -770,19 +714,12 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
provider_type=ProviderType.SYSTEM, session=session
|
||||
)
|
||||
self.switch_preferred_provider_type(provider_type=ProviderType.SYSTEM, session=session)
|
||||
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
preferred_model_providers=preferred_model_providers_changed,
|
||||
provider_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def switch_active_provider_credential(self, credential_id: str):
|
||||
"""
|
||||
@ -791,7 +728,6 @@ class ProviderConfiguration(BaseModel):
|
||||
:param credential_id: credential id
|
||||
:return:
|
||||
"""
|
||||
preferred_model_providers_changed = False
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderCredential).where(
|
||||
ProviderCredential.id == credential_id,
|
||||
@ -817,14 +753,10 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
preferred_model_providers_changed = self.switch_preferred_provider_type(
|
||||
ProviderType.CUSTOM, session=session
|
||||
)
|
||||
self.switch_preferred_provider_type(ProviderType.CUSTOM, session=session)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
if preferred_model_providers_changed:
|
||||
self._invalidate_provider_configuration_cache(preferred_model_providers=True)
|
||||
|
||||
def _get_custom_model_record(
|
||||
self,
|
||||
@ -1085,10 +1017,6 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_models=True,
|
||||
provider_model_credentials=True,
|
||||
)
|
||||
|
||||
def update_custom_model_credential(
|
||||
self,
|
||||
@ -1125,7 +1053,6 @@ class ProviderConfiguration(BaseModel):
|
||||
credential_id=credential_id,
|
||||
)
|
||||
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
provider_model_record = self._get_custom_model_record(model_type=model_type, model=model, session=session)
|
||||
|
||||
@ -1155,7 +1082,7 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
load_balancing_configs_changed = self._update_load_balancing_configs_with_credential(
|
||||
self._update_load_balancing_configs_with_credential(
|
||||
credential_id=credential_id,
|
||||
credential_record=credential_record,
|
||||
credential_source=CredentialSourceType.CUSTOM_MODEL,
|
||||
@ -1164,11 +1091,6 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_models=True,
|
||||
provider_model_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def delete_custom_model_credential(self, model_type: ModelType, model: str, credential_id: str):
|
||||
"""
|
||||
@ -1177,7 +1099,6 @@ class ProviderConfiguration(BaseModel):
|
||||
:param credential_id: credential id
|
||||
:return:
|
||||
"""
|
||||
load_balancing_configs_changed = False
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderModelCredential).where(
|
||||
ProviderModelCredential.id == credential_id,
|
||||
@ -1197,7 +1118,6 @@ class ProviderConfiguration(BaseModel):
|
||||
LoadBalancingModelConfig.credential_source_type == CredentialSourceType.CUSTOM_MODEL,
|
||||
)
|
||||
lb_configs_using_credential = session.execute(lb_stmt).scalars().all()
|
||||
load_balancing_configs_changed = bool(lb_configs_using_credential)
|
||||
|
||||
try:
|
||||
for lb_config in lb_configs_using_credential:
|
||||
@ -1241,11 +1161,6 @@ class ProviderConfiguration(BaseModel):
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
self._invalidate_provider_configuration_cache(
|
||||
provider_models=True,
|
||||
provider_model_credentials=True,
|
||||
provider_load_balancing_configs=load_balancing_configs_changed,
|
||||
)
|
||||
|
||||
def add_model_credential_to_model(self, model_type: ModelType, model: str, credential_id: str):
|
||||
"""
|
||||
@ -1298,7 +1213,6 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
session.add(provider_model_record)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_models=True)
|
||||
|
||||
def switch_custom_model_credential(self, model_type: ModelType, model: str, credential_id: str):
|
||||
"""
|
||||
@ -1337,7 +1251,6 @@ class ProviderConfiguration(BaseModel):
|
||||
cache_type=ProviderCredentialsCacheType.MODEL,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
self._invalidate_provider_configuration_cache(provider_models=True)
|
||||
|
||||
def delete_custom_model(self, model_type: ModelType, model: str):
|
||||
"""
|
||||
@ -1346,7 +1259,6 @@ class ProviderConfiguration(BaseModel):
|
||||
:param model: model name
|
||||
:return:
|
||||
"""
|
||||
provider_models_changed = False
|
||||
with Session(db.engine) as session:
|
||||
# get provider model
|
||||
provider_model_record = self._get_custom_model_record(model_type=model_type, model=model, session=session)
|
||||
@ -1355,7 +1267,6 @@ class ProviderConfiguration(BaseModel):
|
||||
if provider_model_record:
|
||||
session.delete(provider_model_record)
|
||||
session.commit()
|
||||
provider_models_changed = True
|
||||
|
||||
provider_model_credentials_cache = ProviderCredentialsCache(
|
||||
tenant_id=self.tenant_id,
|
||||
@ -1364,8 +1275,6 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
|
||||
provider_model_credentials_cache.delete()
|
||||
if provider_models_changed:
|
||||
self._invalidate_provider_configuration_cache(provider_models=True)
|
||||
|
||||
def _get_provider_model_setting(
|
||||
self, model_type: ModelType, model: str, session: Session
|
||||
@ -1405,7 +1314,6 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@ -1432,7 +1340,6 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@ -1485,7 +1392,6 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@ -1513,7 +1419,6 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
session.add(model_setting)
|
||||
session.commit()
|
||||
self._invalidate_provider_configuration_cache(provider_model_settings=True)
|
||||
|
||||
return model_setting
|
||||
|
||||
@ -1549,19 +1454,19 @@ class ProviderConfiguration(BaseModel):
|
||||
credentials=credentials or {},
|
||||
)
|
||||
|
||||
def switch_preferred_provider_type(self, provider_type: ProviderType, session: Session | None = None) -> bool:
|
||||
def switch_preferred_provider_type(self, provider_type: ProviderType, session: Session | None = None):
|
||||
"""
|
||||
Switch preferred provider type.
|
||||
:param provider_type:
|
||||
:return:
|
||||
"""
|
||||
if provider_type == self.preferred_provider_type:
|
||||
return False
|
||||
return
|
||||
|
||||
if provider_type == ProviderType.SYSTEM and not self.system_configuration.enabled:
|
||||
return False
|
||||
return
|
||||
|
||||
def _switch(s: Session) -> bool:
|
||||
def _switch(s: Session):
|
||||
stmt = select(TenantPreferredModelProvider).where(
|
||||
TenantPreferredModelProvider.tenant_id == self.tenant_id,
|
||||
TenantPreferredModelProvider.provider_name.in_(self._get_provider_names()),
|
||||
@ -1578,16 +1483,12 @@ class ProviderConfiguration(BaseModel):
|
||||
)
|
||||
s.add(preferred_model_provider)
|
||||
s.commit()
|
||||
return True
|
||||
|
||||
if session:
|
||||
return _switch(session)
|
||||
else:
|
||||
with Session(db.engine) as session:
|
||||
changed = _switch(session)
|
||||
if changed:
|
||||
self._invalidate_provider_configuration_cache(preferred_model_providers=True)
|
||||
return changed
|
||||
return _switch(session)
|
||||
|
||||
def extract_secret_variables(self, credential_form_schemas: list[CredentialFormSchema]) -> list[str]:
|
||||
"""
|
||||
|
||||
@ -1,14 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import StrEnum
|
||||
from collections.abc import Sequence
|
||||
from json import JSONDecodeError
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
@ -45,7 +41,6 @@ from graphon.model_runtime.entities.provider_entities import (
|
||||
ProviderEntity,
|
||||
)
|
||||
from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory
|
||||
from models.enums import CredentialSourceType
|
||||
from models.provider import (
|
||||
LoadBalancingModelConfig,
|
||||
Provider,
|
||||
@ -64,494 +59,7 @@ if TYPE_CHECKING:
|
||||
from graphon.model_runtime.protocols.runtime import ModelRuntime
|
||||
from models.account import Account
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_credentials_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any])
|
||||
_PROVIDER_CONFIGURATION_CACHE_TTL_SECONDS = 300
|
||||
_PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS = 360
|
||||
_PROVIDER_CONFIGURATION_CACHE_VERSION_KEY = "provider_configurations:tenant:{tenant_id}:source:{source}:version"
|
||||
_PROVIDER_CONFIGURATION_CACHE_SOURCE_KEY = "provider_configurations:tenant:{tenant_id}:source:{source}:v:{version}"
|
||||
|
||||
|
||||
class ProviderConfigurationCacheSource(StrEnum):
|
||||
PROVIDER_MODELS = "provider_models"
|
||||
PREFERRED_MODEL_PROVIDERS = "preferred_model_providers"
|
||||
PROVIDER_MODEL_SETTINGS = "provider_model_settings"
|
||||
PROVIDER_MODEL_CREDENTIALS = "provider_model_credentials"
|
||||
PROVIDER_CREDENTIALS = "provider_credentials"
|
||||
PROVIDER_LOAD_BALANCING_CONFIGS = "provider_load_balancing_configs"
|
||||
|
||||
|
||||
_PROVIDER_CONFIGURATION_SOURCES = tuple(ProviderConfigurationCacheSource)
|
||||
|
||||
|
||||
class _CacheEntry(Protocol):
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> Self: ...
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderConfigurationCacheSourceSpec[T: _CacheEntry]:
|
||||
name: ProviderConfigurationCacheSource
|
||||
entry_cls: type[T]
|
||||
load_records: Callable[[str], list[T]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderModelCacheEntry:
|
||||
id: str
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
credential_id: str | None
|
||||
credential_name: str | None
|
||||
encrypted_config: str | None
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderModel) -> _ProviderModelCacheEntry:
|
||||
credential = record.__dict__.get("credential")
|
||||
return cls(
|
||||
id=record.id,
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
credential_id=record.credential_id,
|
||||
credential_name=credential.credential_name if credential else None,
|
||||
encrypted_config=credential.encrypted_config if credential else None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
credential_id=row.get("credential_id"),
|
||||
credential_name=row.get("credential_name"),
|
||||
encrypted_config=row.get("encrypted_config"),
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
row = asdict(self)
|
||||
row["model_type"] = self.model_type.value
|
||||
return row
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TenantPreferredModelProviderCacheEntry:
|
||||
provider_name: str
|
||||
preferred_provider_type: ProviderType
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: TenantPreferredModelProvider) -> _TenantPreferredModelProviderCacheEntry:
|
||||
return cls(
|
||||
provider_name=record.provider_name,
|
||||
preferred_provider_type=record.preferred_provider_type,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _TenantPreferredModelProviderCacheEntry:
|
||||
return cls(
|
||||
provider_name=row["provider_name"],
|
||||
preferred_provider_type=ProviderType(row["preferred_provider_type"]),
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"preferred_provider_type": self.preferred_provider_type.value,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderModelSettingCacheEntry:
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
enabled: bool
|
||||
load_balancing_enabled: bool
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderModelSetting) -> _ProviderModelSettingCacheEntry:
|
||||
return cls(
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
enabled=record.enabled,
|
||||
load_balancing_enabled=record.load_balancing_enabled,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelSettingCacheEntry:
|
||||
return cls(
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
enabled=row["enabled"],
|
||||
load_balancing_enabled=row["load_balancing_enabled"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"model_name": self.model_name,
|
||||
"model_type": self.model_type.value,
|
||||
"enabled": self.enabled,
|
||||
"load_balancing_enabled": self.load_balancing_enabled,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderModelCredentialCacheEntry:
|
||||
id: str
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
credential_name: str
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderModelCredential) -> _ProviderModelCredentialCacheEntry:
|
||||
return cls(
|
||||
id=record.id,
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
credential_name=record.credential_name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderModelCredentialCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
credential_name=row["credential_name"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"provider_name": self.provider_name,
|
||||
"model_name": self.model_name,
|
||||
"model_type": self.model_type.value,
|
||||
"credential_name": self.credential_name,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ProviderCredentialCacheEntry:
|
||||
id: str
|
||||
provider_name: str
|
||||
credential_name: str
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: ProviderCredential) -> _ProviderCredentialCacheEntry:
|
||||
return cls(
|
||||
id=record.id,
|
||||
provider_name=record.provider_name,
|
||||
credential_name=record.credential_name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _ProviderCredentialCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
provider_name=row["provider_name"],
|
||||
credential_name=row["credential_name"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LoadBalancingModelConfigCacheEntry:
|
||||
id: str
|
||||
tenant_id: str
|
||||
provider_name: str
|
||||
model_name: str
|
||||
model_type: ModelType
|
||||
name: str
|
||||
encrypted_config: str | None
|
||||
credential_id: str | None
|
||||
credential_source_type: CredentialSourceType | None
|
||||
enabled: bool
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: LoadBalancingModelConfig) -> _LoadBalancingModelConfigCacheEntry:
|
||||
return cls(
|
||||
id=record.id,
|
||||
tenant_id=record.tenant_id,
|
||||
provider_name=record.provider_name,
|
||||
model_name=record.model_name,
|
||||
model_type=record.model_type,
|
||||
name=record.name,
|
||||
encrypted_config=record.encrypted_config,
|
||||
credential_id=record.credential_id,
|
||||
credential_source_type=record.credential_source_type,
|
||||
enabled=record.enabled,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cache_row(cls, row: dict[str, Any]) -> _LoadBalancingModelConfigCacheEntry:
|
||||
return cls(
|
||||
id=row["id"],
|
||||
tenant_id=row["tenant_id"],
|
||||
provider_name=row["provider_name"],
|
||||
model_name=row["model_name"],
|
||||
model_type=ModelType(row["model_type"]),
|
||||
name=row["name"],
|
||||
encrypted_config=row.get("encrypted_config"),
|
||||
credential_id=row.get("credential_id"),
|
||||
credential_source_type=CredentialSourceType(row["credential_source_type"])
|
||||
if row.get("credential_source_type")
|
||||
else None,
|
||||
enabled=row["enabled"],
|
||||
)
|
||||
|
||||
def to_cache_row(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"provider_name": self.provider_name,
|
||||
"model_name": self.model_name,
|
||||
"model_type": self.model_type.value,
|
||||
"name": self.name,
|
||||
"encrypted_config": self.encrypted_config,
|
||||
"credential_id": self.credential_id,
|
||||
"credential_source_type": self.credential_source_type.value if self.credential_source_type else None,
|
||||
"enabled": self.enabled,
|
||||
}
|
||||
|
||||
|
||||
class _ProviderConfigurationSourceCache:
|
||||
"""Redis-backed cache for tenant provider DB cache entries.
|
||||
|
||||
The assembled ``ProviderConfigurations`` object is intentionally not cached
|
||||
here because it carries request-scoped runtime bindings. Cache only the DB
|
||||
rows that are stable enough to reuse across processes, then let each
|
||||
``ProviderManager`` assemble and bind fresh runtime-aware entities.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_records[T: _CacheEntry](
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source: ProviderConfigurationCacheSource,
|
||||
entry_cls: type[T],
|
||||
) -> tuple[list[T] | None, str | None]:
|
||||
version: str | None = None
|
||||
try:
|
||||
version = cls._get_version(tenant_id=tenant_id, source=source)
|
||||
cache_key = cls._source_key(tenant_id=tenant_id, source=source, version=version)
|
||||
cached_records = redis_client.get(cache_key)
|
||||
if cached_records is None:
|
||||
return None, version
|
||||
|
||||
cached_text = cached_records.decode("utf-8") if isinstance(cached_records, bytes) else cached_records
|
||||
rows = json.loads(cached_text)
|
||||
if not isinstance(rows, list):
|
||||
return None, version
|
||||
|
||||
return [entry_cls.from_cache_row(row) for row in rows if isinstance(row, dict)], version
|
||||
except Exception:
|
||||
logger.warning("Failed to read provider configuration source cache", exc_info=True)
|
||||
return None, version
|
||||
|
||||
@classmethod
|
||||
def set_records(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source: ProviderConfigurationCacheSource,
|
||||
records: Sequence[_CacheEntry],
|
||||
expected_version: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
version = cls._get_version(tenant_id=tenant_id, source=source)
|
||||
if expected_version is not None and version != expected_version:
|
||||
return
|
||||
cache_key = cls._source_key(tenant_id=tenant_id, source=source, version=version)
|
||||
rows = [record.to_cache_row() for record in records]
|
||||
redis_client.setex(cache_key, _PROVIDER_CONFIGURATION_CACHE_TTL_SECONDS, json.dumps(rows))
|
||||
except Exception:
|
||||
logger.warning("Failed to write provider configuration source cache", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def invalidate_tenant(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
sources: Sequence[ProviderConfigurationCacheSource] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
if sources is None:
|
||||
sources = _PROVIDER_CONFIGURATION_SOURCES
|
||||
for source in sources:
|
||||
version_key = _PROVIDER_CONFIGURATION_CACHE_VERSION_KEY.format(tenant_id=tenant_id, source=source.value)
|
||||
redis_client.incr(version_key)
|
||||
redis_client.expire(version_key, _PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
|
||||
except Exception:
|
||||
logger.warning("Failed to invalidate provider configuration source cache", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def _get_version(cls, *, tenant_id: str, source: ProviderConfigurationCacheSource) -> str:
|
||||
version_key = _PROVIDER_CONFIGURATION_CACHE_VERSION_KEY.format(tenant_id=tenant_id, source=source.value)
|
||||
version = redis_client.get(version_key)
|
||||
if version is None:
|
||||
redis_client.set(version_key, "0", ex=_PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
|
||||
return "0"
|
||||
redis_client.expire(version_key, _PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS)
|
||||
return version.decode("utf-8") if isinstance(version, bytes) else str(version)
|
||||
|
||||
@staticmethod
|
||||
def _source_key(*, tenant_id: str, source: ProviderConfigurationCacheSource, version: str) -> str:
|
||||
return _PROVIDER_CONFIGURATION_CACHE_SOURCE_KEY.format(
|
||||
tenant_id=tenant_id,
|
||||
source=source.value,
|
||||
version=version,
|
||||
)
|
||||
|
||||
|
||||
def _get_cached_or_load_records[T: _CacheEntry](
|
||||
*,
|
||||
tenant_id: str,
|
||||
cache_source: _ProviderConfigurationCacheSourceSpec[T],
|
||||
) -> list[T]:
|
||||
cached_records, cache_version = _ProviderConfigurationSourceCache.get_records(
|
||||
tenant_id=tenant_id,
|
||||
source=cache_source.name,
|
||||
entry_cls=cache_source.entry_cls,
|
||||
)
|
||||
if cached_records is not None:
|
||||
return cached_records
|
||||
|
||||
records = cache_source.load_records(tenant_id)
|
||||
_ProviderConfigurationSourceCache.set_records(
|
||||
tenant_id=tenant_id,
|
||||
source=cache_source.name,
|
||||
records=records,
|
||||
expected_version=cache_version,
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def _attach_active_credentials(
|
||||
*,
|
||||
session: Any,
|
||||
records: Sequence[Provider | ProviderModel],
|
||||
credential_model_cls: type[ProviderCredential | ProviderModelCredential],
|
||||
) -> None:
|
||||
credential_ids = [record.credential_id for record in records if getattr(record, "credential_id", None)]
|
||||
if not credential_ids:
|
||||
return
|
||||
|
||||
credentials = session.scalars(select(credential_model_cls).where(credential_model_cls.id.in_(credential_ids))).all()
|
||||
credential_by_id = {credential.id: credential for credential in credentials}
|
||||
for record in records:
|
||||
if getattr(record, "credential_id", None):
|
||||
record.__dict__["credential"] = credential_by_id.get(record.credential_id)
|
||||
|
||||
|
||||
def _load_provider_model_cache_entries(tenant_id: str) -> list[_ProviderModelCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModel).where(ProviderModel.tenant_id == tenant_id, ProviderModel.is_valid == True)
|
||||
provider_models = list(session.scalars(stmt))
|
||||
_attach_active_credentials(
|
||||
session=session,
|
||||
records=provider_models,
|
||||
credential_model_cls=ProviderModelCredential,
|
||||
)
|
||||
return [_ProviderModelCacheEntry.from_record(provider_model) for provider_model in provider_models]
|
||||
|
||||
|
||||
def _load_preferred_model_provider_cache_entries(tenant_id: str) -> list[_TenantPreferredModelProviderCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(TenantPreferredModelProvider).where(TenantPreferredModelProvider.tenant_id == tenant_id)
|
||||
return [
|
||||
_TenantPreferredModelProviderCacheEntry.from_record(preferred_model_provider)
|
||||
for preferred_model_provider in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_model_setting_cache_entries(tenant_id: str) -> list[_ProviderModelSettingCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModelSetting).where(ProviderModelSetting.tenant_id == tenant_id)
|
||||
return [
|
||||
_ProviderModelSettingCacheEntry.from_record(provider_model_setting)
|
||||
for provider_model_setting in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_model_credential_cache_entries(tenant_id: str) -> list[_ProviderModelCredentialCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(ProviderModelCredential)
|
||||
.where(ProviderModelCredential.tenant_id == tenant_id)
|
||||
.order_by(ProviderModelCredential.created_at.desc())
|
||||
)
|
||||
return [
|
||||
_ProviderModelCredentialCacheEntry.from_record(provider_model_credential)
|
||||
for provider_model_credential in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_credential_cache_entries(tenant_id: str) -> list[_ProviderCredentialCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(ProviderCredential)
|
||||
.where(ProviderCredential.tenant_id == tenant_id)
|
||||
.order_by(ProviderCredential.created_at.desc())
|
||||
)
|
||||
return [
|
||||
_ProviderCredentialCacheEntry.from_record(provider_credential)
|
||||
for provider_credential in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_load_balancing_config_cache_entries(tenant_id: str) -> list[_LoadBalancingModelConfigCacheEntry]:
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(LoadBalancingModelConfig).where(LoadBalancingModelConfig.tenant_id == tenant_id)
|
||||
return [
|
||||
_LoadBalancingModelConfigCacheEntry.from_record(load_balancing_model_config)
|
||||
for load_balancing_model_config in session.scalars(stmt)
|
||||
]
|
||||
|
||||
|
||||
_PROVIDER_MODELS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_MODELS,
|
||||
entry_cls=_ProviderModelCacheEntry,
|
||||
load_records=_load_provider_model_cache_entries,
|
||||
)
|
||||
_PREFERRED_MODEL_PROVIDERS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PREFERRED_MODEL_PROVIDERS,
|
||||
entry_cls=_TenantPreferredModelProviderCacheEntry,
|
||||
load_records=_load_preferred_model_provider_cache_entries,
|
||||
)
|
||||
_PROVIDER_MODEL_SETTINGS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_MODEL_SETTINGS,
|
||||
entry_cls=_ProviderModelSettingCacheEntry,
|
||||
load_records=_load_provider_model_setting_cache_entries,
|
||||
)
|
||||
_PROVIDER_MODEL_CREDENTIALS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_MODEL_CREDENTIALS,
|
||||
entry_cls=_ProviderModelCredentialCacheEntry,
|
||||
load_records=_load_provider_model_credential_cache_entries,
|
||||
)
|
||||
_PROVIDER_CREDENTIALS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS,
|
||||
entry_cls=_ProviderCredentialCacheEntry,
|
||||
load_records=_load_provider_credential_cache_entries,
|
||||
)
|
||||
_PROVIDER_LOAD_BALANCING_CONFIGS_CACHE_SOURCE = _ProviderConfigurationCacheSourceSpec(
|
||||
name=ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,
|
||||
entry_cls=_LoadBalancingModelConfigCacheEntry,
|
||||
load_records=_load_provider_load_balancing_config_cache_entries,
|
||||
)
|
||||
|
||||
|
||||
class ProviderManager:
|
||||
@ -590,14 +98,6 @@ class ProviderManager:
|
||||
|
||||
self._configurations_cache.pop(tenant_id, None)
|
||||
|
||||
@staticmethod
|
||||
def invalidate_configurations_cache(
|
||||
tenant_id: str,
|
||||
sources: Sequence[ProviderConfigurationCacheSource] | None = None,
|
||||
) -> None:
|
||||
"""Invalidate cross-process provider configuration source cache for a tenant."""
|
||||
_ProviderConfigurationSourceCache.invalidate_tenant(tenant_id, sources=sources)
|
||||
|
||||
def get_configurations(self, tenant_id: str) -> ProviderConfigurations:
|
||||
"""
|
||||
Get model provider configurations.
|
||||
@ -692,9 +192,6 @@ class ProviderManager:
|
||||
# Get All provider model credentials
|
||||
provider_name_to_provider_model_credentials_dict = self._get_all_provider_model_credentials(tenant_id)
|
||||
|
||||
# Get All provider credentials
|
||||
provider_name_to_provider_credentials_dict = self._get_all_provider_credentials(tenant_id)
|
||||
|
||||
provider_configurations = ProviderConfigurations(tenant_id=tenant_id)
|
||||
|
||||
# Construct ProviderConfiguration objects for each provider
|
||||
@ -727,12 +224,7 @@ class ProviderManager:
|
||||
|
||||
# Convert to custom configuration
|
||||
custom_configuration = self._to_custom_configuration(
|
||||
tenant_id,
|
||||
provider_entity,
|
||||
provider_records,
|
||||
provider_model_records,
|
||||
provider_model_credentials,
|
||||
provider_name_to_provider_credentials_dict,
|
||||
tenant_id, provider_entity, provider_records, provider_model_records, provider_model_credentials
|
||||
)
|
||||
|
||||
# Convert to system configuration
|
||||
@ -956,115 +448,84 @@ class ProviderManager:
|
||||
provider_name_to_provider_records_dict = defaultdict(list)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(Provider).where(Provider.tenant_id == tenant_id, Provider.is_valid == True)
|
||||
providers = list(session.scalars(stmt))
|
||||
_attach_active_credentials(
|
||||
session=session,
|
||||
records=providers,
|
||||
credential_model_cls=ProviderCredential,
|
||||
)
|
||||
providers = session.scalars(stmt)
|
||||
for provider in providers:
|
||||
# Use provider name with prefix after the data migration
|
||||
provider_name_to_provider_records_dict[str(ModelProviderID(provider.provider_name))].append(provider)
|
||||
return provider_name_to_provider_records_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_models(tenant_id: str) -> dict[str, list[_ProviderModelCacheEntry]]:
|
||||
def _get_all_provider_models(tenant_id: str) -> dict[str, list[ProviderModel]]:
|
||||
"""
|
||||
Get all provider model records of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_models = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_MODELS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_model_records_dict = defaultdict(list)
|
||||
for provider_model in provider_models:
|
||||
provider_name_to_provider_model_records_dict[provider_model.provider_name].append(provider_model)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModel).where(ProviderModel.tenant_id == tenant_id, ProviderModel.is_valid == True)
|
||||
provider_models = session.scalars(stmt)
|
||||
for provider_model in provider_models:
|
||||
provider_name_to_provider_model_records_dict[provider_model.provider_name].append(provider_model)
|
||||
return provider_name_to_provider_model_records_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_preferred_model_providers(tenant_id: str) -> dict[str, _TenantPreferredModelProviderCacheEntry]:
|
||||
def _get_all_preferred_model_providers(tenant_id: str) -> dict[str, TenantPreferredModelProvider]:
|
||||
"""
|
||||
Get All preferred provider types of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
preferred_provider_types = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PREFERRED_MODEL_PROVIDERS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
return {
|
||||
preferred_provider_type.provider_name: preferred_provider_type
|
||||
for preferred_provider_type in preferred_provider_types
|
||||
}
|
||||
provider_name_to_preferred_provider_type_records_dict = {}
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(TenantPreferredModelProvider).where(TenantPreferredModelProvider.tenant_id == tenant_id)
|
||||
preferred_provider_types = session.scalars(stmt)
|
||||
provider_name_to_preferred_provider_type_records_dict = {
|
||||
preferred_provider_type.provider_name: preferred_provider_type
|
||||
for preferred_provider_type in preferred_provider_types
|
||||
}
|
||||
return provider_name_to_preferred_provider_type_records_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_model_settings(tenant_id: str) -> dict[str, list[_ProviderModelSettingCacheEntry]]:
|
||||
def _get_all_provider_model_settings(tenant_id: str) -> dict[str, list[ProviderModelSetting]]:
|
||||
"""
|
||||
Get All provider model settings of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_model_settings = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_MODEL_SETTINGS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_model_settings_dict = defaultdict(list)
|
||||
for provider_model_setting in provider_model_settings:
|
||||
provider_name_to_provider_model_settings_dict[provider_model_setting.provider_name].append(
|
||||
provider_model_setting
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModelSetting).where(ProviderModelSetting.tenant_id == tenant_id)
|
||||
provider_model_settings = session.scalars(stmt)
|
||||
for provider_model_setting in provider_model_settings:
|
||||
provider_name_to_provider_model_settings_dict[provider_model_setting.provider_name].append(
|
||||
provider_model_setting
|
||||
)
|
||||
return provider_name_to_provider_model_settings_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_model_credentials(tenant_id: str) -> dict[str, list[_ProviderModelCredentialCacheEntry]]:
|
||||
def _get_all_provider_model_credentials(tenant_id: str) -> dict[str, list[ProviderModelCredential]]:
|
||||
"""
|
||||
Get All provider model credentials of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_model_credentials = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_MODEL_CREDENTIALS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_model_credentials_dict = defaultdict(list)
|
||||
for provider_model_credential in provider_model_credentials:
|
||||
provider_name_to_provider_model_credentials_dict[provider_model_credential.provider_name].append(
|
||||
provider_model_credential
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(ProviderModelCredential).where(ProviderModelCredential.tenant_id == tenant_id)
|
||||
provider_model_credentials = session.scalars(stmt)
|
||||
for provider_model_credential in provider_model_credentials:
|
||||
provider_name_to_provider_model_credentials_dict[provider_model_credential.provider_name].append(
|
||||
provider_model_credential
|
||||
)
|
||||
return provider_name_to_provider_model_credentials_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_credentials(tenant_id: str) -> dict[str, list[_ProviderCredentialCacheEntry]]:
|
||||
"""
|
||||
Get All provider credentials of the workspace.
|
||||
|
||||
:param tenant_id: workspace id
|
||||
:return:
|
||||
"""
|
||||
provider_credentials = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_CREDENTIALS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_credentials_dict = defaultdict(list)
|
||||
for provider_credential in provider_credentials:
|
||||
provider_name_to_provider_credentials_dict[provider_credential.provider_name].append(provider_credential)
|
||||
return provider_name_to_provider_credentials_dict
|
||||
|
||||
@staticmethod
|
||||
def _get_all_provider_load_balancing_configs(
|
||||
tenant_id: str,
|
||||
) -> dict[str, list[_LoadBalancingModelConfigCacheEntry]]:
|
||||
def _get_all_provider_load_balancing_configs(tenant_id: str) -> dict[str, list[LoadBalancingModelConfig]]:
|
||||
"""
|
||||
Get All provider load balancing configs of the workspace.
|
||||
|
||||
@ -1085,16 +546,14 @@ class ProviderManager:
|
||||
if not model_load_balancing_enabled:
|
||||
return {}
|
||||
|
||||
provider_load_balancing_configs = _get_cached_or_load_records(
|
||||
tenant_id=tenant_id,
|
||||
cache_source=_PROVIDER_LOAD_BALANCING_CONFIGS_CACHE_SOURCE,
|
||||
)
|
||||
|
||||
provider_name_to_provider_load_balancing_model_configs_dict = defaultdict(list)
|
||||
for provider_load_balancing_config in provider_load_balancing_configs:
|
||||
provider_name_to_provider_load_balancing_model_configs_dict[
|
||||
provider_load_balancing_config.provider_name
|
||||
].append(provider_load_balancing_config)
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(LoadBalancingModelConfig).where(LoadBalancingModelConfig.tenant_id == tenant_id)
|
||||
provider_load_balancing_configs = session.scalars(stmt)
|
||||
for provider_load_balancing_config in provider_load_balancing_configs:
|
||||
provider_name_to_provider_load_balancing_model_configs_dict[
|
||||
provider_load_balancing_config.provider_name
|
||||
].append(provider_load_balancing_config)
|
||||
|
||||
return provider_name_to_provider_load_balancing_model_configs_dict
|
||||
|
||||
@ -1263,9 +722,8 @@ class ProviderManager:
|
||||
tenant_id: str,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_records: list[Provider],
|
||||
provider_model_records: list[_ProviderModelCacheEntry],
|
||||
provider_model_credentials: list[_ProviderModelCredentialCacheEntry],
|
||||
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
|
||||
provider_model_records: list[ProviderModel],
|
||||
provider_model_credentials: list[ProviderModelCredential],
|
||||
) -> CustomConfiguration:
|
||||
"""
|
||||
Convert to custom configuration.
|
||||
@ -1278,10 +736,7 @@ class ProviderManager:
|
||||
"""
|
||||
# Get custom provider configuration
|
||||
custom_provider_configuration = self._get_custom_provider_configuration(
|
||||
tenant_id,
|
||||
provider_entity,
|
||||
provider_records,
|
||||
provider_credentials_by_name,
|
||||
tenant_id, provider_entity, provider_records
|
||||
)
|
||||
|
||||
# Get custom models which have not been added to the model list yet
|
||||
@ -1303,11 +758,7 @@ class ProviderManager:
|
||||
)
|
||||
|
||||
def _get_custom_provider_configuration(
|
||||
self,
|
||||
tenant_id: str,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_records: list[Provider],
|
||||
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
|
||||
self, tenant_id: str, provider_entity: ProviderEntity, provider_records: list[Provider]
|
||||
) -> CustomProviderConfiguration | None:
|
||||
"""Get custom provider configuration."""
|
||||
# Find custom provider record (non-system)
|
||||
@ -1339,29 +790,13 @@ class ProviderManager:
|
||||
credentials=provider_credentials,
|
||||
current_credential_name=custom_provider_record.credential_name,
|
||||
current_credential_id=custom_provider_record.credential_id,
|
||||
available_credentials=self._get_provider_available_credentials_from_records(
|
||||
custom_provider_record.provider_name,
|
||||
provider_credentials_by_name,
|
||||
available_credentials=self.get_provider_available_credentials(
|
||||
tenant_id, custom_provider_record.provider_name
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_provider_available_credentials_from_records(
|
||||
provider_name: str,
|
||||
provider_credentials_by_name: dict[str, list[_ProviderCredentialCacheEntry]],
|
||||
) -> list[CredentialConfiguration]:
|
||||
available_credentials: list[CredentialConfiguration] = []
|
||||
for candidate_provider_name in ProviderManager._get_provider_names(provider_name):
|
||||
available_credentials.extend(
|
||||
CredentialConfiguration(credential_id=credential.id, credential_name=credential.credential_name)
|
||||
for credential in provider_credentials_by_name.get(candidate_provider_name, [])
|
||||
)
|
||||
return available_credentials
|
||||
|
||||
def _get_can_added_models(
|
||||
self,
|
||||
provider_model_records: list[_ProviderModelCacheEntry],
|
||||
all_model_credentials: Sequence[_ProviderModelCredentialCacheEntry],
|
||||
self, provider_model_records: list[ProviderModel], all_model_credentials: Sequence[ProviderModelCredential]
|
||||
) -> list[dict]:
|
||||
"""Get the custom models and credentials from enterprise version which haven't add to the model list"""
|
||||
existing_model_set = {(record.model_name, record.model_type) for record in provider_model_records}
|
||||
@ -1394,9 +829,9 @@ class ProviderManager:
|
||||
self,
|
||||
tenant_id: str,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_model_records: list[_ProviderModelCacheEntry],
|
||||
provider_model_records: list[ProviderModel],
|
||||
can_added_models: list[dict],
|
||||
all_model_credentials: Sequence[_ProviderModelCredentialCacheEntry],
|
||||
all_model_credentials: Sequence[ProviderModelCredential],
|
||||
) -> list[CustomModelConfiguration]:
|
||||
"""Get custom model configurations."""
|
||||
# Get model credential secret variables
|
||||
@ -1716,8 +1151,8 @@ class ProviderManager:
|
||||
def _to_model_settings(
|
||||
self,
|
||||
provider_entity: ProviderEntity,
|
||||
provider_model_settings: list[_ProviderModelSettingCacheEntry] | None = None,
|
||||
load_balancing_model_configs: list[_LoadBalancingModelConfigCacheEntry] | None = None,
|
||||
provider_model_settings: list[ProviderModelSetting] | None = None,
|
||||
load_balancing_model_configs: list[LoadBalancingModelConfig] | None = None,
|
||||
) -> list[ModelSettings]:
|
||||
"""
|
||||
Convert to model settings.
|
||||
|
||||
@ -7,12 +7,12 @@ from typing import Any, Literal, NotRequired, TypedDict
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, scoped_session
|
||||
from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
from core.helper.http_client_pooling import get_pooled_http_client
|
||||
from enums.cloud_plan import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.helper import RateLimiter
|
||||
from models import Account, TenantAccountJoin, TenantAccountRole
|
||||
@ -363,10 +363,10 @@ class BillingService:
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
def is_tenant_owner_or_admin(session: Session | scoped_session, current_user: Account):
|
||||
def is_tenant_owner_or_admin(current_user: Account):
|
||||
tenant_id = current_user.current_tenant_id
|
||||
|
||||
join: TenantAccountJoin | None = session.scalar(
|
||||
join: TenantAccountJoin | None = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == current_user.id)
|
||||
.limit(1)
|
||||
|
||||
@ -125,9 +125,9 @@ class ClearFreePlanTenantExpiredLogs:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def process_tenant(cls, flask_app: Flask, tenant_id: str, days: int, batch: int, session: Session):
|
||||
def process_tenant(cls, flask_app: Flask, tenant_id: str, days: int, batch: int):
|
||||
with flask_app.app_context():
|
||||
apps = session.scalars(select(App).where(App.tenant_id == tenant_id)).all()
|
||||
apps = db.session.scalars(select(App).where(App.tenant_id == tenant_id)).all()
|
||||
app_ids = [app.id for app in apps]
|
||||
while True:
|
||||
with sessionmaker(bind=db.engine, autoflush=False).begin() as session:
|
||||
@ -375,8 +375,7 @@ class ClearFreePlanTenantExpiredLogs:
|
||||
or BillingService.get_info(tenant_id)["subscription"]["plan"] == CloudPlan.SANDBOX
|
||||
):
|
||||
# only process sandbox tenant
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
cls.process_tenant(flask_app, tenant_id, days, batch, session)
|
||||
cls.process_tenant(flask_app, tenant_id, days, batch)
|
||||
except Exception:
|
||||
logger.exception("Failed to process tenant %s", tenant_id)
|
||||
finally:
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import InstrumentedAttribute, Session, scoped_session
|
||||
from sqlalchemy.orm import InstrumentedAttribute
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.account import Account
|
||||
from models.credential_permission import CredentialPermission
|
||||
from models.enums import PermissionEnum
|
||||
@ -16,11 +17,9 @@ class CredentialPermissionService:
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_partial_member_list(
|
||||
cls, session: Session | scoped_session, credential_id: str, credential_type: str
|
||||
) -> Sequence[str]:
|
||||
def get_partial_member_list(cls, credential_id: str, credential_type: str) -> Sequence[str]:
|
||||
"""Return account_ids that have partial-member access to a credential."""
|
||||
return session.scalars(
|
||||
return db.session.scalars(
|
||||
select(CredentialPermission.account_id).where(
|
||||
CredentialPermission.credential_id == credential_id,
|
||||
CredentialPermission.credential_type == credential_type,
|
||||
|
||||
@ -7,13 +7,10 @@ from sqlalchemy import or_, select
|
||||
from constants import HIDDEN_VALUE
|
||||
from core.entities.provider_configuration import ProviderConfiguration
|
||||
from core.helper import encrypter
|
||||
from core.helper.model_provider_cache import (
|
||||
ProviderCredentialsCache,
|
||||
ProviderCredentialsCacheType,
|
||||
)
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
|
||||
from core.model_manager import LBModelManager
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly, create_plugin_provider_manager
|
||||
from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager
|
||||
from core.provider_manager import ProviderManager
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import (
|
||||
@ -316,10 +313,6 @@ class ModelLoadBalancingService:
|
||||
)
|
||||
db.session.add(inherit_config)
|
||||
db.session.commit()
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
tenant_id,
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
|
||||
)
|
||||
|
||||
return inherit_config
|
||||
|
||||
@ -441,10 +434,6 @@ class ModelLoadBalancingService:
|
||||
load_balancing_config.enabled = enabled
|
||||
load_balancing_config.updated_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
tenant_id,
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
|
||||
)
|
||||
|
||||
self._clear_credentials_cache(tenant_id, config_id)
|
||||
else:
|
||||
@ -498,20 +487,12 @@ class ModelLoadBalancingService:
|
||||
|
||||
db.session.add(load_balancing_model_config)
|
||||
db.session.commit()
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
tenant_id,
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
|
||||
)
|
||||
|
||||
# get deleted config ids
|
||||
deleted_config_ids = set(current_load_balancing_configs_dict.keys()) - updated_config_ids
|
||||
for config_id in deleted_config_ids:
|
||||
db.session.delete(current_load_balancing_configs_dict[config_id])
|
||||
db.session.commit()
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
tenant_id,
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_LOAD_BALANCING_CONFIGS,),
|
||||
)
|
||||
|
||||
self._clear_credentials_cache(tenant_id, config_id)
|
||||
|
||||
|
||||
@ -8,11 +8,6 @@ Archive V2 writes bundle-level Parquet objects. A bundle contains many workflow
|
||||
Bundle metadata lives in the object-store manifest instead of a database table, so archive/delete/restore does not move
|
||||
the large-table retention problem into another OLTP table.
|
||||
|
||||
Archive campaigns should use fixed absolute UTC windows for every tenant-prefix/shard execution. Relative windows are
|
||||
evaluated at process start and are not safe for multi-day rollout because each command would scan a different window.
|
||||
Per-shard `index.json` objects are derived from bundle manifests and provide run-level idempotency without adding a
|
||||
database claim table; bundle manifests remain the source of truth when an index must be rebuilt.
|
||||
|
||||
Archived tables:
|
||||
- workflow_runs
|
||||
- workflow_app_logs
|
||||
@ -30,11 +25,9 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from threading import Lock
|
||||
from typing import Any, NotRequired, TypedDict, cast
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import click
|
||||
import pyarrow as pa
|
||||
@ -66,7 +59,6 @@ from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWo
|
||||
from services.billing_service import BillingService
|
||||
from services.retention.workflow_run.constants import (
|
||||
ARCHIVE_BUNDLE_FORMAT,
|
||||
ARCHIVE_BUNDLE_INDEX_NAME,
|
||||
ARCHIVE_BUNDLE_MANIFEST_NAME,
|
||||
ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
)
|
||||
@ -98,24 +90,10 @@ class ArchiveManifestDict(TypedDict):
|
||||
min_run_id: str
|
||||
max_run_id: str
|
||||
archived_at: str
|
||||
campaign_id: str
|
||||
archive_window_start: str | None
|
||||
archive_window_end: str
|
||||
run_shard: str
|
||||
tables: dict[str, TableStatsManifestEntry]
|
||||
run_ids: list[str]
|
||||
|
||||
|
||||
class ArchiveBundleIndexDict(TypedDict):
|
||||
schema_version: str
|
||||
archive_format: str
|
||||
object_prefix: str
|
||||
updated_at: str
|
||||
manifest_keys: list[str]
|
||||
run_ids: list[str]
|
||||
campaign_ids: NotRequired[list[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchiveBundleIdentity:
|
||||
"""Stable identity and object prefix for one V2 archive bundle."""
|
||||
@ -149,7 +127,6 @@ class ArchiveResult:
|
||||
object_prefix: str
|
||||
success: bool
|
||||
run_count: int = 0
|
||||
skipped_run_count: int = 0
|
||||
tables: list[TableStats] = field(default_factory=list)
|
||||
object_size_bytes: int = 0
|
||||
skipped: bool = False
|
||||
@ -215,14 +192,11 @@ class WorkflowRunArchiver:
|
||||
tenant_prefixes: list[str]
|
||||
run_shard_index: int | None
|
||||
run_shard_total: int | None
|
||||
campaign_id: str
|
||||
_archive_index_cache: dict[str, set[str]]
|
||||
_archive_index_cache_lock: Lock
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
days: int = 90,
|
||||
batch_size: int = 10000,
|
||||
batch_size: int = 100,
|
||||
start_from: datetime.datetime | None = None,
|
||||
end_before: datetime.datetime | None = None,
|
||||
workers: int = 1,
|
||||
@ -264,10 +238,10 @@ class WorkflowRunArchiver:
|
||||
if start_from or end_before:
|
||||
if start_from is None or end_before is None:
|
||||
raise ValueError("start_from and end_before must be provided together")
|
||||
self.start_from = self._normalize_utc_datetime(start_from)
|
||||
self.end_before = self._normalize_utc_datetime(end_before)
|
||||
if self.start_from >= self.end_before:
|
||||
if start_from >= end_before:
|
||||
raise ValueError("start_from must be earlier than end_before")
|
||||
self.start_from = start_from.replace(tzinfo=datetime.UTC)
|
||||
self.end_before = end_before.replace(tzinfo=datetime.UTC)
|
||||
else:
|
||||
self.start_from = None
|
||||
self.end_before = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days)
|
||||
@ -285,9 +259,6 @@ class WorkflowRunArchiver:
|
||||
raise ValueError("run_shard_index must be between 0 and run_shard_total - 1")
|
||||
self.run_shard_index = run_shard_index
|
||||
self.run_shard_total = run_shard_total
|
||||
self.campaign_id = self._build_campaign_id()
|
||||
self._archive_index_cache = {}
|
||||
self._archive_index_cache_lock = Lock()
|
||||
self.limit = limit
|
||||
self.dry_run = dry_run
|
||||
self.delete_after_archive = delete_after_archive
|
||||
@ -351,23 +322,24 @@ class WorkflowRunArchiver:
|
||||
if not runs_to_process:
|
||||
continue
|
||||
|
||||
bundle_groups = self._group_runs_for_bundles(runs_to_process)
|
||||
summary.total_bundles_processed += len(bundle_groups)
|
||||
for result in self._archive_bundle_groups(session_maker, storage, bundle_groups):
|
||||
attempted_count += result.run_count + result.skipped_run_count
|
||||
summary.runs_skipped += result.skipped_run_count
|
||||
for bundle_runs in self._group_runs_for_bundles(runs_to_process):
|
||||
summary.total_bundles_processed += 1
|
||||
with session_maker() as session:
|
||||
result = self._archive_bundle(session, storage, bundle_runs)
|
||||
|
||||
if result.skipped:
|
||||
attempted_count += result.run_count
|
||||
summary.bundles_skipped += 1
|
||||
summary.runs_skipped += result.run_count
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Skipped bundle {result.bundle_id} (tenant={result.tenant_id}, "
|
||||
f"runs={result.run_count}, skipped_runs={result.skipped_run_count}, "
|
||||
f"reason={result.error or 'already handled'})",
|
||||
f"runs={result.run_count}, reason={result.error or 'already handled'})",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
elif result.success:
|
||||
attempted_count += result.run_count
|
||||
summary.bundles_archived += 1
|
||||
summary.runs_archived += result.run_count
|
||||
self._merge_result_stats(summary, result)
|
||||
@ -375,14 +347,15 @@ class WorkflowRunArchiver:
|
||||
click.style(
|
||||
f"{'[DRY RUN] Would archive' if self.dry_run else 'Archived'} "
|
||||
f"bundle {result.bundle_id} (tenant={result.tenant_id}, runs={result.run_count}, "
|
||||
f"skipped_runs={result.skipped_run_count}, tables={len(result.tables)}, "
|
||||
f"object_size_bytes={result.object_size_bytes}, time={result.elapsed_time:.2f}s)",
|
||||
f"tables={len(result.tables)}, object_size_bytes={result.object_size_bytes}, "
|
||||
f"time={result.elapsed_time:.2f}s)",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
if self.dry_run:
|
||||
self._echo_table_estimates(result.tables)
|
||||
else:
|
||||
attempted_count += result.run_count
|
||||
summary.bundles_failed += 1
|
||||
summary.runs_failed += result.run_count
|
||||
click.echo(
|
||||
@ -520,35 +493,6 @@ class WorkflowRunArchiver:
|
||||
|
||||
return paid
|
||||
|
||||
def _archive_bundle_groups(
|
||||
self,
|
||||
session_maker: sessionmaker[Session],
|
||||
storage: ArchiveStorage | None,
|
||||
bundle_groups: Sequence[Sequence[WorkflowRun]],
|
||||
) -> list[ArchiveResult]:
|
||||
"""Archive grouped bundles, optionally in parallel."""
|
||||
if not bundle_groups:
|
||||
return []
|
||||
if self.workers == 1 or len(bundle_groups) == 1:
|
||||
results: list[ArchiveResult] = []
|
||||
for bundle_runs in bundle_groups:
|
||||
with session_maker() as session:
|
||||
results.append(self._archive_bundle(session, storage, bundle_runs))
|
||||
return results
|
||||
|
||||
results = []
|
||||
max_workers = min(self.workers, len(bundle_groups))
|
||||
|
||||
def archive_in_worker(bundle_runs: Sequence[WorkflowRun]) -> ArchiveResult:
|
||||
with session_maker() as session:
|
||||
return self._archive_bundle(session, storage, bundle_runs)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [executor.submit(archive_in_worker, bundle_runs) for bundle_runs in bundle_groups]
|
||||
for future in as_completed(futures):
|
||||
results.append(future.result())
|
||||
return results
|
||||
|
||||
def _archive_bundle(
|
||||
self,
|
||||
session: Session,
|
||||
@ -559,7 +503,6 @@ class WorkflowRunArchiver:
|
||||
if not runs:
|
||||
raise ValueError("runs must not be empty")
|
||||
start_time = time.time()
|
||||
original_run_count = len(runs)
|
||||
identity = self._build_bundle_identity(runs)
|
||||
result = ArchiveResult(
|
||||
bundle_id=identity.bundle_id,
|
||||
@ -574,36 +517,12 @@ class WorkflowRunArchiver:
|
||||
if storage is None:
|
||||
raise ArchiveStorageNotConfiguredError("Archive storage not configured")
|
||||
if storage.object_exists(self._get_manifest_object_key(identity)):
|
||||
self._write_bundle_index(storage, identity)
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
result.error = "bundle already archived"
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
archived_run_ids = self._load_archived_run_ids_for_identity(storage, identity)
|
||||
runs = [run for run in runs if run.id not in archived_run_ids]
|
||||
result.skipped_run_count = original_run_count - len(runs)
|
||||
if not runs:
|
||||
result.run_count = 0
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
result.error = "all runs already archived in shard index"
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
identity = self._build_bundle_identity(runs)
|
||||
result.bundle_id = identity.bundle_id
|
||||
result.object_prefix = identity.object_prefix
|
||||
result.run_count = len(runs)
|
||||
if storage.object_exists(self._get_manifest_object_key(identity)):
|
||||
self._write_bundle_index(storage, identity)
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
result.error = "filtered bundle already archived"
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
locked_runs = self._lock_runs_for_archive(session, [run.id for run in runs])
|
||||
if len(locked_runs) != len(runs):
|
||||
result.success = True
|
||||
@ -628,7 +547,6 @@ class WorkflowRunArchiver:
|
||||
for table_name, payload in table_payloads.items():
|
||||
storage.put_object(self._get_table_object_key(identity, table_name), payload)
|
||||
storage.put_object(self._get_manifest_object_key(identity), manifest_data)
|
||||
self._merge_bundle_manifest_into_index(storage, identity, [run.id for run in runs])
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
@ -678,68 +596,49 @@ class WorkflowRunArchiver:
|
||||
session: Session,
|
||||
runs: Sequence[WorkflowRun],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Extract archived rows using Core mappings to avoid ORM hydration on large retention batches."""
|
||||
"""Extract all archived table rows for a bundle."""
|
||||
run_ids = [run.id for run in runs]
|
||||
table_data: dict[str, list[dict[str, Any]]] = {}
|
||||
table_data["workflow_runs"] = [self._row_to_dict(run) for run in runs]
|
||||
|
||||
table_data["workflow_app_logs"] = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowAppLog,
|
||||
WorkflowAppLog.workflow_run_id,
|
||||
run_ids,
|
||||
)
|
||||
app_logs = list(session.scalars(select(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids))))
|
||||
table_data["workflow_app_logs"] = [self._row_to_dict(row) for row in app_logs]
|
||||
|
||||
node_exec_records = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowNodeExecutionModel,
|
||||
WorkflowNodeExecutionModel.workflow_run_id,
|
||||
run_ids,
|
||||
)
|
||||
node_exec_ids = [str(record["id"]) for record in node_exec_records]
|
||||
table_data["workflow_node_executions"] = node_exec_records
|
||||
table_data["workflow_node_execution_offload"] = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowNodeExecutionOffload,
|
||||
WorkflowNodeExecutionOffload.node_execution_id,
|
||||
node_exec_ids,
|
||||
node_exec_records = list(
|
||||
session.scalars(
|
||||
select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.workflow_run_id.in_(run_ids))
|
||||
)
|
||||
)
|
||||
node_exec_ids = [record.id for record in node_exec_records]
|
||||
offload_records = []
|
||||
if node_exec_ids:
|
||||
offload_records = list(
|
||||
session.scalars(
|
||||
select(WorkflowNodeExecutionOffload).where(
|
||||
WorkflowNodeExecutionOffload.node_execution_id.in_(node_exec_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
table_data["workflow_node_executions"] = [self._row_to_dict(row) for row in node_exec_records]
|
||||
table_data["workflow_node_execution_offload"] = [self._row_to_dict(row) for row in offload_records]
|
||||
|
||||
pause_records = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowPause,
|
||||
WorkflowPause.workflow_run_id,
|
||||
run_ids,
|
||||
)
|
||||
pause_ids = [str(record["id"]) for record in pause_records]
|
||||
table_data["workflow_pauses"] = pause_records
|
||||
table_data["workflow_pause_reasons"] = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowPauseReason,
|
||||
WorkflowPauseReason.pause_id,
|
||||
pause_ids,
|
||||
)
|
||||
pause_records = list(session.scalars(select(WorkflowPause).where(WorkflowPause.workflow_run_id.in_(run_ids))))
|
||||
pause_ids = [pause.id for pause in pause_records]
|
||||
pause_reason_records = []
|
||||
if pause_ids:
|
||||
pause_reason_records = list(
|
||||
session.scalars(select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id.in_(pause_ids)))
|
||||
)
|
||||
table_data["workflow_pauses"] = [self._row_to_dict(row) for row in pause_records]
|
||||
table_data["workflow_pause_reasons"] = [self._row_to_dict(row) for row in pause_reason_records]
|
||||
|
||||
table_data["workflow_trigger_logs"] = self._select_rows_by_column(
|
||||
session,
|
||||
WorkflowTriggerLog,
|
||||
WorkflowTriggerLog.workflow_run_id,
|
||||
run_ids,
|
||||
)
|
||||
trigger_repo = SQLAlchemyWorkflowTriggerLogRepository(session)
|
||||
trigger_records: list[WorkflowTriggerLog] = []
|
||||
for run_id in run_ids:
|
||||
trigger_records.extend(trigger_repo.list_by_run_id(run_id))
|
||||
table_data["workflow_trigger_logs"] = [self._row_to_dict(row) for row in trigger_records]
|
||||
return table_data
|
||||
|
||||
@staticmethod
|
||||
def _select_rows_by_column(
|
||||
session: Session,
|
||||
model: Any,
|
||||
column: Any,
|
||||
values: Sequence[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not values:
|
||||
return []
|
||||
stmt = select(*model.__table__.columns).where(column.in_(values))
|
||||
return [dict(row) for row in session.execute(stmt).mappings().all()]
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
mapper = inspect(row).mapper
|
||||
@ -791,9 +690,6 @@ class WorkflowRunArchiver:
|
||||
for stat in table_stats
|
||||
}
|
||||
sorted_runs = sorted(runs, key=lambda run: (run.created_at, run.id))
|
||||
end_before = self.end_before
|
||||
if end_before is None:
|
||||
raise ValueError("archive window end must be set")
|
||||
return ArchiveManifestDict(
|
||||
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
archive_format=ARCHIVE_BUNDLE_FORMAT,
|
||||
@ -811,10 +707,6 @@ class WorkflowRunArchiver:
|
||||
min_run_id=min(run.id for run in runs),
|
||||
max_run_id=max(run.id for run in runs),
|
||||
archived_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
campaign_id=self.campaign_id,
|
||||
archive_window_start=self._format_window_datetime(self.start_from),
|
||||
archive_window_end=end_before.isoformat(),
|
||||
run_shard=identity.shard,
|
||||
tables=tables,
|
||||
run_ids=[run.id for run in sorted_runs],
|
||||
)
|
||||
@ -881,158 +773,6 @@ class WorkflowRunArchiver:
|
||||
return "00-of-01"
|
||||
return f"{self.run_shard_index:02d}-of-{self.run_shard_total:02d}"
|
||||
|
||||
def _load_archived_run_ids_for_identity(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> set[str]:
|
||||
index_key = self._get_index_object_key(identity)
|
||||
with self._archive_index_cache_lock:
|
||||
cached_run_ids = self._archive_index_cache.get(index_key)
|
||||
if cached_run_ids is not None:
|
||||
return set(cached_run_ids)
|
||||
|
||||
if storage.object_exists(index_key):
|
||||
index = self._load_bundle_index(storage, identity)
|
||||
else:
|
||||
index = self._write_bundle_index(storage, identity)
|
||||
run_ids = set(index["run_ids"])
|
||||
with self._archive_index_cache_lock:
|
||||
self._archive_index_cache[index_key] = set(run_ids)
|
||||
return run_ids
|
||||
|
||||
def _load_bundle_index(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> ArchiveBundleIndexDict:
|
||||
index_key = self._get_index_object_key(identity)
|
||||
payload = storage.get_object(index_key)
|
||||
loaded = json.loads(payload)
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError(f"archive index must be an object: {index_key}")
|
||||
index = cast(ArchiveBundleIndexDict, loaded)
|
||||
expected_prefix = self._get_shard_object_prefix(identity)
|
||||
if index["schema_version"] != ARCHIVE_BUNDLE_SCHEMA_VERSION:
|
||||
raise ValueError(f"unsupported archive index schema_version: {index['schema_version']}")
|
||||
if index["archive_format"] != ARCHIVE_BUNDLE_FORMAT:
|
||||
raise ValueError(f"unsupported archive index archive_format: {index['archive_format']}")
|
||||
if index["object_prefix"] != expected_prefix:
|
||||
raise ValueError("archive index object_prefix does not match shard prefix")
|
||||
return index
|
||||
|
||||
def _write_bundle_index(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> ArchiveBundleIndexDict:
|
||||
index = self._build_bundle_index(storage, identity)
|
||||
storage.put_object(self._get_index_object_key(identity), json.dumps(index, indent=2).encode("utf-8"))
|
||||
with self._archive_index_cache_lock:
|
||||
self._archive_index_cache[self._get_index_object_key(identity)] = set(index["run_ids"])
|
||||
return index
|
||||
|
||||
def _merge_bundle_manifest_into_index(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
run_ids: Sequence[str],
|
||||
) -> ArchiveBundleIndexDict:
|
||||
index_key = self._get_index_object_key(identity)
|
||||
if storage.object_exists(index_key):
|
||||
index = self._load_bundle_index(storage, identity)
|
||||
else:
|
||||
index = self._build_bundle_index(storage, identity)
|
||||
|
||||
manifest_keys = sorted(set(index["manifest_keys"]) | {self._get_manifest_object_key(identity)})
|
||||
indexed_run_ids = sorted(set(index["run_ids"]) | set(run_ids))
|
||||
campaign_id_set: set[str] = set(index.get("campaign_ids", []))
|
||||
campaign_id_set.add(self.campaign_id)
|
||||
campaign_ids = sorted(campaign_id_set)
|
||||
updated_index = ArchiveBundleIndexDict(
|
||||
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
archive_format=ARCHIVE_BUNDLE_FORMAT,
|
||||
object_prefix=self._get_shard_object_prefix(identity),
|
||||
updated_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
manifest_keys=manifest_keys,
|
||||
run_ids=indexed_run_ids,
|
||||
campaign_ids=campaign_ids,
|
||||
)
|
||||
storage.put_object(index_key, json.dumps(updated_index, indent=2).encode("utf-8"))
|
||||
with self._archive_index_cache_lock:
|
||||
self._archive_index_cache[index_key] = set(indexed_run_ids)
|
||||
return updated_index
|
||||
|
||||
def _build_bundle_index(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> ArchiveBundleIndexDict:
|
||||
shard_prefix = self._get_shard_object_prefix(identity)
|
||||
manifest_keys = sorted(
|
||||
key for key in storage.list_objects(shard_prefix) if key.endswith(f"/{ARCHIVE_BUNDLE_MANIFEST_NAME}")
|
||||
)
|
||||
run_ids: set[str] = set()
|
||||
campaign_ids: set[str] = set()
|
||||
for manifest_key in manifest_keys:
|
||||
manifest_payload = storage.get_object(manifest_key)
|
||||
manifest = json.loads(manifest_payload)
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError(f"archive manifest must be an object: {manifest_key}")
|
||||
if manifest.get("schema_version") != ARCHIVE_BUNDLE_SCHEMA_VERSION:
|
||||
raise ValueError(
|
||||
f"unsupported bundle schema_version in {manifest_key}: {manifest.get('schema_version')}"
|
||||
)
|
||||
if manifest.get("archive_format") != ARCHIVE_BUNDLE_FORMAT:
|
||||
raise ValueError(
|
||||
f"unsupported bundle archive_format in {manifest_key}: {manifest.get('archive_format')}"
|
||||
)
|
||||
manifest_run_ids = manifest.get("run_ids")
|
||||
if not isinstance(manifest_run_ids, list):
|
||||
raise ValueError(f"manifest run_ids must be a list: {manifest_key}")
|
||||
run_ids.update(str(run_id) for run_id in manifest_run_ids)
|
||||
campaign_id = manifest.get("campaign_id")
|
||||
if isinstance(campaign_id, str):
|
||||
campaign_ids.add(campaign_id)
|
||||
return ArchiveBundleIndexDict(
|
||||
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
archive_format=ARCHIVE_BUNDLE_FORMAT,
|
||||
object_prefix=shard_prefix,
|
||||
updated_at=datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
manifest_keys=manifest_keys,
|
||||
run_ids=sorted(run_ids),
|
||||
campaign_ids=sorted(campaign_ids),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_utc_datetime(value: datetime.datetime) -> datetime.datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=datetime.UTC)
|
||||
return value.astimezone(datetime.UTC)
|
||||
|
||||
def _build_campaign_id(self) -> str:
|
||||
start = self._format_window_datetime(self.start_from) or "unbounded"
|
||||
end = self._format_window_datetime(self.end_before)
|
||||
return f"{start}_{end}"
|
||||
|
||||
@staticmethod
|
||||
def _format_window_datetime(value: datetime.datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = WorkflowRunArchiver._normalize_utc_datetime(value)
|
||||
return normalized.isoformat().replace("+00:00", "Z")
|
||||
|
||||
@staticmethod
|
||||
def _get_shard_object_prefix(identity: ArchiveBundleIdentity) -> str:
|
||||
return (
|
||||
f"workflow-runs/v2/tenant_prefix={identity.tenant_prefix}/tenant_id={identity.tenant_id}/"
|
||||
f"year={identity.year:04d}/month={identity.month:02d}/shard={identity.shard}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_index_object_key(cls, identity: ArchiveBundleIdentity) -> str:
|
||||
return f"{cls._get_shard_object_prefix(identity)}/{ARCHIVE_BUNDLE_INDEX_NAME}"
|
||||
|
||||
@staticmethod
|
||||
def _get_table_object_key(identity: ArchiveBundleIdentity, table_name: str) -> str:
|
||||
return f"{identity.object_prefix}/{table_name}.parquet"
|
||||
|
||||
@ -4,7 +4,6 @@ ARCHIVE_BUNDLE_NAME = f"archive.v{ARCHIVE_SCHEMA_VERSION}.zip"
|
||||
ARCHIVE_BUNDLE_SCHEMA_VERSION = "2.0"
|
||||
ARCHIVE_BUNDLE_FORMAT = "parquet"
|
||||
ARCHIVE_BUNDLE_MANIFEST_NAME = "manifest.json"
|
||||
ARCHIVE_BUNDLE_INDEX_NAME = "index.json"
|
||||
ARCHIVE_BUNDLE_DELETE_STARTED_MARKER_NAME = "_DELETE_STARTED"
|
||||
ARCHIVE_BUNDLE_DELETED_MARKER_NAME = "_DELETED"
|
||||
ARCHIVE_BUNDLE_RESTORE_STARTED_MARKER_NAME = "_RESTORE_STARTED"
|
||||
|
||||
@ -427,7 +427,7 @@ class BuiltinToolManageService:
|
||||
if vis_str == "partial_members":
|
||||
credential_entity.partial_member_list = list(
|
||||
CredentialPermissionService.get_partial_member_list(
|
||||
db.session, provider.id, CredPermType.BUILTIN_TOOL_PROVIDER
|
||||
provider.id, CredPermType.BUILTIN_TOOL_PROVIDER
|
||||
)
|
||||
)
|
||||
if provider.id in borrowed_ids:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
@ -14,24 +14,6 @@ from services.retention.workflow_run.archive_paid_plan_workflow_run import (
|
||||
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_FORMAT, ARCHIVE_BUNDLE_SCHEMA_VERSION
|
||||
|
||||
|
||||
class FakeArchiveStorage:
|
||||
def __init__(self, objects: dict[str, bytes] | None = None):
|
||||
self.objects = objects or {}
|
||||
|
||||
def object_exists(self, key: str) -> bool:
|
||||
return key in self.objects
|
||||
|
||||
def get_object(self, key: str) -> bytes:
|
||||
return self.objects[key]
|
||||
|
||||
def put_object(self, key: str, data: bytes) -> str:
|
||||
self.objects[key] = data
|
||||
return "checksum"
|
||||
|
||||
def list_objects(self, prefix: str) -> list[str]:
|
||||
return sorted(key for key in self.objects if key.startswith(prefix))
|
||||
|
||||
|
||||
class TestWorkflowRunArchiverInit:
|
||||
def test_start_from_without_end_before_raises(self):
|
||||
with pytest.raises(ValueError, match="start_from and end_before must be provided together"):
|
||||
@ -180,9 +162,7 @@ class TestBuildArchiveBundle:
|
||||
|
||||
class TestGenerateManifest:
|
||||
def test_manifest_structure(self):
|
||||
start = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC)
|
||||
end = datetime.datetime(2025, 4, 1, tzinfo=datetime.UTC)
|
||||
archiver = WorkflowRunArchiver(start_from=start, end_before=end, run_shard_index=1, run_shard_total=4)
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
from services.retention.workflow_run.archive_paid_plan_workflow_run import TableStats
|
||||
|
||||
run = MagicMock()
|
||||
@ -217,10 +197,6 @@ class TestGenerateManifest:
|
||||
assert manifest["workflow_run_count"] == 1
|
||||
assert manifest["workflow_node_execution_count"] == 2
|
||||
assert manifest["run_ids"] == [run.id]
|
||||
assert manifest["campaign_id"] == "2025-01-01T00:00:00Z_2025-04-01T00:00:00Z"
|
||||
assert manifest["archive_window_start"] == "2025-01-01T00:00:00Z"
|
||||
assert manifest["archive_window_end"] == "2025-04-01T00:00:00Z"
|
||||
assert manifest["run_shard"] == "01-of-04"
|
||||
assert "tables" in manifest
|
||||
assert manifest["tables"]["workflow_runs"]["row_count"] == 1
|
||||
assert manifest["tables"]["workflow_runs"]["checksum"] == "abc123"
|
||||
@ -352,21 +328,6 @@ class TestDryRunArchive:
|
||||
|
||||
|
||||
class TestArchiveRunIdempotency:
|
||||
def _index_payload(self, archiver: WorkflowRunArchiver, run_ids: list[str], run) -> tuple[str, bytes]:
|
||||
identity = archiver._build_bundle_identity([run])
|
||||
index_key = archiver._get_index_object_key(identity)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"schema_version": ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
"archive_format": ARCHIVE_BUNDLE_FORMAT,
|
||||
"object_prefix": archiver._get_shard_object_prefix(identity),
|
||||
"updated_at": "2025-03-15T00:00:00Z",
|
||||
"manifest_keys": [],
|
||||
"run_ids": run_ids,
|
||||
}
|
||||
).encode()
|
||||
return index_key, payload
|
||||
|
||||
def test_locked_bundle_is_skipped(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
run = MagicMock()
|
||||
@ -399,47 +360,3 @@ class TestArchiveRunIdempotency:
|
||||
assert result.success is True
|
||||
assert result.skipped is True
|
||||
assert result.error == "bundle already archived"
|
||||
|
||||
def test_index_skips_all_already_archived_runs(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
run = MagicMock()
|
||||
run.id = "run-1"
|
||||
run.tenant_id = "tenant-1"
|
||||
run.created_at = datetime.datetime(2025, 3, 15, 10, 0, 0)
|
||||
index_key, index_payload = self._index_payload(archiver, ["run-1"], run)
|
||||
storage = FakeArchiveStorage({index_key: index_payload})
|
||||
|
||||
result = archiver._archive_bundle(MagicMock(), storage, [run])
|
||||
|
||||
assert result.success is True
|
||||
assert result.skipped is True
|
||||
assert result.run_count == 0
|
||||
assert result.skipped_run_count == 1
|
||||
assert result.error == "all runs already archived in shard index"
|
||||
|
||||
def test_index_filters_duplicate_runs_before_archive(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
archived_run = MagicMock()
|
||||
archived_run.id = "run-1"
|
||||
archived_run.tenant_id = "tenant-1"
|
||||
archived_run.created_at = datetime.datetime(2025, 3, 15, 10, 0, 0)
|
||||
new_run = MagicMock()
|
||||
new_run.id = "run-2"
|
||||
new_run.tenant_id = "tenant-1"
|
||||
new_run.created_at = datetime.datetime(2025, 3, 15, 11, 0, 0)
|
||||
index_key, index_payload = self._index_payload(archiver, ["run-1"], archived_run)
|
||||
storage = FakeArchiveStorage({index_key: index_payload})
|
||||
|
||||
with (
|
||||
patch.object(archiver, "_lock_runs_for_archive", return_value=[new_run]) as lock_runs,
|
||||
patch.object(archiver, "_extract_bundle_data", return_value={"workflow_runs": [{"id": "run-2"}]}),
|
||||
):
|
||||
result = archiver._archive_bundle(MagicMock(), storage, [archived_run, new_run])
|
||||
|
||||
assert result.success is True
|
||||
assert result.skipped is False
|
||||
assert result.run_count == 1
|
||||
assert result.skipped_run_count == 1
|
||||
lock_runs.assert_called_once_with(ANY, ["run-2"])
|
||||
manifest_keys = [key for key in storage.objects if key.endswith("/manifest.json")]
|
||||
assert len(manifest_keys) == 1
|
||||
|
||||
@ -417,7 +417,7 @@ class TestBillingServiceIsTenantOwnerOrAdmin:
|
||||
account, _ = self._create_account_with_tenant_role(db_session_with_containers, TenantAccountRole.EDITOR)
|
||||
|
||||
with pytest.raises(ValueError, match="Only team owner or team admin can perform this action"):
|
||||
BillingService.is_tenant_owner_or_admin(db_session_with_containers, account)
|
||||
BillingService.is_tenant_owner_or_admin(account)
|
||||
|
||||
def test_is_tenant_owner_or_admin_dataset_operator_raises_error(self, db_session_with_containers: Session) -> None:
|
||||
"""is_tenant_owner_or_admin raises ValueError for DATASET_OPERATOR role."""
|
||||
@ -426,4 +426,4 @@ class TestBillingServiceIsTenantOwnerOrAdmin:
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Only team owner or team admin can perform this action"):
|
||||
BillingService.is_tenant_owner_or_admin(db_session_with_containers, account)
|
||||
BillingService.is_tenant_owner_or_admin(account)
|
||||
|
||||
@ -394,15 +394,13 @@ def test_switch_preferred_provider_type_returns_early_when_no_change_or_unsuppor
|
||||
configuration = _build_provider_configuration()
|
||||
|
||||
with patch("core.entities.provider_configuration.Session") as mock_session_cls:
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
|
||||
assert changed is False
|
||||
configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
|
||||
mock_session_cls.assert_not_called()
|
||||
|
||||
configuration.preferred_provider_type = ProviderType.CUSTOM
|
||||
configuration.system_configuration.enabled = False
|
||||
with patch("core.entities.provider_configuration.Session") as mock_session_cls:
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
|
||||
assert changed is False
|
||||
configuration.switch_preferred_provider_type(ProviderType.SYSTEM)
|
||||
mock_session_cls.assert_not_called()
|
||||
|
||||
|
||||
@ -413,13 +411,10 @@ def test_switch_preferred_provider_type_updates_existing_record_with_session() -
|
||||
existing_record = SimpleNamespace(preferred_provider_type="custom")
|
||||
session.execute.return_value.scalars.return_value.first.return_value = existing_record
|
||||
|
||||
with patch.object(ProviderConfiguration, "_invalidate_provider_configuration_cache") as mock_invalidate:
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.SYSTEM, session=session)
|
||||
configuration.switch_preferred_provider_type(ProviderType.SYSTEM, session=session)
|
||||
|
||||
assert changed is True
|
||||
assert existing_record.preferred_provider_type == ProviderType.SYSTEM
|
||||
session.commit.assert_called_once()
|
||||
mock_invalidate.assert_not_called()
|
||||
|
||||
|
||||
def test_switch_preferred_provider_type_creates_record_when_missing() -> None:
|
||||
@ -428,13 +423,10 @@ def test_switch_preferred_provider_type_creates_record_when_missing() -> None:
|
||||
session = Mock()
|
||||
session.execute.return_value.scalars.return_value.first.return_value = None
|
||||
|
||||
with patch.object(ProviderConfiguration, "_invalidate_provider_configuration_cache") as mock_invalidate:
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.CUSTOM, session=session)
|
||||
configuration.switch_preferred_provider_type(ProviderType.CUSTOM, session=session)
|
||||
|
||||
assert changed is True
|
||||
assert session.add.call_count == 1
|
||||
session.commit.assert_called_once()
|
||||
mock_invalidate.assert_not_called()
|
||||
|
||||
|
||||
def test_get_model_type_instance_and_schema_delegate_to_factory() -> None:
|
||||
@ -1030,14 +1022,13 @@ def test_update_load_balancing_configs_updates_all_matching_configs() -> None:
|
||||
credential_record = SimpleNamespace(encrypted_config='{"api_key":"enc"}', credential_name="API KEY 3")
|
||||
|
||||
with patch("core.entities.provider_configuration.ProviderCredentialsCache") as mock_cache:
|
||||
changed = configuration._update_load_balancing_configs_with_credential(
|
||||
configuration._update_load_balancing_configs_with_credential(
|
||||
credential_id="cred-1",
|
||||
credential_record=credential_record,
|
||||
credential_source=CredentialSourceType.PROVIDER,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert changed is True
|
||||
assert lb_config.encrypted_config == '{"api_key":"enc"}'
|
||||
assert lb_config.name == "API KEY 3"
|
||||
mock_cache.return_value.delete.assert_called_once()
|
||||
@ -1049,14 +1040,13 @@ def test_update_load_balancing_configs_returns_when_no_matching_configs() -> Non
|
||||
session = Mock()
|
||||
session.execute.return_value.scalars.return_value.all.return_value = []
|
||||
|
||||
changed = configuration._update_load_balancing_configs_with_credential(
|
||||
configuration._update_load_balancing_configs_with_credential(
|
||||
credential_id="cred-1",
|
||||
credential_record=SimpleNamespace(encrypted_config="{}", credential_name="Main"),
|
||||
credential_source=CredentialSourceType.PROVIDER,
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert changed is False
|
||||
session.commit.assert_not_called()
|
||||
|
||||
|
||||
@ -1488,15 +1478,12 @@ def test_model_load_balancing_enable_disable_and_switch_preferred_provider_type_
|
||||
switch_session = Mock()
|
||||
with _patched_session(switch_session):
|
||||
switch_session.execute.return_value.scalars.return_value.first.return_value = None
|
||||
with patch.object(ProviderConfiguration, "_invalidate_provider_configuration_cache") as mock_invalidate:
|
||||
changed = configuration.switch_preferred_provider_type(ProviderType.CUSTOM)
|
||||
assert changed is True
|
||||
configuration.switch_preferred_provider_type(ProviderType.CUSTOM)
|
||||
assert any(
|
||||
call.args and call.args[0].__class__.__name__ == "TenantPreferredModelProvider"
|
||||
for call in switch_session.add.call_args_list
|
||||
)
|
||||
switch_session.commit.assert_called()
|
||||
mock_invalidate.assert_called_once_with(preferred_model_providers=True)
|
||||
|
||||
|
||||
def test_system_and_custom_provider_model_helpers_cover_remaining_skip_paths() -> None:
|
||||
|
||||
@ -5,17 +5,10 @@ import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from core.entities.provider_entities import ModelSettings
|
||||
from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager
|
||||
from core.provider_manager import ProviderManager
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models.provider import (
|
||||
LoadBalancingModelConfig,
|
||||
Provider,
|
||||
ProviderCredential,
|
||||
ProviderModelSetting,
|
||||
ProviderType,
|
||||
TenantDefaultModel,
|
||||
)
|
||||
from models.provider import LoadBalancingModelConfig, ProviderModelSetting, TenantDefaultModel
|
||||
from models.provider_ids import ModelProviderID
|
||||
|
||||
|
||||
@ -30,43 +23,6 @@ def _build_session_context(session: Mock) -> MagicMock:
|
||||
return session_cm
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
def __init__(self) -> None:
|
||||
self.store: dict[str, str] = {}
|
||||
self.expirations: dict[str, int] = {}
|
||||
|
||||
def get(self, key: str):
|
||||
return self.store.get(key)
|
||||
|
||||
def set(self, key: str, value: str, *, ex: int | None = None) -> None:
|
||||
self.store[key] = value
|
||||
if ex is not None:
|
||||
self.expirations[key] = ex
|
||||
|
||||
def setex(self, key: str, time: int, value: str) -> None:
|
||||
self.store[key] = value
|
||||
self.expirations[key] = time
|
||||
|
||||
def incr(self, key: str) -> int:
|
||||
value = int(self.store.get(key, "0")) + 1
|
||||
self.store[key] = str(value)
|
||||
return value
|
||||
|
||||
def expire(self, key: str, time: int) -> None:
|
||||
self.expirations[key] = time
|
||||
|
||||
|
||||
class _FakeScalarResult:
|
||||
def __init__(self, values: list[object]) -> None:
|
||||
self._values = values
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._values)
|
||||
|
||||
def all(self) -> list[object]:
|
||||
return self._values
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider_entity():
|
||||
mock_entity = Mock()
|
||||
@ -353,7 +309,6 @@ def test_get_configurations_uses_injected_runtime_and_adds_provider_aliases(mock
|
||||
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_credentials", return_value={}),
|
||||
patch("core.provider_manager.ModelProviderFactory") as mock_factory_cls,
|
||||
):
|
||||
mock_factory_cls.return_value.get_providers.return_value = []
|
||||
@ -406,7 +361,6 @@ def test_get_configurations_binds_manager_runtime_to_provider_configuration(
|
||||
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_credentials", return_value={}),
|
||||
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
|
||||
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
|
||||
patch.object(manager, "_to_model_settings", return_value=[]),
|
||||
@ -434,7 +388,6 @@ def test_get_configurations_reuses_cached_result_for_same_tenant(mocker: MockerF
|
||||
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_credentials", return_value={}),
|
||||
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
|
||||
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
|
||||
patch.object(manager, "_to_model_settings", return_value=[]),
|
||||
@ -471,7 +424,6 @@ def test_clear_configurations_cache_rebuilds_requested_tenant(mocker: MockerFixt
|
||||
patch.object(manager, "_get_all_provider_model_settings", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_load_balancing_configs", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_model_credentials", return_value={}),
|
||||
patch.object(manager, "_get_all_provider_credentials", return_value={}),
|
||||
patch.object(manager, "_to_custom_configuration", return_value=custom_configuration),
|
||||
patch.object(manager, "_to_system_configuration", return_value=system_configuration),
|
||||
patch.object(manager, "_to_model_settings", return_value=[]),
|
||||
@ -626,162 +578,19 @@ def test_get_all_providers_normalizes_provider_names_with_model_provider_id() ->
|
||||
assert list(result[str(ModelProviderID("langgenius/gemini/google"))]) == [gemini_provider]
|
||||
|
||||
|
||||
def test_get_all_providers_attaches_active_credentials() -> None:
|
||||
provider = Provider(
|
||||
tenant_id="tenant-id",
|
||||
provider_name="openai",
|
||||
provider_type=ProviderType.CUSTOM,
|
||||
is_valid=True,
|
||||
credential_id="credential-id",
|
||||
)
|
||||
provider.id = "provider-id"
|
||||
credential = ProviderCredential(
|
||||
tenant_id="tenant-id",
|
||||
provider_name="openai",
|
||||
credential_name="primary",
|
||||
encrypted_config='{"api_key": "secret"}',
|
||||
)
|
||||
credential.id = "credential-id"
|
||||
session = Mock()
|
||||
session.scalars.side_effect = [
|
||||
_FakeScalarResult([provider]),
|
||||
_FakeScalarResult([credential]),
|
||||
]
|
||||
|
||||
with (
|
||||
patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)),
|
||||
):
|
||||
result = ProviderManager._get_all_providers("tenant-id")
|
||||
|
||||
assert session.scalars.call_count == 2
|
||||
assert result[str(ModelProviderID("openai"))][0].credential_name == "primary"
|
||||
assert result[str(ModelProviderID("openai"))][0].encrypted_config == '{"api_key": "secret"}'
|
||||
|
||||
|
||||
def test_invalidate_configurations_cache_bumps_selected_source_version() -> None:
|
||||
fake_redis = _FakeRedis()
|
||||
|
||||
with patch("core.provider_manager.redis_client", fake_redis):
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
"tenant-id",
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS,),
|
||||
)
|
||||
ProviderManager.invalidate_configurations_cache(
|
||||
"tenant-id",
|
||||
sources=(ProviderConfigurationCacheSource.PROVIDER_CREDENTIALS,),
|
||||
)
|
||||
|
||||
assert fake_redis.store["provider_configurations:tenant:tenant-id:source:provider_credentials:version"] == "2"
|
||||
assert fake_redis.expirations["provider_configurations:tenant:tenant-id:source:provider_credentials:version"] == 360
|
||||
assert "provider_configurations:tenant:tenant-id:source:provider_models:version" not in fake_redis.store
|
||||
|
||||
|
||||
def test_provider_model_credentials_cache_returns_cache_entries() -> None:
|
||||
fake_redis = _FakeRedis()
|
||||
credential_record = SimpleNamespace(
|
||||
id="credential-id",
|
||||
provider_name="openai",
|
||||
model_name="gpt-4",
|
||||
model_type=ModelType.LLM,
|
||||
credential_name="primary",
|
||||
)
|
||||
session = Mock()
|
||||
session.scalars.return_value = [credential_record]
|
||||
|
||||
with (
|
||||
patch("core.provider_manager.redis_client", fake_redis),
|
||||
patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)),
|
||||
):
|
||||
first = ProviderManager._get_all_provider_model_credentials("tenant-id")
|
||||
second = ProviderManager._get_all_provider_model_credentials("tenant-id")
|
||||
|
||||
assert session.scalars.call_count == 1
|
||||
version_key = "provider_configurations:tenant:tenant-id:source:provider_model_credentials:version"
|
||||
assert fake_redis.expirations[version_key] == 360
|
||||
assert first["openai"][0] is not credential_record
|
||||
assert second["openai"][0].credential_name == "primary"
|
||||
assert second["openai"][0].model_type == ModelType.LLM
|
||||
|
||||
|
||||
def test_provider_configuration_cache_skips_write_when_version_changes_during_load() -> None:
|
||||
fake_redis = _FakeRedis()
|
||||
version_key = "provider_configurations:tenant:tenant-id:source:provider_model_credentials:version"
|
||||
credential_record = SimpleNamespace(
|
||||
id="credential-id",
|
||||
provider_name="openai",
|
||||
model_name="gpt-4",
|
||||
model_type=ModelType.LLM,
|
||||
credential_name="primary",
|
||||
)
|
||||
session = Mock()
|
||||
|
||||
def load_records(_stmt):
|
||||
fake_redis.incr(version_key)
|
||||
return [credential_record]
|
||||
|
||||
session.scalars.side_effect = load_records
|
||||
|
||||
with (
|
||||
patch("core.provider_manager.redis_client", fake_redis),
|
||||
patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)),
|
||||
):
|
||||
result = ProviderManager._get_all_provider_model_credentials("tenant-id")
|
||||
|
||||
assert fake_redis.store[version_key] == "1"
|
||||
assert "provider_configurations:tenant:tenant-id:source:provider_model_credentials:v:0" not in fake_redis.store
|
||||
assert "provider_configurations:tenant:tenant-id:source:provider_model_credentials:v:1" not in fake_redis.store
|
||||
assert result["openai"][0].credential_name == "primary"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method_name",
|
||||
[
|
||||
"_get_all_provider_models",
|
||||
"_get_all_provider_model_settings",
|
||||
"_get_all_provider_model_credentials",
|
||||
"_get_all_provider_credentials",
|
||||
],
|
||||
)
|
||||
def test_provider_grouping_helpers_group_records_by_provider_name(method_name: str) -> None:
|
||||
def build_record(provider_name: str, index: int):
|
||||
match method_name:
|
||||
case "_get_all_provider_models":
|
||||
return SimpleNamespace(
|
||||
id=f"model-{index}",
|
||||
provider_name=provider_name,
|
||||
model_name=f"model-{index}",
|
||||
model_type=ModelType.LLM,
|
||||
credential_id=None,
|
||||
)
|
||||
case "_get_all_provider_model_settings":
|
||||
return SimpleNamespace(
|
||||
provider_name=provider_name,
|
||||
model_name=f"model-{index}",
|
||||
model_type=ModelType.LLM,
|
||||
enabled=True,
|
||||
load_balancing_enabled=False,
|
||||
)
|
||||
case "_get_all_provider_model_credentials":
|
||||
return SimpleNamespace(
|
||||
id=f"model-credential-{index}",
|
||||
provider_name=provider_name,
|
||||
model_name=f"model-{index}",
|
||||
model_type=ModelType.LLM,
|
||||
credential_name=f"credential-{index}",
|
||||
)
|
||||
case "_get_all_provider_credentials":
|
||||
return SimpleNamespace(
|
||||
id=f"credential-{index}",
|
||||
provider_name=provider_name,
|
||||
credential_name=f"credential-{index}",
|
||||
)
|
||||
case _:
|
||||
raise AssertionError(f"Unexpected method: {method_name}")
|
||||
|
||||
session = Mock()
|
||||
openai_primary = build_record("openai", 1)
|
||||
openai_secondary = build_record("openai", 2)
|
||||
anthropic_record = build_record("anthropic", 3)
|
||||
openai_primary = SimpleNamespace(provider_name="openai")
|
||||
openai_secondary = SimpleNamespace(provider_name="openai")
|
||||
anthropic_record = SimpleNamespace(provider_name="anthropic")
|
||||
session.scalars.return_value = [openai_primary, openai_secondary, anthropic_record]
|
||||
|
||||
with (
|
||||
@ -789,14 +598,14 @@ def test_provider_grouping_helpers_group_records_by_provider_name(method_name: s
|
||||
):
|
||||
result = getattr(ProviderManager, method_name)("tenant-id")
|
||||
|
||||
assert [record.provider_name for record in result["openai"]] == ["openai", "openai"]
|
||||
assert [record.provider_name for record in result["anthropic"]] == ["anthropic"]
|
||||
assert list(result["openai"]) == [openai_primary, openai_secondary]
|
||||
assert list(result["anthropic"]) == [anthropic_record]
|
||||
|
||||
|
||||
def test_get_all_preferred_model_providers_returns_mapping_by_provider_name() -> None:
|
||||
session = Mock()
|
||||
openai_preference = SimpleNamespace(provider_name="openai", preferred_provider_type=ProviderType.SYSTEM)
|
||||
anthropic_preference = SimpleNamespace(provider_name="anthropic", preferred_provider_type=ProviderType.CUSTOM)
|
||||
openai_preference = SimpleNamespace(provider_name="openai")
|
||||
anthropic_preference = SimpleNamespace(provider_name="anthropic")
|
||||
session.scalars.return_value = [openai_preference, anthropic_preference]
|
||||
|
||||
with (
|
||||
@ -804,8 +613,10 @@ def test_get_all_preferred_model_providers_returns_mapping_by_provider_name() ->
|
||||
):
|
||||
result = ProviderManager._get_all_preferred_model_providers("tenant-id")
|
||||
|
||||
assert result["openai"].preferred_provider_type == ProviderType.SYSTEM
|
||||
assert result["anthropic"].preferred_provider_type == ProviderType.CUSTOM
|
||||
assert result == {
|
||||
"openai": openai_preference,
|
||||
"anthropic": anthropic_preference,
|
||||
}
|
||||
|
||||
|
||||
def test_get_all_provider_load_balancing_configs_returns_empty_when_cached_flag_is_disabled() -> None:
|
||||
@ -823,30 +634,8 @@ def test_get_all_provider_load_balancing_configs_returns_empty_when_cached_flag_
|
||||
|
||||
def test_get_all_provider_load_balancing_configs_populates_cache_and_groups_configs() -> None:
|
||||
session = Mock()
|
||||
openai_config = SimpleNamespace(
|
||||
id="lb-1",
|
||||
tenant_id="tenant-id",
|
||||
provider_name="openai",
|
||||
model_name="gpt-4",
|
||||
model_type=ModelType.LLM,
|
||||
name="primary",
|
||||
encrypted_config=None,
|
||||
credential_id=None,
|
||||
credential_source_type=None,
|
||||
enabled=True,
|
||||
)
|
||||
anthropic_config = SimpleNamespace(
|
||||
id="lb-2",
|
||||
tenant_id="tenant-id",
|
||||
provider_name="anthropic",
|
||||
model_name="claude",
|
||||
model_type=ModelType.LLM,
|
||||
name="primary",
|
||||
encrypted_config=None,
|
||||
credential_id=None,
|
||||
credential_source_type=None,
|
||||
enabled=True,
|
||||
)
|
||||
openai_config = SimpleNamespace(provider_name="openai")
|
||||
anthropic_config = SimpleNamespace(provider_name="anthropic")
|
||||
session.scalars.return_value = [openai_config, anthropic_config]
|
||||
|
||||
with (
|
||||
@ -860,6 +649,6 @@ def test_get_all_provider_load_balancing_configs_populates_cache_and_groups_conf
|
||||
):
|
||||
result = ProviderManager._get_all_provider_load_balancing_configs("tenant-id")
|
||||
|
||||
mock_setex.assert_any_call("tenant:tenant-id:model_load_balancing_enabled", 120, "True")
|
||||
assert [record.provider_name for record in result["openai"]] == ["openai"]
|
||||
assert [record.provider_name for record in result["anthropic"]] == ["anthropic"]
|
||||
mock_setex.assert_called_once_with("tenant:tenant-id:model_load_balancing_enabled", 120, "True")
|
||||
assert list(result["openai"]) == [openai_config]
|
||||
assert list(result["anthropic"]) == [anthropic_config]
|
||||
|
||||
@ -1031,7 +1031,8 @@ class TestBillingServiceAccountManagement:
|
||||
@pytest.fixture
|
||||
def mock_db_session(self):
|
||||
"""Mock database session."""
|
||||
return MagicMock()
|
||||
with patch("services.billing_service.db.session") as mock_session:
|
||||
yield mock_session
|
||||
|
||||
def test_delete_account(self, mock_send_request):
|
||||
"""Test account deletion."""
|
||||
@ -1115,8 +1116,7 @@ class TestBillingServiceAccountManagement:
|
||||
mock_db_session.scalar.return_value = mock_join
|
||||
|
||||
# Act - should not raise exception
|
||||
BillingService.is_tenant_owner_or_admin(mock_db_session, current_user)
|
||||
mock_db_session.scalar.assert_called_once()
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
|
||||
def test_is_tenant_owner_or_admin_admin(self, mock_db_session):
|
||||
"""Test tenant owner/admin check for admin role."""
|
||||
@ -1131,8 +1131,7 @@ class TestBillingServiceAccountManagement:
|
||||
mock_db_session.scalar.return_value = mock_join
|
||||
|
||||
# Act - should not raise exception
|
||||
BillingService.is_tenant_owner_or_admin(mock_db_session, current_user)
|
||||
mock_db_session.scalar.assert_called_once()
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
|
||||
def test_is_tenant_owner_or_admin_normal_user_raises_error(self, mock_db_session):
|
||||
"""Test tenant owner/admin check raises error for normal user."""
|
||||
@ -1148,9 +1147,8 @@ class TestBillingServiceAccountManagement:
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
BillingService.is_tenant_owner_or_admin(mock_db_session, current_user)
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
assert "Only team owner or team admin can perform this action" in str(exc_info.value)
|
||||
mock_db_session.scalar.assert_called_once()
|
||||
|
||||
def test_is_tenant_owner_or_admin_no_join_raises_error(self, mock_db_session):
|
||||
"""Test tenant owner/admin check raises error when join not found."""
|
||||
@ -1163,9 +1161,8 @@ class TestBillingServiceAccountManagement:
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
BillingService.is_tenant_owner_or_admin(mock_db_session, current_user)
|
||||
BillingService.is_tenant_owner_or_admin(current_user)
|
||||
assert "Tenant account join not found" in str(exc_info.value)
|
||||
mock_db_session.scalar.assert_called_once()
|
||||
|
||||
|
||||
class TestBillingServiceCacheManagement:
|
||||
|
||||
@ -196,7 +196,9 @@ class _ImmediateExecutor:
|
||||
|
||||
def _session_wrapper_for_no_autoflush(session: Mock) -> Mock:
|
||||
"""
|
||||
Return an object with a no_autoflush context manager for legacy tests that need Session-like wrappers.
|
||||
ClearFreePlanTenantExpiredLogs.process_tenant uses:
|
||||
with Session(db.engine).no_autoflush as session:
|
||||
so Session(db.engine) must return an object with a no_autoflush context manager.
|
||||
"""
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = session
|
||||
@ -222,7 +224,7 @@ def _sessionmaker_wrapper_for_begin(session: Mock) -> Mock:
|
||||
|
||||
|
||||
def _session_wrapper_for_direct(session: Mock) -> Mock:
|
||||
"""Return an object usable as a direct context manager for legacy Session-like test paths."""
|
||||
"""ClearFreePlanTenantExpiredLogs.process uses: with Session(db.engine) as session: (for old code paths)"""
|
||||
wrapper = MagicMock()
|
||||
wrapper.__enter__.return_value = session
|
||||
wrapper.__exit__.return_value = None
|
||||
@ -232,13 +234,17 @@ def _session_wrapper_for_direct(session: Mock) -> Mock:
|
||||
def test_process_tenant_processes_all_batches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
flask_app = service_module.Flask("test-app")
|
||||
|
||||
app_session = MagicMock()
|
||||
app_session.scalars.return_value.all.return_value = [SimpleNamespace(id="app-1"), SimpleNamespace(id="app-2")]
|
||||
|
||||
monkeypatch.setattr(
|
||||
service_module,
|
||||
"db",
|
||||
SimpleNamespace(engine=object()),
|
||||
SimpleNamespace(
|
||||
engine=object(),
|
||||
session=SimpleNamespace(
|
||||
scalars=lambda _stmt: SimpleNamespace(
|
||||
all=lambda: [SimpleNamespace(id="app-1"), SimpleNamespace(id="app-2")]
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
mock_storage = MagicMock()
|
||||
@ -320,10 +326,9 @@ def test_process_tenant_processes_all_batches(monkeypatch: pytest.MonkeyPatch) -
|
||||
lambda _sm: run_repo,
|
||||
)
|
||||
|
||||
ClearFreePlanTenantExpiredLogs.process_tenant(flask_app, "tenant-1", days=7, batch=10, session=app_session)
|
||||
ClearFreePlanTenantExpiredLogs.process_tenant(flask_app, "tenant-1", days=7, batch=10)
|
||||
|
||||
# messages backup, conversations backup, node executions backup, runs backup, workflow app logs backup
|
||||
app_session.scalars.assert_called_once()
|
||||
assert mock_storage.save.call_count >= 5
|
||||
clear_related.assert_called()
|
||||
|
||||
@ -384,7 +389,6 @@ def test_process_with_tenant_ids_filters_by_plan_and_logs_errors(
|
||||
|
||||
# Only sandbox tenant should attempt processing, and its failure should be swallowed + logged.
|
||||
assert process_tenant_mock.call_count == 1
|
||||
assert process_tenant_mock.call_args.args[4] is count_session
|
||||
assert "Failed to process tenant t_sandbox" in caplog.messages
|
||||
assert "Failed to process tenant t_fail" in caplog.messages
|
||||
|
||||
@ -425,14 +429,7 @@ def test_process_without_tenant_ids_batches_and_scales_interval(monkeypatch: pyt
|
||||
batch_session.scalar.side_effect = [200, 200, 200, 50]
|
||||
batch_session.execute.return_value = rows
|
||||
|
||||
tenant_session_a = MagicMock()
|
||||
tenant_session_b = MagicMock()
|
||||
sessions = [
|
||||
_sessionmaker_wrapper_for_begin(total_session),
|
||||
_sessionmaker_wrapper_for_begin(batch_session),
|
||||
_sessionmaker_wrapper_for_begin(tenant_session_a),
|
||||
_sessionmaker_wrapper_for_begin(tenant_session_b),
|
||||
]
|
||||
sessions = [_sessionmaker_wrapper_for_begin(total_session), _sessionmaker_wrapper_for_begin(batch_session)]
|
||||
monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: sessions.pop(0))
|
||||
|
||||
process_tenant_mock = MagicMock()
|
||||
@ -503,12 +500,7 @@ def test_process_without_tenant_ids_all_intervals_too_many_uses_min_interval(mon
|
||||
batch_session.scalar.side_effect = [200, 200, 200, 200, 200]
|
||||
batch_session.execute.return_value = rows
|
||||
|
||||
tenant_session = MagicMock()
|
||||
sessions = [
|
||||
_sessionmaker_wrapper_for_begin(total_session),
|
||||
_sessionmaker_wrapper_for_begin(batch_session),
|
||||
_sessionmaker_wrapper_for_begin(tenant_session),
|
||||
]
|
||||
sessions = [_sessionmaker_wrapper_for_begin(total_session), _sessionmaker_wrapper_for_begin(batch_session)]
|
||||
monkeypatch.setattr(service_module, "sessionmaker", lambda _engine: sessions.pop(0))
|
||||
|
||||
process_tenant_mock = MagicMock()
|
||||
@ -523,13 +515,13 @@ def test_process_without_tenant_ids_all_intervals_too_many_uses_min_interval(mon
|
||||
def test_process_tenant_repo_loops_break_on_empty_second_batch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
flask_app = service_module.Flask("test-app")
|
||||
|
||||
app_session = MagicMock()
|
||||
app_session.scalars.return_value.all.return_value = [SimpleNamespace(id="app-1")]
|
||||
|
||||
monkeypatch.setattr(
|
||||
service_module,
|
||||
"db",
|
||||
SimpleNamespace(engine=object()),
|
||||
SimpleNamespace(
|
||||
engine=object(),
|
||||
session=SimpleNamespace(scalars=lambda _stmt: SimpleNamespace(all=lambda: [SimpleNamespace(id="app-1")])),
|
||||
),
|
||||
)
|
||||
mock_storage = MagicMock()
|
||||
monkeypatch.setattr(service_module, "storage", mock_storage)
|
||||
@ -593,8 +585,7 @@ def test_process_tenant_repo_loops_break_on_empty_second_batch(monkeypatch: pyte
|
||||
lambda _sm: run_repo,
|
||||
)
|
||||
|
||||
ClearFreePlanTenantExpiredLogs.process_tenant(flask_app, "tenant-1", days=7, batch=2, session=app_session)
|
||||
ClearFreePlanTenantExpiredLogs.process_tenant(flask_app, "tenant-1", days=7, batch=2)
|
||||
|
||||
app_session.scalars.assert_called_once()
|
||||
assert node_repo.get_expired_executions_batch.call_count == 2
|
||||
assert run_repo.get_expired_runs_batch.call_count == 2
|
||||
|
||||
@ -5,7 +5,7 @@ and admin bypass behavior.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@ -37,22 +37,20 @@ def credential_id():
|
||||
|
||||
class TestGetPartialMemberList:
|
||||
def test_returns_empty_when_no_permissions(self, credential_id):
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = []
|
||||
result = CredentialPermissionService.get_partial_member_list(
|
||||
session, credential_id, CredentialType.TRIGGER_SUBSCRIPTION
|
||||
)
|
||||
assert result == []
|
||||
session.scalars.assert_called_once()
|
||||
with patch("services.credential_permission_service.db") as mock_db:
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
result = CredentialPermissionService.get_partial_member_list(
|
||||
credential_id, CredentialType.TRIGGER_SUBSCRIPTION
|
||||
)
|
||||
assert result == []
|
||||
|
||||
def test_returns_account_ids(self, credential_id, user_id, other_user_id):
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [user_id, other_user_id]
|
||||
result = CredentialPermissionService.get_partial_member_list(
|
||||
session, credential_id, CredentialType.TRIGGER_SUBSCRIPTION
|
||||
)
|
||||
assert set(result) == {user_id, other_user_id}
|
||||
session.scalars.assert_called_once()
|
||||
with patch("services.credential_permission_service.db") as mock_db:
|
||||
mock_db.session.scalars.return_value.all.return_value = [user_id, other_user_id]
|
||||
result = CredentialPermissionService.get_partial_member_list(
|
||||
credential_id, CredentialType.TRIGGER_SUBSCRIPTION
|
||||
)
|
||||
assert set(result) == {user_id, other_user_id}
|
||||
|
||||
|
||||
class TestApplyVisibilityFilter:
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { DeveloperApiTab } from '@/features/deployments/detail/access-tab/developer-api'
|
||||
import { ApiTokensTab } from '@/features/deployments/detail/api-tokens-tab'
|
||||
|
||||
export default function InstanceDetailApiTokensPage() {
|
||||
return <DeveloperApiTab />
|
||||
return <ApiTokensTab />
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
|
||||
import { InstancesTab } from '@/features/deployments/detail/instances-tab'
|
||||
|
||||
export default function InstanceDetailInstancesPage() {
|
||||
return <DeployTab />
|
||||
return <InstancesTab />
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { VersionsTab } from '@/features/deployments/detail/versions-tab'
|
||||
import { ReleasesTab } from '@/features/deployments/detail/releases-tab'
|
||||
|
||||
export default function InstanceDetailReleasesPage() {
|
||||
return <VersionsTab />
|
||||
return <ReleasesTab />
|
||||
}
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { runCreateAppAttributionBootstrap } from '@/utils/create-app-tracking'
|
||||
|
||||
let mockIsProd = false
|
||||
let mockNonce: string | null = 'test-nonce'
|
||||
|
||||
type BootstrapScriptProps = {
|
||||
id?: string
|
||||
strategy?: string
|
||||
nonce?: string
|
||||
children?: string
|
||||
}
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_PROD() { return mockIsProd },
|
||||
}))
|
||||
|
||||
vi.mock('@/next/headers', () => ({
|
||||
headers: vi.fn(() => ({
|
||||
get: vi.fn((name: string) => {
|
||||
if (name === 'x-nonce')
|
||||
return mockNonce
|
||||
return null
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
|
||||
const loadComponent = async () => {
|
||||
const mod = await import('../create-app-attribution-bootstrap')
|
||||
return mod.CreateAppAttributionBootstrap
|
||||
}
|
||||
|
||||
const runBootstrapScript = () => {
|
||||
runCreateAppAttributionBootstrap()
|
||||
}
|
||||
|
||||
describe('CreateAppAttributionBootstrap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
mockIsProd = false
|
||||
mockNonce = 'test-nonce'
|
||||
window.sessionStorage.clear()
|
||||
window.history.replaceState({}, '', '/apps')
|
||||
})
|
||||
|
||||
it('renders a beforeInteractive script element', async () => {
|
||||
const renderComponent = await loadComponent()
|
||||
const element = await renderComponent() as ReactElement<BootstrapScriptProps>
|
||||
|
||||
expect(element).toBeTruthy()
|
||||
expect(element.props.id).toBe('create-app-attribution-bootstrap')
|
||||
expect(element.props.strategy).toBe('beforeInteractive')
|
||||
expect(element.props.children).toContain('window.sessionStorage.setItem')
|
||||
})
|
||||
|
||||
it('uses the nonce header in production', async () => {
|
||||
mockIsProd = true
|
||||
mockNonce = 'prod-nonce'
|
||||
|
||||
const renderComponent = await loadComponent()
|
||||
const element = await renderComponent() as ReactElement<BootstrapScriptProps>
|
||||
|
||||
expect(element.props.nonce).toBe('prod-nonce')
|
||||
})
|
||||
|
||||
it('stores external attribution and clears only attribution params from the url', () => {
|
||||
window.history.replaceState({}, '', '/apps?action=keep&utm_source=dify_blog&slug=get-started-with-dif#preview')
|
||||
|
||||
runBootstrapScript()
|
||||
|
||||
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBe(JSON.stringify({
|
||||
utmSource: 'blog',
|
||||
utmCampaign: 'get-started-with-dif',
|
||||
}))
|
||||
expect(window.location.pathname).toBe('/apps')
|
||||
expect(window.location.search).toBe('?action=keep')
|
||||
expect(window.location.hash).toBe('#preview')
|
||||
})
|
||||
|
||||
it('does nothing for invalid external sources', () => {
|
||||
window.history.replaceState({}, '', '/apps?action=keep&utm_source=internal&slug=ignored')
|
||||
|
||||
runBootstrapScript()
|
||||
|
||||
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull()
|
||||
expect(window.location.search).toBe('?action=keep&utm_source=internal&slug=ignored')
|
||||
})
|
||||
})
|
||||
@ -1,95 +0,0 @@
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import ExternalAttributionRecorder from '../external-attribution-recorder'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({ IS_CLOUD_EDITION: true }))
|
||||
const { mockRememberCreateAppExternalAttribution } = vi.hoisted(() => ({
|
||||
mockRememberCreateAppExternalAttribution: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.IS_CLOUD_EDITION
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/create-app-tracking', () => ({
|
||||
rememberCreateAppExternalAttribution: (...args: unknown[]) => mockRememberCreateAppExternalAttribution(...args),
|
||||
}))
|
||||
|
||||
const mockUseSearchParams = vi.mocked(useSearchParams)
|
||||
|
||||
const setSearchParams = (search = '') => {
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams(search) as unknown as ReturnType<typeof useSearchParams>)
|
||||
}
|
||||
|
||||
const getUtmInfoCookie = () => {
|
||||
const raw = Cookies.get('utm_info')
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}
|
||||
|
||||
describe('ExternalAttributionRecorder', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Cookies.remove('utm_info')
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
setSearchParams()
|
||||
})
|
||||
|
||||
it('seeds the utm_info cookie and create_app attribution from the landing url', async () => {
|
||||
setSearchParams('utm_source=dify_blog&slug=get-started-with-dify')
|
||||
|
||||
render(<ExternalAttributionRecorder />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getUtmInfoCookie()).toEqual({
|
||||
utm_source: 'dify_blog',
|
||||
slug: 'get-started-with-dify',
|
||||
})
|
||||
})
|
||||
expect(mockRememberCreateAppExternalAttribution).toHaveBeenCalledTimes(1)
|
||||
const firstArg = mockRememberCreateAppExternalAttribution.mock.calls[0]?.[0]
|
||||
expect(firstArg?.searchParams?.get('slug')).toBe('get-started-with-dify')
|
||||
})
|
||||
|
||||
it('does nothing without a utm_source', () => {
|
||||
setSearchParams('slug=get-started-with-dify')
|
||||
|
||||
render(<ExternalAttributionRecorder />)
|
||||
|
||||
expect(getUtmInfoCookie()).toBeNull()
|
||||
expect(mockRememberCreateAppExternalAttribution).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('overwrites a stale utm_info cookie with the latest campaign params', async () => {
|
||||
Cookies.set('utm_info', JSON.stringify({ utm_source: 'newsletter', slug: 'launch-week' }))
|
||||
setSearchParams('utm_source=dify_blog&slug=get-started-with-dify')
|
||||
|
||||
render(<ExternalAttributionRecorder />)
|
||||
|
||||
// The most recent blog click wins, so a stale cookie can't shadow the new slug.
|
||||
await waitFor(() => {
|
||||
expect(getUtmInfoCookie()).toEqual({
|
||||
utm_source: 'dify_blog',
|
||||
slug: 'get-started-with-dify',
|
||||
})
|
||||
})
|
||||
expect(mockRememberCreateAppExternalAttribution).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is a no-op outside the cloud edition', () => {
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
setSearchParams('utm_source=dify_blog&slug=get-started-with-dify')
|
||||
|
||||
render(<ExternalAttributionRecorder />)
|
||||
|
||||
expect(getUtmInfoCookie()).toBeNull()
|
||||
expect(mockRememberCreateAppExternalAttribution).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
17
web/app/components/create-app-attribution-bootstrap.tsx
Normal file
17
web/app/components/create-app-attribution-bootstrap.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { IS_PROD } from '@/config'
|
||||
import { headers } from '@/next/headers'
|
||||
import Script from '@/next/script'
|
||||
import { buildCreateAppAttributionBootstrapScript } from '@/utils/create-app-tracking'
|
||||
|
||||
export async function CreateAppAttributionBootstrap() {
|
||||
const nonce = IS_PROD ? (await headers()).get('x-nonce') ?? undefined : undefined
|
||||
return (
|
||||
<Script
|
||||
id="create-app-attribution-bootstrap"
|
||||
strategy="beforeInteractive"
|
||||
nonce={nonce}
|
||||
>
|
||||
{buildCreateAppAttributionBootstrapScript()}
|
||||
</Script>
|
||||
)
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Cookies from 'js-cookie'
|
||||
import { useEffect } from 'react'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking'
|
||||
|
||||
const UTM_INFO_COOKIE = 'utm_info'
|
||||
const UTM_INFO_COOKIE_EXPIRES_DAYS = 1
|
||||
const UTM_INFO_QUERY_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'slug'] as const
|
||||
|
||||
/**
|
||||
* Captures external-campaign params (utm_* + blog `slug`) from the landing URL.
|
||||
*
|
||||
* Blog links point straight at cloud.dify.ai (e.g. `/apps?utm_source=dify_blog&slug=…`),
|
||||
* bypassing the marketing site that normally seeds the `utm_info` cookie. A new visitor
|
||||
* is bounced to sign-up, and the URL params are lost on that redirect — so we persist
|
||||
* them here, on the landing render, before the redirect happens:
|
||||
*
|
||||
* - `utm_info` cookie → read by the registration trackers, so slug is reported on
|
||||
* registration even when the user registers before creating an app.
|
||||
* - create_app sessionStorage → read by `trackCreateApp`, so slug is reported on
|
||||
* create_app.
|
||||
*
|
||||
* slug is intentionally NOT attached to page-view events; only these conversion events.
|
||||
*/
|
||||
const ExternalAttributionRecorder = () => {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_CLOUD_EDITION)
|
||||
return
|
||||
|
||||
const utmSource = searchParams.get('utm_source')?.trim()
|
||||
if (!utmSource)
|
||||
return
|
||||
|
||||
// create_app conversion attribution (utm_source + slug).
|
||||
rememberCreateAppExternalAttribution({ searchParams })
|
||||
|
||||
// Seed the utm_info cookie the registration trackers read. A campaign click always
|
||||
// overwrites any previous value, so the most recent blog link wins (last-touch) and
|
||||
// a stale cookie from an earlier, un-converted visit can't shadow the new slug. This
|
||||
// mirrors the create_app attribution refreshed just above.
|
||||
const utmInfo: Record<string, string> = {}
|
||||
UTM_INFO_QUERY_KEYS.forEach((key) => {
|
||||
const value = searchParams.get(key)?.trim()
|
||||
if (value)
|
||||
utmInfo[key] = value
|
||||
})
|
||||
|
||||
Cookies.set(UTM_INFO_COOKIE, JSON.stringify(utmInfo), {
|
||||
expires: UTM_INFO_COOKIE_EXPIRES_DAYS,
|
||||
path: '/',
|
||||
})
|
||||
}, [searchParams])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default ExternalAttributionRecorder
|
||||
@ -111,7 +111,7 @@ const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, languag
|
||||
</span>
|
||||
)}
|
||||
statusText={(
|
||||
<span className="block max-w-full min-w-0 [overflow-wrap:anywhere] break-words whitespace-pre-wrap">
|
||||
<span className="block max-w-full wrap-break-word whitespace-pre-line">
|
||||
{plugin.message || errorMsg}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -29,7 +29,7 @@ const PluginItem: FC<PluginItemProps> = ({
|
||||
const pluginName = plugin.labels[language] || plugin.plugin_unique_identifier
|
||||
|
||||
return (
|
||||
<div className="group/item flex w-full max-w-full min-w-0 gap-1 overflow-hidden rounded-lg p-2 hover:bg-state-base-hover">
|
||||
<div className="group/item flex gap-1 rounded-lg p-2 hover:bg-state-base-hover">
|
||||
<div className="relative shrink-0 self-start">
|
||||
{hasPluginIcon
|
||||
? (
|
||||
@ -44,11 +44,11 @@ const PluginItem: FC<PluginItemProps> = ({
|
||||
{statusIcon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 px-1 [overflow-wrap:anywhere]">
|
||||
<div className="flex min-w-0 grow flex-col gap-0.5 px-1">
|
||||
<div className="truncate system-sm-medium text-text-secondary">
|
||||
{plugin.labels[language]}
|
||||
</div>
|
||||
<div className={`max-w-full min-w-0 system-xs-regular [overflow-wrap:anywhere] wrap-break-word ${statusClassName || 'text-text-tertiary'}`}>
|
||||
<div className={`min-w-0 system-xs-regular wrap-break-word ${statusClassName || 'text-text-tertiary'}`}>
|
||||
{statusText}
|
||||
</div>
|
||||
{action}
|
||||
|
||||
@ -33,15 +33,15 @@ function PluginTaskList({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[360px] max-w-[calc(100vw-32px)] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"
|
||||
className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg"
|
||||
data-testid="plugin-task-list"
|
||||
>
|
||||
<ScrollArea
|
||||
className="max-h-[420px] overflow-hidden"
|
||||
label={t('task.installing', { ns: 'plugin' })}
|
||||
slotClassNames={{
|
||||
viewport: 'max-h-[420px] overscroll-contain',
|
||||
content: 'w-full! max-w-full! min-w-0! overflow-x-hidden!',
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-w-0',
|
||||
}}
|
||||
>
|
||||
{runningPlugins.length > 0 && (
|
||||
@ -103,10 +103,13 @@ function PluginTaskList({
|
||||
{t('task.clearAll', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
aria-label={errorSectionTitle}
|
||||
className="w-full max-w-full min-w-0 overflow-hidden"
|
||||
role="region"
|
||||
<ScrollArea
|
||||
className="overflow-hidden"
|
||||
label={errorSectionTitle}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-w-0',
|
||||
}}
|
||||
>
|
||||
{errorPlugins.map(plugin => (
|
||||
<ErrorPluginItem
|
||||
@ -117,7 +120,7 @@ function PluginTaskList({
|
||||
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@ -10,9 +10,9 @@ import { getDatasetMap } from '@/env'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { headers } from '@/next/headers'
|
||||
import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder'
|
||||
import { CreateAppAttributionBootstrap } from './components/create-app-attribution-bootstrap'
|
||||
import { AgentationLoader } from './components/devtools/agentation-loader'
|
||||
import { ReactScanLoader } from './components/devtools/react-scan/loader'
|
||||
import ExternalAttributionRecorder from './components/external-attribution-recorder'
|
||||
import { I18nServerProvider } from './components/provider/i18n-server'
|
||||
import RoutePrefixHandle from './routePrefixHandle'
|
||||
import './styles/globals.css'
|
||||
@ -48,6 +48,7 @@ const LocaleLayout = async ({
|
||||
<meta name="msapplication-TileColor" content="#1C64F2" />
|
||||
<meta name="msapplication-config" content="/browserconfig.xml" />
|
||||
|
||||
<CreateAppAttributionBootstrap />
|
||||
<ReactScanLoader />
|
||||
</head>
|
||||
<body
|
||||
@ -68,7 +69,6 @@ const LocaleLayout = async ({
|
||||
<I18nServerProvider>
|
||||
<ToastHost timeout={5000} limit={3} />
|
||||
<PartnerStackCookieRecorder />
|
||||
<ExternalAttributionRecorder />
|
||||
<TooltipProvider delay={300} closeDelay={200}>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
|
||||
@ -31,12 +31,6 @@ vi.mock('@/service/client', () => ({
|
||||
queryKey: ['getAccessSettings', options.input],
|
||||
}),
|
||||
},
|
||||
getDeveloperApiSettings: {
|
||||
queryOptions: (options: QueryOptions) => ({
|
||||
...options,
|
||||
queryKey: ['getDeveloperApiSettings', options.input],
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -62,10 +56,6 @@ describe('deployment access state', () => {
|
||||
enabled: false,
|
||||
input: skipToken,
|
||||
})
|
||||
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
|
||||
enabled: false,
|
||||
input: skipToken,
|
||||
})
|
||||
|
||||
setDeploymentRoute(store)
|
||||
|
||||
@ -73,9 +63,5 @@ describe('deployment access state', () => {
|
||||
enabled: true,
|
||||
input: { params: { appInstanceId: 'app-instance-1' } },
|
||||
})
|
||||
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
|
||||
enabled: true,
|
||||
input: { params: { appInstanceId: 'app-instance-1' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -10,8 +10,8 @@ import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../components/empty-state'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
|
||||
import { CopyPill, EndpointRow } from '../../components/endpoint'
|
||||
import { Section } from '../../components/section'
|
||||
import { CopyPill, EndpointRow } from '../components/endpoint'
|
||||
import { accessSettingsQueryAtom } from '../state'
|
||||
import { getUrlOrigin } from './url'
|
||||
|
||||
@ -112,6 +112,120 @@ function ChannelRow({ info, children }: {
|
||||
)
|
||||
}
|
||||
|
||||
function WebAppChannelRow({ endpoints }: {
|
||||
endpoints?: AccessEndpoint[]
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const webappRows = endpoints?.flatMap((endpoint) => {
|
||||
const endpointUrl = endpoint.endpointUrl
|
||||
if (!endpointUrl)
|
||||
return []
|
||||
|
||||
return [{
|
||||
endpoint,
|
||||
endpointUrl,
|
||||
}]
|
||||
}) ?? []
|
||||
|
||||
return (
|
||||
<ChannelRow
|
||||
info={(
|
||||
<ChannelInfo
|
||||
icon={<span className="i-ri-global-line size-3.5" aria-hidden="true" />}
|
||||
title={t('access.runAccess.webapp')}
|
||||
description={t('access.runAccess.webappDesc')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{webappRows.length > 0
|
||||
? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{webappRows.map(({ endpoint, endpointUrl }) => (
|
||||
<EndpointRow
|
||||
key={`webapp-${endpoint.environment?.id ?? endpointUrl}`}
|
||||
envName={endpoint.environment?.displayName ?? '—'}
|
||||
label={t('access.runAccess.urlLabel')}
|
||||
value={endpointUrl}
|
||||
openLabel={t('access.runAccess.openWebapp')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<DeploymentNoticeState>
|
||||
{t('access.runAccess.webappEmpty')}
|
||||
</DeploymentNoticeState>
|
||||
)}
|
||||
</ChannelRow>
|
||||
)
|
||||
}
|
||||
|
||||
function CliChannelRow({ endpoint }: {
|
||||
endpoint?: AccessEndpoint
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const cliDomain = getUrlOrigin(endpoint?.endpointUrl)
|
||||
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
|
||||
|
||||
return (
|
||||
<ChannelRow
|
||||
info={(
|
||||
<ChannelInfo
|
||||
icon={<span className="i-ri-terminal-box-line size-3.5" aria-hidden="true" />}
|
||||
title={t('access.cli.title')}
|
||||
description={t('access.cli.description')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{cliDomain
|
||||
? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CopyPill
|
||||
label={t('access.cli.domain')}
|
||||
value={cliDomain}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<a
|
||||
href={cliDocsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
<span className="i-ri-download-cloud-2-line size-3.5" />
|
||||
{t('access.cli.install')}
|
||||
</a>
|
||||
<a
|
||||
href={cliDocsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
<span className="i-ri-book-open-line size-3.5" />
|
||||
{t('access.cli.docs')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<DeploymentNoticeState>
|
||||
{t('access.cli.empty')}
|
||||
</DeploymentNoticeState>
|
||||
)}
|
||||
</ChannelRow>
|
||||
)
|
||||
}
|
||||
|
||||
function EnabledAccessChannels({ webAppEndpoints, cliEndpoint }: {
|
||||
webAppEndpoints?: AccessEndpoint[]
|
||||
cliEndpoint?: AccessEndpoint
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
|
||||
<WebAppChannelRow endpoints={webAppEndpoints} />
|
||||
<CliChannelRow endpoint={cliEndpoint} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccessChannelsSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
@ -122,18 +236,6 @@ export function AccessChannelsSection() {
|
||||
const isLoading = accessSettingsQuery.isLoading
|
||||
const isError = accessSettingsQuery.isError
|
||||
const runEnabled = accessChannels?.webAppEnabled ?? false
|
||||
const webappRows = webAppEndpoints?.flatMap((endpoint) => {
|
||||
const endpointUrl = endpoint.endpointUrl
|
||||
if (!endpointUrl)
|
||||
return []
|
||||
|
||||
return [{
|
||||
endpoint,
|
||||
endpointUrl,
|
||||
}]
|
||||
}) ?? []
|
||||
const cliDomain = getUrlOrigin(cliEndpoint?.endpointUrl)
|
||||
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
|
||||
|
||||
return (
|
||||
<Section
|
||||
@ -161,80 +263,10 @@ export function AccessChannelsSection() {
|
||||
? <DeploymentStateMessage variant="section">{t('common.loadFailed')}</DeploymentStateMessage>
|
||||
: runEnabled
|
||||
? (
|
||||
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
|
||||
<ChannelRow
|
||||
info={(
|
||||
<ChannelInfo
|
||||
icon={<span className="i-ri-global-line size-3.5" aria-hidden="true" />}
|
||||
title={t('access.runAccess.webapp')}
|
||||
description={t('access.runAccess.webappDesc')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{webappRows.length > 0
|
||||
? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{webappRows.map(({ endpoint, endpointUrl }) => (
|
||||
<EndpointRow
|
||||
key={`webapp-${endpoint.environment?.id ?? endpointUrl}`}
|
||||
envName={endpoint.environment?.displayName ?? '—'}
|
||||
label={t('access.runAccess.urlLabel')}
|
||||
value={endpointUrl}
|
||||
openLabel={t('access.runAccess.openWebapp')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<DeploymentNoticeState>
|
||||
{t('access.runAccess.webappEmpty')}
|
||||
</DeploymentNoticeState>
|
||||
)}
|
||||
</ChannelRow>
|
||||
<ChannelRow
|
||||
info={(
|
||||
<ChannelInfo
|
||||
icon={<span className="i-ri-terminal-box-line size-3.5" aria-hidden="true" />}
|
||||
title={t('access.cli.title')}
|
||||
description={t('access.cli.description')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{cliDomain
|
||||
? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CopyPill
|
||||
label={t('access.cli.domain')}
|
||||
value={cliDomain}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<a
|
||||
href={cliDocsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
<span className="i-ri-download-cloud-2-line size-3.5" />
|
||||
{t('access.cli.install')}
|
||||
</a>
|
||||
<a
|
||||
href={cliDocsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
<span className="i-ri-book-open-line size-3.5" />
|
||||
{t('access.cli.docs')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<DeploymentNoticeState>
|
||||
{t('access.cli.empty')}
|
||||
</DeploymentNoticeState>
|
||||
)}
|
||||
</ChannelRow>
|
||||
</div>
|
||||
<EnabledAccessChannels
|
||||
webAppEndpoints={webAppEndpoints}
|
||||
cliEndpoint={cliEndpoint}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<DeploymentEmptyState
|
||||
|
||||
@ -1,242 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Environment } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ButtonProps } from '@langgenius/dify-ui/button'
|
||||
import type { FormEvent, ReactNode } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
|
||||
import { generateApiTokenName } from './api-token-name'
|
||||
|
||||
export function ApiKeyGenerateMenu({
|
||||
environments,
|
||||
onCreatedToken,
|
||||
triggerVariant = 'secondary',
|
||||
triggerClassName,
|
||||
children,
|
||||
}: {
|
||||
environments: Environment[]
|
||||
onCreatedToken: (token: string) => void
|
||||
triggerVariant?: ButtonProps['variant']
|
||||
triggerClassName?: string
|
||||
children?: (props: { trigger: ReactNode }) => ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const nameInputId = useId()
|
||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string>()
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [nameError, setNameError] = useState(false)
|
||||
const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions())
|
||||
const selectableEnvironments = environments
|
||||
const selectedEnvironment = selectedEnvironmentId
|
||||
? selectableEnvironments.find(env => env.id === selectedEnvironmentId)
|
||||
: undefined
|
||||
const disabled = !appInstanceId || selectableEnvironments.length === 0
|
||||
const isCreating = generateApiKey.isPending
|
||||
|
||||
useEffect(() => {
|
||||
if (createDialogOpen)
|
||||
nameInputRef.current?.focus()
|
||||
}, [createDialogOpen])
|
||||
|
||||
function handleOpenCreateDialog() {
|
||||
const firstEnvironment = selectableEnvironments[0]
|
||||
if (!firstEnvironment)
|
||||
return
|
||||
|
||||
setSelectedEnvironmentId(firstEnvironment.id)
|
||||
setDraftName(generateApiTokenName())
|
||||
setNameError(false)
|
||||
setCreateDialogOpen(true)
|
||||
}
|
||||
|
||||
function handleEnvironmentChange(environmentId: string) {
|
||||
setSelectedEnvironmentId(environmentId)
|
||||
setNameError(false)
|
||||
}
|
||||
|
||||
function handleDraftNameChange(nextDraftName: string) {
|
||||
setDraftName(nextDraftName)
|
||||
if (nameError && nextDraftName.trim())
|
||||
setNameError(false)
|
||||
}
|
||||
|
||||
function resetCreateDialog() {
|
||||
setCreateDialogOpen(false)
|
||||
setSelectedEnvironmentId(undefined)
|
||||
setDraftName('')
|
||||
setNameError(false)
|
||||
}
|
||||
|
||||
function handleDialogOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen || isCreating)
|
||||
return
|
||||
|
||||
resetCreateDialog()
|
||||
}
|
||||
|
||||
function handleGenerateApiKey(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const name = draftName.trim()
|
||||
|
||||
if (!appInstanceId || !selectedEnvironmentId || !name) {
|
||||
setNameError(true)
|
||||
return
|
||||
}
|
||||
|
||||
generateApiKey.mutate(
|
||||
{
|
||||
params: {
|
||||
appInstanceId,
|
||||
environmentId: selectedEnvironmentId,
|
||||
},
|
||||
body: {
|
||||
appInstanceId,
|
||||
environmentId: selectedEnvironmentId,
|
||||
displayName: name,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
if (response.token)
|
||||
onCreatedToken(response.token)
|
||||
resetCreateDialog()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('access.api.createFailed'))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
type="button"
|
||||
variant={triggerVariant}
|
||||
disabled={disabled}
|
||||
onClick={handleOpenCreateDialog}
|
||||
className={cn('gap-1.5', triggerClassName)}
|
||||
>
|
||||
<span className="i-ri-add-line size-4" aria-hidden="true" />
|
||||
{t('access.api.newKey')}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{children ? children({ trigger }) : trigger}
|
||||
<Dialog open={createDialogOpen} onOpenChange={handleDialogOpenChange}>
|
||||
<DialogContent className="w-120 max-w-[calc(100vw-32px)] overflow-hidden p-0">
|
||||
<DialogCloseButton disabled={isCreating} />
|
||||
<form onSubmit={handleGenerateApiKey}>
|
||||
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
|
||||
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{t('access.api.createKeyTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{t('access.api.description')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 px-6 py-5">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={nameInputId}
|
||||
className="mb-1 block system-sm-medium text-text-secondary"
|
||||
>
|
||||
{t('access.api.nameLabel')}
|
||||
</label>
|
||||
<Input
|
||||
ref={nameInputRef}
|
||||
id={nameInputId}
|
||||
value={draftName}
|
||||
disabled={isCreating}
|
||||
aria-invalid={nameError || undefined}
|
||||
aria-describedby={nameError ? `${nameInputId}-error` : undefined}
|
||||
placeholder={t('access.api.namePlaceholder')}
|
||||
onChange={(event) => {
|
||||
handleDraftNameChange(event.target.value)
|
||||
}}
|
||||
/>
|
||||
{nameError && (
|
||||
<div id={`${nameInputId}-error`} className="mt-1 system-xs-regular text-text-destructive">
|
||||
{t('access.api.nameRequired')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select
|
||||
value={selectedEnvironmentId ?? null}
|
||||
disabled={isCreating}
|
||||
onValueChange={value => value && handleEnvironmentChange(value)}
|
||||
>
|
||||
<SelectLabel className="mb-1 block system-sm-medium text-text-secondary">
|
||||
{t('access.api.table.environment')}
|
||||
</SelectLabel>
|
||||
<SelectTrigger>
|
||||
{selectedEnvironment?.displayName ?? '—'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableEnvironments.map(env => (
|
||||
<SelectItem key={env.id} value={env.id}>
|
||||
<SelectItemText>{env.displayName}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isCreating}
|
||||
onClick={() => handleDialogOpenChange(false)}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={isCreating}
|
||||
disabled={isCreating || !selectedEnvironmentId}
|
||||
>
|
||||
{t('access.api.createKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -26,6 +26,7 @@ describe('DeploymentAccessControlDialog', () => {
|
||||
render(
|
||||
<DeploymentAccessControlDialog
|
||||
open
|
||||
resetKey={1}
|
||||
initialKind="specific"
|
||||
initialSubjects={[
|
||||
{
|
||||
@ -61,4 +62,20 @@ describe('DeploymentAccessControlDialog', () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should disable the close button while saving', () => {
|
||||
render(
|
||||
<DeploymentAccessControlDialog
|
||||
open
|
||||
resetKey={1}
|
||||
initialKind="organization"
|
||||
initialSubjects={[]}
|
||||
saving
|
||||
onClose={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AccessPermissionKind, SelectableAccessSubject } from './access-policy'
|
||||
import type { AccessSubjectSelectionValue } from './access-subject-selector/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@ -28,6 +28,7 @@ import { AccessSubjectSelectionList } from './access-subject-selector/selection-
|
||||
|
||||
export function DeploymentAccessControlDialog({
|
||||
open,
|
||||
resetKey,
|
||||
initialKind,
|
||||
initialSubjects,
|
||||
saving,
|
||||
@ -35,6 +36,7 @@ export function DeploymentAccessControlDialog({
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
resetKey: number
|
||||
initialKind: AccessPermissionKind
|
||||
initialSubjects: SelectableAccessSubject[]
|
||||
saving?: boolean
|
||||
@ -46,24 +48,29 @@ export function DeploymentAccessControlDialog({
|
||||
initialSubjects.map(subject => `${subject.subjectType}:${subject.id}`).join(','),
|
||||
].join(':')
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen || saving)
|
||||
return
|
||||
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} disablePointerDismissal onOpenChange={open => !open && onClose()}>
|
||||
<Dialog open={open} disablePointerDismissal onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-shadow',
|
||||
)}
|
||||
>
|
||||
<DialogCloseButton className="top-5 right-5 size-8" />
|
||||
{open && (
|
||||
<DeploymentAccessControlDialogBody
|
||||
key={draftKey}
|
||||
initialKind={initialKind}
|
||||
initialSubjects={initialSubjects}
|
||||
saving={saving}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
<DialogCloseButton disabled={saving} className="top-5 right-5 size-8" />
|
||||
<DeploymentAccessControlDialogBody
|
||||
key={`${resetKey}:${draftKey}`}
|
||||
initialKind={initialKind}
|
||||
initialSubjects={initialSubjects}
|
||||
saving={saving}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@ -162,9 +169,10 @@ function DeploymentAccessControlDialogBody({
|
||||
)
|
||||
}
|
||||
|
||||
function AccessControlItem({ type, children }: PropsWithChildren<{
|
||||
function AccessControlItem({ type, children }: {
|
||||
type: AppAccessMode
|
||||
}>) {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<RadioRoot<AppAccessMode>
|
||||
value={type}
|
||||
|
||||
@ -16,7 +16,6 @@ import {
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { useSearchAccessSubjects } from '@/service/access-control/use-access-subjects'
|
||||
import { SelectedGroupsBreadCrumb, SubjectItem } from './subject-options'
|
||||
@ -66,7 +65,8 @@ export function AccessSubjectAddButton({
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (anchorRef.current) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && hasMore)
|
||||
const entry = entries[0]
|
||||
if (entry?.isIntersecting && !isLoading && !isFetchingNextPage && hasMore)
|
||||
fetchNextPage()
|
||||
}, { root: scrollRootRef.current, rootMargin: '20px' })
|
||||
observer.observe(anchorRef.current)
|
||||
@ -178,7 +178,7 @@ export function AccessSubjectAddButton({
|
||||
/>
|
||||
)}
|
||||
</ComboboxList>
|
||||
{isFetchingNextPage && <Loading />}
|
||||
{isFetchingNextPage && <SubjectOptionsLoadingStatus />}
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
</>
|
||||
)
|
||||
@ -195,6 +195,20 @@ export function AccessSubjectAddButton({
|
||||
)
|
||||
}
|
||||
|
||||
function SubjectOptionsLoadingStatus() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t('loading', { ns: 'appApi' })}
|
||||
className="flex h-8 items-center justify-center"
|
||||
>
|
||||
<span aria-hidden className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SubjectOptionsSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@ -91,7 +91,7 @@ function RenderGroupsAndMembers({
|
||||
return (
|
||||
<>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('accessControlDialog.groups', { ns: 'app', count: selectedGroups.length ?? 0 })}
|
||||
{t('accessControlDialog.groups', { ns: 'app', count: selectedGroups.length })}
|
||||
</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{selectedGroups.map(group => (
|
||||
@ -105,7 +105,7 @@ function RenderGroupsAndMembers({
|
||||
))}
|
||||
</div>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('accessControlDialog.members', { ns: 'app', count: selectedMembers.length ?? 0 })}
|
||||
{t('accessControlDialog.members', { ns: 'app', count: selectedMembers.length })}
|
||||
</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{selectedMembers.map(member => (
|
||||
|
||||
@ -5,7 +5,6 @@ import type {
|
||||
AccessControlAccount,
|
||||
AccessControlGroup,
|
||||
Subject,
|
||||
SubjectAccount,
|
||||
SubjectGroup,
|
||||
} from '@/models/access-control'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
@ -19,6 +18,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { SubjectType } from '@/models/access-control'
|
||||
|
||||
function isSubjectGroup(subject: Subject): subject is SubjectGroup {
|
||||
return subject.subjectType === SubjectType.GROUP
|
||||
}
|
||||
|
||||
export function SubjectItem({
|
||||
subject,
|
||||
selectedGroups,
|
||||
@ -30,10 +33,10 @@ export function SubjectItem({
|
||||
selectedMembers: AccessControlAccount[]
|
||||
onExpandGroup: (group: AccessControlGroup) => void
|
||||
}) {
|
||||
if (subject.subjectType === SubjectType.GROUP) {
|
||||
if (isSubjectGroup(subject)) {
|
||||
return (
|
||||
<GroupItem
|
||||
group={(subject as SubjectGroup).groupData}
|
||||
group={subject.groupData}
|
||||
subject={subject}
|
||||
selectedGroups={selectedGroups}
|
||||
onExpandGroup={onExpandGroup}
|
||||
@ -43,7 +46,7 @@ export function SubjectItem({
|
||||
|
||||
return (
|
||||
<MemberItem
|
||||
member={(subject as SubjectAccount).accountData}
|
||||
member={subject.accountData}
|
||||
subject={subject}
|
||||
selectedMembers={selectedMembers}
|
||||
/>
|
||||
|
||||
@ -8,6 +8,10 @@ import type {
|
||||
} from '@/models/access-control'
|
||||
import { SubjectType } from '@/models/access-control'
|
||||
|
||||
function isSubjectGroup(subject: Subject): subject is SubjectGroup {
|
||||
return subject.subjectType === SubjectType.GROUP
|
||||
}
|
||||
|
||||
function groupToSubject(group: AccessControlGroup): SubjectGroup {
|
||||
return {
|
||||
subjectId: group.id,
|
||||
@ -25,10 +29,10 @@ function memberToSubject(member: AccessControlAccount): SubjectAccount {
|
||||
}
|
||||
|
||||
export function getSubjectLabel(subject: Subject) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
return (subject as SubjectGroup).groupData.name
|
||||
if (isSubjectGroup(subject))
|
||||
return subject.groupData.name
|
||||
|
||||
return (subject as SubjectAccount).accountData.name
|
||||
return subject.accountData.name
|
||||
}
|
||||
|
||||
export function getSubjectValue(subject: Subject) {
|
||||
@ -54,10 +58,10 @@ export function subjectsToSelectionValue(subjects: Subject[]): AccessSubjectSele
|
||||
const members: AccessControlAccount[] = []
|
||||
|
||||
subjects.forEach((subject) => {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
groups.push((subject as SubjectGroup).groupData)
|
||||
if (isSubjectGroup(subject))
|
||||
groups.push(subject.groupData)
|
||||
else
|
||||
members.push((subject as SubjectAccount).accountData)
|
||||
members.push(subject.accountData)
|
||||
})
|
||||
|
||||
return { groups, members }
|
||||
|
||||
@ -32,19 +32,17 @@ type AccessPermissionDraft = {
|
||||
subjects: SelectableAccessSubject[]
|
||||
}
|
||||
|
||||
type EnvironmentPermissionRowProps = {
|
||||
disabled?: boolean
|
||||
environment: Environment
|
||||
summaryPolicy?: AccessPolicy
|
||||
resolvedSubjects?: Subject[]
|
||||
}
|
||||
|
||||
export function EnvironmentPermissionRow({
|
||||
disabled,
|
||||
environment,
|
||||
summaryPolicy,
|
||||
resolvedSubjects = [],
|
||||
}: EnvironmentPermissionRowProps) {
|
||||
}: {
|
||||
disabled?: boolean
|
||||
environment: Environment
|
||||
summaryPolicy?: AccessPolicy
|
||||
resolvedSubjects?: Subject[]
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const environmentId = environment.id
|
||||
@ -56,6 +54,7 @@ export function EnvironmentPermissionRow({
|
||||
: 'no-policy'
|
||||
const [draft, setDraft] = useState<AccessPermissionDraft>()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [dialogSessionKey, setDialogSessionKey] = useState(0)
|
||||
const subjectLabelCandidates = [
|
||||
...(draft?.subjects ?? []),
|
||||
...resolvedSubjects
|
||||
@ -124,6 +123,11 @@ export function EnvironmentPermissionRow({
|
||||
})
|
||||
}
|
||||
|
||||
function handleOpenDialog() {
|
||||
setDialogSessionKey(sessionKey => sessionKey + 1)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2 border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0">
|
||||
<div className="flex min-w-0 items-center">
|
||||
@ -137,10 +141,11 @@ export function EnvironmentPermissionRow({
|
||||
disabled={controlsDisabled}
|
||||
loading={isSaving}
|
||||
environmentLabel={envName}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
onClick={handleOpenDialog}
|
||||
/>
|
||||
<DeploymentAccessControlDialog
|
||||
open={dialogOpen}
|
||||
resetKey={dialogSessionKey}
|
||||
initialKind={permissionKind}
|
||||
initialSubjects={subjects}
|
||||
saving={isSaving}
|
||||
|
||||
@ -17,16 +17,3 @@ export const accessSettingsQueryAtom = atomWithQuery((get) => {
|
||||
enabled: Boolean(appInstanceId),
|
||||
})
|
||||
})
|
||||
|
||||
export const developerApiSettingsQueryAtom = atomWithQuery((get) => {
|
||||
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
|
||||
|
||||
return consoleQuery.enterprise.accessService.getDeveloperApiSettings.queryOptions({
|
||||
input: appInstanceId
|
||||
? {
|
||||
params: { appInstanceId },
|
||||
}
|
||||
: skipToken,
|
||||
enabled: Boolean(appInstanceId),
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,19 +1,10 @@
|
||||
import type { Environment } from '@dify/contracts/enterprise/types.gen'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../../../route-state'
|
||||
import { ApiKeyGenerateMenu } from '../api-key-generate-menu'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CreateApiKeyButton } from '../create-api-key-button'
|
||||
import { CreateApiKeyDialog } from '../create-api-key-dialog'
|
||||
|
||||
const mockMutate = vi.hoisted(() => vi.fn())
|
||||
const mockUseAtomValue = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('jotai', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('jotai')>()
|
||||
return {
|
||||
...actual,
|
||||
useAtomValue: mockUseAtomValue,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useMutation: () => ({
|
||||
@ -41,25 +32,28 @@ function createEnvironment(): Environment {
|
||||
} as Environment
|
||||
}
|
||||
|
||||
describe('ApiKeyGenerateMenu', () => {
|
||||
function renderCreateApiKeyDialog() {
|
||||
return render(
|
||||
<CreateApiKeyDialog
|
||||
appInstanceId="app-instance-1"
|
||||
environments={[createEnvironment()]}
|
||||
open
|
||||
sessionKey={0}
|
||||
onCreatedToken={vi.fn()}
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
// API token creation keeps validation and mutation payload shaping inside the dialog content.
|
||||
describe('CreateApiKeyDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAtomValue.mockImplementation((atom) => {
|
||||
if (atom === deploymentRouteAppInstanceIdAtom)
|
||||
return 'app-instance-1'
|
||||
return undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('should show the required name error when submitting an empty name', () => {
|
||||
render(
|
||||
<ApiKeyGenerateMenu
|
||||
environments={[createEnvironment()]}
|
||||
onCreatedToken={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
renderCreateApiKeyDialog()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.newKey' }))
|
||||
fireEvent.change(screen.getByLabelText('deployments.access.api.nameLabel'), {
|
||||
target: { value: ' ' },
|
||||
})
|
||||
@ -70,14 +64,8 @@ describe('ApiKeyGenerateMenu', () => {
|
||||
})
|
||||
|
||||
it('should clear the required name error when typing a valid name', () => {
|
||||
render(
|
||||
<ApiKeyGenerateMenu
|
||||
environments={[createEnvironment()]}
|
||||
onCreatedToken={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
renderCreateApiKeyDialog()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.newKey' }))
|
||||
const nameInput = screen.getByLabelText('deployments.access.api.nameLabel')
|
||||
|
||||
fireEvent.change(nameInput, {
|
||||
@ -93,15 +81,45 @@ describe('ApiKeyGenerateMenu', () => {
|
||||
expect(screen.queryByText('deployments.access.api.nameRequired')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable the trigger when route app instance is missing', () => {
|
||||
mockUseAtomValue.mockReturnValue(undefined)
|
||||
it('should create an api key with the entered name and default environment', () => {
|
||||
renderCreateApiKeyDialog()
|
||||
|
||||
render(
|
||||
<ApiKeyGenerateMenu
|
||||
environments={[createEnvironment()]}
|
||||
onCreatedToken={vi.fn()}
|
||||
/>,
|
||||
fireEvent.change(screen.getByLabelText('deployments.access.api.nameLabel'), {
|
||||
target: { value: ' Production key ' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.createKey' }))
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
params: {
|
||||
appInstanceId: 'app-instance-1',
|
||||
environmentId: 'environment-1',
|
||||
},
|
||||
body: {
|
||||
appInstanceId: 'app-instance-1',
|
||||
environmentId: 'environment-1',
|
||||
displayName: 'Production key',
|
||||
},
|
||||
},
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// The trigger is a placement-neutral button; the owning section controls dialog state.
|
||||
describe('CreateApiKeyButton', () => {
|
||||
it('should call the supplied action when enabled', () => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(<CreateApiKeyButton onClick={handleClick} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.newKey' }))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should disable the trigger when creation is not available', () => {
|
||||
render(<CreateApiKeyButton disabled onClick={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'deployments.access.api.newKey' })).toBeDisabled()
|
||||
})
|
||||
@ -0,0 +1,67 @@
|
||||
import type { Getter } from 'jotai'
|
||||
import { skipToken } from '@tanstack/react-query'
|
||||
import { atom, createStore } from 'jotai'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
|
||||
|
||||
type QueryOptions = {
|
||||
enabled?: boolean
|
||||
input?: unknown
|
||||
queryKey?: readonly unknown[]
|
||||
}
|
||||
|
||||
vi.mock('jotai-tanstack-query', () => ({
|
||||
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({
|
||||
...createOptions(get),
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
isSuccess: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
enterprise: {
|
||||
accessService: {
|
||||
getDeveloperApiSettings: {
|
||||
queryOptions: (options: QueryOptions) => ({
|
||||
...options,
|
||||
queryKey: ['getDeveloperApiSettings', options.input],
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
async function loadState() {
|
||||
return await import('../state')
|
||||
}
|
||||
|
||||
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
|
||||
store.set(setNextRouteStateAtom, {
|
||||
pathname: `/deployments/${appInstanceId}/api-tokens`,
|
||||
params: { appInstanceId },
|
||||
})
|
||||
}
|
||||
|
||||
describe('deployment api tokens state', () => {
|
||||
it('should gate developer api settings until a route app instance exists', async () => {
|
||||
const state = await loadState()
|
||||
const store = createStore()
|
||||
|
||||
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
|
||||
enabled: false,
|
||||
input: skipToken,
|
||||
})
|
||||
|
||||
setDeploymentRoute(store)
|
||||
|
||||
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
|
||||
enabled: true,
|
||||
input: { params: { appInstanceId: 'app-instance-1' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -28,10 +28,10 @@ import {
|
||||
DetailTableHead,
|
||||
DetailTableHeader,
|
||||
DetailTableRow,
|
||||
} from '../../components/detail-table'
|
||||
} from '../components/detail-table'
|
||||
import {
|
||||
API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES,
|
||||
} from '../../components/detail-table-styles'
|
||||
} from '../components/detail-table-styles'
|
||||
|
||||
function ApiKeyName({ apiKey }: {
|
||||
apiKey: ApiKey
|
||||
@ -29,7 +29,11 @@ const API_TOKEN_NAME_NOUNS = [
|
||||
]
|
||||
|
||||
function randomListItem(items: string[]) {
|
||||
return items[Math.floor(Math.random() * items.length)]!
|
||||
const item = items[Math.floor(Math.random() * items.length)]
|
||||
if (item === undefined)
|
||||
throw new Error('Cannot generate an API token name from an empty list.')
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
export function generateApiTokenName() {
|
||||
@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import type { ButtonProps } from '@langgenius/dify-ui/button'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function CreateApiKeyButton({
|
||||
disabled,
|
||||
triggerVariant = 'secondary',
|
||||
triggerClassName,
|
||||
onClick,
|
||||
}: {
|
||||
disabled?: boolean
|
||||
triggerVariant?: ButtonProps['variant']
|
||||
triggerClassName?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={triggerVariant}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn('gap-1.5', triggerClassName)}
|
||||
>
|
||||
<span className="i-ri-add-line size-4" aria-hidden="true" />
|
||||
{t('access.api.newKey')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import type { Environment } from '@dify/contracts/enterprise/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { generateApiTokenName } from './api-token-name'
|
||||
|
||||
type CreateApiKeyFormValues = {
|
||||
displayName: string
|
||||
environmentId: string
|
||||
}
|
||||
|
||||
export function CreateApiKeyDialog({
|
||||
appInstanceId,
|
||||
environments,
|
||||
open,
|
||||
sessionKey,
|
||||
onCreatedToken,
|
||||
onOpenChange,
|
||||
}: {
|
||||
appInstanceId: string
|
||||
environments: Environment[]
|
||||
open: boolean
|
||||
sessionKey: number
|
||||
onCreatedToken: (token: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const closeBlockedRef = useRef(false)
|
||||
const [closeBlocked, setCloseBlocked] = useState(false)
|
||||
|
||||
function handleCloseBlockedChange(blocked: boolean) {
|
||||
closeBlockedRef.current = blocked
|
||||
setCloseBlocked(blocked)
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen && closeBlockedRef.current)
|
||||
return
|
||||
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} disablePointerDismissal={closeBlocked} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="w-120 max-w-[calc(100vw-32px)] overflow-hidden p-0">
|
||||
<CreateApiKeyDialogContent
|
||||
key={sessionKey}
|
||||
appInstanceId={appInstanceId}
|
||||
environments={environments}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onCloseBlockedChange={handleCloseBlockedChange}
|
||||
onCreatedToken={onCreatedToken}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateApiKeyDialogContent({
|
||||
appInstanceId,
|
||||
environments,
|
||||
onClose,
|
||||
onCloseBlockedChange,
|
||||
onCreatedToken,
|
||||
}: {
|
||||
appInstanceId: string
|
||||
environments: Environment[]
|
||||
onClose: () => void
|
||||
onCloseBlockedChange: (blocked: boolean) => void
|
||||
onCreatedToken: (token: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions())
|
||||
const isCreating = generateApiKey.isPending
|
||||
const firstEnvironment = environments[0]
|
||||
const nameRequiredMessage = t('access.api.nameRequired')
|
||||
|
||||
function handleClose() {
|
||||
if (isCreating)
|
||||
return
|
||||
onClose()
|
||||
}
|
||||
|
||||
function handleGenerateApiKey(values: CreateApiKeyFormValues) {
|
||||
const displayName = values.displayName.trim()
|
||||
const environmentId = values.environmentId
|
||||
|
||||
if (!environmentId || !displayName)
|
||||
return
|
||||
|
||||
onCloseBlockedChange(true)
|
||||
generateApiKey.mutate(
|
||||
{
|
||||
params: {
|
||||
appInstanceId,
|
||||
environmentId,
|
||||
},
|
||||
body: {
|
||||
appInstanceId,
|
||||
environmentId,
|
||||
displayName,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
if (response.token)
|
||||
onCreatedToken(response.token)
|
||||
onClose()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('access.api.createFailed'))
|
||||
},
|
||||
onSettled: () => {
|
||||
onCloseBlockedChange(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogCloseButton disabled={isCreating} />
|
||||
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
|
||||
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{t('access.api.createKeyTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{t('access.api.description')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<Form<CreateApiKeyFormValues> onFormSubmit={handleGenerateApiKey}>
|
||||
<div className="flex flex-col gap-4 px-6 py-5">
|
||||
<FieldRoot
|
||||
name="displayName"
|
||||
validate={(value) => {
|
||||
if (typeof value === 'string' && value.length > 0 && !value.trim())
|
||||
return nameRequiredMessage
|
||||
|
||||
return null
|
||||
}}
|
||||
>
|
||||
<FieldLabel className="system-sm-medium text-text-secondary">
|
||||
{t('access.api.nameLabel')}
|
||||
</FieldLabel>
|
||||
<FieldControl
|
||||
defaultValue={generateApiTokenName()}
|
||||
disabled={isCreating}
|
||||
autoComplete="off"
|
||||
placeholder={t('access.api.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
<FieldError match="valueMissing" className="system-xs-regular">{nameRequiredMessage}</FieldError>
|
||||
<FieldError match="customError" className="system-xs-regular" />
|
||||
</FieldRoot>
|
||||
|
||||
<FieldRoot name="environmentId">
|
||||
<Select
|
||||
name="environmentId"
|
||||
defaultValue={firstEnvironment?.id}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<SelectLabel className="system-sm-medium text-text-secondary">
|
||||
{t('access.api.table.environment')}
|
||||
</SelectLabel>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{environments.map(env => (
|
||||
<SelectItem key={env.id} value={env.id}>
|
||||
<SelectItemText>{env.displayName}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldRoot>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isCreating}
|
||||
onClick={handleClose}
|
||||
>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={isCreating}
|
||||
disabled={isCreating || !firstEnvironment}
|
||||
>
|
||||
{t('access.api.createKey')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -53,44 +53,63 @@ function CurlExample({ apiUrl, token }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function CreatedApiTokenDialog({ token, apiUrl, onDismiss }: {
|
||||
token: string
|
||||
function CreatedApiTokenDialogContent({ token, apiUrl, onDismiss }: {
|
||||
token?: string
|
||||
apiUrl?: string
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
if (!token)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogCloseButton />
|
||||
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
|
||||
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{t('access.api.newTokenTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{t('access.api.newTokenDescription')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 px-6 py-5">
|
||||
<CopyPill
|
||||
label={t('access.api.newTokenLabel')}
|
||||
value={token}
|
||||
/>
|
||||
{apiUrl && (
|
||||
<CurlExample
|
||||
apiUrl={apiUrl}
|
||||
token={token}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
|
||||
<Button variant="primary" onClick={onDismiss}>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function CreatedApiTokenDialog({ token, apiUrl, onDismiss }: {
|
||||
token?: string
|
||||
apiUrl?: string
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={Boolean(token)} onOpenChange={open => !open && onDismiss()} disablePointerDismissal>
|
||||
<DialogContent className="w-120 max-w-[calc(100vw-32px)] overflow-hidden p-0">
|
||||
<DialogCloseButton />
|
||||
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
|
||||
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{t('access.api.newTokenTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{t('access.api.newTokenDescription')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 px-6 py-5">
|
||||
<CopyPill
|
||||
label={t('access.api.newTokenLabel')}
|
||||
value={token}
|
||||
/>
|
||||
{apiUrl && (
|
||||
<CurlExample
|
||||
apiUrl={apiUrl}
|
||||
token={token}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
|
||||
<Button variant="primary" onClick={onDismiss}>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
<CreatedApiTokenDialogContent
|
||||
token={token}
|
||||
apiUrl={apiUrl}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessChannels } from '@dify/contracts/enterprise/types.gen'
|
||||
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
|
||||
import { developerApiSettingsQueryAtom } from './state'
|
||||
|
||||
function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
|
||||
checked: boolean
|
||||
accessChannels?: AccessChannels
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
|
||||
|
||||
return (
|
||||
<Switch
|
||||
aria-label={t('access.api.developerTitle')}
|
||||
checked={checked}
|
||||
disabled={disabled || !appInstanceId}
|
||||
loading={toggleDeveloperAPI.isPending}
|
||||
onCheckedChange={(enabled) => {
|
||||
if (!appInstanceId)
|
||||
return
|
||||
|
||||
toggleDeveloperAPI.mutate({
|
||||
params: { appInstanceId },
|
||||
body: {
|
||||
appInstanceId,
|
||||
webAppEnabled: accessChannels?.webAppEnabled ?? false,
|
||||
developerApiEnabled: enabled,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeveloperApiHeaderSwitch() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
|
||||
const accessChannels = developerApiSettingsQuery.data?.accessChannels
|
||||
const apiEnabled = accessChannels?.developerApiEnabled ?? false
|
||||
|
||||
if (developerApiSettingsQuery.isLoading)
|
||||
return <SwitchSkeleton />
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="system-xs-medium text-text-tertiary">
|
||||
{apiEnabled ? t('overview.enabled') : t('overview.disabled')}
|
||||
</span>
|
||||
<DeveloperApiSwitch
|
||||
checked={apiEnabled}
|
||||
accessChannels={accessChannels}
|
||||
disabled={developerApiSettingsQuery.isError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -22,7 +22,7 @@ import { useLocale } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { getDocLanguage } from '@/i18n-config/language'
|
||||
import { AppModeEnum, Theme } from '@/types/app'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
|
||||
|
||||
type PromptVariable = { key: string, name: string }
|
||||
type WorkflowApiDocAppDetail = Pick<App, 'id' | 'mode' | 'api_base_url'>
|
||||
@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { DeveloperApiSection } from './section'
|
||||
import { ApiTokensSection } from './section'
|
||||
|
||||
export function DeveloperApiTab() {
|
||||
export function ApiTokensTab() {
|
||||
return (
|
||||
<div className="flex w-full max-w-[960px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:px-20 sm:py-8">
|
||||
<DeveloperApiSection />
|
||||
<ApiTokensSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,88 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
AccessChannels,
|
||||
ApiKey,
|
||||
Environment,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../components/empty-state'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
|
||||
import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
|
||||
import { CopyPill } from '../components/endpoint'
|
||||
import { developerApiSettingsQueryAtom } from '../state'
|
||||
import { ApiKeyGenerateMenu } from './api-key-generate-menu'
|
||||
import { ApiKeyList } from './api-key-list'
|
||||
import { CreateApiKeyButton } from './create-api-key-button'
|
||||
import { CreateApiKeyDialog } from './create-api-key-dialog'
|
||||
import { CreatedApiTokenDialog } from './created-token-dialog'
|
||||
import { DeveloperApiDocsDrawer } from './docs-drawer'
|
||||
import { DeveloperApiSkeleton } from './skeleton'
|
||||
import { developerApiSettingsQueryAtom } from './state'
|
||||
|
||||
type CreatedApiToken = {
|
||||
appInstanceId: string
|
||||
token: string
|
||||
}
|
||||
|
||||
function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
|
||||
checked: boolean
|
||||
accessChannels?: AccessChannels
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
|
||||
|
||||
return (
|
||||
<Switch
|
||||
aria-label={t('access.api.developerTitle')}
|
||||
checked={checked}
|
||||
disabled={disabled || !appInstanceId}
|
||||
loading={toggleDeveloperAPI.isPending}
|
||||
onCheckedChange={(enabled) => {
|
||||
if (!appInstanceId)
|
||||
return
|
||||
|
||||
toggleDeveloperAPI.mutate({
|
||||
params: { appInstanceId },
|
||||
body: {
|
||||
appInstanceId,
|
||||
webAppEnabled: accessChannels?.webAppEnabled ?? false,
|
||||
developerApiEnabled: enabled,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeveloperApiHeaderSwitch() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
|
||||
const accessChannels = developerApiSettingsQuery.data?.accessChannels
|
||||
const apiEnabled = accessChannels?.developerApiEnabled ?? false
|
||||
|
||||
if (developerApiSettingsQuery.isLoading)
|
||||
return <SwitchSkeleton />
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="system-xs-medium text-text-tertiary">
|
||||
{apiEnabled ? t('overview.enabled') : t('overview.disabled')}
|
||||
</span>
|
||||
<DeveloperApiSwitch
|
||||
checked={apiEnabled}
|
||||
accessChannels={accessChannels}
|
||||
disabled={developerApiSettingsQuery.isError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyListSection({ apiKeys, environments, action }: {
|
||||
apiKeys: ApiKey[]
|
||||
environments: Environment[]
|
||||
@ -141,20 +83,36 @@ function DeveloperApiEndpoint({ apiUrl }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function DeveloperApiSection() {
|
||||
export function ApiTokensSection() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const [createdApiToken, setCreatedApiToken] = useState<CreatedApiToken>()
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [createDialogSessionKey, setCreateDialogSessionKey] = useState(0)
|
||||
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
|
||||
const accessChannels = developerApiSettingsQuery.data?.accessChannels
|
||||
const apiEnabled = accessChannels?.developerApiEnabled ?? false
|
||||
const apiUrl = developerApiSettingsQuery.data?.developerApiUrl.apiUrl
|
||||
const apiKeys: ApiKey[] = developerApiSettingsQuery.data?.apiKeys ?? []
|
||||
const environments = developerApiSettingsQuery.data?.environments ?? []
|
||||
const selectableEnvironments = environments.flatMap((environment) => {
|
||||
if (!environment.id)
|
||||
return []
|
||||
|
||||
return [environment]
|
||||
})
|
||||
const visibleCreatedApiToken = createdApiToken && createdApiToken.appInstanceId === appInstanceId
|
||||
? createdApiToken.token
|
||||
: undefined
|
||||
const hasSelectableEnvironment = environments.some(environment => Boolean(environment.id))
|
||||
const hasSelectableEnvironment = selectableEnvironments.length > 0
|
||||
|
||||
function handleOpenCreateDialog() {
|
||||
if (!hasSelectableEnvironment)
|
||||
return
|
||||
|
||||
setCreateDialogSessionKey(sessionKey => sessionKey + 1)
|
||||
setCreateDialogOpen(true)
|
||||
}
|
||||
|
||||
if (developerApiSettingsQuery.isLoading)
|
||||
return <DeveloperApiSkeleton />
|
||||
@ -181,31 +139,33 @@ export function DeveloperApiSection() {
|
||||
/>
|
||||
)}
|
||||
{hasSelectableEnvironment
|
||||
? (
|
||||
<ApiKeyGenerateMenu
|
||||
environments={environments}
|
||||
triggerVariant="primary"
|
||||
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
|
||||
>
|
||||
{({ trigger }) => apiKeys.length === 0
|
||||
? (
|
||||
<DeploymentEmptyState
|
||||
variant="section"
|
||||
icon="i-ri-key-2-line"
|
||||
title={t('access.api.noKeysTitle')}
|
||||
description={t('access.api.noKeys')}
|
||||
action={trigger}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<ApiKeyListSection
|
||||
apiKeys={apiKeys}
|
||||
environments={environments}
|
||||
action={trigger}
|
||||
/>
|
||||
)}
|
||||
</ApiKeyGenerateMenu>
|
||||
)
|
||||
? apiKeys.length === 0
|
||||
? (
|
||||
<DeploymentEmptyState
|
||||
variant="section"
|
||||
icon="i-ri-key-2-line"
|
||||
title={t('access.api.noKeysTitle')}
|
||||
description={t('access.api.noKeys')}
|
||||
action={(
|
||||
<CreateApiKeyButton
|
||||
triggerVariant="primary"
|
||||
onClick={handleOpenCreateDialog}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<ApiKeyListSection
|
||||
apiKeys={apiKeys}
|
||||
environments={environments}
|
||||
action={(
|
||||
<CreateApiKeyButton
|
||||
triggerVariant="primary"
|
||||
onClick={handleOpenCreateDialog}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
: apiKeys.length === 0
|
||||
? (
|
||||
<DeploymentEmptyState
|
||||
@ -221,13 +181,19 @@ export function DeveloperApiSection() {
|
||||
environments={environments}
|
||||
/>
|
||||
)}
|
||||
{visibleCreatedApiToken && (
|
||||
<CreatedApiTokenDialog
|
||||
token={visibleCreatedApiToken}
|
||||
apiUrl={apiUrl}
|
||||
onDismiss={() => setCreatedApiToken(undefined)}
|
||||
/>
|
||||
)}
|
||||
<CreateApiKeyDialog
|
||||
appInstanceId={appInstanceId}
|
||||
environments={selectableEnvironments}
|
||||
open={createDialogOpen}
|
||||
sessionKey={createDialogSessionKey}
|
||||
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
/>
|
||||
<CreatedApiTokenDialog
|
||||
token={visibleCreatedApiToken}
|
||||
apiUrl={apiUrl}
|
||||
onDismiss={() => setCreatedApiToken(undefined)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -11,8 +11,8 @@ import {
|
||||
DetailTableHead,
|
||||
DetailTableHeader,
|
||||
DetailTableRow,
|
||||
} from '../../components/detail-table'
|
||||
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../../components/detail-table-styles'
|
||||
} from '../components/detail-table'
|
||||
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../components/detail-table-styles'
|
||||
|
||||
const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key']
|
||||
|
||||
19
web/features/deployments/detail/api-tokens-tab/state.ts
Normal file
19
web/features/deployments/detail/api-tokens-tab/state.ts
Normal file
@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { skipToken } from '@tanstack/react-query'
|
||||
import { atomWithQuery } from 'jotai-tanstack-query'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
|
||||
|
||||
export const developerApiSettingsQueryAtom = atomWithQuery((get) => {
|
||||
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
|
||||
|
||||
return consoleQuery.enterprise.accessService.getDeveloperApiSettings.queryOptions({
|
||||
input: appInstanceId
|
||||
? {
|
||||
params: { appInstanceId },
|
||||
}
|
||||
: skipToken,
|
||||
enabled: Boolean(appInstanceId),
|
||||
})
|
||||
})
|
||||
@ -1,135 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../components/detail-table-styles'
|
||||
|
||||
export function DeploymentActionsDropdown({
|
||||
currentReleaseId,
|
||||
deployActionLabel,
|
||||
failedReleaseId,
|
||||
isDeployFailed,
|
||||
isDeploymentInProgress,
|
||||
isUndeployed,
|
||||
undeployActionDisabled,
|
||||
onDeploy,
|
||||
onRequestUndeploy,
|
||||
onViewError,
|
||||
}: {
|
||||
currentReleaseId?: string
|
||||
deployActionLabel: string
|
||||
failedReleaseId?: string
|
||||
isDeployFailed: boolean
|
||||
isDeploymentInProgress: boolean
|
||||
isUndeployed: boolean
|
||||
undeployActionDisabled: boolean
|
||||
onDeploy: (releaseId?: string) => void
|
||||
onRequestUndeploy: () => void
|
||||
onViewError: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (isDeploymentInProgress)
|
||||
return null
|
||||
|
||||
function handleDeployAction(releaseId?: string) {
|
||||
onDeploy(releaseId)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function handleViewError() {
|
||||
onViewError()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function handleRequestUndeploy() {
|
||||
if (undeployActionDisabled)
|
||||
return
|
||||
|
||||
onRequestUndeploy()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('deployTab.moreActions')}
|
||||
className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
{open && (
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44">
|
||||
{isDeployFailed
|
||||
? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={handleViewError}
|
||||
>
|
||||
<span aria-hidden className="i-ri-error-warning-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">{t('deployTab.viewError')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={() => handleDeployAction(failedReleaseId)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{failedReleaseId ? t('deployTab.retry') : t('deployTab.deployOtherVersion')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{!isUndeployed && currentReleaseId && (
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={() => handleDeployAction(currentReleaseId)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">{t('deployTab.redeploy')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={() => handleDeployAction()}
|
||||
>
|
||||
<span aria-hidden className="i-ri-rocket-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">{deployActionLabel}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{!isUndeployed && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={undeployActionDisabled}
|
||||
aria-disabled={undeployActionDisabled}
|
||||
className={cn(
|
||||
'gap-2 px-3',
|
||||
undeployActionDisabled && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
onClick={handleRequestUndeploy}
|
||||
>
|
||||
<span aria-hidden className="i-ri-logout-box-line size-4 shrink-0" />
|
||||
<span className="system-sm-regular">{t('deployTab.undeploy')}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@ -10,10 +10,10 @@ import Link from '@/next/link'
|
||||
import { useSelectedLayoutSegment } from '@/next/navigation'
|
||||
import { CreateReleaseControl } from '../create-release'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
|
||||
import { DeveloperApiHeaderSwitch } from './access-tab/developer-api/section'
|
||||
import { NewDeploymentHeaderAction } from './deploy-tab/new-deployment-button'
|
||||
import { DeveloperApiHeaderSwitch } from './api-tokens-tab/developer-api-header-switch'
|
||||
import { NewDeploymentHeaderAction } from './instances-tab/new-deployment-button'
|
||||
import { releasesTabLocalAtoms } from './releases-tab/state'
|
||||
import { INSTANCE_DETAIL_TAB_KEYS, isInstanceDetailTabKey } from './tabs'
|
||||
import { versionsTabLocalAtoms } from './versions-tab/state'
|
||||
|
||||
function MobileDetailTabs({ appInstanceId, activeTab }: {
|
||||
appInstanceId: string
|
||||
@ -63,7 +63,7 @@ export function InstanceDetail({ children }: {
|
||||
<ScopeProvider
|
||||
key={appInstanceId}
|
||||
atoms={[
|
||||
...versionsTabLocalAtoms,
|
||||
...releasesTabLocalAtoms,
|
||||
]}
|
||||
name="DeploymentDetail"
|
||||
>
|
||||
|
||||
@ -75,7 +75,6 @@ function CurrentReleaseMobileSummary({ release }: {
|
||||
function DeploymentEnvironmentMobileRow({ row }: {
|
||||
row: EnvironmentDeployment
|
||||
}) {
|
||||
const envId = row.environment.id
|
||||
const release = row.currentRelease
|
||||
|
||||
return (
|
||||
@ -87,7 +86,7 @@ function DeploymentEnvironmentMobileRow({ row }: {
|
||||
</div>
|
||||
{!isUndeployedDeploymentRow(row) && <CurrentReleaseMobileSummary release={release} />}
|
||||
<div className="flex min-w-0 items-center justify-start gap-2">
|
||||
<DeploymentRowActions envId={envId} row={row} />
|
||||
<DeploymentRowActions row={row} />
|
||||
</div>
|
||||
</div>
|
||||
</DetailTableCard>
|
||||
@ -114,7 +113,7 @@ function DeploymentEnvironmentDesktopRows({ rows }: {
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions}>
|
||||
<div className="flex min-h-8 justify-end">
|
||||
<DeploymentRowActions envId={envId} row={row} />
|
||||
<DeploymentRowActions row={row} />
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
</DetailTableRow>
|
||||
@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
|
||||
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
|
||||
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../components/detail-table-styles'
|
||||
|
||||
export function DeploymentActionsDropdown({
|
||||
row,
|
||||
undeployActionDisabled,
|
||||
onDeploy,
|
||||
onRequestUndeploy,
|
||||
onViewError,
|
||||
}: {
|
||||
row: EnvironmentDeployment
|
||||
undeployActionDisabled: boolean
|
||||
onDeploy: (releaseId?: string) => void
|
||||
onRequestUndeploy: () => void
|
||||
onViewError: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [open, setOpen] = useState(false)
|
||||
const isUndeployed = isUndeployedDeploymentRow(row)
|
||||
const status = row.status
|
||||
const isDeploymentInProgress = isRuntimeDeploymentInProgress(status)
|
||||
const isDeployFailed = status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_FAILED
|
||||
const currentReleaseId = row.currentRelease?.id
|
||||
const failedReleaseId = row.desiredRelease?.id ?? row.currentRelease?.id
|
||||
const deployActionLabel = isUndeployed
|
||||
? t('deployDrawer.deploy')
|
||||
: t('deployTab.deployOtherVersion')
|
||||
|
||||
if (isDeploymentInProgress)
|
||||
return null
|
||||
|
||||
function handleDeployAction(releaseId?: string) {
|
||||
onDeploy(releaseId)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function handleViewError() {
|
||||
onViewError()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function handleRequestUndeploy() {
|
||||
if (undeployActionDisabled)
|
||||
return
|
||||
|
||||
onRequestUndeploy()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('deployTab.moreActions')}
|
||||
className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44">
|
||||
{isDeployFailed
|
||||
? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={handleViewError}
|
||||
>
|
||||
<span aria-hidden className="i-ri-error-warning-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">{t('deployTab.viewError')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={() => handleDeployAction(failedReleaseId)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{failedReleaseId ? t('deployTab.retry') : t('deployTab.deployOtherVersion')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{!isUndeployed && currentReleaseId && (
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={() => handleDeployAction(currentReleaseId)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">{t('deployTab.redeploy')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={() => handleDeployAction()}
|
||||
>
|
||||
<span aria-hidden className="i-ri-rocket-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">{deployActionLabel}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{!isUndeployed && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={undeployActionDisabled}
|
||||
aria-disabled={undeployActionDisabled}
|
||||
className={cn(
|
||||
'gap-2 px-3',
|
||||
undeployActionDisabled && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
onClick={handleRequestUndeploy}
|
||||
>
|
||||
<span aria-hidden className="i-ri-logout-box-line size-4 shrink-0" />
|
||||
<span className="system-sm-regular">{t('deployTab.undeploy')}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@ -5,7 +5,6 @@ import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
|
||||
@ -15,27 +14,21 @@ import { DeploymentErrorDialog } from './deployment-error-dialog'
|
||||
import { DeploymentActionsDropdown } from './deployment-row-actions-menu'
|
||||
import { UndeployDeploymentDialog } from './undeploy-deployment-dialog'
|
||||
|
||||
export function DeploymentRowActions({ envId, row }: {
|
||||
envId: string
|
||||
export function DeploymentRowActions({ row }: {
|
||||
row: EnvironmentDeployment
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const routeAppInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
|
||||
const undeployDeployment = useMutation(consoleQuery.enterprise.deploymentService.undeploy.mutationOptions())
|
||||
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
|
||||
const [showErrorDetail, setShowErrorDetail] = useState(false)
|
||||
const envId = row.environment.id
|
||||
const isUndeployed = isUndeployedDeploymentRow(row)
|
||||
const status = row.status
|
||||
const isUndeployRequesting = undeployDeployment.isPending
|
||||
const undeployActionDisabled = isUndeployRequesting
|
||||
const isDeploymentInProgress = isRuntimeDeploymentInProgress(status)
|
||||
const isDeployFailed = status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_FAILED
|
||||
const currentReleaseId = row.currentRelease?.id
|
||||
const failedReleaseId = row.desiredRelease?.id ?? row.currentRelease?.id
|
||||
const deployActionLabel = isUndeployed
|
||||
? t('deployDrawer.deploy')
|
||||
: t('deployTab.deployOtherVersion')
|
||||
|
||||
if (!routeAppInstanceId)
|
||||
return null
|
||||
@ -75,36 +68,27 @@ export function DeploymentRowActions({ envId, row }: {
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
>
|
||||
<DeploymentActionsDropdown
|
||||
currentReleaseId={currentReleaseId}
|
||||
deployActionLabel={deployActionLabel}
|
||||
failedReleaseId={failedReleaseId}
|
||||
isDeployFailed={isDeployFailed}
|
||||
isDeploymentInProgress={isDeploymentInProgress}
|
||||
isUndeployed={isUndeployed}
|
||||
row={row}
|
||||
undeployActionDisabled={undeployActionDisabled}
|
||||
onDeploy={handleDeployAction}
|
||||
onRequestUndeploy={() => setShowUndeployConfirm(true)}
|
||||
onViewError={() => setShowErrorDetail(true)}
|
||||
/>
|
||||
|
||||
{isDeployFailed && (
|
||||
<DeploymentErrorDialog
|
||||
open={showErrorDetail}
|
||||
row={row}
|
||||
onOpenChange={setShowErrorDetail}
|
||||
/>
|
||||
)}
|
||||
<DeploymentErrorDialog
|
||||
open={showErrorDetail && isDeployFailed}
|
||||
row={row}
|
||||
onOpenChange={setShowErrorDetail}
|
||||
/>
|
||||
|
||||
{!isUndeployed && !isDeploymentInProgress && (
|
||||
<UndeployDeploymentDialog
|
||||
open={showUndeployConfirm}
|
||||
row={row}
|
||||
isRequesting={isUndeployRequesting}
|
||||
disabled={undeployActionDisabled}
|
||||
onConfirm={handleUndeploy}
|
||||
onOpenChange={setShowUndeployConfirm}
|
||||
/>
|
||||
)}
|
||||
<UndeployDeploymentDialog
|
||||
open={showUndeployConfirm && !isUndeployed && !isDeploymentInProgress}
|
||||
row={row}
|
||||
isRequesting={isUndeployRequesting}
|
||||
disabled={undeployActionDisabled}
|
||||
onConfirm={handleUndeploy}
|
||||
onOpenChange={setShowUndeployConfirm}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -87,7 +87,7 @@ function DeploymentEnvironmentListSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
export function DeployTab() {
|
||||
export function InstancesTab() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
|
||||
const environmentDeployments = environmentDeploymentsQuery.data
|
||||
@ -11,16 +11,6 @@ import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
|
||||
import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge'
|
||||
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME } from './card-styles'
|
||||
|
||||
type AccessStatusSectionProps = {
|
||||
accessChannels?: AccessChannels
|
||||
}
|
||||
|
||||
type ApiTokenSummarySectionProps = {
|
||||
accessChannels?: AccessChannels
|
||||
apiKeySummary?: ApiKeySummary
|
||||
deployedEnvironmentCount: number
|
||||
}
|
||||
|
||||
type AccessStatusItem = {
|
||||
key: 'webapp' | 'cli'
|
||||
href: string
|
||||
@ -32,7 +22,9 @@ type AccessStatusItem = {
|
||||
|
||||
const ACCESS_STATUS_SKELETON_KEYS = ['webapp', 'cli']
|
||||
|
||||
export function AccessStatusSection({ accessChannels }: AccessStatusSectionProps) {
|
||||
export function AccessStatusSection({ accessChannels }: {
|
||||
accessChannels?: AccessChannels
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
|
||||
@ -108,7 +100,11 @@ export function ApiTokenSummarySection({
|
||||
accessChannels,
|
||||
apiKeySummary,
|
||||
deployedEnvironmentCount,
|
||||
}: ApiTokenSummarySectionProps) {
|
||||
}: {
|
||||
accessChannels?: AccessChannels
|
||||
apiKeySummary?: ApiKeySummary
|
||||
deployedEnvironmentCount: number
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const apiEnabled = Boolean(accessChannels?.developerApiEnabled)
|
||||
|
||||
@ -16,12 +16,10 @@ import { EnvironmentTile } from './environment-tile'
|
||||
|
||||
const OVERVIEW_RUNTIME_INSTANCE_LIMIT = 4
|
||||
|
||||
type EnvironmentStripProps = {
|
||||
export function EnvironmentStrip({ rows, releaseRows }: {
|
||||
rows: EnvironmentDeployment[]
|
||||
releaseRows: Release[]
|
||||
}
|
||||
|
||||
export function EnvironmentStrip({ rows, releaseRows }: EnvironmentStripProps) {
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const runtimeRows = rows.filter(hasRuntimeInstanceDeployment)
|
||||
|
||||
@ -27,12 +27,10 @@ import {
|
||||
} from './environment-tile-utils'
|
||||
import { computeDrift, latestReleaseId } from './overview-drift'
|
||||
|
||||
type EnvironmentTileProps = {
|
||||
export function EnvironmentTile({ row, releaseRows }: {
|
||||
row: EnvironmentDeployment
|
||||
releaseRows: Release[]
|
||||
}
|
||||
|
||||
export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) {
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
|
||||
@ -102,8 +100,8 @@ export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) {
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<RuntimeStatusSignal status={status} t={t} />
|
||||
{showStatusSignal && <StatusSignal config={config} drift={drift} t={t} />}
|
||||
<RuntimeStatusSignal status={status} />
|
||||
{showStatusSignal && <StatusSignal config={config} drift={drift} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -138,10 +136,10 @@ export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function RuntimeStatusSignal({ status, t }: {
|
||||
function RuntimeStatusSignal({ status }: {
|
||||
status: RuntimeInstanceStatusValue
|
||||
t: ReturnType<typeof useTranslation<'deployments'>>['t']
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const label = t(deploymentStatusLabelKey(status))
|
||||
|
||||
return (
|
||||
@ -151,12 +149,12 @@ function RuntimeStatusSignal({ status, t }: {
|
||||
)
|
||||
}
|
||||
|
||||
function StatusSignal({ className, config, drift, t }: {
|
||||
function StatusSignal({ className, config, drift }: {
|
||||
className?: string
|
||||
config: TileConfig
|
||||
drift: ReturnType<typeof computeDrift>
|
||||
t: ReturnType<typeof useTranslation<'deployments'>>['t']
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const title = renderDriftTitle(config.kind, drift, t)
|
||||
|
||||
return (
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from '@/next/link'
|
||||
@ -11,7 +12,7 @@ import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySectio
|
||||
import { EnvironmentStrip, EnvironmentStripSkeleton } from './environment-strip'
|
||||
import { ReleaseHero, ReleaseHeroSkeleton } from './release-hero'
|
||||
|
||||
function OverviewLayout({ children }: { children: React.ReactNode }) {
|
||||
function OverviewLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex w-full min-w-0 flex-col gap-6 px-6 py-6">
|
||||
{children}
|
||||
@ -20,7 +21,7 @@ function OverviewLayout({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function LatestReleaseSection({ children }: {
|
||||
children: React.ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
|
||||
@ -18,18 +18,10 @@ import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
|
||||
import { formatDate, releaseCommit } from '../../shared/domain/release'
|
||||
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME } from './card-styles'
|
||||
|
||||
type ReleaseHeroProps = {
|
||||
export function ReleaseHero({ latestRelease, releaseCount }: {
|
||||
latestRelease?: Release
|
||||
releaseCount: number
|
||||
}
|
||||
|
||||
type ReleaseMetaItemProps = {
|
||||
label?: string
|
||||
showSeparator?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) {
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
@ -99,7 +91,11 @@ export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseMetaItem({ label, showSeparator = true, children }: ReleaseMetaItemProps) {
|
||||
function ReleaseMetaItem({ label, showSeparator = true, children }: {
|
||||
label?: string
|
||||
showSeparator?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
{showSeparator && (
|
||||
|
||||
@ -103,7 +103,7 @@ describe('DeployReleaseMenu', () => {
|
||||
|
||||
render(
|
||||
<DeployReleaseMenu
|
||||
releaseId={release.id}
|
||||
release={release}
|
||||
releaseRows={[release]}
|
||||
/>,
|
||||
)
|
||||
@ -52,6 +52,13 @@ function createReleaseRow(overrides: Partial<ReleaseWithSummaryDeployments> = {}
|
||||
} as ReleaseWithSummaryDeployments
|
||||
}
|
||||
|
||||
function requireElement<T extends Element>(element: T | null, label: string): T {
|
||||
expect(element).not.toBeNull()
|
||||
if (!element)
|
||||
throw new Error(`${label} should exist`)
|
||||
return element
|
||||
}
|
||||
|
||||
describe('ReleaseHistoryRows', () => {
|
||||
it('should render the desktop release list with the knowledge table style', () => {
|
||||
const { container } = render(
|
||||
@ -60,11 +67,11 @@ describe('ReleaseHistoryRows', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const table = container.querySelector('table')
|
||||
const tableScope = within(table!)
|
||||
const header = table!.querySelector('thead')
|
||||
const headerCell = table!.querySelector('th')
|
||||
const bodyRow = table!.querySelector('tbody tr')
|
||||
const table = requireElement(container.querySelector('table'), 'release table')
|
||||
const tableScope = within(table)
|
||||
const header = requireElement(table.querySelector('thead'), 'release table header')
|
||||
const headerCell = requireElement(table.querySelector('th'), 'release table header cell')
|
||||
const bodyRow = requireElement(table.querySelector('tbody tr'), 'release table row')
|
||||
|
||||
expect(table).toHaveClass('w-full', 'border-collapse', 'border-0', 'text-sm')
|
||||
expect(header).toHaveClass('border-b', 'border-divider-subtle')
|
||||
@ -88,8 +95,8 @@ describe('ReleaseHistoryRows', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const table = container.querySelector('table')
|
||||
const deploymentLabel = table!.querySelector('.text-util-colors-green-green-600')
|
||||
const table = requireElement(container.querySelector('table'), 'release table')
|
||||
const deploymentLabel = requireElement(table.querySelector('.text-util-colors-green-green-600'), 'deployment label')
|
||||
|
||||
expect(deploymentLabel).toHaveTextContent('test-cpu')
|
||||
expect(deploymentLabel).toHaveClass('text-util-colors-green-green-600', 'system-xs-medium')
|
||||
@ -109,8 +116,8 @@ describe('ReleaseHistoryRows', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const table = container.querySelector('table')
|
||||
const sourceLink = within(table!).getByRole('link', { name: /Source Workflow/ })
|
||||
const table = requireElement(container.querySelector('table'), 'release table')
|
||||
const sourceLink = within(table).getByRole('link', { name: /Source Workflow/ })
|
||||
|
||||
expect(sourceLink).toHaveAttribute('href', '/app/source-app-1/workflow')
|
||||
expect(sourceLink).toHaveAttribute('target', '_blank')
|
||||
@ -39,17 +39,19 @@ type ExportReleaseDslInput = {
|
||||
appInstanceName?: string
|
||||
}
|
||||
|
||||
export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
|
||||
releaseId: string
|
||||
export function DeployReleaseMenu({ release, releaseRows, onDeleted }: {
|
||||
release: Release
|
||||
releaseRows: Release[]
|
||||
onDeleted?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const releaseId = release.id
|
||||
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
|
||||
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
|
||||
const openReleaseMenuId = useAtomValue(deployReleaseMenuOpenReleaseIdAtom)
|
||||
const setDeployReleaseMenuOpen = useSetAtom(setDeployReleaseMenuOpenAtom)
|
||||
const [showEditDialog, setShowEditDialog] = useState(false)
|
||||
const [editDialogSessionKey, setEditDialogSessionKey] = useState(0)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const open = openReleaseMenuId === releaseId
|
||||
const environmentDeploymentsQuery = useAtomValue(deployReleaseMenuEnvironmentDeploymentsQueryAtom)
|
||||
@ -63,13 +65,8 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
|
||||
const environments = (environmentDeploymentsQuery.data?.environmentDeployments ?? [])
|
||||
.map(row => row.environment)
|
||||
const deploymentRows = environmentDeploymentsQuery.data?.environmentDeployments.filter(row => !isUndeployedDeploymentRow(row)) ?? []
|
||||
const targetRelease = releaseRows.find(release => release.id === releaseId)
|
||||
const appInstanceName = appInstanceQuery.data?.appInstance.displayName
|
||||
|
||||
if (!targetRelease)
|
||||
return null
|
||||
|
||||
const release = targetRelease
|
||||
const targetReleaseName = release.displayName
|
||||
const deleteUsageCount = releaseUsageCount(releaseId, deploymentRows)
|
||||
const isCheckingDeleteUsage = open && environmentDeploymentsQuery.isLoading
|
||||
@ -148,96 +145,96 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
{open && (
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-60">
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-60">
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={() => {
|
||||
handleOpenChange(false)
|
||||
setEditDialogSessionKey(sessionKey => sessionKey + 1)
|
||||
setShowEditDialog(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t('versions.editRelease')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={isExportingDsl}
|
||||
aria-disabled={isExportingDsl}
|
||||
className={cn(
|
||||
'gap-2 px-3',
|
||||
isExportingDsl && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
onClick={handleExportDsl}
|
||||
>
|
||||
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{isExportingDsl ? t('versions.exportingDsl') : t('versions.exportDsl')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{groupedRows.length > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
|
||||
{groupedRows.map((section, sectionIndex) => (
|
||||
<div key={section.group}>
|
||||
{sectionIndex > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
|
||||
<div className="px-3 pt-1.5 pb-1 system-2xs-medium-uppercase text-text-quaternary">
|
||||
{t(`versions.groupHeader.${section.group}`)}
|
||||
</div>
|
||||
{section.rows.map((row) => {
|
||||
const isDisabled = row.state === 'current' || row.state === 'deploying'
|
||||
return (
|
||||
<TitleTooltip key={row.environmentId} content={isDisabled ? row.disabledReason : undefined}>
|
||||
<DropdownMenuItem
|
||||
disabled={isDisabled}
|
||||
aria-disabled={isDisabled}
|
||||
className={cn(
|
||||
'gap-2 px-3',
|
||||
isDisabled && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isDisabled || !appInstanceId)
|
||||
return
|
||||
handleOpenChange(false)
|
||||
openDeployDrawer({ appInstanceId, environmentId: row.environmentId, releaseId })
|
||||
}}
|
||||
>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{row.label}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</TitleTooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div className="my-1 border-t border-divider-subtle" aria-hidden />
|
||||
<TitleTooltip content={deleteDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={() => {
|
||||
handleOpenChange(false)
|
||||
setShowEditDialog(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t('versions.editRelease')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={isExportingDsl}
|
||||
aria-disabled={isExportingDsl}
|
||||
variant="destructive"
|
||||
disabled={deleteActionDisabled}
|
||||
aria-disabled={deleteActionDisabled}
|
||||
className={cn(
|
||||
'gap-2 px-3',
|
||||
isExportingDsl && 'cursor-not-allowed opacity-60',
|
||||
deleteActionDisabled && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
onClick={handleExportDsl}
|
||||
onClick={() => {
|
||||
if (deleteActionDisabled)
|
||||
return
|
||||
handleOpenChange(false)
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{isExportingDsl ? t('versions.exportingDsl') : t('versions.exportDsl')}
|
||||
</span>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span className="system-sm-regular">{t('versions.deleteRelease')}</span>
|
||||
</DropdownMenuItem>
|
||||
{groupedRows.length > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
|
||||
{groupedRows.map((section, sectionIndex) => (
|
||||
<div key={section.group}>
|
||||
{sectionIndex > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
|
||||
<div className="px-3 pt-1.5 pb-1 system-2xs-medium-uppercase text-text-quaternary">
|
||||
{t(`versions.groupHeader.${section.group}`)}
|
||||
</div>
|
||||
{section.rows.map((row) => {
|
||||
const isDisabled = row.state === 'current' || row.state === 'deploying'
|
||||
return (
|
||||
<TitleTooltip key={row.environmentId} content={isDisabled ? row.disabledReason : undefined}>
|
||||
<DropdownMenuItem
|
||||
disabled={isDisabled}
|
||||
aria-disabled={isDisabled}
|
||||
className={cn(
|
||||
'gap-2 px-3',
|
||||
isDisabled && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isDisabled || !appInstanceId)
|
||||
return
|
||||
handleOpenChange(false)
|
||||
openDeployDrawer({ appInstanceId, environmentId: row.environmentId, releaseId })
|
||||
}}
|
||||
>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{row.label}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</TitleTooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div className="my-1 border-t border-divider-subtle" aria-hidden />
|
||||
<TitleTooltip content={deleteDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={deleteActionDisabled}
|
||||
aria-disabled={deleteActionDisabled}
|
||||
className={cn(
|
||||
'gap-2 px-3',
|
||||
deleteActionDisabled && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (deleteActionDisabled)
|
||||
return
|
||||
handleOpenChange(false)
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span className="system-sm-regular">{t('versions.deleteRelease')}</span>
|
||||
</DropdownMenuItem>
|
||||
</TitleTooltip>
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</TitleTooltip>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<EditReleaseDialog
|
||||
release={release}
|
||||
open={showEditDialog}
|
||||
resetKey={editDialogSessionKey}
|
||||
onOpenChange={setShowEditDialog}
|
||||
/>
|
||||
|
||||
@ -0,0 +1,246 @@
|
||||
'use client'
|
||||
|
||||
import type { Release } from '@dify/contracts/enterprise/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type EditReleaseFormValues = {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
function normalizedEditReleaseFormValues(values: EditReleaseFormValues) {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function canSubmitEditReleaseForm(initialValues: EditReleaseFormValues, values: EditReleaseFormValues) {
|
||||
const normalizedValues = normalizedEditReleaseFormValues(values)
|
||||
|
||||
return Boolean(
|
||||
normalizedValues.name
|
||||
&& (
|
||||
normalizedValues.name !== initialValues.name
|
||||
|| normalizedValues.description !== initialValues.description
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function EditReleaseForm({
|
||||
initialValues,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
initialValues: EditReleaseFormValues
|
||||
isSaving: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (values: EditReleaseFormValues) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const nameLabel = t('versions.releaseNameLabel')
|
||||
const nameRequiredMessage = t('versions.releaseNameRequired')
|
||||
|
||||
function handleSubmit(values: EditReleaseFormValues) {
|
||||
if (!canSubmitEditReleaseForm(initialValues, values))
|
||||
return
|
||||
|
||||
onSubmit(normalizedEditReleaseFormValues(values))
|
||||
}
|
||||
|
||||
return (
|
||||
<Form<EditReleaseFormValues> className="flex flex-col gap-4" onFormSubmit={handleSubmit}>
|
||||
<FieldRoot
|
||||
name="name"
|
||||
className="gap-2"
|
||||
validate={(value) => {
|
||||
if (typeof value === 'string' && value.length > 0 && !value.trim())
|
||||
return nameRequiredMessage
|
||||
|
||||
return null
|
||||
}}
|
||||
>
|
||||
<FieldLabel className="system-xs-medium-uppercase text-text-tertiary">
|
||||
{nameLabel}
|
||||
</FieldLabel>
|
||||
<FieldControl
|
||||
type="text"
|
||||
defaultValue={initialValues.name}
|
||||
maxLength={128}
|
||||
autoComplete="off"
|
||||
required
|
||||
className="h-8"
|
||||
/>
|
||||
<FieldError match="valueMissing" className="system-xs-regular">{nameRequiredMessage}</FieldError>
|
||||
<FieldError match="customError" className="system-xs-regular" />
|
||||
</FieldRoot>
|
||||
<FieldRoot name="description" className="gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FieldLabel className="system-xs-medium-uppercase text-text-tertiary">
|
||||
{t('versions.releaseDescriptionLabel')}
|
||||
</FieldLabel>
|
||||
<span className="system-xs-regular text-text-quaternary">{t('versions.optional')}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
defaultValue={initialValues.description}
|
||||
maxLength={512}
|
||||
autoComplete="off"
|
||||
className="min-h-24"
|
||||
/>
|
||||
</FieldRoot>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isSaving}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('versions.cancelEdit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isSaving}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t('versions.saveEdit')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
function EditReleaseDialogContent({
|
||||
release,
|
||||
resetKey,
|
||||
onClose,
|
||||
onCloseBlockedChange,
|
||||
}: {
|
||||
release: Release
|
||||
resetKey: number
|
||||
onClose: () => void
|
||||
onCloseBlockedChange: (blocked: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const updateRelease = useMutation(consoleQuery.enterprise.releaseService.updateRelease.mutationOptions())
|
||||
const formKey = `${resetKey}:${release.id}:${release.displayName}:${release.description}`
|
||||
const isSaving = updateRelease.isPending
|
||||
|
||||
function handleClose() {
|
||||
if (isSaving)
|
||||
return
|
||||
|
||||
onClose()
|
||||
}
|
||||
|
||||
function handleSubmit(values: EditReleaseFormValues) {
|
||||
onCloseBlockedChange(true)
|
||||
updateRelease.mutate(
|
||||
{
|
||||
params: {
|
||||
releaseId: release.id,
|
||||
},
|
||||
body: {
|
||||
releaseId: release.id,
|
||||
displayName: values.name,
|
||||
description: values.description,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
const updatedName = data.release.displayName
|
||||
toast.success(t('versions.editSuccess', { name: updatedName }))
|
||||
onClose()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('versions.editFailed'))
|
||||
},
|
||||
onSettled: () => {
|
||||
onCloseBlockedChange(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogCloseButton disabled={isSaving} />
|
||||
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
|
||||
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{t('versions.editRelease')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{t('versions.editReleaseDescription')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="px-6 py-5">
|
||||
<EditReleaseForm
|
||||
key={formKey}
|
||||
initialValues={{
|
||||
name: release.displayName,
|
||||
description: release.description,
|
||||
}}
|
||||
isSaving={isSaving}
|
||||
onClose={handleClose}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditReleaseDialog({
|
||||
release,
|
||||
open,
|
||||
resetKey,
|
||||
onOpenChange,
|
||||
}: {
|
||||
release: Release
|
||||
open: boolean
|
||||
resetKey: number
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const closeBlockedRef = useRef(false)
|
||||
const [closeBlocked, setCloseBlocked] = useState(false)
|
||||
|
||||
function handleCloseBlockedChange(blocked: boolean) {
|
||||
closeBlockedRef.current = blocked
|
||||
setCloseBlocked(blocked)
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen && closeBlockedRef.current)
|
||||
return
|
||||
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} disablePointerDismissal={closeBlocked} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="w-120 max-w-[calc(100vw-32px)] p-0">
|
||||
<EditReleaseDialogContent
|
||||
release={release}
|
||||
resetKey={resetKey}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onCloseBlockedChange={handleCloseBlockedChange}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import { ReleaseHistoryTable } from './release-history-table'
|
||||
|
||||
export function VersionsTab() {
|
||||
export function ReleasesTab() {
|
||||
return (
|
||||
<div className="flex w-full min-w-0 flex-col gap-4 px-6 py-6">
|
||||
<ReleaseHistoryTable />
|
||||
@ -24,7 +24,11 @@ function releaseDslFileName({ release, appInstanceName }: {
|
||||
}) {
|
||||
const projectName = sanitizeFileNamePart(appInstanceName)
|
||||
const releaseName = sanitizeFileNamePart(release.displayName) || 'release'
|
||||
const baseName = [projectName, releaseName].filter(Boolean).join('-')
|
||||
const fileNameParts: string[] = []
|
||||
if (projectName)
|
||||
fileNameParts.push(projectName)
|
||||
fileNameParts.push(releaseName)
|
||||
const baseName = fileNameParts.join('-')
|
||||
|
||||
return `${baseName}.yaml`
|
||||
}
|
||||
@ -116,63 +116,130 @@ function ReleaseSourceLink({ sourceAppId }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseHistoryMobileRows({ releaseRows, onReleaseDeleted }: {
|
||||
function ReleaseHistoryMobileRow({ release, releaseRows, onReleaseDeleted }: {
|
||||
release: ReleaseWithSummaryDeployments
|
||||
releaseRows: ReleaseWithSummaryDeployments[]
|
||||
onReleaseDeleted?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const hasDeployments = release.summaryDeployments.length > 0
|
||||
|
||||
return (
|
||||
<DetailTableCardList className="pc:hidden">
|
||||
{releaseRows.map((row) => {
|
||||
const release = row
|
||||
const releaseId = release.id
|
||||
const hasDeployments = row.summaryDeployments.length > 0
|
||||
|
||||
return (
|
||||
<DetailTableCard key={releaseId}>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<ReleaseTitleTooltip release={release} />
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 system-xs-regular text-text-secondary">
|
||||
<CreatedAtCell createdAt={release.createdAt} />
|
||||
<span aria-hidden>·</span>
|
||||
<span>{row.createdBy.displayName}</span>
|
||||
{(release.sourceAppId || release.source === ReleaseSource.RELEASE_SOURCE_UPLOAD) && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="inline-flex max-w-full min-w-0 items-baseline gap-1">
|
||||
<span className="shrink-0">{t('versions.col.sourceApp')}</span>
|
||||
<ReleaseSourceCell release={release} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-end gap-1">
|
||||
<DeployReleaseMenu
|
||||
releaseId={releaseId}
|
||||
releaseRows={releaseRows}
|
||||
onDeleted={onReleaseDeleted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasDeployments && (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
<ReleaseDeploymentsContent
|
||||
items={row.summaryDeployments}
|
||||
/>
|
||||
</div>
|
||||
<DetailTableCard>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<ReleaseTitleTooltip release={release} />
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 system-xs-regular text-text-secondary">
|
||||
<CreatedAtCell createdAt={release.createdAt} />
|
||||
<span aria-hidden>·</span>
|
||||
<span>{release.createdBy.displayName}</span>
|
||||
{(release.sourceAppId || release.source === ReleaseSource.RELEASE_SOURCE_UPLOAD) && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="inline-flex max-w-full min-w-0 items-baseline gap-1">
|
||||
<span className="shrink-0">{t('versions.col.sourceApp')}</span>
|
||||
<ReleaseSourceCell release={release} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DetailTableCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-end gap-1">
|
||||
<DeployReleaseMenu
|
||||
release={release}
|
||||
releaseRows={releaseRows}
|
||||
onDeleted={onReleaseDeleted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasDeployments && (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
<ReleaseDeploymentsContent
|
||||
items={release.summaryDeployments}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailTableCard>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseHistoryMobileRows({ releaseRows, onReleaseDeleted }: {
|
||||
releaseRows: ReleaseWithSummaryDeployments[]
|
||||
onReleaseDeleted?: () => void
|
||||
}) {
|
||||
return (
|
||||
<DetailTableCardList className="pc:hidden">
|
||||
{releaseRows.map(release => (
|
||||
<ReleaseHistoryMobileRow
|
||||
key={release.id}
|
||||
release={release}
|
||||
releaseRows={releaseRows}
|
||||
onReleaseDeleted={onReleaseDeleted}
|
||||
/>
|
||||
))}
|
||||
</DetailTableCardList>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseHistoryDesktopRow({ release, releaseRows, onReleaseDeleted }: {
|
||||
release: ReleaseWithSummaryDeployments
|
||||
releaseRows: ReleaseWithSummaryDeployments[]
|
||||
onReleaseDeleted?: () => void
|
||||
}) {
|
||||
return (
|
||||
<DetailTableRow>
|
||||
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>
|
||||
<ReleaseTitleTooltip release={release} />
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.sourceApp}>
|
||||
<ReleaseSourceCell release={release} />
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt} text-text-secondary`}>
|
||||
<CreatedAtCell createdAt={release.createdAt} />
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author} truncate text-text-secondary`}>
|
||||
{release.createdBy.displayName}
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<ReleaseDeploymentsContent
|
||||
items={release.summaryDeployments}
|
||||
/>
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action}>
|
||||
<div className="flex justify-end">
|
||||
<DeployReleaseMenu
|
||||
release={release}
|
||||
releaseRows={releaseRows}
|
||||
onDeleted={onReleaseDeleted}
|
||||
/>
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
</DetailTableRow>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseHistoryDesktopRows({ releaseRows, onReleaseDeleted }: {
|
||||
releaseRows: ReleaseWithSummaryDeployments[]
|
||||
onReleaseDeleted?: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{releaseRows.map(release => (
|
||||
<ReleaseHistoryDesktopRow
|
||||
key={release.id}
|
||||
release={release}
|
||||
releaseRows={releaseRows}
|
||||
onReleaseDeleted={onReleaseDeleted}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReleaseHistoryRows({ releaseRows, onReleaseDeleted }: {
|
||||
releaseRows: ReleaseWithSummaryDeployments[]
|
||||
onReleaseDeleted?: () => void
|
||||
@ -198,43 +265,10 @@ export function ReleaseHistoryRows({ releaseRows, onReleaseDeleted }: {
|
||||
</DetailTableRow>
|
||||
</DetailTableHeader>
|
||||
<DetailTableBody>
|
||||
{releaseRows.map((row) => {
|
||||
const release = row
|
||||
const releaseId = release.id
|
||||
|
||||
return (
|
||||
<DetailTableRow key={releaseId}>
|
||||
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>
|
||||
<ReleaseTitleTooltip release={release} />
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.sourceApp}>
|
||||
<ReleaseSourceCell release={release} />
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt} text-text-secondary`}>
|
||||
<CreatedAtCell createdAt={release.createdAt} />
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author} truncate text-text-secondary`}>
|
||||
{row.createdBy.displayName}
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<ReleaseDeploymentsContent
|
||||
items={row.summaryDeployments}
|
||||
/>
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action}>
|
||||
<div className="flex justify-end">
|
||||
<DeployReleaseMenu
|
||||
releaseId={releaseId}
|
||||
releaseRows={releaseRows}
|
||||
onDeleted={onReleaseDeleted}
|
||||
/>
|
||||
</div>
|
||||
</DetailTableCell>
|
||||
</DetailTableRow>
|
||||
)
|
||||
})}
|
||||
<ReleaseHistoryDesktopRows
|
||||
releaseRows={releaseRows}
|
||||
onReleaseDeleted={onReleaseDeleted}
|
||||
/>
|
||||
</DetailTableBody>
|
||||
</DetailTable>
|
||||
</div>
|
||||
@ -83,7 +83,7 @@ export const setDeployReleaseMenuOpenAtom = atom(null, (get, set, {
|
||||
set(deployReleaseMenuOpenReleaseIdAtom, undefined)
|
||||
})
|
||||
|
||||
export const versionsTabLocalAtoms = [
|
||||
export const releasesTabLocalAtoms = [
|
||||
releaseHistoryCurrentPageAtom,
|
||||
deployReleaseMenuOpenReleaseIdAtom,
|
||||
] as const
|
||||
@ -1,188 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Release } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type EditReleaseFormValues = {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
function EditReleaseForm({
|
||||
release,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
release: Release
|
||||
isSaving: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (values: EditReleaseFormValues) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const initialName = release.displayName
|
||||
const initialDescription = release.description
|
||||
const [name, setName] = useState(initialName)
|
||||
const [description, setDescription] = useState(initialDescription)
|
||||
const normalizedName = name.trim()
|
||||
const normalizedDescription = description.trim()
|
||||
const nameRequired = !normalizedName
|
||||
const hasChanges = normalizedName !== initialName || normalizedDescription !== initialDescription
|
||||
const canSave = Boolean(!nameRequired && hasChanges && !isSaving)
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!canSave)
|
||||
return
|
||||
|
||||
onSubmit({
|
||||
name: normalizedName,
|
||||
description: normalizedDescription,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" noValidate autoComplete="off" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-edit-name">
|
||||
{t('versions.releaseNameLabel')}
|
||||
</label>
|
||||
<Input
|
||||
id="release-edit-name"
|
||||
type="text"
|
||||
value={name}
|
||||
maxLength={128}
|
||||
autoComplete="off"
|
||||
aria-invalid={nameRequired || undefined}
|
||||
aria-describedby={nameRequired ? 'release-edit-name-error' : undefined}
|
||||
onChange={event => setName(event.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
{nameRequired && (
|
||||
<div id="release-edit-name-error" role="alert" className="system-xs-regular text-text-destructive">
|
||||
{t('versions.releaseNameRequired')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-edit-description">
|
||||
{t('versions.releaseDescriptionLabel')}
|
||||
</label>
|
||||
<span className="system-xs-regular text-text-quaternary">{t('versions.optional')}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="release-edit-description"
|
||||
value={description}
|
||||
maxLength={512}
|
||||
autoComplete="off"
|
||||
onValueChange={setDescription}
|
||||
className="min-h-24"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isSaving}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('versions.cancelEdit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!canSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t('versions.saveEdit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditReleaseDialog({
|
||||
release,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
release: Release
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const updateRelease = useMutation(consoleQuery.enterprise.releaseService.updateRelease.mutationOptions())
|
||||
const formKey = `${release.id}-${release.displayName}-${release.description}`
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen && updateRelease.isPending)
|
||||
return
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
function handleSubmit(values: EditReleaseFormValues) {
|
||||
updateRelease.mutate(
|
||||
{
|
||||
params: {
|
||||
releaseId: release.id,
|
||||
},
|
||||
body: {
|
||||
releaseId: release.id,
|
||||
displayName: values.name,
|
||||
description: values.description,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
const updatedName = data.release.displayName
|
||||
toast.success(t('versions.editSuccess', { name: updatedName }))
|
||||
handleOpenChange(false)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('versions.editFailed'))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="w-120 max-w-[calc(100vw-32px)] p-0">
|
||||
<DialogCloseButton disabled={updateRelease.isPending} />
|
||||
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
|
||||
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{t('versions.editRelease')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{t('versions.editReleaseDescription')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="px-6 py-5">
|
||||
<EditReleaseForm
|
||||
key={formKey}
|
||||
release={release}
|
||||
isSaving={updateRelease.isPending}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -18,33 +18,28 @@ describe('create-app-tracking', () => {
|
||||
})
|
||||
|
||||
describe('extractExternalCreateAppAttribution', () => {
|
||||
it('should keep the raw utm_source and report slug under its own field', () => {
|
||||
it('should map campaign links to external attribution', () => {
|
||||
const attribution = extractExternalCreateAppAttribution({
|
||||
searchParams: new URLSearchParams('utm_source=x&slug=how-to-build-rag-agent'),
|
||||
})
|
||||
|
||||
expect(attribution).toEqual({
|
||||
utmSource: 'x',
|
||||
slug: 'how-to-build-rag-agent',
|
||||
utmSource: 'twitter/x',
|
||||
utmCampaign: 'how-to-build-rag-agent',
|
||||
})
|
||||
})
|
||||
|
||||
it('should gate on known external sources but keep raw values', () => {
|
||||
it('should map newsletter and blog sources to blog', () => {
|
||||
expect(extractExternalCreateAppAttribution({
|
||||
searchParams: new URLSearchParams('utm_source=newsletter'),
|
||||
})).toEqual({ utmSource: 'newsletter' })
|
||||
})).toEqual({ utmSource: 'blog' })
|
||||
|
||||
expect(extractExternalCreateAppAttribution({
|
||||
utmInfo: { utm_source: 'dify_blog', slug: 'launch-week' },
|
||||
})).toEqual({
|
||||
utmSource: 'dify_blog',
|
||||
slug: 'launch-week',
|
||||
utmSource: 'blog',
|
||||
utmCampaign: 'launch-week',
|
||||
})
|
||||
|
||||
// Unknown sources are still rejected.
|
||||
expect(extractExternalCreateAppAttribution({
|
||||
searchParams: new URLSearchParams('utm_source=random&slug=x'),
|
||||
})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -135,7 +130,7 @@ describe('create-app-tracking', () => {
|
||||
},
|
||||
{
|
||||
utmSource: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
utmCampaign: 'agent-launch',
|
||||
},
|
||||
new Date(2026, 3, 13, 7, 6, 5),
|
||||
)).toEqual({
|
||||
@ -144,7 +139,7 @@ describe('create-app-tracking', () => {
|
||||
time: '04-13-07:06:05',
|
||||
template_id: 'template-1',
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
utm_campaign: 'agent-launch',
|
||||
})
|
||||
})
|
||||
|
||||
@ -169,8 +164,8 @@ describe('create-app-tracking', () => {
|
||||
app_mode: 'workflow',
|
||||
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
|
||||
template_id: 'template-1',
|
||||
utm_source: 'newsletter',
|
||||
slug: 'how-to-build-rag-agent',
|
||||
utm_source: 'blog',
|
||||
utm_campaign: 'how-to-build-rag-agent',
|
||||
})
|
||||
|
||||
trackCreateApp({ source: 'studio_template_list', appMode: AppModeEnum.WORKFLOW, templateId: 'template-1' })
|
||||
@ -200,7 +195,7 @@ describe('create-app-tracking', () => {
|
||||
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
|
||||
template_id: 'template-2',
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
utm_campaign: 'agent-launch',
|
||||
})
|
||||
})
|
||||
|
||||
@ -229,7 +224,7 @@ describe('create-app-tracking', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('should consume snake_case sessionStorage attribution and map legacy utm_campaign to slug', () => {
|
||||
it('should read, normalize, and consume snake_case sessionStorage attribution', () => {
|
||||
window.sessionStorage.setItem('create_app_external_attribution', JSON.stringify({
|
||||
utm_source: 'twitter',
|
||||
utm_campaign: 'launch-week',
|
||||
@ -241,8 +236,8 @@ describe('create-app-tracking', () => {
|
||||
source: 'external',
|
||||
app_mode: 'chatflow',
|
||||
time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/),
|
||||
utm_source: 'twitter',
|
||||
slug: 'launch-week',
|
||||
utm_source: 'twitter/x',
|
||||
utm_campaign: 'launch-week',
|
||||
})
|
||||
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull()
|
||||
})
|
||||
|
||||
@ -3,6 +3,7 @@ import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY = 'create_app_external_attribution'
|
||||
const CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS = ['utm_source', 'utm_campaign', 'slug'] as const
|
||||
|
||||
const EXTERNAL_UTM_SOURCE_MAP = {
|
||||
'blog': 'blog',
|
||||
@ -36,11 +37,12 @@ export type TrackCreateAppParams = {
|
||||
}
|
||||
|
||||
type ExternalCreateAppAttribution = {
|
||||
// Raw utm_source from the link (e.g. "dify_blog"), reported as-is to stay consistent
|
||||
// with the registration event. EXTERNAL_UTM_SOURCE_MAP is only used to gate which
|
||||
// sources count as external, not to rewrite the reported value.
|
||||
utmSource: string
|
||||
slug?: string
|
||||
utmSource: typeof EXTERNAL_UTM_SOURCE_MAP[keyof typeof EXTERNAL_UTM_SOURCE_MAP]
|
||||
utmCampaign?: string
|
||||
}
|
||||
|
||||
const serializeBootstrapValue = (value: unknown) => {
|
||||
return JSON.stringify(value).replace(/</g, '\\u003c')
|
||||
}
|
||||
|
||||
const normalizeString = (value?: string | null) => {
|
||||
@ -99,6 +101,65 @@ const mapOriginalCreateAppMode = (appMode: AppModeEnum): OriginalCreateAppMode =
|
||||
return 'chatflow'
|
||||
}
|
||||
|
||||
export const runCreateAppAttributionBootstrap = (
|
||||
sourceMap = EXTERNAL_UTM_SOURCE_MAP,
|
||||
storageKey = CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY,
|
||||
queryKeys = CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS,
|
||||
) => {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.sessionStorage)
|
||||
return
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const rawSource = searchParams.get('utm_source')
|
||||
|
||||
if (!rawSource)
|
||||
return
|
||||
|
||||
const normalizedSource = rawSource.trim().toLowerCase()
|
||||
const mappedSource = sourceMap[normalizedSource as keyof typeof sourceMap]
|
||||
|
||||
if (!mappedSource)
|
||||
return
|
||||
|
||||
const normalizedSlug = searchParams.get('slug')?.trim()
|
||||
const normalizedCampaign = searchParams.get('utm_campaign')?.trim()
|
||||
const utmCampaign = normalizedSlug || normalizedCampaign
|
||||
const attribution = utmCampaign
|
||||
? { utmSource: mappedSource, utmCampaign }
|
||||
: { utmSource: mappedSource }
|
||||
|
||||
window.sessionStorage.setItem(storageKey, JSON.stringify(attribution))
|
||||
|
||||
const nextSearchParams = new URLSearchParams(window.location.search)
|
||||
let hasChanges = false
|
||||
|
||||
queryKeys.forEach((key) => {
|
||||
if (!nextSearchParams.has(key))
|
||||
return
|
||||
|
||||
nextSearchParams.delete(key)
|
||||
hasChanges = true
|
||||
})
|
||||
|
||||
if (!hasChanges)
|
||||
return
|
||||
|
||||
const nextSearch = nextSearchParams.toString()
|
||||
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`
|
||||
|
||||
try {
|
||||
window.history.replaceState(window.history.state, '', nextUrl)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
export const buildCreateAppAttributionBootstrapScript = () => {
|
||||
return `(${runCreateAppAttributionBootstrap.toString()})(${serializeBootstrapValue(EXTERNAL_UTM_SOURCE_MAP)}, ${serializeBootstrapValue(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)}, ${serializeBootstrapValue(CREATE_APP_EXTERNAL_ATTRIBUTION_QUERY_KEYS)});`
|
||||
}
|
||||
|
||||
export const extractExternalCreateAppAttribution = ({
|
||||
searchParams,
|
||||
utmInfo,
|
||||
@ -107,37 +168,36 @@ export const extractExternalCreateAppAttribution = ({
|
||||
utmInfo?: Record<string, unknown> | null
|
||||
}) => {
|
||||
const rawSource = getSearchParamValue(searchParams, 'utm_source') ?? getObjectStringValue(utmInfo?.utm_source)
|
||||
const mappedSource = mapExternalUtmSource(rawSource)
|
||||
|
||||
// Gate on known external sources, but keep the raw value for reporting.
|
||||
if (!rawSource || !mapExternalUtmSource(rawSource))
|
||||
if (!mappedSource)
|
||||
return null
|
||||
|
||||
const slug = getSearchParamValue(searchParams, 'slug')
|
||||
const utmCampaign = getSearchParamValue(searchParams, 'slug')
|
||||
?? getSearchParamValue(searchParams, 'utm_campaign')
|
||||
?? getObjectStringValue(utmInfo?.slug)
|
||||
?? getObjectStringValue(utmInfo?.utm_campaign)
|
||||
|
||||
return {
|
||||
utmSource: rawSource,
|
||||
...(slug ? { slug } : {}),
|
||||
utmSource: mappedSource,
|
||||
...(utmCampaign ? { utmCampaign } : {}),
|
||||
} satisfies ExternalCreateAppAttribution
|
||||
}
|
||||
|
||||
const readRememberedExternalCreateAppAttribution = (): ExternalCreateAppAttribution | null => {
|
||||
const attribution = parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY))
|
||||
const rawSource = getObjectStringValue(attribution?.utmSource) ?? getObjectStringValue(attribution?.utm_source)
|
||||
const utmSource = mapExternalUtmSource(
|
||||
getObjectStringValue(attribution?.utmSource) ?? getObjectStringValue(attribution?.utm_source),
|
||||
)
|
||||
|
||||
// Gate on known external sources, but keep the raw value for reporting.
|
||||
if (!rawSource || !mapExternalUtmSource(rawSource))
|
||||
if (!utmSource)
|
||||
return null
|
||||
|
||||
const slug = getObjectStringValue(attribution?.slug)
|
||||
?? getObjectStringValue(attribution?.utmCampaign)
|
||||
?? getObjectStringValue(attribution?.utm_campaign)
|
||||
const utmCampaign = getObjectStringValue(attribution?.utmCampaign) ?? getObjectStringValue(attribution?.utm_campaign)
|
||||
|
||||
return {
|
||||
utmSource: rawSource,
|
||||
...(slug ? { slug } : {}),
|
||||
utmSource,
|
||||
...(utmCampaign ? { utmCampaign } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@ -192,7 +252,7 @@ export const buildCreateAppEventPayload = (
|
||||
...(externalAttribution
|
||||
? {
|
||||
utm_source: externalAttribution.utmSource,
|
||||
...(externalAttribution.slug ? { slug: externalAttribution.slug } : {}),
|
||||
...(externalAttribution.utmCampaign ? { utm_campaign: externalAttribution.utmCampaign } : {}),
|
||||
}
|
||||
: {}),
|
||||
} satisfies Record<string, string>
|
||||
|
||||
Reference in New Issue
Block a user