Compare commits

..

14 Commits

93 changed files with 1901 additions and 2872 deletions

View File

@ -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.

View File

@ -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(

View File

@ -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)

View File

@ -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,

View File

@ -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]:
"""

View File

@ -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.

View File

@ -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)

View File

@ -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:

View File

@ -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,

View File

@ -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)

View File

@ -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"

View File

@ -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"

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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]

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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 />
}

View File

@ -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 />
}

View File

@ -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 />
}

View File

@ -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')
})
})

View File

@ -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()
})
})

View 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>
)
}

View File

@ -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

View File

@ -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>
)}

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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' } },
})
})
})

View File

@ -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

View File

@ -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>
</>
)
}

View File

@ -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()
})
})

View File

@ -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}

View File

@ -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">

View File

@ -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 => (

View File

@ -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}
/>

View File

@ -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 }

View File

@ -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}

View File

@ -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),
})
})

View File

@ -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()
})

View File

@ -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' } },
})
})
})

View File

@ -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

View File

@ -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() {

View File

@ -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>
)
}

View File

@ -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>
</>
)
}

View File

@ -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>
)

View File

@ -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>
)
}

View File

@ -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'>

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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']

View 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),
})
})

View File

@ -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>
)
}

View File

@ -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"
>

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -87,7 +87,7 @@ function DeploymentEnvironmentListSkeleton() {
)
}
export function DeployTab() {
export function InstancesTab() {
const { t } = useTranslation('deployments')
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
const environmentDeployments = environmentDeploymentsQuery.data

View File

@ -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)

View File

@ -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)

View File

@ -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 (

View File

@ -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)

View File

@ -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 && (

View File

@ -103,7 +103,7 @@ describe('DeployReleaseMenu', () => {
render(
<DeployReleaseMenu
releaseId={release.id}
release={release}
releaseRows={[release]}
/>,
)

View File

@ -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')

View File

@ -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}
/>

View File

@ -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>
)
}

View File

@ -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 />

View File

@ -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`
}

View File

@ -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>

View File

@ -83,7 +83,7 @@ export const setDeployReleaseMenuOpenAtom = atom(null, (get, set, {
set(deployReleaseMenuOpenReleaseIdAtom, undefined)
})
export const versionsTabLocalAtoms = [
export const releasesTabLocalAtoms = [
releaseHistoryCurrentPageAtom,
deployReleaseMenuOpenReleaseIdAtom,
] as const

View File

@ -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>
)
}

View File

@ -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()
})

View File

@ -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>