mirror of
https://github.com/langgenius/dify.git
synced 2026-07-19 16:44:00 -04:00
feat: archive logs export (#38207)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -26,6 +26,7 @@ from .rbac import migrate_dataset_permissions_to_rbac, migrate_member_roles_to_r
|
||||
from .retention import (
|
||||
archive_workflow_runs,
|
||||
archive_workflow_runs_plan,
|
||||
backfill_workflow_run_archive_bundles,
|
||||
clean_expired_messages,
|
||||
clean_workflow_runs,
|
||||
cleanup_orphaned_draft_variables,
|
||||
@@ -54,6 +55,7 @@ __all__ = [
|
||||
"archive_workflow_runs",
|
||||
"archive_workflow_runs_plan",
|
||||
"backfill_plugin_auto_upgrade",
|
||||
"backfill_workflow_run_archive_bundles",
|
||||
"clean_expired_messages",
|
||||
"clean_workflow_runs",
|
||||
"cleanup_orphaned_draft_variables",
|
||||
|
||||
@@ -65,6 +65,15 @@ def _parse_tenant_prefixes(prefixes: str | None) -> list[str]:
|
||||
return sorted(set(parsed))
|
||||
|
||||
|
||||
def _parse_comma_separated_ids(raw_ids: str | None, *, param_name: str) -> list[str] | None:
|
||||
if raw_ids is None:
|
||||
return None
|
||||
parsed = sorted({raw_id.strip() for raw_id in raw_ids.split(",") if raw_id.strip()})
|
||||
if not parsed:
|
||||
raise click.BadParameter(f"{param_name} must not be empty")
|
||||
return parsed
|
||||
|
||||
|
||||
def _get_archive_candidate_tenant_ids_by_prefix(
|
||||
session: Session,
|
||||
prefix: str,
|
||||
@@ -725,6 +734,82 @@ def archive_workflow_runs(
|
||||
)
|
||||
|
||||
|
||||
@click.command(
|
||||
"backfill-workflow-run-archive-bundles",
|
||||
help="Backfill workflow-run archive bundle DB index from object-storage manifests.",
|
||||
)
|
||||
@click.option("--tenant-ids", default=None, help="Optional comma-separated tenant IDs.")
|
||||
@click.option(
|
||||
"--tenant-prefixes",
|
||||
default=None,
|
||||
help="Optional comma-separated tenant ID first hex digits, e.g. 0,1,a,f.",
|
||||
)
|
||||
@click.option("--year", default=None, type=click.IntRange(min=1, max=9999), help="Optional archive year filter.")
|
||||
@click.option("--month", default=None, type=click.IntRange(min=1, max=12), help="Optional archive month filter.")
|
||||
@click.option("--limit", default=None, type=click.IntRange(min=1), help="Maximum number of manifests to process.")
|
||||
@click.option("--dry-run", is_flag=True, help="Preview without writing workflow_run_archive_bundles.")
|
||||
def backfill_workflow_run_archive_bundles(
|
||||
tenant_ids: str | None,
|
||||
tenant_prefixes: str | None,
|
||||
year: int | None,
|
||||
month: int | None,
|
||||
limit: int | None,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Reconcile `workflow_run_archive_bundles` from V2 archive manifests.
|
||||
|
||||
This command is meant for bootstrapping the listing/download index after deploy or repairing index drift. The R2
|
||||
manifests remain the source of truth; this command only mirrors their query metadata into the database.
|
||||
"""
|
||||
from services.retention.workflow_run.archive_bundle_index import WorkflowRunArchiveBundleIndexBackfill
|
||||
|
||||
if tenant_ids and tenant_prefixes:
|
||||
raise click.UsageError("Choose either --tenant-ids or --tenant-prefixes, not both.")
|
||||
if month is not None and year is None:
|
||||
raise click.UsageError("--month must be used with --year.")
|
||||
|
||||
parsed_tenant_ids = _parse_comma_separated_ids(tenant_ids, param_name="tenant-ids")
|
||||
parsed_tenant_prefixes = _parse_tenant_prefixes(tenant_prefixes)
|
||||
if not parsed_tenant_ids and not parsed_tenant_prefixes:
|
||||
click.echo(
|
||||
click.style(
|
||||
"No tenant scope supplied; scanning the full workflow-runs/v2/ archive prefix.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
|
||||
started_at = datetime.datetime.now(datetime.UTC)
|
||||
click.echo(click.style(f"Starting archive bundle index backfill at {started_at.isoformat()}.", fg="white"))
|
||||
|
||||
backfill = WorkflowRunArchiveBundleIndexBackfill()
|
||||
summary = backfill.run(
|
||||
tenant_ids=parsed_tenant_ids,
|
||||
tenant_prefixes=parsed_tenant_prefixes or None,
|
||||
year=year,
|
||||
month=month,
|
||||
limit=limit,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
status = "completed with failures" if summary.bundles_failed else "completed successfully"
|
||||
fg = "red" if summary.bundles_failed else "green"
|
||||
action = "would_upsert" if dry_run else "upserted"
|
||||
action_count = summary.bundles_processed if dry_run else summary.bundles_upserted
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Backfill {status}. manifests_found={summary.manifests_found} "
|
||||
f"bundles_processed={summary.bundles_processed} {action}={action_count} "
|
||||
f"bundles_failed={summary.bundles_failed} runs={summary.workflow_run_count} rows={summary.row_count} "
|
||||
f"archive_bytes={summary.archive_bytes} duration={summary.elapsed_time:.2f}s",
|
||||
fg=fg,
|
||||
)
|
||||
)
|
||||
for error in summary.errors[:10]:
|
||||
click.echo(click.style(f" failed {error}", fg="red"))
|
||||
if len(summary.errors) > 10:
|
||||
click.echo(click.style(f" ... and {len(summary.errors) - 10} more failures", fg="red"))
|
||||
|
||||
|
||||
def _echo_bundle_archive_operation_summary(summary) -> None:
|
||||
status = "completed successfully" if summary.bundles_failed == 0 else "completed with failures"
|
||||
fg = "green" if summary.bundles_failed == 0 else "red"
|
||||
|
||||
@@ -43,6 +43,7 @@ from . import (
|
||||
setup,
|
||||
spec,
|
||||
version,
|
||||
workflow_run_archive,
|
||||
)
|
||||
from .agent import composer as agent_composer
|
||||
from .agent import roster as agent_roster
|
||||
@@ -238,6 +239,7 @@ __all__ = [
|
||||
"workflow_draft_variable",
|
||||
"workflow_node_output_inspector",
|
||||
"workflow_run",
|
||||
"workflow_run_archive",
|
||||
"workflow_statistic",
|
||||
"workflow_trigger",
|
||||
"workspace",
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import redirect
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import Conflict, NotFound
|
||||
|
||||
from controllers.common.fields import RedirectResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_enabled,
|
||||
cloud_edition_billing_paid_plan_required,
|
||||
is_admin_or_owner_required,
|
||||
only_edition_cloud,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.archive_storage import get_export_storage
|
||||
from libs.helper import dump_response
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from services.retention.workflow_run.archive_download_preparation import ARCHIVE_DOWNLOAD_MIME_TYPE
|
||||
from services.retention.workflow_run.archive_download_task_cache import (
|
||||
WorkflowRunArchiveDownloadStatus,
|
||||
)
|
||||
from services.retention.workflow_run.archive_log_service import (
|
||||
WorkflowRunArchiveDownloadNotReadyError,
|
||||
WorkflowRunArchiveDownloadTaskNotFoundError,
|
||||
WorkflowRunArchiveNotFoundError,
|
||||
create_workflow_run_archive_download_task,
|
||||
get_ready_workflow_run_archive_download_task,
|
||||
get_workflow_run_archive_download_task,
|
||||
list_workflow_run_archives,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowRunArchiveDownloadPayload(BaseModel):
|
||||
"""Request body for preparing one monthly workflow-run archive download."""
|
||||
|
||||
year: int = Field(ge=1)
|
||||
month: int = Field(ge=1, le=12)
|
||||
|
||||
|
||||
class WorkflowRunArchiveSummaryResponse(ResponseModel):
|
||||
archived_month_count: int
|
||||
workflow_run_count: int
|
||||
archive_bytes: int
|
||||
latest_archived_at: datetime.datetime | None = None
|
||||
|
||||
|
||||
class WorkflowRunArchiveDownloadTaskResponse(ResponseModel):
|
||||
download_id: str
|
||||
year: int
|
||||
month: int
|
||||
bundle_count: int
|
||||
archive_bytes: int
|
||||
status: WorkflowRunArchiveDownloadStatus
|
||||
file_name: str | None = None
|
||||
file_size_bytes: int | None = None
|
||||
error: str | None = None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
expires_at: datetime.datetime
|
||||
started_at: datetime.datetime | None = None
|
||||
finished_at: datetime.datetime | None = None
|
||||
|
||||
|
||||
class WorkflowRunArchiveMonthResponse(ResponseModel):
|
||||
year: int
|
||||
month: int
|
||||
bundle_count: int
|
||||
workflow_run_count: int
|
||||
row_count: int
|
||||
archive_bytes: int
|
||||
latest_archived_at: datetime.datetime
|
||||
download_task: WorkflowRunArchiveDownloadTaskResponse | None = None
|
||||
|
||||
|
||||
class WorkflowRunArchiveListResponse(ResponseModel):
|
||||
summary: WorkflowRunArchiveSummaryResponse
|
||||
months: list[WorkflowRunArchiveMonthResponse]
|
||||
|
||||
|
||||
register_schema_models(console_ns, WorkflowRunArchiveDownloadPayload)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
WorkflowRunArchiveSummaryResponse,
|
||||
WorkflowRunArchiveMonthResponse,
|
||||
WorkflowRunArchiveListResponse,
|
||||
WorkflowRunArchiveDownloadTaskResponse,
|
||||
RedirectResponse,
|
||||
)
|
||||
|
||||
|
||||
def _current_ids() -> tuple[str, str]:
|
||||
"""Return current `(tenant_id, account_id)` or raise when no workspace is selected."""
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
if not current_tenant_id:
|
||||
raise NotFound("Current workspace not found")
|
||||
return current_tenant_id, current_user.id
|
||||
|
||||
|
||||
def _presigned_url_expires_in(expires_at: datetime.datetime) -> int:
|
||||
"""Keep the storage URL no longer-lived than the Redis task and cap it for browser downloads."""
|
||||
expires_at_utc = expires_at if expires_at.tzinfo else expires_at.replace(tzinfo=datetime.UTC)
|
||||
remaining_seconds = int((expires_at_utc - datetime.datetime.now(datetime.UTC)).total_seconds())
|
||||
return max(1, min(3600, remaining_seconds))
|
||||
|
||||
|
||||
@console_ns.route("/workflow-run-archives")
|
||||
class WorkflowRunArchivesApi(Resource):
|
||||
@console_ns.doc("list_workflow_run_archives")
|
||||
@console_ns.doc(description="List monthly workflow-run archive metadata for the current workspace")
|
||||
@console_ns.response(200, "Success", console_ns.models[WorkflowRunArchiveListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@cloud_edition_billing_enabled
|
||||
@cloud_edition_billing_paid_plan_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
|
||||
)
|
||||
def get(self):
|
||||
tenant_id, _ = _current_ids()
|
||||
return dump_response(WorkflowRunArchiveListResponse, list_workflow_run_archives(db.session(), tenant_id))
|
||||
|
||||
|
||||
@console_ns.route("/workflow-run-archives/downloads")
|
||||
class WorkflowRunArchiveDownloadsApi(Resource):
|
||||
@console_ns.doc("create_workflow_run_archive_download")
|
||||
@console_ns.doc(description="Create or return a temporary workflow-run archive download task")
|
||||
@console_ns.expect(console_ns.models[WorkflowRunArchiveDownloadPayload.__name__])
|
||||
@console_ns.response(
|
||||
HTTPStatus.ACCEPTED,
|
||||
"Download task accepted",
|
||||
console_ns.models[WorkflowRunArchiveDownloadTaskResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@cloud_edition_billing_enabled
|
||||
@cloud_edition_billing_paid_plan_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
|
||||
)
|
||||
def post(self):
|
||||
tenant_id, account_id = _current_ids()
|
||||
payload = WorkflowRunArchiveDownloadPayload.model_validate(console_ns.payload or {})
|
||||
try:
|
||||
task = create_workflow_run_archive_download_task(
|
||||
db.session(),
|
||||
tenant_id=tenant_id,
|
||||
requested_by=account_id,
|
||||
year=payload.year,
|
||||
month=payload.month,
|
||||
)
|
||||
except WorkflowRunArchiveNotFoundError as exc:
|
||||
raise NotFound(str(exc)) from exc
|
||||
return dump_response(WorkflowRunArchiveDownloadTaskResponse, task), HTTPStatus.ACCEPTED
|
||||
|
||||
|
||||
@console_ns.route("/workflow-run-archives/downloads/<string:download_id>")
|
||||
class WorkflowRunArchiveDownloadApi(Resource):
|
||||
@console_ns.doc("get_workflow_run_archive_download")
|
||||
@console_ns.doc(description="Get a temporary workflow-run archive download task")
|
||||
@console_ns.response(200, "Success", console_ns.models[WorkflowRunArchiveDownloadTaskResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@cloud_edition_billing_enabled
|
||||
@cloud_edition_billing_paid_plan_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
|
||||
)
|
||||
def get(self, download_id: str):
|
||||
tenant_id, _ = _current_ids()
|
||||
try:
|
||||
task = get_workflow_run_archive_download_task(tenant_id=tenant_id, download_id=download_id)
|
||||
except WorkflowRunArchiveDownloadTaskNotFoundError as exc:
|
||||
raise NotFound(str(exc)) from exc
|
||||
return dump_response(WorkflowRunArchiveDownloadTaskResponse, task)
|
||||
|
||||
|
||||
@console_ns.route("/workflow-run-archives/downloads/<string:download_id>/file")
|
||||
class WorkflowRunArchiveDownloadFileApi(Resource):
|
||||
@console_ns.doc("download_workflow_run_archive_file")
|
||||
@console_ns.doc(description="Redirect to a prepared workflow-run archive ZIP file")
|
||||
@console_ns.response(
|
||||
302,
|
||||
"Redirect to pre-signed archive storage URL",
|
||||
console_ns.models[RedirectResponse.__name__],
|
||||
)
|
||||
@console_ns.response(409, "Download task is not ready")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@cloud_edition_billing_enabled
|
||||
@cloud_edition_billing_paid_plan_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
|
||||
)
|
||||
def get(self, download_id: str):
|
||||
tenant_id, _ = _current_ids()
|
||||
try:
|
||||
task = get_ready_workflow_run_archive_download_task(tenant_id=tenant_id, download_id=download_id)
|
||||
except WorkflowRunArchiveDownloadTaskNotFoundError as exc:
|
||||
raise NotFound(str(exc)) from exc
|
||||
except WorkflowRunArchiveDownloadNotReadyError as exc:
|
||||
raise Conflict(str(exc)) from exc
|
||||
|
||||
storage_key = task.storage_key
|
||||
if storage_key is None:
|
||||
raise Conflict(f"Workflow run archive download is not ready: {download_id}")
|
||||
|
||||
storage = get_export_storage()
|
||||
presigned_url = storage.generate_presigned_url(
|
||||
storage_key,
|
||||
expires_in=_presigned_url_expires_in(task.expires_at),
|
||||
filename=task.file_name,
|
||||
content_type=ARCHIVE_DOWNLOAD_MIME_TYPE,
|
||||
)
|
||||
return redirect(presigned_url, code=HTTPStatus.FOUND)
|
||||
@@ -28,6 +28,7 @@ from models import Account
|
||||
from models.account import AccountStatus
|
||||
from models.dataset import RateLimitLog
|
||||
from models.model import DifySetup
|
||||
from services.billing_service import BillingService
|
||||
from services.feature_service import FeatureService, LicenseStatus
|
||||
from services.operation_service import OperationService, UtmInfo
|
||||
|
||||
@@ -168,6 +169,21 @@ def cloud_edition_billing_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R
|
||||
return decorated
|
||||
|
||||
|
||||
def cloud_edition_billing_paid_plan_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
billing_info = BillingService.get_info(current_tenant_id, exclude_vector_space=True)
|
||||
if not billing_info["enabled"] or billing_info["subscription"]["plan"] not in (
|
||||
CloudPlan.PROFESSIONAL,
|
||||
CloudPlan.TEAM,
|
||||
):
|
||||
abort(403, "This feature requires a paid plan.")
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def cloud_edition_billing_resource_check[**P, R](resource: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
def interceptor(view: Callable[P, R]):
|
||||
@wraps(view)
|
||||
|
||||
@@ -157,6 +157,7 @@ def init_app(app: DifyApp) -> Celery:
|
||||
"tasks.regenerate_summary_index_task", # summary index regeneration
|
||||
"tasks.initialize_created_app_rbac_access_task", # app access initialization
|
||||
"tasks.app_generate.resume_agent_app_task", # ENG-635: Agent v2 chat ask_human resume
|
||||
"tasks.workflow_run_archive_download_tasks", # workflow-run archive download preparation
|
||||
]
|
||||
day = dify_config.CELERY_BEAT_SCHEDULER_TIME
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ def init_app(app: DifyApp):
|
||||
archive_workflow_runs,
|
||||
archive_workflow_runs_plan,
|
||||
backfill_plugin_auto_upgrade,
|
||||
backfill_workflow_run_archive_bundles,
|
||||
clean_expired_messages,
|
||||
clean_workflow_runs,
|
||||
cleanup_orphaned_draft_variables,
|
||||
@@ -77,6 +78,7 @@ def init_app(app: DifyApp):
|
||||
install_rag_pipeline_plugins,
|
||||
archive_workflow_runs_plan,
|
||||
archive_workflow_runs,
|
||||
backfill_workflow_run_archive_bundles,
|
||||
delete_archived_workflow_runs,
|
||||
restore_workflow_runs,
|
||||
clean_workflow_runs,
|
||||
|
||||
@@ -11,6 +11,7 @@ import hashlib
|
||||
import logging
|
||||
from collections.abc import Generator
|
||||
from typing import Any, cast
|
||||
from urllib.parse import quote
|
||||
|
||||
import boto3
|
||||
import orjson
|
||||
@@ -197,13 +198,22 @@ class ArchiveStorage:
|
||||
except ClientError as e:
|
||||
raise ArchiveStorageError(f"Failed to delete object '{key}': {e}")
|
||||
|
||||
def generate_presigned_url(self, key: str, expires_in: int = 3600) -> str:
|
||||
def generate_presigned_url(
|
||||
self,
|
||||
key: str,
|
||||
expires_in: int = 3600,
|
||||
*,
|
||||
filename: str | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a pre-signed URL for downloading an object.
|
||||
|
||||
Args:
|
||||
key: Object key (path) within the bucket
|
||||
expires_in: URL validity duration in seconds (default: 1 hour)
|
||||
filename: Optional browser download filename
|
||||
content_type: Optional response content type
|
||||
|
||||
Returns:
|
||||
Pre-signed URL string.
|
||||
@@ -211,10 +221,15 @@ class ArchiveStorage:
|
||||
Raises:
|
||||
ArchiveStorageError: If generation fails
|
||||
"""
|
||||
params = {"Bucket": self.bucket, "Key": key}
|
||||
if filename:
|
||||
params["ResponseContentDisposition"] = f"attachment; filename*=UTF-8''{quote(filename)}"
|
||||
if content_type:
|
||||
params["ResponseContentType"] = content_type
|
||||
try:
|
||||
return self.client.generate_presigned_url(
|
||||
ClientMethod="get_object",
|
||||
Params={"Bucket": self.bucket, "Key": key},
|
||||
Params=params,
|
||||
ExpiresIn=expires_in,
|
||||
)
|
||||
except ClientError as e:
|
||||
|
||||
@@ -22,6 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
CSRF_WHITE_LIST = [
|
||||
re.compile(r"/console/api/apps/[a-f0-9-]+/workflows/draft"),
|
||||
re.compile(r"/console/api/workflow-run-archives/downloads/[a-f0-9]+/file"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
"""add workflow run archive bundle index table
|
||||
|
||||
Revision ID: 7a1c2d9e4b60
|
||||
Revises: c3d4e5f6a7b8
|
||||
Create Date: 2026-06-25 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "7a1c2d9e4b60"
|
||||
down_revision = "c3d4e5f6a7b8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _uuid_column(name: str, **kwargs):
|
||||
if op.get_bind().dialect.name == "postgresql":
|
||||
kwargs.setdefault("server_default", sa.text("uuidv7()"))
|
||||
return sa.Column(name, models.types.StringUUID(), **kwargs)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"workflow_run_archive_bundles",
|
||||
_uuid_column("id", nullable=False),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("year", sa.Integer(), nullable=False),
|
||||
sa.Column("month", sa.Integer(), nullable=False),
|
||||
sa.Column("shard", sa.String(length=32), nullable=False),
|
||||
sa.Column("bundle_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("workflow_run_count", sa.Integer(), nullable=False),
|
||||
sa.Column("row_count", sa.BigInteger(), nullable=False),
|
||||
sa.Column("archive_bytes", sa.BigInteger(), nullable=False),
|
||||
sa.Column("archived_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="workflow_run_archive_bundle_pkey"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"year",
|
||||
"month",
|
||||
"shard",
|
||||
"bundle_id",
|
||||
name="workflow_run_archive_bundle_identity_uq",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"workflow_run_archive_bundle_tenant_month_idx",
|
||||
"workflow_run_archive_bundles",
|
||||
["tenant_id", "year", "month"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("workflow_run_archive_bundle_tenant_month_idx", table_name="workflow_run_archive_bundles")
|
||||
op.drop_table("workflow_run_archive_bundles")
|
||||
@@ -144,6 +144,7 @@ from .workflow import (
|
||||
WorkflowNodeExecutionTriggeredFrom,
|
||||
WorkflowPause,
|
||||
WorkflowRun,
|
||||
WorkflowRunArchiveBundle,
|
||||
WorkflowType,
|
||||
resolve_workflow_kind,
|
||||
)
|
||||
@@ -282,6 +283,7 @@ __all__ = [
|
||||
"WorkflowNodeExecutionTriggeredFrom",
|
||||
"WorkflowPause",
|
||||
"WorkflowRun",
|
||||
"WorkflowRunArchiveBundle",
|
||||
"WorkflowRunTriggeredFrom",
|
||||
"WorkflowSchedulePlan",
|
||||
"WorkflowToolProvider",
|
||||
|
||||
@@ -1446,6 +1446,40 @@ class WorkflowArchiveLog(TypeBase):
|
||||
}
|
||||
|
||||
|
||||
class WorkflowRunArchiveBundle(DefaultFieldsDCMixin, TypeBase):
|
||||
"""
|
||||
Query index for one immutable V2 workflow-run archive bundle.
|
||||
|
||||
R2 manifest objects remain the recoverable archive source of truth. This table stores the small subset needed to
|
||||
list tenant/month archives and locate bundles without listing object storage online. Missing rows can be rebuilt
|
||||
from existing manifests by a backfill/reconciliation command.
|
||||
"""
|
||||
|
||||
__tablename__ = "workflow_run_archive_bundles"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="workflow_run_archive_bundle_pkey"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"year",
|
||||
"month",
|
||||
"shard",
|
||||
"bundle_id",
|
||||
name="workflow_run_archive_bundle_identity_uq",
|
||||
),
|
||||
sa.Index("workflow_run_archive_bundle_tenant_month_idx", "tenant_id", "year", "month"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
year: Mapped[int] = mapped_column(sa.Integer, nullable=False)
|
||||
month: Mapped[int] = mapped_column(sa.Integer, nullable=False)
|
||||
shard: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
bundle_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
workflow_run_count: Mapped[int] = mapped_column(sa.Integer, nullable=False)
|
||||
row_count: Mapped[int] = mapped_column(sa.BigInteger, nullable=False)
|
||||
archive_bytes: Mapped[int] = mapped_column(sa.BigInteger, nullable=False)
|
||||
archived_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
|
||||
class ConversationVariable(TypeBase):
|
||||
__tablename__ = "workflow_conversation_variables"
|
||||
|
||||
|
||||
@@ -9721,6 +9721,61 @@ Suggest example workflow-generator instructions for the tenant
|
||||
| 200 | Suggestions generated successfully | **application/json**: [WorkflowInstructionSuggestionsResponse](#workflowinstructionsuggestionsresponse)<br> |
|
||||
| 400 | Invalid request parameters | |
|
||||
|
||||
### [GET] /workflow-run-archives
|
||||
List monthly workflow-run archive metadata for the current workspace
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [WorkflowRunArchiveListResponse](#workflowrunarchivelistresponse)<br> |
|
||||
|
||||
### [POST] /workflow-run-archives/downloads
|
||||
Create or return a temporary workflow-run archive download task
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [WorkflowRunArchiveDownloadPayload](#workflowrunarchivedownloadpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 202 | Download task accepted | **application/json**: [WorkflowRunArchiveDownloadTaskResponse](#workflowrunarchivedownloadtaskresponse)<br> |
|
||||
|
||||
### [GET] /workflow-run-archives/downloads/{download_id}
|
||||
Get a temporary workflow-run archive download task
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| download_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [WorkflowRunArchiveDownloadTaskResponse](#workflowrunarchivedownloadtaskresponse)<br> |
|
||||
|
||||
### [GET] /workflow-run-archives/downloads/{download_id}/file
|
||||
Redirect to a prepared workflow-run archive ZIP file
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| download_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 302 | Redirect to pre-signed archive storage URL | **application/json**: [RedirectResponse](#redirectresponse)<br> |
|
||||
| 409 | Download task is not ready | |
|
||||
|
||||
### [GET] /workflow/{workflow_run_id}/events
|
||||
**Get workflow execution events stream after resume**
|
||||
|
||||
@@ -23495,6 +23550,71 @@ tenant's default model. The underlying generator never raises — an empty
|
||||
| result | string | | Yes |
|
||||
| updated_at | integer | | Yes |
|
||||
|
||||
#### WorkflowRunArchiveDownloadPayload
|
||||
|
||||
Request body for preparing one monthly workflow-run archive download.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| month | integer | | Yes |
|
||||
| year | integer | | Yes |
|
||||
|
||||
#### WorkflowRunArchiveDownloadStatus
|
||||
|
||||
Lifecycle state for an asynchronous archive download request.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| WorkflowRunArchiveDownloadStatus | string | Lifecycle state for an asynchronous archive download request. | |
|
||||
|
||||
#### WorkflowRunArchiveDownloadTaskResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| archive_bytes | integer | | Yes |
|
||||
| bundle_count | integer | | Yes |
|
||||
| created_at | dateTime | | Yes |
|
||||
| download_id | string | | Yes |
|
||||
| error | string | | No |
|
||||
| expires_at | dateTime | | Yes |
|
||||
| file_name | string | | No |
|
||||
| file_size_bytes | integer | | No |
|
||||
| finished_at | string | | No |
|
||||
| month | integer | | Yes |
|
||||
| started_at | string | | No |
|
||||
| status | [WorkflowRunArchiveDownloadStatus](#workflowrunarchivedownloadstatus) | | Yes |
|
||||
| updated_at | dateTime | | Yes |
|
||||
| year | integer | | Yes |
|
||||
|
||||
#### WorkflowRunArchiveListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| months | [ [WorkflowRunArchiveMonthResponse](#workflowrunarchivemonthresponse) ] | | Yes |
|
||||
| summary | [WorkflowRunArchiveSummaryResponse](#workflowrunarchivesummaryresponse) | | Yes |
|
||||
|
||||
#### WorkflowRunArchiveMonthResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| archive_bytes | integer | | Yes |
|
||||
| bundle_count | integer | | Yes |
|
||||
| download_task | [WorkflowRunArchiveDownloadTaskResponse](#workflowrunarchivedownloadtaskresponse) | | No |
|
||||
| latest_archived_at | dateTime | | Yes |
|
||||
| month | integer | | Yes |
|
||||
| row_count | integer | | Yes |
|
||||
| workflow_run_count | integer | | Yes |
|
||||
| year | integer | | Yes |
|
||||
|
||||
#### WorkflowRunArchiveSummaryResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| archive_bytes | integer | | Yes |
|
||||
| archived_month_count | integer | | Yes |
|
||||
| latest_archived_at | string | | No |
|
||||
| workflow_run_count | integer | | Yes |
|
||||
|
||||
#### WorkflowRunCountQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
Workflow-run archive bundle index helpers.
|
||||
|
||||
Archive manifests in object storage remain the recoverable source of truth. This module mirrors their small query
|
||||
surface into `workflow_run_archive_bundles` so console listing and download jobs can avoid listing R2 on request.
|
||||
The backfill path is intentionally idempotent: every manifest is decoded, checked against the V2 schema markers, and
|
||||
upserted by immutable bundle identity.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TypedDict, cast
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from extensions.ext_database import db
|
||||
from libs.archive_storage import ArchiveStorage, get_archive_storage
|
||||
from models.workflow import WorkflowRunArchiveBundle
|
||||
from services.retention.workflow_run.constants import (
|
||||
ARCHIVE_BUNDLE_FORMAT,
|
||||
ARCHIVE_BUNDLE_MANIFEST_NAME,
|
||||
ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ARCHIVE_BUNDLE_ROOT_PREFIX = "workflow-runs/v2/"
|
||||
|
||||
|
||||
class ArchiveBundleTableManifestEntry(TypedDict):
|
||||
row_count: int
|
||||
checksum: str
|
||||
size_bytes: int
|
||||
object_key: str
|
||||
|
||||
|
||||
class ArchiveBundleManifest(TypedDict):
|
||||
schema_version: str
|
||||
archive_format: str
|
||||
tenant_id: str
|
||||
tenant_prefix: str
|
||||
year: int
|
||||
month: int
|
||||
shard: str
|
||||
bundle_id: str
|
||||
object_prefix: str
|
||||
workflow_run_count: int
|
||||
workflow_node_execution_count: int
|
||||
min_created_at: str
|
||||
max_created_at: str
|
||||
min_run_id: str
|
||||
max_run_id: str
|
||||
archived_at: str
|
||||
tables: dict[str, ArchiveBundleTableManifestEntry]
|
||||
run_ids: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchiveBundleIndexValues:
|
||||
"""Computed DB-index values derived from one manifest."""
|
||||
|
||||
row_count: int
|
||||
archive_bytes: int
|
||||
archived_at: datetime.datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveBundleIndexBackfillSummary:
|
||||
"""Aggregate result for a manifest-to-DB-index reconciliation run."""
|
||||
|
||||
manifests_found: int = 0
|
||||
bundles_processed: int = 0
|
||||
bundles_upserted: int = 0
|
||||
bundles_failed: int = 0
|
||||
workflow_run_count: int = 0
|
||||
row_count: int = 0
|
||||
archive_bytes: int = 0
|
||||
elapsed_time: float = 0.0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def decode_archive_bundle_manifest(manifest_data: bytes) -> ArchiveBundleManifest:
|
||||
"""Decode raw manifest bytes into the V2 archive manifest shape."""
|
||||
return cast(ArchiveBundleManifest, json.loads(manifest_data.decode("utf-8")))
|
||||
|
||||
|
||||
def parse_archive_manifest_datetime(value: str) -> datetime.datetime:
|
||||
"""Parse manifest datetimes and normalize timezone-aware values to naive UTC for DB storage."""
|
||||
parsed = datetime.datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed
|
||||
return parsed.astimezone(datetime.UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def calculate_archive_bundle_index_values(
|
||||
manifest: ArchiveBundleManifest,
|
||||
manifest_size_bytes: int,
|
||||
) -> ArchiveBundleIndexValues:
|
||||
"""Calculate row count, stored bytes, and archived timestamp for the DB index."""
|
||||
_validate_archive_bundle_manifest(manifest)
|
||||
row_count = sum(entry["row_count"] for entry in manifest["tables"].values())
|
||||
archive_bytes = manifest_size_bytes + sum(entry["size_bytes"] for entry in manifest["tables"].values())
|
||||
return ArchiveBundleIndexValues(
|
||||
row_count=row_count,
|
||||
archive_bytes=archive_bytes,
|
||||
archived_at=parse_archive_manifest_datetime(manifest["archived_at"]),
|
||||
)
|
||||
|
||||
|
||||
def upsert_archive_bundle_index_from_manifest(
|
||||
session: Session,
|
||||
manifest: ArchiveBundleManifest,
|
||||
manifest_size_bytes: int,
|
||||
) -> WorkflowRunArchiveBundle:
|
||||
"""
|
||||
Persist one archive manifest into `workflow_run_archive_bundles`.
|
||||
|
||||
The caller owns transaction boundaries. Re-running this function for the same manifest is safe and refreshes the
|
||||
mutable metrics derived from object sizes and row counts.
|
||||
"""
|
||||
values = calculate_archive_bundle_index_values(manifest, manifest_size_bytes)
|
||||
existing = session.scalar(
|
||||
select(WorkflowRunArchiveBundle).where(
|
||||
WorkflowRunArchiveBundle.tenant_id == manifest["tenant_id"],
|
||||
WorkflowRunArchiveBundle.year == manifest["year"],
|
||||
WorkflowRunArchiveBundle.month == manifest["month"],
|
||||
WorkflowRunArchiveBundle.shard == manifest["shard"],
|
||||
WorkflowRunArchiveBundle.bundle_id == manifest["bundle_id"],
|
||||
)
|
||||
)
|
||||
if existing is None:
|
||||
bundle = WorkflowRunArchiveBundle(
|
||||
tenant_id=manifest["tenant_id"],
|
||||
year=manifest["year"],
|
||||
month=manifest["month"],
|
||||
shard=manifest["shard"],
|
||||
bundle_id=manifest["bundle_id"],
|
||||
workflow_run_count=manifest["workflow_run_count"],
|
||||
row_count=values.row_count,
|
||||
archive_bytes=values.archive_bytes,
|
||||
archived_at=values.archived_at,
|
||||
)
|
||||
session.add(bundle)
|
||||
return bundle
|
||||
|
||||
existing.workflow_run_count = manifest["workflow_run_count"]
|
||||
existing.row_count = values.row_count
|
||||
existing.archive_bytes = values.archive_bytes
|
||||
existing.archived_at = values.archived_at
|
||||
return existing
|
||||
|
||||
|
||||
class WorkflowRunArchiveBundleIndexBackfill:
|
||||
"""
|
||||
Rebuild the DB bundle index by scanning object-store manifests.
|
||||
|
||||
Tenant IDs are the cheapest scope because they map directly to the object prefix. Tenant prefixes are supported for
|
||||
rollout reconciliation, but they still require listing all tenants under that prefix and filtering keys locally.
|
||||
"""
|
||||
|
||||
storage: ArchiveStorage | None
|
||||
session_factory: sessionmaker[Session]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
storage: ArchiveStorage | None = None,
|
||||
session_factory: sessionmaker[Session] | None = None,
|
||||
) -> None:
|
||||
self.storage = storage
|
||||
self.session_factory = session_factory or sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
|
||||
def run(
|
||||
self,
|
||||
*,
|
||||
tenant_ids: Sequence[str] | None = None,
|
||||
tenant_prefixes: Sequence[str] | None = None,
|
||||
year: int | None = None,
|
||||
month: int | None = None,
|
||||
limit: int | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> ArchiveBundleIndexBackfillSummary:
|
||||
"""Scan matching manifest objects and idempotently upsert their DB index rows."""
|
||||
start_time = time.time()
|
||||
summary = ArchiveBundleIndexBackfillSummary()
|
||||
storage = self.storage or get_archive_storage()
|
||||
manifest_keys = self._list_manifest_keys(
|
||||
storage,
|
||||
tenant_ids=tenant_ids,
|
||||
tenant_prefixes=tenant_prefixes,
|
||||
year=year,
|
||||
month=month,
|
||||
)
|
||||
summary.manifests_found = len(manifest_keys)
|
||||
|
||||
if limit is not None:
|
||||
manifest_keys = manifest_keys[:limit]
|
||||
|
||||
for manifest_key in manifest_keys:
|
||||
try:
|
||||
manifest_data = storage.get_object(manifest_key)
|
||||
manifest = decode_archive_bundle_manifest(manifest_data)
|
||||
self._validate_manifest_scope(
|
||||
manifest,
|
||||
manifest_key=manifest_key,
|
||||
tenant_ids=tenant_ids,
|
||||
tenant_prefixes=tenant_prefixes,
|
||||
year=year,
|
||||
month=month,
|
||||
)
|
||||
values = calculate_archive_bundle_index_values(manifest, len(manifest_data))
|
||||
summary.bundles_processed += 1
|
||||
summary.workflow_run_count += manifest["workflow_run_count"]
|
||||
summary.row_count += values.row_count
|
||||
summary.archive_bytes += values.archive_bytes
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
with self.session_factory() as session:
|
||||
upsert_archive_bundle_index_from_manifest(session, manifest, len(manifest_data))
|
||||
session.commit()
|
||||
summary.bundles_upserted += 1
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to backfill workflow archive bundle index from %s", manifest_key, exc_info=True)
|
||||
summary.bundles_failed += 1
|
||||
summary.errors.append(f"{manifest_key}: {exc}")
|
||||
|
||||
summary.elapsed_time = time.time() - start_time
|
||||
return summary
|
||||
|
||||
@classmethod
|
||||
def _list_manifest_keys(
|
||||
cls,
|
||||
storage: ArchiveStorage,
|
||||
*,
|
||||
tenant_ids: Sequence[str] | None,
|
||||
tenant_prefixes: Sequence[str] | None,
|
||||
year: int | None,
|
||||
month: int | None,
|
||||
) -> list[str]:
|
||||
prefixes = cls._list_prefixes(tenant_ids=tenant_ids, tenant_prefixes=tenant_prefixes, year=year, month=month)
|
||||
keys: list[str] = []
|
||||
for prefix in prefixes:
|
||||
keys.extend(storage.list_objects(prefix))
|
||||
return sorted(
|
||||
key
|
||||
for key in keys
|
||||
if key.endswith(f"/{ARCHIVE_BUNDLE_MANIFEST_NAME}")
|
||||
and cls._manifest_key_matches_scope(
|
||||
key,
|
||||
tenant_ids=tenant_ids,
|
||||
tenant_prefixes=tenant_prefixes,
|
||||
year=year,
|
||||
month=month,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _list_prefixes(
|
||||
*,
|
||||
tenant_ids: Sequence[str] | None,
|
||||
tenant_prefixes: Sequence[str] | None,
|
||||
year: int | None,
|
||||
month: int | None,
|
||||
) -> list[str]:
|
||||
if tenant_ids:
|
||||
prefixes = []
|
||||
for tenant_id in sorted(set(tenant_ids)):
|
||||
prefix = f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix={tenant_id[0].lower()}/tenant_id={tenant_id}/"
|
||||
if year is not None:
|
||||
prefix += f"year={year:04d}/"
|
||||
if month is not None:
|
||||
prefix += f"month={month:02d}/"
|
||||
prefixes.append(prefix)
|
||||
return prefixes
|
||||
|
||||
if tenant_prefixes:
|
||||
return [
|
||||
f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix={tenant_prefix}/"
|
||||
for tenant_prefix in sorted(set(tenant_prefixes))
|
||||
]
|
||||
|
||||
return [ARCHIVE_BUNDLE_ROOT_PREFIX]
|
||||
|
||||
@staticmethod
|
||||
def _manifest_key_matches_scope(
|
||||
key: str,
|
||||
*,
|
||||
tenant_ids: Sequence[str] | None,
|
||||
tenant_prefixes: Sequence[str] | None,
|
||||
year: int | None,
|
||||
month: int | None,
|
||||
) -> bool:
|
||||
if tenant_ids and _extract_key_part(key, "tenant_id") not in set(tenant_ids):
|
||||
return False
|
||||
if tenant_prefixes and _extract_key_part(key, "tenant_prefix") not in set(tenant_prefixes):
|
||||
return False
|
||||
if year is not None and _extract_key_part(key, "year") != f"{year:04d}":
|
||||
return False
|
||||
if month is not None and _extract_key_part(key, "month") != f"{month:02d}":
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _validate_manifest_scope(
|
||||
manifest: ArchiveBundleManifest,
|
||||
*,
|
||||
manifest_key: str,
|
||||
tenant_ids: Sequence[str] | None,
|
||||
tenant_prefixes: Sequence[str] | None,
|
||||
year: int | None,
|
||||
month: int | None,
|
||||
) -> None:
|
||||
expected_object_prefix = manifest_key.removesuffix(f"/{ARCHIVE_BUNDLE_MANIFEST_NAME}")
|
||||
if manifest["object_prefix"] != expected_object_prefix:
|
||||
raise ValueError(
|
||||
f"manifest object_prefix mismatch: expected={expected_object_prefix}, "
|
||||
f"actual={manifest['object_prefix']}"
|
||||
)
|
||||
if tenant_ids and manifest["tenant_id"] not in tenant_ids:
|
||||
raise ValueError(f"manifest tenant_id is outside requested scope: {manifest['tenant_id']}")
|
||||
if tenant_prefixes and manifest["tenant_prefix"] not in tenant_prefixes:
|
||||
raise ValueError(f"manifest tenant_prefix is outside requested scope: {manifest['tenant_prefix']}")
|
||||
if year is not None and manifest["year"] != year:
|
||||
raise ValueError(f"manifest year is outside requested scope: {manifest['year']}")
|
||||
if month is not None and manifest["month"] != month:
|
||||
raise ValueError(f"manifest month is outside requested scope: {manifest['month']}")
|
||||
|
||||
|
||||
def _validate_archive_bundle_manifest(manifest: ArchiveBundleManifest) -> None:
|
||||
if manifest["schema_version"] != ARCHIVE_BUNDLE_SCHEMA_VERSION:
|
||||
raise ValueError(f"unsupported archive bundle schema version: {manifest['schema_version']}")
|
||||
if manifest["archive_format"] != ARCHIVE_BUNDLE_FORMAT:
|
||||
raise ValueError(f"unsupported archive bundle format: {manifest['archive_format']}")
|
||||
|
||||
|
||||
def _extract_key_part(key: str, name: str) -> str | None:
|
||||
prefix = f"{name}="
|
||||
for part in key.split("/"):
|
||||
if part.startswith(prefix):
|
||||
return part[len(prefix) :]
|
||||
return None
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Prepare monthly workflow-run archive downloads.
|
||||
|
||||
Console requests create a short-lived Redis task and Celery runs this module in the background. The DB bundle index is
|
||||
the online lookup source: this preparer never lists archive storage, and it validates the indexed bundle set against the
|
||||
stable download id before packaging archive Parquet objects into one user-facing CSV ZIP file.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import zipfile
|
||||
from collections.abc import Sequence
|
||||
from typing import cast
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.compute as pc
|
||||
import pyarrow.csv as pa_csv
|
||||
import pyarrow.parquet as pq
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.helper.csv_sanitizer import CSVSanitizer
|
||||
from extensions.ext_database import db
|
||||
from libs.archive_storage import ArchiveStorage, get_archive_storage, get_export_storage
|
||||
from models.workflow import WorkflowRunArchiveBundle
|
||||
from services.retention.workflow_run.archive_bundle_index import (
|
||||
ARCHIVE_BUNDLE_ROOT_PREFIX,
|
||||
ArchiveBundleManifest,
|
||||
ArchiveBundleTableManifestEntry,
|
||||
decode_archive_bundle_manifest,
|
||||
)
|
||||
from services.retention.workflow_run.archive_download_task_cache import (
|
||||
WorkflowRunArchiveDownloadStatus,
|
||||
WorkflowRunArchiveDownloadTask,
|
||||
WorkflowRunArchiveDownloadTaskCache,
|
||||
build_archive_download_id,
|
||||
)
|
||||
from services.retention.workflow_run.constants import (
|
||||
ARCHIVE_BUNDLE_FORMAT,
|
||||
ARCHIVE_BUNDLE_MANIFEST_NAME,
|
||||
ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ARCHIVE_DOWNLOAD_ROOT_PREFIX = "workflow-runs/downloads/v1/"
|
||||
ARCHIVE_DOWNLOAD_MIME_TYPE = "application/zip"
|
||||
_CSV_FORMULA_PREFIX_PATTERN = f"^[{re.escape(''.join(CSVSanitizer.FORMULA_CHARS))}]"
|
||||
|
||||
|
||||
class WorkflowRunArchiveDownloadPreparer:
|
||||
"""
|
||||
Build one ready-to-download CSV ZIP for a Redis archive download task.
|
||||
|
||||
The output object is deterministic for a given `download_id`, so retrying a failed task overwrites the same
|
||||
temporary object instead of creating unbounded duplicate files. Source archive bundles are read from the archive
|
||||
bucket, while the prepared ZIP is written to the export bucket so object lifecycle policies can expire downloads
|
||||
without touching long-lived archives.
|
||||
"""
|
||||
|
||||
archive_storage: ArchiveStorage | None
|
||||
download_storage: ArchiveStorage | None
|
||||
cache: WorkflowRunArchiveDownloadTaskCache
|
||||
session_factory: sessionmaker[Session]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
storage: ArchiveStorage | None = None,
|
||||
archive_storage: ArchiveStorage | None = None,
|
||||
download_storage: ArchiveStorage | None = None,
|
||||
cache: WorkflowRunArchiveDownloadTaskCache | None = None,
|
||||
session_factory: sessionmaker[Session] | None = None,
|
||||
) -> None:
|
||||
self.archive_storage = archive_storage or storage
|
||||
self.download_storage = download_storage or storage
|
||||
self.cache = cache or WorkflowRunArchiveDownloadTaskCache()
|
||||
self.session_factory = session_factory or sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
|
||||
def prepare(self, *, tenant_id: str, download_id: str) -> WorkflowRunArchiveDownloadTask | None:
|
||||
"""Prepare a ZIP for an existing Redis task and persist terminal task state."""
|
||||
with self.cache.lock(tenant_id=tenant_id, download_id=download_id):
|
||||
task = self.cache.get(tenant_id=tenant_id, download_id=download_id)
|
||||
if task is None:
|
||||
logger.info("Workflow run archive download task expired before preparation: %s", download_id)
|
||||
return None
|
||||
if task.status != WorkflowRunArchiveDownloadStatus.PENDING:
|
||||
logger.info("Skipping workflow run archive download task in %s state: %s", task.status, download_id)
|
||||
return task
|
||||
processing_task = self._mark_processing(task)
|
||||
try:
|
||||
archive_storage = self.archive_storage or get_archive_storage()
|
||||
download_storage = self.download_storage or get_export_storage()
|
||||
bundles = self._get_task_bundles(processing_task)
|
||||
payload = self._build_zip_payload(archive_storage, processing_task, bundles)
|
||||
storage_key = build_archive_download_storage_key(processing_task)
|
||||
download_storage.put_object(storage_key, payload)
|
||||
return self._mark_ready(processing_task, storage_key=storage_key, file_size_bytes=len(payload))
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to prepare workflow run archive download %s", download_id)
|
||||
return self._mark_failed(processing_task, error=str(exc))
|
||||
|
||||
def _get_task_bundles(self, task: WorkflowRunArchiveDownloadTask) -> list[WorkflowRunArchiveBundle]:
|
||||
with self.session_factory() as session:
|
||||
return _list_task_bundles(session, task)
|
||||
|
||||
def _build_zip_payload(
|
||||
self,
|
||||
storage: ArchiveStorage,
|
||||
task: WorkflowRunArchiveDownloadTask,
|
||||
bundles: Sequence[WorkflowRunArchiveBundle],
|
||||
) -> bytes:
|
||||
zip_root = f"workflow-run-logs-{task.year:04d}-{task.month:02d}"
|
||||
csv_buffers: dict[str, io.BytesIO] = {}
|
||||
csv_headers_written: set[str] = set()
|
||||
|
||||
for bundle in bundles:
|
||||
object_prefix = _build_archive_bundle_object_prefix(task, bundle)
|
||||
_, manifest = _load_and_validate_manifest(storage, task, bundle, object_prefix)
|
||||
for table_name in sorted(manifest["tables"]):
|
||||
entry = manifest["tables"][table_name]
|
||||
object_key = entry["object_key"]
|
||||
table_payload = storage.get_object(object_key)
|
||||
_validate_table_payload(object_key=object_key, entry=entry, payload=table_payload)
|
||||
csv_payload = _parquet_payload_to_csv(
|
||||
table_payload,
|
||||
include_header=table_name not in csv_headers_written,
|
||||
)
|
||||
if not csv_payload:
|
||||
continue
|
||||
csv_buffers.setdefault(table_name, io.BytesIO()).write(csv_payload)
|
||||
csv_headers_written.add(table_name)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for table_name, csv_buffer in sorted(csv_buffers.items()):
|
||||
archive.writestr(f"{zip_root}/{table_name}.csv", csv_buffer.getvalue())
|
||||
return buffer.getvalue()
|
||||
|
||||
def _mark_processing(self, task: WorkflowRunArchiveDownloadTask) -> WorkflowRunArchiveDownloadTask:
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
processing_task = task.model_copy(
|
||||
update={
|
||||
"status": WorkflowRunArchiveDownloadStatus.PROCESSING,
|
||||
"error": None,
|
||||
"updated_at": now,
|
||||
"started_at": task.started_at or now,
|
||||
}
|
||||
)
|
||||
self.cache.save(processing_task)
|
||||
return processing_task
|
||||
|
||||
def _mark_ready(
|
||||
self,
|
||||
task: WorkflowRunArchiveDownloadTask,
|
||||
*,
|
||||
storage_key: str,
|
||||
file_size_bytes: int,
|
||||
) -> WorkflowRunArchiveDownloadTask:
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
ready_task = task.model_copy(
|
||||
update={
|
||||
"status": WorkflowRunArchiveDownloadStatus.READY,
|
||||
"file_name": build_archive_download_file_name(task),
|
||||
"storage_key": storage_key,
|
||||
"file_size_bytes": file_size_bytes,
|
||||
"error": None,
|
||||
"updated_at": now,
|
||||
"finished_at": now,
|
||||
}
|
||||
)
|
||||
return self._save_terminal_task(task, ready_task)
|
||||
|
||||
def _mark_failed(self, task: WorkflowRunArchiveDownloadTask, *, error: str) -> WorkflowRunArchiveDownloadTask:
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
failed_task = task.model_copy(
|
||||
update={
|
||||
"status": WorkflowRunArchiveDownloadStatus.FAILED,
|
||||
"error": error,
|
||||
"updated_at": now,
|
||||
"finished_at": now,
|
||||
}
|
||||
)
|
||||
return self._save_terminal_task(task, failed_task)
|
||||
|
||||
def _save_terminal_task(
|
||||
self,
|
||||
processing_task: WorkflowRunArchiveDownloadTask,
|
||||
terminal_task: WorkflowRunArchiveDownloadTask,
|
||||
) -> WorkflowRunArchiveDownloadTask:
|
||||
with self.cache.lock(tenant_id=processing_task.tenant_id, download_id=processing_task.download_id):
|
||||
current = self.cache.get(
|
||||
tenant_id=processing_task.tenant_id,
|
||||
download_id=processing_task.download_id,
|
||||
)
|
||||
if (
|
||||
current is None
|
||||
or current.status != WorkflowRunArchiveDownloadStatus.PROCESSING
|
||||
or current.celery_task_id != processing_task.celery_task_id
|
||||
):
|
||||
return current or terminal_task
|
||||
self.cache.save(terminal_task)
|
||||
return terminal_task
|
||||
|
||||
|
||||
def build_archive_download_file_name(task: WorkflowRunArchiveDownloadTask) -> str:
|
||||
"""Return the browser download filename for one monthly archive."""
|
||||
return f"workflow-run-logs-{task.year:04d}-{task.month:02d}.zip"
|
||||
|
||||
|
||||
def build_archive_download_storage_key(task: WorkflowRunArchiveDownloadTask) -> str:
|
||||
"""Return the deterministic object-store key for a prepared download ZIP."""
|
||||
return (
|
||||
f"{ARCHIVE_DOWNLOAD_ROOT_PREFIX}tenant_prefix={task.tenant_id[0].lower()}/tenant_id={task.tenant_id}/"
|
||||
f"year={task.year:04d}/month={task.month:02d}/{task.download_id}.zip"
|
||||
)
|
||||
|
||||
|
||||
def _list_task_bundles(session: Session, task: WorkflowRunArchiveDownloadTask) -> list[WorkflowRunArchiveBundle]:
|
||||
stmt = (
|
||||
select(WorkflowRunArchiveBundle)
|
||||
.where(
|
||||
WorkflowRunArchiveBundle.tenant_id == task.tenant_id,
|
||||
WorkflowRunArchiveBundle.year == task.year,
|
||||
WorkflowRunArchiveBundle.month == task.month,
|
||||
)
|
||||
.order_by(WorkflowRunArchiveBundle.shard, WorkflowRunArchiveBundle.bundle_id)
|
||||
)
|
||||
indexed_bundles = list(session.scalars(stmt))
|
||||
if task.bundle_refs:
|
||||
requested_refs = [(ref.shard, ref.bundle_id) for ref in task.bundle_refs]
|
||||
else:
|
||||
requested_bundle_ids = set(task.bundle_ids)
|
||||
requested_refs = [
|
||||
(bundle.shard, bundle.bundle_id) for bundle in indexed_bundles if bundle.bundle_id in requested_bundle_ids
|
||||
]
|
||||
|
||||
bundle_by_ref = {(bundle.shard, bundle.bundle_id): bundle for bundle in indexed_bundles}
|
||||
missing_refs = [ref for ref in requested_refs if ref not in bundle_by_ref]
|
||||
if missing_refs:
|
||||
raise ValueError(f"archive bundle index is missing requested bundles: {missing_refs}")
|
||||
|
||||
bundles = [bundle_by_ref[ref] for ref in requested_refs]
|
||||
if len(bundles) != task.bundle_count:
|
||||
raise ValueError(f"archive bundle count changed: expected={task.bundle_count}, actual={len(bundles)}")
|
||||
|
||||
download_id = build_archive_download_id(
|
||||
tenant_id=task.tenant_id,
|
||||
year=task.year,
|
||||
month=task.month,
|
||||
bundle_refs=requested_refs,
|
||||
)
|
||||
if download_id != task.download_id:
|
||||
raise ValueError("archive download id no longer matches indexed bundle set")
|
||||
|
||||
return bundles
|
||||
|
||||
|
||||
def _build_archive_bundle_object_prefix(
|
||||
task: WorkflowRunArchiveDownloadTask,
|
||||
bundle: WorkflowRunArchiveBundle,
|
||||
) -> str:
|
||||
return (
|
||||
f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix={task.tenant_id[0].lower()}/tenant_id={task.tenant_id}/"
|
||||
f"year={task.year:04d}/month={task.month:02d}/shard={bundle.shard}/bundle={bundle.bundle_id}"
|
||||
)
|
||||
|
||||
|
||||
def _load_and_validate_manifest(
|
||||
storage: ArchiveStorage,
|
||||
task: WorkflowRunArchiveDownloadTask,
|
||||
bundle: WorkflowRunArchiveBundle,
|
||||
object_prefix: str,
|
||||
) -> tuple[bytes, ArchiveBundleManifest]:
|
||||
manifest_key = f"{object_prefix}/{ARCHIVE_BUNDLE_MANIFEST_NAME}"
|
||||
manifest_data = storage.get_object(manifest_key)
|
||||
manifest = decode_archive_bundle_manifest(manifest_data)
|
||||
_validate_manifest(task=task, bundle=bundle, manifest=manifest, object_prefix=object_prefix)
|
||||
return manifest_data, manifest
|
||||
|
||||
|
||||
def _validate_manifest(
|
||||
*,
|
||||
task: WorkflowRunArchiveDownloadTask,
|
||||
bundle: WorkflowRunArchiveBundle,
|
||||
manifest: ArchiveBundleManifest,
|
||||
object_prefix: str,
|
||||
) -> None:
|
||||
if manifest["schema_version"] != ARCHIVE_BUNDLE_SCHEMA_VERSION:
|
||||
raise ValueError(f"unsupported archive bundle schema version: {manifest['schema_version']}")
|
||||
if manifest["archive_format"] != ARCHIVE_BUNDLE_FORMAT:
|
||||
raise ValueError(f"unsupported archive bundle format: {manifest['archive_format']}")
|
||||
if manifest["tenant_id"] != task.tenant_id:
|
||||
raise ValueError(f"manifest tenant_id mismatch: expected={task.tenant_id}, actual={manifest['tenant_id']}")
|
||||
if manifest["year"] != task.year:
|
||||
raise ValueError(f"manifest year mismatch: expected={task.year}, actual={manifest['year']}")
|
||||
if manifest["month"] != task.month:
|
||||
raise ValueError(f"manifest month mismatch: expected={task.month}, actual={manifest['month']}")
|
||||
if manifest["shard"] != bundle.shard:
|
||||
raise ValueError(f"manifest shard mismatch: expected={bundle.shard}, actual={manifest['shard']}")
|
||||
if manifest["bundle_id"] != bundle.bundle_id:
|
||||
raise ValueError(f"manifest bundle_id mismatch: expected={bundle.bundle_id}, actual={manifest['bundle_id']}")
|
||||
if manifest["object_prefix"] != object_prefix:
|
||||
raise ValueError(
|
||||
f"manifest object_prefix mismatch: expected={object_prefix}, actual={manifest['object_prefix']}"
|
||||
)
|
||||
if not manifest["tables"]:
|
||||
raise ValueError("manifest tables must not be empty")
|
||||
for table_name, raw_entry in manifest["tables"].items():
|
||||
entry = cast(ArchiveBundleTableManifestEntry, raw_entry)
|
||||
expected_object_key = f"{object_prefix}/{table_name}.parquet"
|
||||
if entry["object_key"] != expected_object_key:
|
||||
raise ValueError(
|
||||
f"manifest object_key mismatch for {table_name}: "
|
||||
f"expected={expected_object_key}, actual={entry['object_key']}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_table_payload(
|
||||
*,
|
||||
object_key: str,
|
||||
entry: ArchiveBundleTableManifestEntry,
|
||||
payload: bytes,
|
||||
) -> None:
|
||||
if len(payload) != entry["size_bytes"]:
|
||||
raise ValueError(f"archive object size mismatch for {object_key}")
|
||||
checksum = hashlib.md5(payload).hexdigest()
|
||||
if checksum != entry["checksum"]:
|
||||
raise ValueError(f"archive object checksum mismatch for {object_key}")
|
||||
|
||||
|
||||
def _parquet_payload_to_csv(payload: bytes, *, include_header: bool) -> bytes:
|
||||
table = pq.read_table(io.BytesIO(payload))
|
||||
if table.num_columns == 0:
|
||||
return b""
|
||||
for index, field in enumerate(table.schema):
|
||||
if pa.types.is_string(field.type) or pa.types.is_large_string(field.type):
|
||||
sanitized_column = pc.replace_substring_regex( # pyrefly: ignore[missing-attribute]
|
||||
table.column(index),
|
||||
pattern=_CSV_FORMULA_PREFIX_PATTERN,
|
||||
replacement=r"'\0",
|
||||
max_replacements=1,
|
||||
)
|
||||
table = table.set_column(index, field, sanitized_column)
|
||||
buffer = io.BytesIO()
|
||||
pa_csv.write_csv(
|
||||
table,
|
||||
buffer,
|
||||
write_options=pa_csv.WriteOptions(include_header=include_header),
|
||||
)
|
||||
return buffer.getvalue()
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Redis-backed temporary state for workflow-run archive downloads."""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from extensions.ext_redis import RedisClientWrapper, redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ARCHIVE_DOWNLOAD_FORMAT_VERSION = "v1"
|
||||
DEFAULT_ARCHIVE_DOWNLOAD_TASK_TTL_SECONDS = 24 * 60 * 60
|
||||
ARCHIVE_DOWNLOAD_TASK_LOCK_TIMEOUT_SECONDS = 30
|
||||
_CACHE_KEY_PREFIX = "workflow_run_archive_download"
|
||||
|
||||
|
||||
class WorkflowRunArchiveDownloadStatus(StrEnum):
|
||||
"""Lifecycle state for an asynchronous archive download request."""
|
||||
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
READY = "ready"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class WorkflowRunArchiveBundleRef(BaseModel):
|
||||
"""Immutable object-store identity for one bundle included in a download task."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
shard: str
|
||||
bundle_id: str
|
||||
|
||||
|
||||
class WorkflowRunArchiveDownloadTask(BaseModel):
|
||||
"""Temporary Redis payload for a monthly archive download request."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
download_id: str
|
||||
tenant_id: str
|
||||
requested_by: str
|
||||
year: int = Field(ge=1)
|
||||
month: int = Field(ge=1, le=12)
|
||||
bundle_ids: list[str]
|
||||
bundle_refs: list[WorkflowRunArchiveBundleRef] = Field(default_factory=list)
|
||||
bundle_count: int = Field(ge=0)
|
||||
archive_bytes: int = Field(ge=0)
|
||||
status: WorkflowRunArchiveDownloadStatus
|
||||
file_name: str | None = None
|
||||
storage_key: str | None = None
|
||||
file_size_bytes: int | None = Field(default=None, ge=0)
|
||||
celery_task_id: str | None = None
|
||||
error: str | None = None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
expires_at: datetime.datetime
|
||||
started_at: datetime.datetime | None = None
|
||||
finished_at: datetime.datetime | None = None
|
||||
|
||||
|
||||
class WorkflowRunArchiveDownloadTaskCache:
|
||||
"""Store ephemeral archive download task state in Redis with a TTL."""
|
||||
|
||||
_redis: RedisClientWrapper
|
||||
|
||||
def __init__(self, redis: RedisClientWrapper = redis_client) -> None:
|
||||
self._redis = redis
|
||||
|
||||
def get(self, *, tenant_id: str, download_id: str) -> WorkflowRunArchiveDownloadTask | None:
|
||||
raw = self._redis.get(self._cache_key(tenant_id=tenant_id, download_id=download_id))
|
||||
if raw is None:
|
||||
return None
|
||||
data = raw.decode("utf-8") if isinstance(raw, bytes | bytearray) else raw
|
||||
try:
|
||||
return WorkflowRunArchiveDownloadTask.model_validate_json(data)
|
||||
except ValueError:
|
||||
logger.warning("Malformed workflow run archive download task cache entry: %s", download_id)
|
||||
return None
|
||||
|
||||
def save(self, task: WorkflowRunArchiveDownloadTask) -> None:
|
||||
ttl_seconds = self._ttl_seconds(task.expires_at)
|
||||
self._redis.setex(
|
||||
self._cache_key(tenant_id=task.tenant_id, download_id=task.download_id),
|
||||
ttl_seconds,
|
||||
task.model_dump_json(),
|
||||
)
|
||||
|
||||
def lock(self, *, tenant_id: str, download_id: str) -> Any:
|
||||
return self._redis.lock(
|
||||
f"{self._cache_key(tenant_id=tenant_id, download_id=download_id)}:lock",
|
||||
timeout=ARCHIVE_DOWNLOAD_TASK_LOCK_TIMEOUT_SECONDS,
|
||||
blocking_timeout=ARCHIVE_DOWNLOAD_TASK_LOCK_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
def delete(self, *, tenant_id: str, download_id: str) -> None:
|
||||
self._redis.delete(self._cache_key(tenant_id=tenant_id, download_id=download_id))
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(*, tenant_id: str, download_id: str) -> str:
|
||||
return f"{_CACHE_KEY_PREFIX}:{tenant_id}:{download_id}"
|
||||
|
||||
@staticmethod
|
||||
def _ttl_seconds(expires_at: datetime.datetime) -> int:
|
||||
expires_at_utc = expires_at if expires_at.tzinfo else expires_at.replace(tzinfo=datetime.UTC)
|
||||
remaining = expires_at_utc - datetime.datetime.now(datetime.UTC)
|
||||
return max(int(remaining.total_seconds()), 1)
|
||||
|
||||
|
||||
def build_pending_archive_download_task(
|
||||
*,
|
||||
tenant_id: str,
|
||||
requested_by: str,
|
||||
year: int,
|
||||
month: int,
|
||||
bundle_ids: Sequence[str],
|
||||
bundle_refs: Sequence[tuple[str, str]] = (),
|
||||
archive_bytes: int,
|
||||
download_id: str,
|
||||
ttl_seconds: int = DEFAULT_ARCHIVE_DOWNLOAD_TASK_TTL_SECONDS,
|
||||
now: datetime.datetime | None = None,
|
||||
) -> WorkflowRunArchiveDownloadTask:
|
||||
"""Create the Redis payload stored when the console starts an archive download."""
|
||||
created_at = now or datetime.datetime.now(datetime.UTC)
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=datetime.UTC)
|
||||
normalized_bundle_ids = list(bundle_ids)
|
||||
normalized_bundle_refs = [
|
||||
WorkflowRunArchiveBundleRef(shard=shard, bundle_id=bundle_id) for shard, bundle_id in bundle_refs
|
||||
]
|
||||
return WorkflowRunArchiveDownloadTask(
|
||||
download_id=download_id,
|
||||
tenant_id=tenant_id,
|
||||
requested_by=requested_by,
|
||||
year=year,
|
||||
month=month,
|
||||
bundle_ids=normalized_bundle_ids,
|
||||
bundle_refs=normalized_bundle_refs,
|
||||
bundle_count=len(normalized_bundle_ids),
|
||||
archive_bytes=archive_bytes,
|
||||
status=WorkflowRunArchiveDownloadStatus.PENDING,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
expires_at=created_at + datetime.timedelta(seconds=ttl_seconds),
|
||||
)
|
||||
|
||||
|
||||
def build_archive_download_id(
|
||||
*,
|
||||
tenant_id: str,
|
||||
year: int,
|
||||
month: int,
|
||||
bundle_refs: Sequence[tuple[str, str]],
|
||||
download_format_version: str = ARCHIVE_DOWNLOAD_FORMAT_VERSION,
|
||||
) -> str:
|
||||
"""Build a stable id for the exact archive download content."""
|
||||
if not bundle_refs:
|
||||
raise ValueError("bundle_refs must not be empty")
|
||||
normalized_refs = sorted(f"{shard}:{bundle_id}" for shard, bundle_id in bundle_refs)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"year": year,
|
||||
"month": month,
|
||||
"bundle_refs": normalized_refs,
|
||||
"download_format_version": download_format_version,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
|
||||
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
Console-facing workflow-run archive queries.
|
||||
|
||||
The object store remains the recoverable archive source of truth. This module only reads the DB bundle index and writes
|
||||
temporary Redis download-task state, so console requests never list R2 online.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.workflow import WorkflowRunArchiveBundle
|
||||
from services.retention.workflow_run.archive_download_task_cache import (
|
||||
WorkflowRunArchiveDownloadStatus,
|
||||
WorkflowRunArchiveDownloadTask,
|
||||
WorkflowRunArchiveDownloadTaskCache,
|
||||
build_archive_download_id,
|
||||
build_pending_archive_download_task,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ArchiveDownloadTaskDispatcher = Callable[
|
||||
[WorkflowRunArchiveDownloadTask, WorkflowRunArchiveDownloadTaskCache],
|
||||
WorkflowRunArchiveDownloadTask,
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowRunArchiveMonth:
|
||||
"""Aggregated archive metadata for one tenant/month."""
|
||||
|
||||
year: int
|
||||
month: int
|
||||
bundle_count: int
|
||||
workflow_run_count: int
|
||||
row_count: int
|
||||
archive_bytes: int
|
||||
latest_archived_at: datetime.datetime
|
||||
download_task: WorkflowRunArchiveDownloadTask | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowRunArchiveSummary:
|
||||
"""Top-level archive totals shown on the console page."""
|
||||
|
||||
archived_month_count: int
|
||||
workflow_run_count: int
|
||||
archive_bytes: int
|
||||
latest_archived_at: datetime.datetime | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowRunArchiveList:
|
||||
"""Console response model before controller serialization."""
|
||||
|
||||
summary: WorkflowRunArchiveSummary
|
||||
months: list[WorkflowRunArchiveMonth]
|
||||
|
||||
|
||||
class WorkflowRunArchiveNotFoundError(Exception):
|
||||
"""Raised when no archive bundles exist for a requested tenant/month."""
|
||||
|
||||
|
||||
class WorkflowRunArchiveDownloadTaskNotFoundError(Exception):
|
||||
"""Raised when the temporary Redis task has expired or never existed."""
|
||||
|
||||
|
||||
class WorkflowRunArchiveDownloadNotReadyError(Exception):
|
||||
"""Raised when a cached download task has not produced a file yet."""
|
||||
|
||||
|
||||
def list_workflow_run_archives(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
*,
|
||||
cache: WorkflowRunArchiveDownloadTaskCache | None = None,
|
||||
) -> WorkflowRunArchiveList:
|
||||
"""Return monthly archive metadata for one tenant from the DB bundle index."""
|
||||
stmt = (
|
||||
select(WorkflowRunArchiveBundle)
|
||||
.where(WorkflowRunArchiveBundle.tenant_id == tenant_id)
|
||||
.order_by(
|
||||
WorkflowRunArchiveBundle.year.desc(),
|
||||
WorkflowRunArchiveBundle.month.desc(),
|
||||
WorkflowRunArchiveBundle.shard,
|
||||
WorkflowRunArchiveBundle.bundle_id,
|
||||
)
|
||||
)
|
||||
month_bundles: dict[tuple[int, int], list[WorkflowRunArchiveBundle]] = {}
|
||||
for bundle in session.scalars(stmt):
|
||||
month_bundles.setdefault((bundle.year, bundle.month), []).append(bundle)
|
||||
|
||||
task_cache = cache or WorkflowRunArchiveDownloadTaskCache()
|
||||
months: list[WorkflowRunArchiveMonth] = []
|
||||
for (year, month), bundles in month_bundles.items():
|
||||
bundle_refs = [(bundle.shard, bundle.bundle_id) for bundle in bundles]
|
||||
months.append(
|
||||
WorkflowRunArchiveMonth(
|
||||
year=year,
|
||||
month=month,
|
||||
bundle_count=len(bundles),
|
||||
workflow_run_count=sum(bundle.workflow_run_count for bundle in bundles),
|
||||
row_count=sum(bundle.row_count for bundle in bundles),
|
||||
archive_bytes=sum(bundle.archive_bytes for bundle in bundles),
|
||||
latest_archived_at=max(bundle.archived_at for bundle in bundles),
|
||||
download_task=_get_cached_month_download_task(
|
||||
task_cache,
|
||||
tenant_id=tenant_id,
|
||||
year=year,
|
||||
month=month,
|
||||
bundle_refs=bundle_refs,
|
||||
),
|
||||
)
|
||||
)
|
||||
latest_archived_at = max((month.latest_archived_at for month in months), default=None)
|
||||
return WorkflowRunArchiveList(
|
||||
summary=WorkflowRunArchiveSummary(
|
||||
archived_month_count=len(months),
|
||||
workflow_run_count=sum(month.workflow_run_count for month in months),
|
||||
archive_bytes=sum(month.archive_bytes for month in months),
|
||||
latest_archived_at=latest_archived_at,
|
||||
),
|
||||
months=months,
|
||||
)
|
||||
|
||||
|
||||
def _get_cached_month_download_task(
|
||||
cache: WorkflowRunArchiveDownloadTaskCache,
|
||||
*,
|
||||
tenant_id: str,
|
||||
year: int,
|
||||
month: int,
|
||||
bundle_refs: list[tuple[str, str]],
|
||||
) -> WorkflowRunArchiveDownloadTask | None:
|
||||
if not bundle_refs:
|
||||
return None
|
||||
download_id = build_archive_download_id(
|
||||
tenant_id=tenant_id,
|
||||
year=year,
|
||||
month=month,
|
||||
bundle_refs=bundle_refs,
|
||||
)
|
||||
try:
|
||||
return cache.get(tenant_id=tenant_id, download_id=download_id)
|
||||
except Exception:
|
||||
logger.warning("Failed to read cached workflow run archive download task: %s", download_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def create_workflow_run_archive_download_task(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
requested_by: str,
|
||||
year: int,
|
||||
month: int,
|
||||
cache: WorkflowRunArchiveDownloadTaskCache | None = None,
|
||||
dispatcher: ArchiveDownloadTaskDispatcher | None = None,
|
||||
) -> WorkflowRunArchiveDownloadTask:
|
||||
"""
|
||||
Create or return the idempotent Redis task for downloading one tenant/month archive.
|
||||
|
||||
The task identity is based on the exact ordered bundle set currently indexed for the month. If the month receives a
|
||||
new bundle later, the next request gets a different download id and prepares a fresh file.
|
||||
"""
|
||||
bundles = _list_archive_bundles(session, tenant_id=tenant_id, year=year, month=month)
|
||||
if not bundles:
|
||||
raise WorkflowRunArchiveNotFoundError(f"Workflow run archive not found: {year:04d}-{month:02d}")
|
||||
|
||||
bundle_refs = [(bundle.shard, bundle.bundle_id) for bundle in bundles]
|
||||
download_id = build_archive_download_id(
|
||||
tenant_id=tenant_id,
|
||||
year=year,
|
||||
month=month,
|
||||
bundle_refs=bundle_refs,
|
||||
)
|
||||
task = build_pending_archive_download_task(
|
||||
tenant_id=tenant_id,
|
||||
requested_by=requested_by,
|
||||
year=year,
|
||||
month=month,
|
||||
bundle_ids=[bundle.bundle_id for bundle in bundles],
|
||||
bundle_refs=bundle_refs,
|
||||
archive_bytes=sum(bundle.archive_bytes for bundle in bundles),
|
||||
download_id=download_id,
|
||||
)
|
||||
task_cache = cache or WorkflowRunArchiveDownloadTaskCache()
|
||||
dispatch = dispatcher or _dispatch_workflow_run_archive_download_task
|
||||
with task_cache.lock(tenant_id=tenant_id, download_id=download_id):
|
||||
existing = task_cache.get(tenant_id=tenant_id, download_id=download_id)
|
||||
if existing is None or existing.status == WorkflowRunArchiveDownloadStatus.FAILED:
|
||||
task_to_queue = task
|
||||
elif existing.status == WorkflowRunArchiveDownloadStatus.PENDING and not existing.celery_task_id:
|
||||
task_to_queue = existing
|
||||
else:
|
||||
return existing
|
||||
|
||||
queued_task = task_to_queue.model_copy(
|
||||
update={"celery_task_id": uuid.uuid4().hex, "updated_at": datetime.datetime.now(datetime.UTC)}
|
||||
)
|
||||
task_cache.save(queued_task)
|
||||
|
||||
return dispatch(queued_task, task_cache)
|
||||
|
||||
|
||||
def get_workflow_run_archive_download_task(
|
||||
*,
|
||||
tenant_id: str,
|
||||
download_id: str,
|
||||
cache: WorkflowRunArchiveDownloadTaskCache | None = None,
|
||||
) -> WorkflowRunArchiveDownloadTask:
|
||||
"""Return a cached archive download task or raise when the TTL has expired."""
|
||||
task_cache = cache or WorkflowRunArchiveDownloadTaskCache()
|
||||
task = task_cache.get(tenant_id=tenant_id, download_id=download_id)
|
||||
if task is None:
|
||||
raise WorkflowRunArchiveDownloadTaskNotFoundError(f"Workflow run archive download not found: {download_id}")
|
||||
return task
|
||||
|
||||
|
||||
def get_ready_workflow_run_archive_download_task(
|
||||
*,
|
||||
tenant_id: str,
|
||||
download_id: str,
|
||||
cache: WorkflowRunArchiveDownloadTaskCache | None = None,
|
||||
) -> WorkflowRunArchiveDownloadTask:
|
||||
"""Return a ready cached archive download task or raise when the file is not available."""
|
||||
task = get_workflow_run_archive_download_task(tenant_id=tenant_id, download_id=download_id, cache=cache)
|
||||
if task.status != WorkflowRunArchiveDownloadStatus.READY or not task.storage_key or not task.file_name:
|
||||
raise WorkflowRunArchiveDownloadNotReadyError(f"Workflow run archive download is not ready: {download_id}")
|
||||
return task
|
||||
|
||||
|
||||
def _list_archive_bundles(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
year: int,
|
||||
month: int,
|
||||
) -> list[WorkflowRunArchiveBundle]:
|
||||
stmt = (
|
||||
select(WorkflowRunArchiveBundle)
|
||||
.where(
|
||||
WorkflowRunArchiveBundle.tenant_id == tenant_id,
|
||||
WorkflowRunArchiveBundle.year == year,
|
||||
WorkflowRunArchiveBundle.month == month,
|
||||
)
|
||||
.order_by(WorkflowRunArchiveBundle.shard, WorkflowRunArchiveBundle.bundle_id)
|
||||
)
|
||||
return list(session.scalars(stmt))
|
||||
|
||||
|
||||
def _dispatch_workflow_run_archive_download_task(
|
||||
task: WorkflowRunArchiveDownloadTask,
|
||||
cache: WorkflowRunArchiveDownloadTaskCache,
|
||||
) -> WorkflowRunArchiveDownloadTask:
|
||||
"""
|
||||
Enqueue background ZIP preparation after the caller atomically claimed and cached the Celery id.
|
||||
"""
|
||||
from tasks.workflow_run_archive_download_tasks import prepare_workflow_run_archive_download_task
|
||||
|
||||
celery_task_id = task.celery_task_id
|
||||
if celery_task_id is None:
|
||||
raise ValueError("celery_task_id is required before dispatch")
|
||||
|
||||
try:
|
||||
prepare_workflow_run_archive_download_task.apply_async(
|
||||
args=(task.tenant_id, task.download_id),
|
||||
task_id=celery_task_id,
|
||||
)
|
||||
except Exception:
|
||||
failure_time = datetime.datetime.now(datetime.UTC)
|
||||
failed_task = task.model_copy(
|
||||
update={
|
||||
"status": WorkflowRunArchiveDownloadStatus.FAILED,
|
||||
"error": "Failed to enqueue archive download task.",
|
||||
"updated_at": failure_time,
|
||||
"finished_at": failure_time,
|
||||
}
|
||||
)
|
||||
with cache.lock(tenant_id=task.tenant_id, download_id=task.download_id):
|
||||
current = cache.get(tenant_id=task.tenant_id, download_id=task.download_id)
|
||||
if (
|
||||
current is not None
|
||||
and current.status == WorkflowRunArchiveDownloadStatus.PENDING
|
||||
and current.celery_task_id == celery_task_id
|
||||
):
|
||||
cache.save(failed_task)
|
||||
current = failed_task
|
||||
logger.exception("Failed to enqueue workflow run archive download task %s", task.download_id)
|
||||
return current or failed_task
|
||||
|
||||
return task
|
||||
@@ -5,8 +5,8 @@ This service archives workflow run logs for paid plan users older than the confi
|
||||
90 days) to S3-compatible storage.
|
||||
|
||||
Archive V2 writes bundle-level Parquet objects. A bundle contains many workflow runs and their related table rows.
|
||||
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.
|
||||
Bundle metadata lives in the object-store manifest as the recoverable source of truth. Completed bundles are also
|
||||
mirrored into a small database index so console listing and download jobs do not list object storage online.
|
||||
|
||||
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.
|
||||
@@ -64,6 +64,12 @@ from repositories.api_workflow_node_execution_repository import DifyAPIWorkflowN
|
||||
from repositories.api_workflow_run_repository import APIWorkflowRunRepository
|
||||
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
|
||||
from services.billing_service import BillingService
|
||||
from services.retention.workflow_run.archive_bundle_index import (
|
||||
ArchiveBundleManifest,
|
||||
ArchiveBundleTableManifestEntry,
|
||||
decode_archive_bundle_manifest,
|
||||
upsert_archive_bundle_index_from_manifest,
|
||||
)
|
||||
from services.retention.workflow_run.constants import (
|
||||
ARCHIVE_BUNDLE_FORMAT,
|
||||
ARCHIVE_BUNDLE_INDEX_NAME,
|
||||
@@ -634,6 +640,7 @@ class WorkflowRunArchiver:
|
||||
raise ArchiveStorageNotConfiguredError("Archive storage not configured")
|
||||
if storage.object_exists(self._get_manifest_object_key(identity)):
|
||||
self._write_bundle_index(storage, identity)
|
||||
self._sync_existing_bundle_index(session, storage, identity)
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
result.error = "bundle already archived"
|
||||
@@ -657,6 +664,7 @@ class WorkflowRunArchiver:
|
||||
result.run_count = len(runs)
|
||||
if storage.object_exists(self._get_manifest_object_key(identity)):
|
||||
self._write_bundle_index(storage, identity)
|
||||
self._sync_existing_bundle_index(session, storage, identity)
|
||||
result.success = True
|
||||
result.skipped = True
|
||||
result.error = "filtered bundle already archived"
|
||||
@@ -688,6 +696,8 @@ class WorkflowRunArchiver:
|
||||
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])
|
||||
manifest = decode_archive_bundle_manifest(manifest_data)
|
||||
upsert_archive_bundle_index_from_manifest(session, manifest, len(manifest_data))
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
@@ -714,6 +724,23 @@ class WorkflowRunArchiver:
|
||||
result.elapsed_time = time.time() - start_time
|
||||
return result
|
||||
|
||||
def _sync_existing_bundle_index(
|
||||
self,
|
||||
session: Session,
|
||||
storage: ArchiveStorage,
|
||||
identity: ArchiveBundleIdentity,
|
||||
) -> None:
|
||||
"""Best-effort DB index sync for a bundle whose manifest already exists in archive storage."""
|
||||
manifest_key = self._get_manifest_object_key(identity)
|
||||
try:
|
||||
manifest_data = storage.get_object(manifest_key)
|
||||
manifest = decode_archive_bundle_manifest(manifest_data)
|
||||
upsert_archive_bundle_index_from_manifest(session, manifest, len(manifest_data))
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.warning("Failed to sync workflow archive bundle index for %s", manifest_key, exc_info=True)
|
||||
|
||||
def _lock_runs_for_archive(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -841,9 +868,9 @@ class WorkflowRunArchiver:
|
||||
identity: ArchiveBundleIdentity,
|
||||
runs: Sequence[WorkflowRun],
|
||||
table_stats: list[TableStats],
|
||||
) -> ArchiveManifestDict:
|
||||
) -> ArchiveBundleManifest:
|
||||
"""Generate a manifest for the archived workflow run bundle."""
|
||||
tables: dict[str, TableStatsManifestEntry] = {
|
||||
tables: dict[str, ArchiveBundleTableManifestEntry] = {
|
||||
stat.table_name: {
|
||||
"row_count": stat.row_count,
|
||||
"checksum": stat.checksum,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
Maintain V2 workflow-run archive bundles.
|
||||
|
||||
Archive V2 keeps bundle metadata in object-store manifests, not in a database table. This module discovers bundles by
|
||||
listing `manifest.json` objects, uses object-store marker files for delete/restore state, and only touches the database
|
||||
for source-table validation, deletion, and restoration.
|
||||
Archive V2 keeps object-store manifests as the recoverable bundle source of truth. This maintenance module still
|
||||
discovers delete/restore targets by listing `manifest.json` objects and uses object-store marker files for
|
||||
delete/restore state. The separate database bundle index is intended for console listing and download jobs, not as the
|
||||
source of truth for destructive maintenance.
|
||||
|
||||
Each bundle is processed in its own database transaction. A failed bundle leaves source rows unchanged unless the
|
||||
transaction has already committed; marker handling makes the next run able to reconcile the common committed-but-marker
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Celery tasks for preparing workflow-run archive downloads."""
|
||||
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
from services.retention.workflow_run.archive_download_preparation import WorkflowRunArchiveDownloadPreparer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WORKFLOW_RUN_ARCHIVE_DOWNLOAD_QUEUE = "workflow_archive"
|
||||
|
||||
|
||||
@shared_task(queue=WORKFLOW_RUN_ARCHIVE_DOWNLOAD_QUEUE)
|
||||
def prepare_workflow_run_archive_download_task(tenant_id: str, download_id: str) -> None:
|
||||
"""Prepare a cached workflow-run archive download in the background."""
|
||||
logger.info("Preparing workflow run archive download: tenant=%s download_id=%s", tenant_id, download_id)
|
||||
WorkflowRunArchiveDownloadPreparer().prepare(tenant_id=tenant_id, download_id=download_id)
|
||||
@@ -8,6 +8,7 @@ import pyarrow.parquet as pq
|
||||
import pytest
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from models.workflow import WorkflowRunArchiveBundle
|
||||
from services.retention.workflow_run.archive_paid_plan_workflow_run import (
|
||||
ArchiveResult,
|
||||
ArchiveSummary,
|
||||
@@ -518,6 +519,37 @@ class TestArchiveRunIdempotency:
|
||||
assert result.skipped is True
|
||||
assert result.error == "bundle already archived"
|
||||
|
||||
def test_successful_bundle_persists_archive_index(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
run = MagicMock()
|
||||
run.id = str(uuid.uuid4())
|
||||
run.tenant_id = str(uuid.uuid4())
|
||||
run.created_at = datetime.datetime(2025, 3, 15, 10, 0, 0)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = None
|
||||
storage = MagicMock()
|
||||
storage.object_exists.return_value = False
|
||||
table_data = {
|
||||
"workflow_runs": [{"id": run.id, "tenant_id": run.tenant_id}],
|
||||
"workflow_node_executions": [{"id": str(uuid.uuid4()), "workflow_run_id": run.id}],
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(archiver, "_lock_runs_for_archive", return_value=[run]),
|
||||
patch.object(archiver, "_extract_bundle_data", return_value=table_data),
|
||||
):
|
||||
result = archiver._archive_bundle(session, storage, [run])
|
||||
|
||||
archived_bundle = session.add.call_args.args[0]
|
||||
assert result.success is True
|
||||
assert isinstance(archived_bundle, WorkflowRunArchiveBundle)
|
||||
assert archived_bundle.tenant_id == run.tenant_id
|
||||
assert archived_bundle.year == 2025
|
||||
assert archived_bundle.month == 3
|
||||
assert archived_bundle.workflow_run_count == 1
|
||||
assert archived_bundle.row_count == 2
|
||||
session.commit.assert_called_once()
|
||||
|
||||
def test_index_skips_all_already_archived_runs(self):
|
||||
archiver = WorkflowRunArchiver(days=90)
|
||||
run = MagicMock()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
|
||||
from controllers.console.workflow_run_archive import (
|
||||
WorkflowRunArchiveDownloadApi,
|
||||
WorkflowRunArchiveDownloadFileApi,
|
||||
WorkflowRunArchiveDownloadsApi,
|
||||
WorkflowRunArchivesApi,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method",
|
||||
[
|
||||
WorkflowRunArchivesApi.get,
|
||||
WorkflowRunArchiveDownloadsApi.post,
|
||||
WorkflowRunArchiveDownloadApi.get,
|
||||
WorkflowRunArchiveDownloadFileApi.get,
|
||||
],
|
||||
)
|
||||
def test_workflow_run_archive_endpoints_require_cloud_paid_plan(method) -> None:
|
||||
decorator_names = set()
|
||||
while hasattr(method, "__wrapped__"):
|
||||
decorator_names.add(method.__code__.co_qualname.partition(".<locals>")[0])
|
||||
method = method.__wrapped__
|
||||
|
||||
assert {
|
||||
"only_edition_cloud",
|
||||
"cloud_edition_billing_enabled",
|
||||
"cloud_edition_billing_paid_plan_required",
|
||||
} <= decorator_names
|
||||
@@ -16,6 +16,7 @@ from controllers.console.wraps import (
|
||||
_is_setup_completed,
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_enabled,
|
||||
cloud_edition_billing_paid_plan_required,
|
||||
cloud_edition_billing_rate_limit_check,
|
||||
cloud_edition_billing_resource_check,
|
||||
cloud_utm_record,
|
||||
@@ -488,6 +489,53 @@ class TestBillingEnabled:
|
||||
get_features.assert_not_called()
|
||||
|
||||
|
||||
class TestBillingPaidPlanRequired:
|
||||
@pytest.mark.parametrize("plan", ["professional", "team"])
|
||||
def test_should_allow_paid_plan(self, plan: str):
|
||||
@cloud_edition_billing_paid_plan_required
|
||||
def paid_view():
|
||||
return "paid_success"
|
||||
|
||||
billing_info = {"enabled": True, "subscription": {"plan": plan}}
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.wraps.current_account_with_tenant",
|
||||
return_value=(MockUser("test_user"), "tenant123"),
|
||||
),
|
||||
patch("controllers.console.wraps.BillingService.get_info", return_value=billing_info) as get_info,
|
||||
):
|
||||
result = paid_view()
|
||||
|
||||
assert result == "paid_success"
|
||||
get_info.assert_called_once_with("tenant123", exclude_vector_space=True)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enabled", "plan"),
|
||||
[(False, "professional"), (True, "sandbox"), (True, "unknown")],
|
||||
)
|
||||
def test_should_reject_non_paid_plan(self, enabled: bool, plan: str):
|
||||
app = create_app_with_login()
|
||||
|
||||
@cloud_edition_billing_paid_plan_required
|
||||
def paid_view():
|
||||
return "paid_success"
|
||||
|
||||
billing_info = {"enabled": enabled, "subscription": {"plan": plan}}
|
||||
with app.test_request_context():
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.wraps.current_account_with_tenant",
|
||||
return_value=(MockUser("test_user"), "tenant123"),
|
||||
),
|
||||
patch("controllers.console.wraps.BillingService.get_info", return_value=billing_info),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
paid_view()
|
||||
|
||||
assert exc_info.value.code == 403
|
||||
assert "requires a paid plan" in str(exc_info.value.description)
|
||||
|
||||
|
||||
class TestBillingResourceLimits:
|
||||
"""Test billing resource limit decorators"""
|
||||
|
||||
|
||||
@@ -247,6 +247,32 @@ def test_generate_presigned_url(monkeypatch: pytest.MonkeyPatch):
|
||||
assert url == "http://signed-url"
|
||||
|
||||
|
||||
def test_generate_presigned_url_with_download_headers(monkeypatch: pytest.MonkeyPatch):
|
||||
_configure_storage(monkeypatch)
|
||||
client, _ = _mock_client(monkeypatch)
|
||||
client.generate_presigned_url.return_value = "http://signed-url"
|
||||
storage = ArchiveStorage(bucket=BUCKET_NAME)
|
||||
|
||||
url = storage.generate_presigned_url(
|
||||
"key",
|
||||
expires_in=123,
|
||||
filename="workflow-run-logs-2025-03.zip",
|
||||
content_type="application/zip",
|
||||
)
|
||||
|
||||
client.generate_presigned_url.assert_called_once_with(
|
||||
ClientMethod="get_object",
|
||||
Params={
|
||||
"Bucket": "archive-bucket",
|
||||
"Key": "key",
|
||||
"ResponseContentDisposition": "attachment; filename*=UTF-8''workflow-run-logs-2025-03.zip",
|
||||
"ResponseContentType": "application/zip",
|
||||
},
|
||||
ExpiresIn=123,
|
||||
)
|
||||
assert url == "http://signed-url"
|
||||
|
||||
|
||||
def test_generate_presigned_url_error(monkeypatch: pytest.MonkeyPatch):
|
||||
_configure_storage(monkeypatch)
|
||||
client, _ = _mock_client(monkeypatch)
|
||||
|
||||
@@ -3,6 +3,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Request
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_WEBAPP_ACCESS_TOKEN
|
||||
@@ -11,10 +12,17 @@ from libs.token import extract_access_token, extract_webapp_access_token, set_cs
|
||||
|
||||
|
||||
class MockRequest:
|
||||
def __init__(self, headers: dict[str, str], cookies: dict[str, str], args: dict[str, str]):
|
||||
def __init__(
|
||||
self,
|
||||
headers: dict[str, str],
|
||||
cookies: dict[str, str],
|
||||
args: dict[str, str],
|
||||
path: str = "/console/api/test",
|
||||
):
|
||||
self.headers: dict[str, str] = headers
|
||||
self.cookies: dict[str, str] = cookies
|
||||
self.args: dict[str, str] = args
|
||||
self.path = path
|
||||
|
||||
|
||||
def test_extract_access_token():
|
||||
@@ -63,3 +71,24 @@ def test_set_csrf_cookie_includes_domain_when_configured(monkeypatch: pytest.Mon
|
||||
assert any("csrf_token=abc123" in c for c in cookies)
|
||||
assert any("Domain=example.com" in c for c in cookies)
|
||||
assert all("__Host-" not in c for c in cookies)
|
||||
|
||||
|
||||
def test_workflow_run_archive_download_file_bypasses_csrf():
|
||||
request = cast(
|
||||
Request,
|
||||
MockRequest(
|
||||
headers={},
|
||||
cookies={},
|
||||
args={},
|
||||
path="/console/api/workflow-run-archives/downloads/5923ce20291444af45f0580fb49f1cc9/file",
|
||||
),
|
||||
)
|
||||
|
||||
token.check_csrf_token(request, "account-1")
|
||||
|
||||
|
||||
def test_non_whitelisted_path_requires_csrf():
|
||||
request = cast(Request, MockRequest(headers={}, cookies={}, args={}, path="/console/api/test"))
|
||||
|
||||
with pytest.raises(Unauthorized):
|
||||
token.check_csrf_token(request, "account-1")
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import datetime
|
||||
import json
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from services.retention.workflow_run.archive_bundle_index import (
|
||||
ARCHIVE_BUNDLE_ROOT_PREFIX,
|
||||
ArchiveBundleManifest,
|
||||
WorkflowRunArchiveBundleIndexBackfill,
|
||||
calculate_archive_bundle_index_values,
|
||||
decode_archive_bundle_manifest,
|
||||
upsert_archive_bundle_index_from_manifest,
|
||||
)
|
||||
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_FORMAT, ARCHIVE_BUNDLE_SCHEMA_VERSION
|
||||
|
||||
TENANT_ID = "1251fe32-c0c7-4fe2-a7bd-a8105267faf5"
|
||||
BUNDLE_ID = "bundle-a"
|
||||
OBJECT_PREFIX = (
|
||||
f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix=1/tenant_id={TENANT_ID}/"
|
||||
f"year=2025/month=03/shard=00-of-01/bundle={BUNDLE_ID}"
|
||||
)
|
||||
MANIFEST_KEY = f"{OBJECT_PREFIX}/manifest.json"
|
||||
|
||||
|
||||
class FakeArchiveStorage:
|
||||
listed_prefixes: list[str]
|
||||
objects: dict[str, bytes]
|
||||
|
||||
def __init__(self, objects: dict[str, bytes]) -> None:
|
||||
self.objects = objects
|
||||
self.listed_prefixes = []
|
||||
|
||||
def list_objects(self, prefix: str) -> list[str]:
|
||||
self.listed_prefixes.append(prefix)
|
||||
return sorted(key for key in self.objects if key.startswith(prefix))
|
||||
|
||||
def get_object(self, key: str) -> bytes:
|
||||
return self.objects[key]
|
||||
|
||||
|
||||
def _manifest(*, object_prefix: str = OBJECT_PREFIX, month: int = 3) -> ArchiveBundleManifest:
|
||||
return ArchiveBundleManifest(
|
||||
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
archive_format=ARCHIVE_BUNDLE_FORMAT,
|
||||
tenant_id=TENANT_ID,
|
||||
tenant_prefix="1",
|
||||
year=2025,
|
||||
month=month,
|
||||
shard="00-of-01",
|
||||
bundle_id=BUNDLE_ID,
|
||||
object_prefix=object_prefix,
|
||||
workflow_run_count=2,
|
||||
workflow_node_execution_count=3,
|
||||
min_created_at="2025-03-01T00:00:00+00:00",
|
||||
max_created_at="2025-03-02T00:00:00+00:00",
|
||||
min_run_id="run-a",
|
||||
max_run_id="run-b",
|
||||
archived_at="2026-06-25T08:00:00+00:00",
|
||||
tables={
|
||||
"workflow_runs": {
|
||||
"row_count": 2,
|
||||
"checksum": "checksum-a",
|
||||
"size_bytes": 100,
|
||||
"object_key": f"{object_prefix}/workflow_runs.parquet",
|
||||
},
|
||||
"workflow_node_executions": {
|
||||
"row_count": 3,
|
||||
"checksum": "checksum-b",
|
||||
"size_bytes": 200,
|
||||
"object_key": f"{object_prefix}/workflow_node_executions.parquet",
|
||||
},
|
||||
},
|
||||
run_ids=["run-a", "run-b"],
|
||||
)
|
||||
|
||||
|
||||
def _manifest_bytes(manifest: ArchiveBundleManifest | None = None) -> bytes:
|
||||
return json.dumps(manifest or _manifest()).encode("utf-8")
|
||||
|
||||
|
||||
def test_decode_and_calculate_archive_bundle_index_values() -> None:
|
||||
data = _manifest_bytes()
|
||||
|
||||
manifest = decode_archive_bundle_manifest(data)
|
||||
values = calculate_archive_bundle_index_values(manifest, len(data))
|
||||
|
||||
assert manifest["tenant_id"] == TENANT_ID
|
||||
assert values.row_count == 5
|
||||
assert values.archive_bytes == len(data) + 300
|
||||
assert values.archived_at == datetime.datetime(2026, 6, 25, 8, 0)
|
||||
|
||||
|
||||
def test_upsert_archive_bundle_index_inserts_new_bundle() -> None:
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = None
|
||||
data = _manifest_bytes()
|
||||
|
||||
bundle = upsert_archive_bundle_index_from_manifest(session, decode_archive_bundle_manifest(data), len(data))
|
||||
|
||||
assert bundle.tenant_id == TENANT_ID
|
||||
assert bundle.year == 2025
|
||||
assert bundle.month == 3
|
||||
assert bundle.workflow_run_count == 2
|
||||
assert bundle.row_count == 5
|
||||
assert bundle.archive_bytes == len(data) + 300
|
||||
session.add.assert_called_once_with(bundle)
|
||||
|
||||
|
||||
def test_upsert_archive_bundle_index_updates_existing_bundle() -> None:
|
||||
existing = MagicMock()
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = existing
|
||||
data = _manifest_bytes()
|
||||
|
||||
bundle = upsert_archive_bundle_index_from_manifest(session, decode_archive_bundle_manifest(data), len(data))
|
||||
|
||||
assert bundle is existing
|
||||
assert existing.workflow_run_count == 2
|
||||
assert existing.row_count == 5
|
||||
assert existing.archive_bytes == len(data) + 300
|
||||
assert existing.archived_at == datetime.datetime(2026, 6, 25, 8, 0)
|
||||
session.add.assert_not_called()
|
||||
|
||||
|
||||
def test_backfill_lists_tenant_month_prefix_and_upserts_bundle_index() -> None:
|
||||
storage = FakeArchiveStorage({MANIFEST_KEY: _manifest_bytes()})
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = None
|
||||
session_factory = MagicMock()
|
||||
session_factory.return_value.__enter__.return_value = session
|
||||
backfill = WorkflowRunArchiveBundleIndexBackfill(
|
||||
storage=cast(MagicMock, storage),
|
||||
session_factory=cast(MagicMock, session_factory),
|
||||
)
|
||||
|
||||
summary = backfill.run(tenant_ids=[TENANT_ID], year=2025, month=3)
|
||||
|
||||
assert storage.listed_prefixes == [
|
||||
f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix=1/tenant_id={TENANT_ID}/year=2025/month=03/"
|
||||
]
|
||||
assert summary.manifests_found == 1
|
||||
assert summary.bundles_processed == 1
|
||||
assert summary.bundles_upserted == 1
|
||||
assert summary.bundles_failed == 0
|
||||
session.add.assert_called_once()
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_backfill_dry_run_filters_by_year_month_without_database_write() -> None:
|
||||
other_month_prefix = OBJECT_PREFIX.replace("month=03", "month=04")
|
||||
storage = FakeArchiveStorage(
|
||||
{
|
||||
MANIFEST_KEY: _manifest_bytes(),
|
||||
f"{other_month_prefix}/manifest.json": _manifest_bytes(
|
||||
_manifest(object_prefix=other_month_prefix, month=4)
|
||||
),
|
||||
}
|
||||
)
|
||||
session_factory = MagicMock()
|
||||
backfill = WorkflowRunArchiveBundleIndexBackfill(
|
||||
storage=cast(MagicMock, storage),
|
||||
session_factory=cast(MagicMock, session_factory),
|
||||
)
|
||||
|
||||
summary = backfill.run(tenant_prefixes=["1"], year=2025, month=3, dry_run=True)
|
||||
|
||||
assert storage.listed_prefixes == [f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix=1/"]
|
||||
assert summary.manifests_found == 1
|
||||
assert summary.bundles_processed == 1
|
||||
assert summary.bundles_upserted == 0
|
||||
assert summary.archive_bytes > 0
|
||||
session_factory.assert_not_called()
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from libs.archive_storage import ArchiveStorage
|
||||
from models.workflow import WorkflowRunArchiveBundle
|
||||
from services.retention.workflow_run.archive_bundle_index import ARCHIVE_BUNDLE_ROOT_PREFIX, ArchiveBundleManifest
|
||||
from services.retention.workflow_run.archive_download_preparation import (
|
||||
WorkflowRunArchiveDownloadPreparer,
|
||||
build_archive_download_storage_key,
|
||||
)
|
||||
from services.retention.workflow_run.archive_download_task_cache import (
|
||||
WorkflowRunArchiveDownloadStatus,
|
||||
WorkflowRunArchiveDownloadTask,
|
||||
WorkflowRunArchiveDownloadTaskCache,
|
||||
build_archive_download_id,
|
||||
build_pending_archive_download_task,
|
||||
)
|
||||
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_FORMAT, ARCHIVE_BUNDLE_SCHEMA_VERSION
|
||||
|
||||
TENANT_ID = "1251fe32-c0c7-4fe2-a7bd-a8105267faf5"
|
||||
BUNDLE_ID = "bundle-a"
|
||||
SHARD = "00-of-01"
|
||||
OBJECT_PREFIX = (
|
||||
f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix=1/tenant_id={TENANT_ID}/"
|
||||
f"year=2025/month=03/shard={SHARD}/bundle={BUNDLE_ID}"
|
||||
)
|
||||
MANIFEST_KEY = f"{OBJECT_PREFIX}/manifest.json"
|
||||
|
||||
|
||||
class FakeArchiveStorage:
|
||||
objects: dict[str, bytes]
|
||||
put_objects: dict[str, bytes]
|
||||
|
||||
def __init__(self, objects: dict[str, bytes]) -> None:
|
||||
self.objects = dict(objects)
|
||||
self.put_objects = {}
|
||||
|
||||
def get_object(self, key: str) -> bytes:
|
||||
return self.objects[key]
|
||||
|
||||
def put_object(self, key: str, data: bytes) -> str:
|
||||
self.put_objects[key] = data
|
||||
return hashlib.md5(data).hexdigest()
|
||||
|
||||
|
||||
class FakeTaskCache:
|
||||
task: WorkflowRunArchiveDownloadTask | None
|
||||
saved_tasks: list[WorkflowRunArchiveDownloadTask]
|
||||
|
||||
def __init__(self, task: WorkflowRunArchiveDownloadTask | None) -> None:
|
||||
self.task = task
|
||||
self.saved_tasks = []
|
||||
|
||||
def get(self, *, tenant_id: str, download_id: str) -> WorkflowRunArchiveDownloadTask | None:
|
||||
if self.task and self.task.tenant_id == tenant_id and self.task.download_id == download_id:
|
||||
return self.task
|
||||
return None
|
||||
|
||||
def lock(self, *, tenant_id: str, download_id: str):
|
||||
return nullcontext()
|
||||
|
||||
def save(self, task: WorkflowRunArchiveDownloadTask) -> None:
|
||||
self.task = task
|
||||
self.saved_tasks.append(task)
|
||||
|
||||
|
||||
def _object_prefix(bundle_id: str = BUNDLE_ID) -> str:
|
||||
return (
|
||||
f"{ARCHIVE_BUNDLE_ROOT_PREFIX}tenant_prefix=1/tenant_id={TENANT_ID}/"
|
||||
f"year=2025/month=03/shard={SHARD}/bundle={bundle_id}"
|
||||
)
|
||||
|
||||
|
||||
def _bundle(bundle_id: str = BUNDLE_ID) -> WorkflowRunArchiveBundle:
|
||||
return cast(WorkflowRunArchiveBundle, SimpleNamespace(shard=SHARD, bundle_id=bundle_id))
|
||||
|
||||
|
||||
def _task(bundle_refs: list[tuple[str, str]] | None = None) -> WorkflowRunArchiveDownloadTask:
|
||||
refs = bundle_refs or [(SHARD, BUNDLE_ID)]
|
||||
return build_pending_archive_download_task(
|
||||
tenant_id=TENANT_ID,
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_ids=[bundle_id for _, bundle_id in refs],
|
||||
bundle_refs=refs,
|
||||
archive_bytes=1024,
|
||||
download_id=build_archive_download_id(
|
||||
tenant_id=TENANT_ID,
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_refs=refs,
|
||||
),
|
||||
now=datetime.datetime(2026, 6, 25, 8, 0, tzinfo=datetime.UTC),
|
||||
)
|
||||
|
||||
|
||||
def _manifest_bytes(table_payloads: dict[str, bytes], *, bundle_id: str = BUNDLE_ID) -> bytes:
|
||||
object_prefix = _object_prefix(bundle_id)
|
||||
manifest = ArchiveBundleManifest(
|
||||
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
|
||||
archive_format=ARCHIVE_BUNDLE_FORMAT,
|
||||
tenant_id=TENANT_ID,
|
||||
tenant_prefix="1",
|
||||
year=2025,
|
||||
month=3,
|
||||
shard=SHARD,
|
||||
bundle_id=bundle_id,
|
||||
object_prefix=object_prefix,
|
||||
workflow_run_count=2,
|
||||
workflow_node_execution_count=0,
|
||||
min_created_at="2025-03-01T00:00:00+00:00",
|
||||
max_created_at="2025-03-02T00:00:00+00:00",
|
||||
min_run_id="run-a",
|
||||
max_run_id="run-b",
|
||||
archived_at="2026-06-25T08:00:00+00:00",
|
||||
tables={
|
||||
table_name: {
|
||||
"row_count": 1,
|
||||
"checksum": hashlib.md5(payload).hexdigest(),
|
||||
"size_bytes": len(payload),
|
||||
"object_key": f"{object_prefix}/{table_name}.parquet",
|
||||
}
|
||||
for table_name, payload in table_payloads.items()
|
||||
},
|
||||
run_ids=["run-a", "run-b"],
|
||||
)
|
||||
return json.dumps(manifest).encode("utf-8")
|
||||
|
||||
|
||||
def _preparer(
|
||||
*,
|
||||
storage: FakeArchiveStorage | None = None,
|
||||
archive_storage: FakeArchiveStorage | None = None,
|
||||
download_storage: FakeArchiveStorage | None = None,
|
||||
cache: FakeTaskCache,
|
||||
bundles: list[WorkflowRunArchiveBundle] | None = None,
|
||||
) -> WorkflowRunArchiveDownloadPreparer:
|
||||
source_storage = archive_storage or storage
|
||||
target_storage = download_storage or storage
|
||||
assert source_storage is not None
|
||||
assert target_storage is not None
|
||||
session = MagicMock()
|
||||
session.scalars.return_value = bundles or [_bundle()]
|
||||
session_factory = MagicMock()
|
||||
session_factory.return_value.__enter__.return_value = session
|
||||
return WorkflowRunArchiveDownloadPreparer(
|
||||
archive_storage=cast(ArchiveStorage, source_storage),
|
||||
download_storage=cast(ArchiveStorage, target_storage),
|
||||
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
|
||||
session_factory=cast(sessionmaker[Session], session_factory),
|
||||
)
|
||||
|
||||
|
||||
def _parquet_bytes(records: list[dict[str, object]]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
pq.write_table(pa.Table.from_pylist(records), buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def test_prepare_workflow_run_archive_download_builds_csv_zip_and_marks_ready() -> None:
|
||||
bundle_refs = [(SHARD, "bundle-a"), (SHARD, "bundle-b")]
|
||||
task = _task(bundle_refs)
|
||||
first_bundle_payloads = {
|
||||
"workflow_app_logs": _parquet_bytes([{"id": "log-a", "workflow_run_id": "run-a"}]),
|
||||
"workflow_runs": _parquet_bytes([{"id": "run-a", "status": "succeeded", "error": "=1+1"}]),
|
||||
}
|
||||
second_bundle_payloads = {
|
||||
"workflow_app_logs": _parquet_bytes([{"id": "log-b", "workflow_run_id": "run-b"}]),
|
||||
"workflow_runs": _parquet_bytes([{"id": "run-b", "status": "failed", "error": "safe"}]),
|
||||
}
|
||||
archive_storage = FakeArchiveStorage(
|
||||
{
|
||||
f"{_object_prefix('bundle-a')}/manifest.json": _manifest_bytes(
|
||||
first_bundle_payloads,
|
||||
bundle_id="bundle-a",
|
||||
),
|
||||
**{
|
||||
f"{_object_prefix('bundle-a')}/{table}.parquet": payload
|
||||
for table, payload in first_bundle_payloads.items()
|
||||
},
|
||||
f"{_object_prefix('bundle-b')}/manifest.json": _manifest_bytes(
|
||||
second_bundle_payloads,
|
||||
bundle_id="bundle-b",
|
||||
),
|
||||
**{
|
||||
f"{_object_prefix('bundle-b')}/{table}.parquet": payload
|
||||
for table, payload in second_bundle_payloads.items()
|
||||
},
|
||||
}
|
||||
)
|
||||
download_storage = FakeArchiveStorage({})
|
||||
cache = FakeTaskCache(task)
|
||||
preparer = _preparer(
|
||||
archive_storage=archive_storage,
|
||||
download_storage=download_storage,
|
||||
cache=cache,
|
||||
bundles=[_bundle("bundle-a"), _bundle("bundle-b")],
|
||||
)
|
||||
|
||||
result = preparer.prepare(tenant_id=TENANT_ID, download_id=task.download_id)
|
||||
|
||||
assert result is not None
|
||||
assert result.status == WorkflowRunArchiveDownloadStatus.READY
|
||||
assert result.storage_key == build_archive_download_storage_key(task)
|
||||
assert result.file_name == "workflow-run-logs-2025-03.zip"
|
||||
assert cache.saved_tasks[0].status == WorkflowRunArchiveDownloadStatus.PROCESSING
|
||||
assert cache.saved_tasks[-1].status == WorkflowRunArchiveDownloadStatus.READY
|
||||
assert archive_storage.put_objects == {}
|
||||
|
||||
archive_payload = download_storage.put_objects[result.storage_key]
|
||||
with zipfile.ZipFile(io.BytesIO(archive_payload)) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert names == {
|
||||
"workflow-run-logs-2025-03/workflow_app_logs.csv",
|
||||
"workflow-run-logs-2025-03/workflow_runs.csv",
|
||||
}
|
||||
workflow_runs_csv = archive.read("workflow-run-logs-2025-03/workflow_runs.csv").decode("utf-8")
|
||||
assert workflow_runs_csv.count('"id","status","error"') == 1
|
||||
assert '"run-a","succeeded","\'=1+1"' in workflow_runs_csv
|
||||
assert '"run-b","failed","safe"' in workflow_runs_csv
|
||||
|
||||
|
||||
def test_prepare_workflow_run_archive_download_marks_failed_on_checksum_mismatch() -> None:
|
||||
task = _task()
|
||||
table_payloads = {"workflow_runs": _parquet_bytes([{"id": "run-a", "status": "succeeded"}])}
|
||||
manifest_data = json.loads(_manifest_bytes(table_payloads).decode("utf-8"))
|
||||
manifest_data["tables"]["workflow_runs"]["checksum"] = "bad-checksum"
|
||||
storage = FakeArchiveStorage(
|
||||
{
|
||||
MANIFEST_KEY: json.dumps(manifest_data).encode("utf-8"),
|
||||
f"{OBJECT_PREFIX}/workflow_runs.parquet": table_payloads["workflow_runs"],
|
||||
}
|
||||
)
|
||||
cache = FakeTaskCache(task)
|
||||
preparer = _preparer(storage=storage, cache=cache)
|
||||
|
||||
result = preparer.prepare(tenant_id=TENANT_ID, download_id=task.download_id)
|
||||
|
||||
assert result is not None
|
||||
assert result.status == WorkflowRunArchiveDownloadStatus.FAILED
|
||||
assert "checksum mismatch" in (result.error or "")
|
||||
assert storage.put_objects == {}
|
||||
|
||||
|
||||
def test_prepare_workflow_run_archive_download_skips_duplicate_worker() -> None:
|
||||
task = _task().model_copy(update={"celery_task_id": "celery-task-1"})
|
||||
storage = FakeArchiveStorage({})
|
||||
cache = FakeTaskCache(task)
|
||||
preparer = _preparer(storage=storage, cache=cache, bundles=[])
|
||||
nested_results: list[WorkflowRunArchiveDownloadTask | None] = []
|
||||
preparer._get_task_bundles = MagicMock(return_value=[])
|
||||
|
||||
def build_payload(*_args: object) -> bytes:
|
||||
nested_results.append(preparer.prepare(tenant_id=TENANT_ID, download_id=task.download_id))
|
||||
return b"zip"
|
||||
|
||||
preparer._build_zip_payload = MagicMock(side_effect=build_payload)
|
||||
|
||||
result = preparer.prepare(tenant_id=TENANT_ID, download_id=task.download_id)
|
||||
|
||||
assert result is not None
|
||||
assert result.status == WorkflowRunArchiveDownloadStatus.READY
|
||||
assert nested_results[0] is not None
|
||||
assert nested_results[0].status == WorkflowRunArchiveDownloadStatus.PROCESSING
|
||||
preparer._build_zip_payload.assert_called_once()
|
||||
|
||||
|
||||
def test_failed_worker_cannot_overwrite_ready_task() -> None:
|
||||
processing_task = _task().model_copy(
|
||||
update={"status": WorkflowRunArchiveDownloadStatus.PROCESSING, "celery_task_id": "celery-task-1"}
|
||||
)
|
||||
ready_task = processing_task.model_copy(update={"status": WorkflowRunArchiveDownloadStatus.READY})
|
||||
cache = FakeTaskCache(ready_task)
|
||||
preparer = _preparer(storage=FakeArchiveStorage({}), cache=cache)
|
||||
|
||||
result = preparer._mark_failed(processing_task, error="late failure")
|
||||
|
||||
assert result == ready_task
|
||||
assert cache.task == ready_task
|
||||
assert cache.saved_tasks == []
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import datetime
|
||||
from contextlib import nullcontext
|
||||
|
||||
import pytest
|
||||
|
||||
from services.retention.workflow_run.archive_download_task_cache import (
|
||||
ARCHIVE_DOWNLOAD_FORMAT_VERSION,
|
||||
ARCHIVE_DOWNLOAD_TASK_LOCK_TIMEOUT_SECONDS,
|
||||
WorkflowRunArchiveDownloadStatus,
|
||||
WorkflowRunArchiveDownloadTask,
|
||||
WorkflowRunArchiveDownloadTaskCache,
|
||||
build_archive_download_id,
|
||||
build_pending_archive_download_task,
|
||||
)
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
store: dict[str, tuple[int | datetime.timedelta, str]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.store = {}
|
||||
self.lock_calls: list[tuple[str, float | None, float | None]] = []
|
||||
|
||||
def get(self, name: str | bytes) -> bytes | str | None:
|
||||
key = name.decode("utf-8") if isinstance(name, bytes) else name
|
||||
item = self.store.get(key)
|
||||
return item[1] if item else None
|
||||
|
||||
def setex(self, name: str | bytes, time: int | datetime.timedelta, value: str) -> object:
|
||||
key = name.decode("utf-8") if isinstance(name, bytes) else name
|
||||
self.store[key] = (time, value)
|
||||
return True
|
||||
|
||||
def delete(self, *names: str | bytes) -> object:
|
||||
deleted = 0
|
||||
for name in names:
|
||||
key = name.decode("utf-8") if isinstance(name, bytes) else name
|
||||
if self.store.pop(key, None) is not None:
|
||||
deleted += 1
|
||||
return deleted
|
||||
|
||||
def lock(
|
||||
self,
|
||||
name: str,
|
||||
timeout: float | None = None,
|
||||
blocking_timeout: float | None = None,
|
||||
):
|
||||
self.lock_calls.append((name, timeout, blocking_timeout))
|
||||
return nullcontext()
|
||||
|
||||
|
||||
def test_build_pending_archive_download_task_sets_ephemeral_payload() -> None:
|
||||
now = datetime.datetime(2026, 6, 25, 8, 0, tzinfo=datetime.UTC)
|
||||
|
||||
task = build_pending_archive_download_task(
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_ids=["bundle-a", "bundle-b"],
|
||||
archive_bytes=1024,
|
||||
ttl_seconds=3600,
|
||||
download_id="download-1",
|
||||
now=now,
|
||||
)
|
||||
|
||||
assert task.status == WorkflowRunArchiveDownloadStatus.PENDING
|
||||
assert task.bundle_count == 2
|
||||
assert task.expires_at == now + datetime.timedelta(seconds=3600)
|
||||
|
||||
|
||||
def test_build_archive_download_id_is_stable_for_same_bundle_set() -> None:
|
||||
first = build_archive_download_id(
|
||||
tenant_id="tenant-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_refs=[("01-of-02", "bundle-b"), ("00-of-02", "bundle-a")],
|
||||
)
|
||||
second = build_archive_download_id(
|
||||
tenant_id="tenant-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_refs=[("00-of-02", "bundle-a"), ("01-of-02", "bundle-b")],
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert len(first) == 32
|
||||
|
||||
|
||||
def test_build_archive_download_id_changes_when_content_or_format_changes() -> None:
|
||||
base = build_archive_download_id(
|
||||
tenant_id="tenant-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_refs=[("00-of-01", "bundle-a")],
|
||||
)
|
||||
changed_bundle = build_archive_download_id(
|
||||
tenant_id="tenant-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_refs=[("00-of-01", "bundle-b")],
|
||||
)
|
||||
changed_format = build_archive_download_id(
|
||||
tenant_id="tenant-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_refs=[("00-of-01", "bundle-a")],
|
||||
download_format_version=f"{ARCHIVE_DOWNLOAD_FORMAT_VERSION}-next",
|
||||
)
|
||||
|
||||
assert base != changed_bundle
|
||||
assert base != changed_format
|
||||
|
||||
|
||||
def test_build_archive_download_id_rejects_empty_bundle_refs() -> None:
|
||||
with pytest.raises(ValueError, match="bundle_refs must not be empty"):
|
||||
build_archive_download_id(tenant_id="tenant-1", year=2025, month=3, bundle_refs=[])
|
||||
|
||||
|
||||
def test_archive_download_task_cache_round_trips_with_ttl() -> None:
|
||||
redis = FakeRedis()
|
||||
cache = WorkflowRunArchiveDownloadTaskCache(redis)
|
||||
task = build_pending_archive_download_task(
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_ids=["bundle-a"],
|
||||
archive_bytes=1024,
|
||||
ttl_seconds=3600,
|
||||
download_id="download-1",
|
||||
)
|
||||
|
||||
cache.save(task)
|
||||
restored = cache.get(tenant_id="tenant-1", download_id="download-1")
|
||||
|
||||
assert restored == task
|
||||
ttl, _ = redis.store["workflow_run_archive_download:tenant-1:download-1"]
|
||||
assert isinstance(ttl, int)
|
||||
assert 0 < ttl <= 3600
|
||||
|
||||
|
||||
def test_archive_download_task_cache_uses_per_download_lock() -> None:
|
||||
redis = FakeRedis()
|
||||
cache = WorkflowRunArchiveDownloadTaskCache(redis)
|
||||
|
||||
with cache.lock(tenant_id="tenant-1", download_id="download-1"):
|
||||
pass
|
||||
|
||||
assert redis.lock_calls == [
|
||||
(
|
||||
"workflow_run_archive_download:tenant-1:download-1:lock",
|
||||
ARCHIVE_DOWNLOAD_TASK_LOCK_TIMEOUT_SECONDS,
|
||||
ARCHIVE_DOWNLOAD_TASK_LOCK_TIMEOUT_SECONDS,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_archive_download_task_cache_delete_removes_entry() -> None:
|
||||
redis = FakeRedis()
|
||||
cache = WorkflowRunArchiveDownloadTaskCache(redis)
|
||||
task = WorkflowRunArchiveDownloadTask(
|
||||
download_id="download-1",
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_ids=[],
|
||||
bundle_count=0,
|
||||
archive_bytes=0,
|
||||
status=WorkflowRunArchiveDownloadStatus.FAILED,
|
||||
error="failed",
|
||||
created_at=datetime.datetime.now(datetime.UTC),
|
||||
updated_at=datetime.datetime.now(datetime.UTC),
|
||||
expires_at=datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=3600),
|
||||
)
|
||||
cache.save(task)
|
||||
|
||||
cache.delete(tenant_id="tenant-1", download_id="download-1")
|
||||
|
||||
assert cache.get(tenant_id="tenant-1", download_id="download-1") is None
|
||||
|
||||
|
||||
def test_archive_download_task_cache_ignores_malformed_json() -> None:
|
||||
redis = FakeRedis()
|
||||
cache = WorkflowRunArchiveDownloadTaskCache(redis)
|
||||
redis.setex("workflow_run_archive_download:tenant-1:download-1", 3600, "{")
|
||||
|
||||
assert cache.get(tenant_id="tenant-1", download_id="download-1") is None
|
||||
@@ -0,0 +1,380 @@
|
||||
import datetime
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from models.workflow import WorkflowRunArchiveBundle
|
||||
from services.retention.workflow_run.archive_download_task_cache import (
|
||||
WorkflowRunArchiveDownloadStatus,
|
||||
WorkflowRunArchiveDownloadTask,
|
||||
WorkflowRunArchiveDownloadTaskCache,
|
||||
build_archive_download_id,
|
||||
build_pending_archive_download_task,
|
||||
)
|
||||
from services.retention.workflow_run.archive_log_service import (
|
||||
ArchiveDownloadTaskDispatcher,
|
||||
WorkflowRunArchiveDownloadNotReadyError,
|
||||
WorkflowRunArchiveNotFoundError,
|
||||
create_workflow_run_archive_download_task,
|
||||
get_ready_workflow_run_archive_download_task,
|
||||
list_workflow_run_archives,
|
||||
)
|
||||
|
||||
|
||||
class FakeTaskCache:
|
||||
saved_task: WorkflowRunArchiveDownloadTask | None
|
||||
existing_task: WorkflowRunArchiveDownloadTask | None
|
||||
tasks_by_download_id: dict[str, WorkflowRunArchiveDownloadTask]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
existing_task: WorkflowRunArchiveDownloadTask | None = None,
|
||||
tasks_by_download_id: dict[str, WorkflowRunArchiveDownloadTask] | None = None,
|
||||
) -> None:
|
||||
self.saved_task = None
|
||||
self.existing_task = existing_task
|
||||
self.tasks_by_download_id = tasks_by_download_id or {}
|
||||
|
||||
def lock(self, *, tenant_id: str, download_id: str):
|
||||
return nullcontext()
|
||||
|
||||
def get(self, *, tenant_id: str, download_id: str) -> WorkflowRunArchiveDownloadTask | None:
|
||||
if self.tasks_by_download_id:
|
||||
return self.tasks_by_download_id.get(download_id)
|
||||
return self.existing_task
|
||||
|
||||
def save(self, task: WorkflowRunArchiveDownloadTask) -> None:
|
||||
self.saved_task = task
|
||||
self.existing_task = task
|
||||
|
||||
|
||||
def _bundle(
|
||||
*,
|
||||
shard: str,
|
||||
bundle_id: str,
|
||||
archive_bytes: int,
|
||||
year: int = 2025,
|
||||
month: int = 3,
|
||||
workflow_run_count: int = 1,
|
||||
row_count: int = 9,
|
||||
archived_at: datetime.datetime | None = None,
|
||||
) -> WorkflowRunArchiveBundle:
|
||||
return cast(
|
||||
WorkflowRunArchiveBundle,
|
||||
SimpleNamespace(
|
||||
year=year,
|
||||
month=month,
|
||||
shard=shard,
|
||||
bundle_id=bundle_id,
|
||||
workflow_run_count=workflow_run_count,
|
||||
row_count=row_count,
|
||||
archive_bytes=archive_bytes,
|
||||
archived_at=archived_at or datetime.datetime(2026, 6, 25, 8, 0),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _fake_dispatcher(dispatched_tasks: list[WorkflowRunArchiveDownloadTask]) -> ArchiveDownloadTaskDispatcher:
|
||||
def dispatch(
|
||||
task: WorkflowRunArchiveDownloadTask,
|
||||
cache: WorkflowRunArchiveDownloadTaskCache,
|
||||
) -> WorkflowRunArchiveDownloadTask:
|
||||
dispatched_tasks.append(task)
|
||||
return task
|
||||
|
||||
return dispatch
|
||||
|
||||
|
||||
def test_list_workflow_run_archives_aggregates_month_rows() -> None:
|
||||
latest = datetime.datetime(2026, 6, 25, 8, 0)
|
||||
previous = datetime.datetime(2026, 6, 24, 8, 0)
|
||||
session = MagicMock()
|
||||
march_download_id = build_archive_download_id(
|
||||
tenant_id="tenant-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_refs=[("00-of-01", "bundle-a"), ("00-of-01", "bundle-b")],
|
||||
)
|
||||
ready_task = build_pending_archive_download_task(
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_ids=["bundle-a", "bundle-b"],
|
||||
bundle_refs=[("00-of-01", "bundle-a"), ("00-of-01", "bundle-b")],
|
||||
archive_bytes=4096,
|
||||
download_id=march_download_id,
|
||||
).model_copy(
|
||||
update={
|
||||
"status": WorkflowRunArchiveDownloadStatus.READY,
|
||||
"file_name": "workflow-run-logs-2025-03.zip",
|
||||
"storage_key": "workflow-run-archive-downloads/tenant-1/2025/03/download.zip",
|
||||
"file_size_bytes": 8192,
|
||||
}
|
||||
)
|
||||
cache = FakeTaskCache(tasks_by_download_id={march_download_id: ready_task})
|
||||
session.scalars.return_value = [
|
||||
_bundle(
|
||||
year=2025,
|
||||
month=3,
|
||||
shard="00-of-01",
|
||||
bundle_id="bundle-a",
|
||||
workflow_run_count=40,
|
||||
row_count=360,
|
||||
archive_bytes=1024,
|
||||
archived_at=previous,
|
||||
),
|
||||
_bundle(
|
||||
year=2025,
|
||||
month=3,
|
||||
shard="00-of-01",
|
||||
bundle_id="bundle-b",
|
||||
workflow_run_count=60,
|
||||
row_count=540,
|
||||
archive_bytes=3072,
|
||||
archived_at=latest,
|
||||
),
|
||||
_bundle(
|
||||
year=2025,
|
||||
month=2,
|
||||
shard="00-of-01",
|
||||
bundle_id="bundle-c",
|
||||
workflow_run_count=20,
|
||||
row_count=180,
|
||||
archive_bytes=1024,
|
||||
archived_at=previous,
|
||||
),
|
||||
]
|
||||
|
||||
result = list_workflow_run_archives(session, "tenant-1", cache=cast(WorkflowRunArchiveDownloadTaskCache, cache))
|
||||
|
||||
assert result.summary.archived_month_count == 2
|
||||
assert result.summary.workflow_run_count == 120
|
||||
assert result.summary.archive_bytes == 5120
|
||||
assert result.summary.latest_archived_at == latest
|
||||
assert result.months[0].year == 2025
|
||||
assert result.months[0].month == 3
|
||||
assert result.months[0].bundle_count == 2
|
||||
assert result.months[0].workflow_run_count == 100
|
||||
assert result.months[0].row_count == 900
|
||||
assert result.months[0].download_task == ready_task
|
||||
assert result.months[1].download_task is None
|
||||
|
||||
|
||||
def test_create_workflow_run_archive_download_task_creates_stable_pending_task() -> None:
|
||||
session = MagicMock()
|
||||
session.scalars.return_value = [
|
||||
_bundle(shard="01-of-02", bundle_id="bundle-b", archive_bytes=2048),
|
||||
_bundle(shard="00-of-02", bundle_id="bundle-a", archive_bytes=1024),
|
||||
]
|
||||
cache = FakeTaskCache()
|
||||
dispatched_tasks: list[WorkflowRunArchiveDownloadTask] = []
|
||||
|
||||
task = create_workflow_run_archive_download_task(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
|
||||
dispatcher=_fake_dispatcher(dispatched_tasks),
|
||||
)
|
||||
|
||||
assert task.download_id == build_archive_download_id(
|
||||
tenant_id="tenant-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_refs=[("01-of-02", "bundle-b"), ("00-of-02", "bundle-a")],
|
||||
)
|
||||
assert task.requested_by == "account-1"
|
||||
assert task.bundle_ids == ["bundle-b", "bundle-a"]
|
||||
assert [(ref.shard, ref.bundle_id) for ref in task.bundle_refs] == [
|
||||
("01-of-02", "bundle-b"),
|
||||
("00-of-02", "bundle-a"),
|
||||
]
|
||||
assert task.archive_bytes == 3072
|
||||
assert cache.saved_task == dispatched_tasks[0]
|
||||
assert task.celery_task_id is not None
|
||||
|
||||
|
||||
def test_create_workflow_run_archive_download_task_returns_existing_task_when_cache_key_exists() -> None:
|
||||
session = MagicMock()
|
||||
session.scalars.return_value = [_bundle(shard="00-of-01", bundle_id="bundle-a", archive_bytes=1024)]
|
||||
existing_task = build_pending_archive_download_task(
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_ids=["bundle-a"],
|
||||
archive_bytes=1024,
|
||||
download_id="existing-download",
|
||||
).model_copy(update={"celery_task_id": "celery-task-1"})
|
||||
cache = FakeTaskCache(existing_task=existing_task)
|
||||
dispatched_tasks: list[WorkflowRunArchiveDownloadTask] = []
|
||||
|
||||
task = create_workflow_run_archive_download_task(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
|
||||
dispatcher=_fake_dispatcher(dispatched_tasks),
|
||||
)
|
||||
|
||||
assert task == existing_task
|
||||
assert cache.saved_task is None
|
||||
assert dispatched_tasks == []
|
||||
|
||||
|
||||
def test_create_workflow_run_archive_download_task_retries_failed_cached_task() -> None:
|
||||
session = MagicMock()
|
||||
session.scalars.return_value = [_bundle(shard="00-of-01", bundle_id="bundle-a", archive_bytes=1024)]
|
||||
existing_task = build_pending_archive_download_task(
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_ids=["bundle-a"],
|
||||
bundle_refs=[("00-of-01", "bundle-a")],
|
||||
archive_bytes=1024,
|
||||
download_id=build_archive_download_id(
|
||||
tenant_id="tenant-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_refs=[("00-of-01", "bundle-a")],
|
||||
),
|
||||
).model_copy(update={"status": WorkflowRunArchiveDownloadStatus.FAILED, "error": "failed"})
|
||||
cache = FakeTaskCache(existing_task=existing_task)
|
||||
dispatched_tasks: list[WorkflowRunArchiveDownloadTask] = []
|
||||
|
||||
task = create_workflow_run_archive_download_task(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
|
||||
dispatcher=_fake_dispatcher(dispatched_tasks),
|
||||
)
|
||||
|
||||
assert task.status == WorkflowRunArchiveDownloadStatus.PENDING
|
||||
assert task.error is None
|
||||
assert task.celery_task_id is not None
|
||||
assert cache.saved_task == dispatched_tasks[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retry_failed_task", [False, True])
|
||||
def test_create_workflow_run_archive_download_task_claims_dispatch_once(retry_failed_task: bool) -> None:
|
||||
session = MagicMock()
|
||||
session.scalars.return_value = [_bundle(shard="00-of-01", bundle_id="bundle-a", archive_bytes=1024)]
|
||||
download_id = build_archive_download_id(
|
||||
tenant_id="tenant-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_refs=[("00-of-01", "bundle-a")],
|
||||
)
|
||||
existing_task = None
|
||||
if retry_failed_task:
|
||||
existing_task = build_pending_archive_download_task(
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_ids=["bundle-a"],
|
||||
bundle_refs=[("00-of-01", "bundle-a")],
|
||||
archive_bytes=1024,
|
||||
download_id=download_id,
|
||||
).model_copy(update={"status": WorkflowRunArchiveDownloadStatus.FAILED})
|
||||
cache = FakeTaskCache(existing_task=existing_task)
|
||||
dispatched_tasks: list[WorkflowRunArchiveDownloadTask] = []
|
||||
concurrent_results: list[WorkflowRunArchiveDownloadTask] = []
|
||||
|
||||
def dispatch(
|
||||
task: WorkflowRunArchiveDownloadTask,
|
||||
task_cache: WorkflowRunArchiveDownloadTaskCache,
|
||||
) -> WorkflowRunArchiveDownloadTask:
|
||||
dispatched_tasks.append(task)
|
||||
concurrent_results.append(
|
||||
create_workflow_run_archive_download_task(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-2",
|
||||
year=2025,
|
||||
month=3,
|
||||
cache=task_cache,
|
||||
dispatcher=dispatch,
|
||||
)
|
||||
)
|
||||
return task
|
||||
|
||||
result = create_workflow_run_archive_download_task(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
|
||||
dispatcher=dispatch,
|
||||
)
|
||||
|
||||
assert dispatched_tasks == [result]
|
||||
assert concurrent_results == [result]
|
||||
|
||||
|
||||
def test_create_workflow_run_archive_download_task_rejects_missing_month() -> None:
|
||||
session = MagicMock()
|
||||
session.scalars.return_value = []
|
||||
|
||||
with pytest.raises(WorkflowRunArchiveNotFoundError):
|
||||
create_workflow_run_archive_download_task(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
cache=cast(WorkflowRunArchiveDownloadTaskCache, FakeTaskCache()),
|
||||
dispatcher=_fake_dispatcher([]),
|
||||
)
|
||||
|
||||
|
||||
def test_get_ready_workflow_run_archive_download_task_requires_ready_file() -> None:
|
||||
pending_task = build_pending_archive_download_task(
|
||||
tenant_id="tenant-1",
|
||||
requested_by="account-1",
|
||||
year=2025,
|
||||
month=3,
|
||||
bundle_ids=["bundle-a"],
|
||||
archive_bytes=1024,
|
||||
download_id="download-1",
|
||||
)
|
||||
cache = FakeTaskCache(existing_task=pending_task)
|
||||
|
||||
with pytest.raises(WorkflowRunArchiveDownloadNotReadyError):
|
||||
get_ready_workflow_run_archive_download_task(
|
||||
tenant_id="tenant-1",
|
||||
download_id="download-1",
|
||||
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
|
||||
)
|
||||
|
||||
ready_task = pending_task.model_copy(
|
||||
update={
|
||||
"status": WorkflowRunArchiveDownloadStatus.READY,
|
||||
"storage_key": "downloads/download-1.zip",
|
||||
"file_name": "workflow-run-logs-2025-03.zip",
|
||||
}
|
||||
)
|
||||
cache = FakeTaskCache(existing_task=ready_task)
|
||||
|
||||
assert (
|
||||
get_ready_workflow_run_archive_download_task(
|
||||
tenant_id="tenant-1",
|
||||
download_id="download-1",
|
||||
cache=cast(WorkflowRunArchiveDownloadTaskCache, cache),
|
||||
)
|
||||
== ready_task
|
||||
)
|
||||
@@ -78,5 +78,9 @@ export const contractLoaders = {
|
||||
workflow: () => import('./workflow/orpc.gen').then(({ workflow }) => ({ workflow })),
|
||||
workflowGenerate: () =>
|
||||
import('./workflow-generate/orpc.gen').then(({ workflowGenerate }) => ({ workflowGenerate })),
|
||||
workflowRunArchives: () =>
|
||||
import('./workflow-run-archives/orpc.gen').then(({ workflowRunArchives }) => ({
|
||||
workflowRunArchives,
|
||||
})),
|
||||
workspaces: () => import('./workspaces/orpc.gen').then(({ workspaces }) => ({ workspaces })),
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import { trialModels } from './trial-models/orpc.gen'
|
||||
import { version } from './version/orpc.gen'
|
||||
import { website } from './website/orpc.gen'
|
||||
import { workflowGenerate } from './workflow-generate/orpc.gen'
|
||||
import { workflowRunArchives } from './workflow-run-archives/orpc.gen'
|
||||
import { workflow } from './workflow/orpc.gen'
|
||||
import { workspaces } from './workspaces/orpc.gen'
|
||||
|
||||
@@ -108,6 +109,7 @@ const communityContract = {
|
||||
website,
|
||||
workflow,
|
||||
workflowGenerate,
|
||||
workflowRunArchives,
|
||||
workspaces,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { oc } from '@orpc/contract'
|
||||
import * as z from 'zod'
|
||||
import {
|
||||
zGetWorkflowRunArchivesDownloadsByDownloadIdPath,
|
||||
zGetWorkflowRunArchivesDownloadsByDownloadIdResponse,
|
||||
zGetWorkflowRunArchivesResponse,
|
||||
zPostWorkflowRunArchivesDownloadsBody,
|
||||
zPostWorkflowRunArchivesDownloadsResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
/**
|
||||
* Get a temporary workflow-run archive download task
|
||||
*/
|
||||
export const get = oc
|
||||
.route({
|
||||
description: 'Get a temporary workflow-run archive download task',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getWorkflowRunArchivesDownloadsByDownloadId',
|
||||
path: '/workflow-run-archives/downloads/{download_id}',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetWorkflowRunArchivesDownloadsByDownloadIdPath }))
|
||||
.output(zGetWorkflowRunArchivesDownloadsByDownloadIdResponse)
|
||||
|
||||
export const byDownloadId = {
|
||||
get,
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or return a temporary workflow-run archive download task
|
||||
*/
|
||||
export const post = oc
|
||||
.route({
|
||||
description: 'Create or return a temporary workflow-run archive download task',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postWorkflowRunArchivesDownloads',
|
||||
path: '/workflow-run-archives/downloads',
|
||||
successStatus: 202,
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ body: zPostWorkflowRunArchivesDownloadsBody }))
|
||||
.output(zPostWorkflowRunArchivesDownloadsResponse)
|
||||
|
||||
export const downloads = {
|
||||
post,
|
||||
byDownloadId,
|
||||
}
|
||||
|
||||
/**
|
||||
* List monthly workflow-run archive metadata for the current workspace
|
||||
*/
|
||||
export const get2 = oc
|
||||
.route({
|
||||
description: 'List monthly workflow-run archive metadata for the current workspace',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getWorkflowRunArchives',
|
||||
path: '/workflow-run-archives',
|
||||
tags: ['console'],
|
||||
})
|
||||
.output(zGetWorkflowRunArchivesResponse)
|
||||
|
||||
export const workflowRunArchives = {
|
||||
get: get2,
|
||||
downloads,
|
||||
}
|
||||
|
||||
export const contract = {
|
||||
workflowRunArchives,
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type ClientOptions = {
|
||||
baseUrl: `${string}://${string}/console/api` | (string & {})
|
||||
}
|
||||
|
||||
export type WorkflowRunArchiveListResponse = {
|
||||
months: Array<WorkflowRunArchiveMonthResponse>
|
||||
summary: WorkflowRunArchiveSummaryResponse
|
||||
}
|
||||
|
||||
export type WorkflowRunArchiveDownloadPayload = {
|
||||
month: number
|
||||
year: number
|
||||
}
|
||||
|
||||
export type WorkflowRunArchiveDownloadTaskResponse = {
|
||||
archive_bytes: number
|
||||
bundle_count: number
|
||||
created_at: string
|
||||
download_id: string
|
||||
error?: string | null
|
||||
expires_at: string
|
||||
file_name?: string | null
|
||||
file_size_bytes?: number | null
|
||||
finished_at?: string | null
|
||||
month: number
|
||||
started_at?: string | null
|
||||
status: WorkflowRunArchiveDownloadStatus
|
||||
updated_at: string
|
||||
year: number
|
||||
}
|
||||
|
||||
export type WorkflowRunArchiveMonthResponse = {
|
||||
archive_bytes: number
|
||||
bundle_count: number
|
||||
download_task?: WorkflowRunArchiveDownloadTaskResponse | null
|
||||
latest_archived_at: string
|
||||
month: number
|
||||
row_count: number
|
||||
workflow_run_count: number
|
||||
year: number
|
||||
}
|
||||
|
||||
export type WorkflowRunArchiveSummaryResponse = {
|
||||
archive_bytes: number
|
||||
archived_month_count: number
|
||||
latest_archived_at?: string | null
|
||||
workflow_run_count: number
|
||||
}
|
||||
|
||||
export type WorkflowRunArchiveDownloadStatus = 'failed' | 'pending' | 'processing' | 'ready'
|
||||
|
||||
export type GetWorkflowRunArchivesData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workflow-run-archives'
|
||||
}
|
||||
|
||||
export type GetWorkflowRunArchivesResponses = {
|
||||
200: WorkflowRunArchiveListResponse
|
||||
}
|
||||
|
||||
export type GetWorkflowRunArchivesResponse =
|
||||
GetWorkflowRunArchivesResponses[keyof GetWorkflowRunArchivesResponses]
|
||||
|
||||
export type PostWorkflowRunArchivesDownloadsData = {
|
||||
body: WorkflowRunArchiveDownloadPayload
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workflow-run-archives/downloads'
|
||||
}
|
||||
|
||||
export type PostWorkflowRunArchivesDownloadsResponses = {
|
||||
202: WorkflowRunArchiveDownloadTaskResponse
|
||||
}
|
||||
|
||||
export type PostWorkflowRunArchivesDownloadsResponse =
|
||||
PostWorkflowRunArchivesDownloadsResponses[keyof PostWorkflowRunArchivesDownloadsResponses]
|
||||
|
||||
export type GetWorkflowRunArchivesDownloadsByDownloadIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
download_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workflow-run-archives/downloads/{download_id}'
|
||||
}
|
||||
|
||||
export type GetWorkflowRunArchivesDownloadsByDownloadIdResponses = {
|
||||
200: WorkflowRunArchiveDownloadTaskResponse
|
||||
}
|
||||
|
||||
export type GetWorkflowRunArchivesDownloadsByDownloadIdResponse =
|
||||
GetWorkflowRunArchivesDownloadsByDownloadIdResponses[keyof GetWorkflowRunArchivesDownloadsByDownloadIdResponses]
|
||||
@@ -0,0 +1,99 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import * as z from 'zod'
|
||||
|
||||
/**
|
||||
* WorkflowRunArchiveDownloadPayload
|
||||
*
|
||||
* Request body for preparing one monthly workflow-run archive download.
|
||||
*/
|
||||
export const zWorkflowRunArchiveDownloadPayload = z.object({
|
||||
month: z.int().gte(1).lte(12),
|
||||
year: z.int().gte(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunArchiveSummaryResponse
|
||||
*/
|
||||
export const zWorkflowRunArchiveSummaryResponse = z.object({
|
||||
archive_bytes: z.int(),
|
||||
archived_month_count: z.int(),
|
||||
latest_archived_at: z.iso.datetime().nullish(),
|
||||
workflow_run_count: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunArchiveDownloadStatus
|
||||
*
|
||||
* Lifecycle state for an asynchronous archive download request.
|
||||
*/
|
||||
export const zWorkflowRunArchiveDownloadStatus = z.enum([
|
||||
'failed',
|
||||
'pending',
|
||||
'processing',
|
||||
'ready',
|
||||
])
|
||||
|
||||
/**
|
||||
* WorkflowRunArchiveDownloadTaskResponse
|
||||
*/
|
||||
export const zWorkflowRunArchiveDownloadTaskResponse = z.object({
|
||||
archive_bytes: z.int(),
|
||||
bundle_count: z.int(),
|
||||
created_at: z.iso.datetime(),
|
||||
download_id: z.string(),
|
||||
error: z.string().nullish(),
|
||||
expires_at: z.iso.datetime(),
|
||||
file_name: z.string().nullish(),
|
||||
file_size_bytes: z.int().nullish(),
|
||||
finished_at: z.iso.datetime().nullish(),
|
||||
month: z.int(),
|
||||
started_at: z.iso.datetime().nullish(),
|
||||
status: zWorkflowRunArchiveDownloadStatus,
|
||||
updated_at: z.iso.datetime(),
|
||||
year: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunArchiveMonthResponse
|
||||
*/
|
||||
export const zWorkflowRunArchiveMonthResponse = z.object({
|
||||
archive_bytes: z.int(),
|
||||
bundle_count: z.int(),
|
||||
download_task: zWorkflowRunArchiveDownloadTaskResponse.nullish(),
|
||||
latest_archived_at: z.iso.datetime(),
|
||||
month: z.int(),
|
||||
row_count: z.int(),
|
||||
workflow_run_count: z.int(),
|
||||
year: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunArchiveListResponse
|
||||
*/
|
||||
export const zWorkflowRunArchiveListResponse = z.object({
|
||||
months: z.array(zWorkflowRunArchiveMonthResponse),
|
||||
summary: zWorkflowRunArchiveSummaryResponse,
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetWorkflowRunArchivesResponse = zWorkflowRunArchiveListResponse
|
||||
|
||||
export const zPostWorkflowRunArchivesDownloadsBody = zWorkflowRunArchiveDownloadPayload
|
||||
|
||||
/**
|
||||
* Download task accepted
|
||||
*/
|
||||
export const zPostWorkflowRunArchivesDownloadsResponse = zWorkflowRunArchiveDownloadTaskResponse
|
||||
|
||||
export const zGetWorkflowRunArchivesDownloadsByDownloadIdPath = z.object({
|
||||
download_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetWorkflowRunArchivesDownloadsByDownloadIdResponse =
|
||||
zWorkflowRunArchiveDownloadTaskResponse
|
||||
@@ -0,0 +1,82 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { ArchivedLogsNotice } from '../archived-logs-notice'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
IS_CLOUD_EDITION: true,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/workspace-state', () => ({ isCurrentWorkspaceManagerAtom: {} }))
|
||||
vi.mock('jotai', () => ({ useAtomValue: () => true }))
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/modal-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/modal-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useModalContextSelector: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseModalContextSelector = vi.mocked(useModalContextSelector)
|
||||
|
||||
function mockProviderPlan(planType: Plan) {
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('ArchivedLogsNotice', () => {
|
||||
const setShowAccountSettingModal = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockProviderPlan(Plan.professional)
|
||||
mockUseModalContextSelector.mockImplementation((selector) =>
|
||||
selector({
|
||||
setShowAccountSettingModal,
|
||||
} as unknown as Parameters<typeof selector>[0]),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show notice for paid workspace managers', () => {
|
||||
render(<ArchivedLogsNotice />)
|
||||
|
||||
expect(screen.getByText('appLog.archives.notice.description')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.notice.action' }))
|
||||
expect(setShowAccountSettingModal).toHaveBeenCalledWith({
|
||||
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show notice for sandbox workspaces', () => {
|
||||
mockProviderPlan(Plan.sandbox)
|
||||
|
||||
render(<ArchivedLogsNotice />)
|
||||
|
||||
expect(screen.queryByText('appLog.archives.notice.description')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
const LAST_THREE_MONTHS_PERIOD = '4'
|
||||
|
||||
type TimePeriodMapping = Record<string, { value: number }>
|
||||
|
||||
export function shouldShowArchivedLogsNotice(period: string, periodMapping: TimePeriodMapping) {
|
||||
const periodValue = periodMapping[period]?.value
|
||||
const lastThreeMonthsValue = periodMapping[LAST_THREE_MONTHS_PERIOD]?.value
|
||||
if (periodValue === undefined || lastThreeMonthsValue === undefined) return false
|
||||
|
||||
return periodValue < 0 || periodValue > lastThreeMonthsValue
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
|
||||
export function ArchivedLogsNotice() {
|
||||
const { t } = useTranslation()
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const { enableBilling, plan } = useProviderContext()
|
||||
const setShowAccountSettingModal = useModalContextSelector(
|
||||
(state) => state.setShowAccountSettingModal,
|
||||
)
|
||||
|
||||
if (
|
||||
!IS_CLOUD_EDITION ||
|
||||
!isCurrentWorkspaceManager ||
|
||||
!enableBilling ||
|
||||
plan.type === Plan.sandbox
|
||||
)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-3 py-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-0.5 i-ri-information-line size-4 shrink-0 text-util-colors-warning-warning-600"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 system-xs-regular text-util-colors-warning-warning-700">
|
||||
{t(($) => $['archives.notice.description'], { ns: 'appLog' })}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 system-xs-semibold text-util-colors-warning-warning-700 underline underline-offset-2 hover:text-text-primary"
|
||||
onClick={() =>
|
||||
setShowAccountSettingModal({
|
||||
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(($) => $['archives.notice.action'], { ns: 'appLog' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { useWorkflowLogs } from '@/service/use-log'
|
||||
import PageTitle from '../log-annotation/page-title'
|
||||
import { ArchivedLogsNotice } from '../log/archived-logs-notice'
|
||||
import { shouldShowArchivedLogsNotice } from '../log/archived-logs-notice-utils'
|
||||
import Filter, { TIME_PERIOD_MAPPING } from './filter'
|
||||
import List from './list'
|
||||
|
||||
@@ -69,6 +71,10 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
})
|
||||
const total = workflowLogs?.total
|
||||
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
|
||||
const showArchivedLogsNotice = shouldShowArchivedLogsNotice(
|
||||
queryParams.period,
|
||||
TIME_PERIOD_MAPPING,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -78,6 +84,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
/>
|
||||
<div className="flex max-h-[calc(100%-16px)] flex-1 flex-col py-4">
|
||||
<Filter queryParams={queryParams} setQueryParams={setQueryParams} />
|
||||
{showArchivedLogsNotice && <ArchivedLogsNotice />}
|
||||
{/* workflow log */}
|
||||
{total === undefined ? (
|
||||
<Loading type="app" />
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('AccountSetting Constants', () => {
|
||||
expect(ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS).toBe('roles-and-permissions')
|
||||
expect(ACCOUNT_SETTING_TAB.PERMISSION_SET).toBe('permission-set')
|
||||
expect(ACCOUNT_SETTING_TAB.BILLING).toBe('billing')
|
||||
expect(ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES).toBe('workflow-log-archives')
|
||||
expect(ACCOUNT_SETTING_TAB.DATA_SOURCE).toBe('data-source')
|
||||
expect(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION).toBe('custom-endpoint')
|
||||
expect(ACCOUNT_SETTING_TAB.CUSTOM).toBe('custom')
|
||||
@@ -31,6 +32,7 @@ describe('AccountSetting Constants', () => {
|
||||
expect(isValidSettingsTab('roles-and-permissions')).toBe(true)
|
||||
expect(isValidSettingsTab('permission-set')).toBe(true)
|
||||
expect(isValidSettingsTab('billing')).toBe(true)
|
||||
expect(isValidSettingsTab('workflow-log-archives')).toBe(true)
|
||||
expect(isValidSettingsTab('preferences')).toBe(true)
|
||||
expect(isValidSettingsTab('language')).toBe(true)
|
||||
expect(isValidSettingsTab('provider')).toBe(true)
|
||||
|
||||
@@ -9,10 +9,22 @@ import { ACCOUNT_SETTING_TAB } from '../constants'
|
||||
import AccountSetting from '../index'
|
||||
|
||||
const mockResetModelProviderListExpanded = vi.fn()
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
IS_CLOUD_EDITION: true,
|
||||
}))
|
||||
const mockAppContextState = vi.hoisted(() => ({
|
||||
current: null as unknown,
|
||||
}))
|
||||
const mockUseAppContext = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.IS_CLOUD_EDITION
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
@@ -240,12 +252,12 @@ describe('AccountSetting', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfig.IS_CLOUD_EDITION = true
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
enableBilling: true,
|
||||
enableReplaceWebAppLogo: true,
|
||||
})
|
||||
mockUseAppContext.mockReturnValue(baseAppContextValue)
|
||||
mockAppContextState.current = baseAppContextValue
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
|
||||
})
|
||||
@@ -267,9 +279,15 @@ describe('AccountSetting', () => {
|
||||
screen.getByRole('button', { name: 'common.settings.permissionSet' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('common.settings.billing'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'appLog.archives.title' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.settings.dataSource'))!.not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.settings.customEndpoint'))!.not.toBeInTheDocument()
|
||||
expect(screen.getByText('custom.custom'))!.toBeInTheDocument()
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: 'custom.custom' })
|
||||
.compareDocumentPosition(screen.getByRole('button', { name: 'appLog.archives.title' })),
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
expect(screen.getByText('common.settings.preferences'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -372,7 +390,6 @@ describe('AccountSetting', () => {
|
||||
...baseAppContextValue,
|
||||
isCurrentWorkspaceDatasetOperator: true,
|
||||
}
|
||||
mockUseAppContext.mockReturnValue(datasetOperatorContext)
|
||||
mockAppContextState.current = datasetOperatorContext
|
||||
|
||||
// Act
|
||||
@@ -387,6 +404,7 @@ describe('AccountSetting', () => {
|
||||
screen.getByRole('button', { name: 'common.settings.permissionSet' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.settings.billing' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'appLog.archives.title' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'custom.custom' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'common.settings.preferences' }),
|
||||
@@ -401,7 +419,6 @@ describe('AccountSetting', () => {
|
||||
(key) => key !== 'api_extension.manage',
|
||||
),
|
||||
}
|
||||
mockUseAppContext.mockReturnValue(contextWithoutApiExtensionPermission)
|
||||
mockAppContextState.current = contextWithoutApiExtensionPermission
|
||||
|
||||
// Act
|
||||
@@ -421,7 +438,6 @@ describe('AccountSetting', () => {
|
||||
(key) => key !== 'customization.manage',
|
||||
),
|
||||
}
|
||||
mockUseAppContext.mockReturnValue(contextWithoutCustomizationPermission)
|
||||
mockAppContextState.current = contextWithoutCustomizationPermission
|
||||
|
||||
// Act
|
||||
@@ -430,6 +446,7 @@ describe('AccountSetting', () => {
|
||||
// Assert
|
||||
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.settings.members' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'appLog.archives.title' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'custom.custom' })).toBeInTheDocument()
|
||||
expect(screen.getByText('common.settings.preferences'))!.toBeInTheDocument()
|
||||
})
|
||||
@@ -442,7 +459,6 @@ describe('AccountSetting', () => {
|
||||
(key) => key !== 'workspace.role.manage',
|
||||
),
|
||||
}
|
||||
mockUseAppContext.mockReturnValue(contextWithoutRoleManagePermission)
|
||||
mockAppContextState.current = contextWithoutRoleManagePermission
|
||||
|
||||
// Act
|
||||
@@ -507,7 +523,6 @@ describe('AccountSetting', () => {
|
||||
(key) => key !== 'billing.view',
|
||||
),
|
||||
}
|
||||
mockUseAppContext.mockReturnValue(contextWithoutBillingViewPermission)
|
||||
mockAppContextState.current = contextWithoutBillingViewPermission
|
||||
|
||||
// Act
|
||||
@@ -519,6 +534,57 @@ describe('AccountSetting', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide workflow log archives outside cloud edition', () => {
|
||||
// Arrange
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
|
||||
// Act
|
||||
renderAccountSetting()
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'appLog.archives.title' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide workflow log archives from custom RBAC roles that are not owner or admin', () => {
|
||||
// Arrange
|
||||
const contextWithRoleManagePermissionButNotManager = {
|
||||
...baseAppContextValue,
|
||||
currentWorkspace: {
|
||||
...baseAppContextValue.currentWorkspace,
|
||||
role: 'normal' as const,
|
||||
},
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
workspacePermissionKeys: [
|
||||
...(baseAppContextValue.workspacePermissionKeys ?? []),
|
||||
'workspace.role.manage',
|
||||
],
|
||||
}
|
||||
mockAppContextState.current = contextWithRoleManagePermissionButNotManager
|
||||
|
||||
// Act
|
||||
renderAccountSetting()
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'appLog.archives.title' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render workflow log archives page outside cloud edition', () => {
|
||||
// Arrange
|
||||
mockConfig.IS_CLOUD_EDITION = false
|
||||
|
||||
// Act
|
||||
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES })
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('workflow-log-archives-page')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not render billing page when active billing tab lacks billing view permission', () => {
|
||||
// Arrange
|
||||
const contextWithoutBillingViewPermission = {
|
||||
@@ -527,7 +593,6 @@ describe('AccountSetting', () => {
|
||||
(key) => key !== 'billing.view',
|
||||
),
|
||||
}
|
||||
mockUseAppContext.mockReturnValue(contextWithoutBillingViewPermission)
|
||||
mockAppContextState.current = contextWithoutBillingViewPermission
|
||||
|
||||
// Act
|
||||
@@ -566,6 +631,10 @@ describe('AccountSetting', () => {
|
||||
// Custom Page uses 'custom.custom' key as well.
|
||||
expect(screen.getAllByText('custom.custom').length).toBeGreaterThan(1)
|
||||
|
||||
// Workflow Log Archives
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.title' }))
|
||||
expect(screen.getByTestId('workflow-log-archives-page')).toBeInTheDocument()
|
||||
|
||||
// Members
|
||||
fireEvent.click(screen.getAllByText('common.settings.members')[0]!)
|
||||
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(1)
|
||||
|
||||
@@ -9,6 +9,7 @@ export const ACCOUNT_SETTING_TAB = {
|
||||
ROLES_AND_PERMISSIONS: 'roles-and-permissions',
|
||||
PERMISSION_SET: 'permission-set',
|
||||
BILLING: 'billing',
|
||||
WORKFLOW_LOG_ARCHIVES: 'workflow-log-archives',
|
||||
DATA_SOURCE: 'data-source',
|
||||
API_BASED_EXTENSION: 'custom-endpoint',
|
||||
CUSTOM: 'custom',
|
||||
@@ -25,6 +26,7 @@ const WORKSPACE_SETTING_TAB_VALUES = [
|
||||
ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS,
|
||||
ACCOUNT_SETTING_TAB.PERMISSION_SET,
|
||||
ACCOUNT_SETTING_TAB.BILLING,
|
||||
ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
|
||||
ACCOUNT_SETTING_TAB.CUSTOM,
|
||||
] as const
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ import BillingPage from '@/app/components/billing/billing-page'
|
||||
import CustomPage from '@/app/components/custom/custom-page'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { BillingPermission, hasPermission } from '@/utils/permission'
|
||||
@@ -24,6 +26,7 @@ import ModelProviderPage from './model-provider-page'
|
||||
import { useResetModelProviderListExpanded } from './model-provider-page/atoms'
|
||||
import PermissionsPage from './permissions-page'
|
||||
import PreferencePage from './preference-page'
|
||||
import WorkflowLogArchivesPage from './workflow-log-archives-page'
|
||||
|
||||
const iconClassName = `
|
||||
w-4 h-4 mr-2
|
||||
@@ -54,17 +57,24 @@ export default function AccountSetting({
|
||||
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const canManageWorkspaceRoles =
|
||||
isRbacEnabled && hasPermission(workspacePermissionKeys, 'workspace.role.manage')
|
||||
const canViewBilling =
|
||||
enableBilling && hasPermission(workspacePermissionKeys, BillingPermission.View)
|
||||
const canViewWorkflowLogArchives = IS_CLOUD_EDITION && isCurrentWorkspaceManager
|
||||
// Keep legacy `language` deep links opening Preferences during the tab rename migration.
|
||||
const normalizedActiveTab =
|
||||
activeTab === ACCOUNT_SETTING_TAB.LANGUAGE ? ACCOUNT_SETTING_TAB.PREFERENCES : activeTab
|
||||
const activeMenu = (() => {
|
||||
if (normalizedActiveTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling)
|
||||
return ACCOUNT_SETTING_TAB.PREFERENCES
|
||||
if (
|
||||
normalizedActiveTab === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES &&
|
||||
!canViewWorkflowLogArchives
|
||||
)
|
||||
return ACCOUNT_SETTING_TAB.MEMBERS
|
||||
if (
|
||||
(normalizedActiveTab === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS ||
|
||||
normalizedActiveTab === ACCOUNT_SETTING_TAB.PERMISSION_SET) &&
|
||||
@@ -108,6 +118,13 @@ export default function AccountSetting({
|
||||
icon: <span className={cn('i-ri-money-dollar-circle-line', iconClassName)} />,
|
||||
activeIcon: <span className={cn('i-ri-money-dollar-circle-fill', iconClassName)} />,
|
||||
},
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
|
||||
name: t(($) => $['archives.title'], { ns: 'appLog' }),
|
||||
description: t(($) => $['archives.description'], { ns: 'appLog' }),
|
||||
icon: <span className={cn('i-ri-archive-drawer-line', iconClassName)} />,
|
||||
activeIcon: <span className={cn('i-ri-archive-drawer-fill', iconClassName)} />,
|
||||
},
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.DATA_SOURCE,
|
||||
name: t(($) => $['settings.dataSource'], { ns: 'common' }),
|
||||
@@ -150,6 +167,8 @@ export default function AccountSetting({
|
||||
|
||||
if (enableReplaceWebAppLogo || enableBilling) visibleTabs.push(ACCOUNT_SETTING_TAB.CUSTOM)
|
||||
|
||||
if (canViewWorkflowLogArchives) visibleTabs.push(ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES)
|
||||
|
||||
return visibleTabs
|
||||
.map((tab) => settingItems.find((item) => item.key === tab))
|
||||
.filter((item): item is GroupItem => Boolean(item))
|
||||
@@ -276,6 +295,9 @@ export default function AccountSetting({
|
||||
)}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.PERMISSION_SET && <AccessRulesPage />}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.BILLING && <BillingPage />}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && (
|
||||
<WorkflowLogArchivesPage />
|
||||
)}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.DATA_SOURCE && <DataSourcePage />}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && <ApiBasedExtensionPage />}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.CUSTOM && <CustomPage />}
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import type { GetWorkflowRunArchivesResponse } from '@dify/contracts/api/console/workflow-run-archives/types.gen'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import {
|
||||
createTestQueryClient,
|
||||
renderWithSystemFeatures,
|
||||
} from '@/__tests__/utils/mock-system-features'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import WorkflowLogArchivesPage from '../index'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
IS_CLOUD_EDITION: true,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/modal-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/modal-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useModalContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseModalContext = vi.mocked(useModalContext)
|
||||
|
||||
const archiveData: GetWorkflowRunArchivesResponse = {
|
||||
summary: {
|
||||
archived_month_count: 1,
|
||||
workflow_run_count: 125,
|
||||
archive_bytes: 1048576,
|
||||
latest_archived_at: '2025-03-03T00:00:00Z',
|
||||
},
|
||||
months: [
|
||||
{
|
||||
year: 2025,
|
||||
month: 3,
|
||||
workflow_run_count: 125,
|
||||
row_count: 1125,
|
||||
archive_bytes: 1048576,
|
||||
bundle_count: 2,
|
||||
latest_archived_at: '2025-03-03T00:00:00Z',
|
||||
download_task: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function mockPlan(planType: Plan.sandbox | Plan.professional) {
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = createTestQueryClient()
|
||||
queryClient.setQueryData(consoleQuery.workflowRunArchives.get.queryKey(), archiveData)
|
||||
|
||||
return renderWithSystemFeatures(<WorkflowLogArchivesPage />, {
|
||||
queryClient,
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkflowLogArchivesPage', () => {
|
||||
const setShowPricingModal = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPlan(Plan.professional)
|
||||
mockUseModalContext.mockReturnValue({
|
||||
setShowPricingModal,
|
||||
} as unknown as ReturnType<typeof useModalContext>)
|
||||
})
|
||||
|
||||
describe('Plan access', () => {
|
||||
it('should show upgrade guidance instead of archive content for sandbox workspaces', () => {
|
||||
// Arrange
|
||||
mockPlan(Plan.sandbox)
|
||||
|
||||
// Act
|
||||
renderPage()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('appLog.archives.upgradeTip.title')).toBeInTheDocument()
|
||||
expect(screen.queryByText('2025-03')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open pricing modal from the sandbox upgrade guidance', () => {
|
||||
// Arrange
|
||||
mockPlan(Plan.sandbox)
|
||||
renderPage()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }))
|
||||
|
||||
// Assert
|
||||
expect(setShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show archive content for paid workspaces', () => {
|
||||
// Arrange
|
||||
mockPlan(Plan.professional)
|
||||
|
||||
// Act
|
||||
renderPage()
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('appLog.archives.upgradeTip.title')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('2025-03')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('125').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,420 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
WorkflowRunArchiveDownloadStatus,
|
||||
WorkflowRunArchiveDownloadTaskResponse,
|
||||
WorkflowRunArchiveMonthResponse,
|
||||
} from '@dify/contracts/api/console/workflow-run-archives/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { API_PREFIX, IS_CLOUD_EDITION } from '@/config'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat()
|
||||
const byteFormatter = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const DOWNLOAD_TASK_POLLING_INTERVAL = 3000
|
||||
const ARCHIVE_MONTH_PAGE_SIZE = 20
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return numberFormatter.format(value)
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes <= 0) return '0 B'
|
||||
|
||||
let value = bytes
|
||||
let unitIndex = 0
|
||||
while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
|
||||
value /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
return `${byteFormatter.format(value)} ${BYTE_UNITS[unitIndex]}`
|
||||
}
|
||||
|
||||
function formatMonth(year: number, month: number) {
|
||||
return `${year}-${String(month).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) return '-'
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
|
||||
function isPreparingStatus(status: WorkflowRunArchiveDownloadStatus | undefined) {
|
||||
return status === 'pending' || status === 'processing'
|
||||
}
|
||||
|
||||
function buildArchiveDownloadFileUrl(downloadId: string) {
|
||||
return `${API_PREFIX}/workflow-run-archives/downloads/${downloadId}/file`
|
||||
}
|
||||
|
||||
const tableGridClassName = 'grid-cols-[0.66fr_0.78fr_0.78fr_1fr]'
|
||||
|
||||
export default function WorkflowLogArchivesPage() {
|
||||
const { t } = useTranslation()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const [visibleArchiveMonthCount, setVisibleArchiveMonthCount] = useState(ARCHIVE_MONTH_PAGE_SIZE)
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null)
|
||||
const canViewArchiveContent = IS_CLOUD_EDITION && enableBilling && plan.type !== Plan.sandbox
|
||||
const archiveListQuery = useQuery(
|
||||
consoleQuery.workflowRunArchives.get.queryOptions({
|
||||
enabled: canViewArchiveContent,
|
||||
}),
|
||||
)
|
||||
const archiveData = archiveListQuery.data
|
||||
const archiveMonths = archiveData?.months ?? []
|
||||
const visibleArchiveMonths = archiveMonths.slice(0, visibleArchiveMonthCount)
|
||||
const summary = archiveData?.summary
|
||||
const isLoading = archiveListQuery.isLoading
|
||||
const hasMoreArchives = visibleArchiveMonths.length < archiveMonths.length
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMoreArchives) return
|
||||
|
||||
const loadMoreElement = loadMoreRef.current
|
||||
if (!loadMoreElement) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries[0]?.isIntersecting) return
|
||||
|
||||
setVisibleArchiveMonthCount((count) =>
|
||||
Math.min(count + ARCHIVE_MONTH_PAGE_SIZE, archiveMonths.length),
|
||||
)
|
||||
},
|
||||
{ rootMargin: '120px' },
|
||||
)
|
||||
observer.observe(loadMoreElement)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [archiveMonths.length, hasMoreArchives, visibleArchiveMonthCount])
|
||||
|
||||
const summaryItems = [
|
||||
{
|
||||
label: t(($) => $['archives.summary.months'], { ns: 'appLog' }),
|
||||
value: summary ? formatNumber(summary.archived_month_count) : '0',
|
||||
icon: 'i-ri-calendar-2-line',
|
||||
},
|
||||
{
|
||||
label: t(($) => $['archives.summary.runs'], { ns: 'appLog' }),
|
||||
value: summary ? formatNumber(summary.workflow_run_count) : '0',
|
||||
icon: 'i-ri-git-branch-line',
|
||||
},
|
||||
{
|
||||
label: t(($) => $['archives.summary.size'], { ns: 'appLog' }),
|
||||
value: summary ? formatBytes(summary.archive_bytes) : '0 B',
|
||||
icon: 'i-ri-hard-drive-2-line',
|
||||
},
|
||||
{
|
||||
label: t(($) => $['archives.summary.latest'], { ns: 'appLog' }),
|
||||
value: formatDate(summary?.latest_archived_at),
|
||||
icon: 'i-ri-time-line',
|
||||
},
|
||||
]
|
||||
|
||||
if (!canViewArchiveContent) {
|
||||
return (
|
||||
<div data-testid="workflow-log-archives-page" className="pb-6">
|
||||
<ArchivedLogsUpgradeBanner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="workflow-log-archives-page" className="flex flex-col gap-4 pb-6">
|
||||
<div className="rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn p-2">
|
||||
<div className="grid grid-cols-2 gap-1 lg:grid-cols-4">
|
||||
{summaryItems.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex min-h-[92px] flex-col gap-2 rounded-xl bg-components-panel-bg p-4"
|
||||
>
|
||||
<span className={cn(item.icon, 'size-4 text-text-tertiary')} aria-hidden="true" />
|
||||
<div className="system-xs-medium text-text-tertiary">{item.label}</div>
|
||||
<div className="flex min-h-6 items-center">
|
||||
{isLoading ? (
|
||||
<SkeletonRectangle className="h-5 w-20 animate-pulse rounded-md" />
|
||||
) : (
|
||||
<div className="system-md-semibold whitespace-nowrap text-text-primary">
|
||||
{item.value}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg shadow-xs">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[460px]">
|
||||
<div
|
||||
className={cn(
|
||||
'grid h-8 items-center gap-3 border-b border-divider-subtle bg-background-section-burn px-4 system-xs-medium-uppercase text-text-tertiary',
|
||||
tableGridClassName,
|
||||
)}
|
||||
>
|
||||
<div className="text-center">
|
||||
{t(($) => $['archives.table.month'], { ns: 'appLog' })}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{t(($) => $['archives.table.runs'], { ns: 'appLog' })}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{t(($) => $['archives.table.size'], { ns: 'appLog' })}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{t(($) => $['archives.table.action'], { ns: 'appLog' })}
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<>
|
||||
{['first', 'second', 'third'].map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'grid min-h-15 items-center gap-3 border-b border-divider-subtle px-4 py-3 last:border-b-0',
|
||||
tableGridClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<SkeletonRectangle className="h-4 w-16 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<SkeletonRectangle className="h-4 w-16 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<SkeletonRectangle className="h-4 w-14 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<SkeletonRectangle className="h-8 w-24 animate-pulse rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!isLoading && archiveListQuery.isError && (
|
||||
<div className="flex min-h-36 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<span
|
||||
className="i-ri-error-warning-line size-6 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
{t(($) => $['archives.error.title'], { ns: 'appLog' })}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['archives.error.description'], { ns: 'appLog' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !archiveListQuery.isError && archiveMonths.length === 0 && (
|
||||
<div className="flex min-h-36 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<span className="i-ri-archive-line size-6 text-text-tertiary" aria-hidden="true" />
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
{t(($) => $['archives.empty.title'], { ns: 'appLog' })}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['archives.empty.description'], { ns: 'appLog' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading &&
|
||||
!archiveListQuery.isError &&
|
||||
visibleArchiveMonths.map((archive) => {
|
||||
const archiveMonth = formatMonth(archive.year, archive.month)
|
||||
|
||||
return <WorkflowArchiveMonthRow key={archiveMonth} archive={archive} />
|
||||
})}
|
||||
{!isLoading && !archiveListQuery.isError && hasMoreArchives && (
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className="flex h-10 items-center justify-center border-t border-divider-subtle bg-components-card-bg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<SkeletonRectangle className="h-4 w-20 animate-pulse rounded-md" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArchivedLogsUpgradeBanner() {
|
||||
const { t } = useTranslation()
|
||||
const { setShowPricingModal } = useModalContext()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-xl bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-4 pl-6 shadow-lg backdrop-blur-xs sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1 text-text-primary-on-surface">
|
||||
<div className="title-xl-semi-bold">
|
||||
{t(($) => $['archives.upgradeTip.title'], { ns: 'appLog' })}
|
||||
</div>
|
||||
<div className="system-sm-regular">
|
||||
{t(($) => $['archives.upgradeTip.description'], { ns: 'appLog' })}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-[120px] shrink-0 cursor-pointer items-center justify-center rounded-3xl border-none bg-white p-0 system-md-semibold text-text-accent shadow-xs hover:opacity-95"
|
||||
onClick={() => setShowPricingModal()}
|
||||
>
|
||||
{t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowArchiveMonthRow({ archive }: { archive: WorkflowRunArchiveMonthResponse }) {
|
||||
const { t } = useTranslation()
|
||||
const [downloadTask, setDownloadTask] = useState<WorkflowRunArchiveDownloadTaskResponse | null>(
|
||||
null,
|
||||
)
|
||||
const archiveMonth = formatMonth(archive.year, archive.month)
|
||||
const cachedTask = downloadTask ?? archive.download_task ?? null
|
||||
const downloadTaskId = cachedTask?.download_id
|
||||
const taskQuery = useQuery(
|
||||
consoleQuery.workflowRunArchives.downloads.byDownloadId.get.queryOptions({
|
||||
input: downloadTaskId
|
||||
? {
|
||||
params: {
|
||||
download_id: downloadTaskId,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
enabled: !!downloadTaskId && isPreparingStatus(cachedTask?.status),
|
||||
refetchInterval: (query) =>
|
||||
isPreparingStatus(query.state.data?.status ?? cachedTask?.status)
|
||||
? DOWNLOAD_TASK_POLLING_INTERVAL
|
||||
: false,
|
||||
}),
|
||||
)
|
||||
const createDownloadMutation = useMutation(
|
||||
consoleQuery.workflowRunArchives.downloads.post.mutationOptions(),
|
||||
)
|
||||
const currentTask = taskQuery.data ?? cachedTask
|
||||
const isPreparing = createDownloadMutation.isPending || isPreparingStatus(currentTask?.status)
|
||||
const isReady = currentTask?.status === 'ready'
|
||||
const isFailed = currentTask?.status === 'failed'
|
||||
const failureReason = isFailed ? currentTask?.error?.trim() : undefined
|
||||
const downloadHint = isReady
|
||||
? t(($) => $['archives.downloadHint.ready'], { ns: 'appLog' })
|
||||
: isFailed
|
||||
? failureReason
|
||||
? t(($) => $['archives.downloadHint.failedWithReason'], {
|
||||
ns: 'appLog',
|
||||
reason: failureReason,
|
||||
})
|
||||
: t(($) => $['archives.downloadHint.failed'], { ns: 'appLog' })
|
||||
: isPreparing
|
||||
? t(($) => $['archives.downloadHint.preparing'], { ns: 'appLog' })
|
||||
: t(($) => $['archives.downloadHint.prepare'], { ns: 'appLog' })
|
||||
|
||||
const prepareDownload = () => {
|
||||
if (createDownloadMutation.isPending) return
|
||||
|
||||
createDownloadMutation.mutate(
|
||||
{
|
||||
body: {
|
||||
year: archive.year,
|
||||
month: archive.month,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (task) => {
|
||||
setDownloadTask(task)
|
||||
toast.success(
|
||||
task.status === 'ready'
|
||||
? t(($) => $['archives.action.downloadReady'], { ns: 'appLog' })
|
||||
: t(($) => $['archives.action.prepareStarted'], { ns: 'appLog' }),
|
||||
)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['archives.action.prepareFailed'], { ns: 'appLog' }))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const downloadArchive = () => {
|
||||
if (!currentTask || currentTask.status !== 'ready') return
|
||||
|
||||
globalThis.location.assign(buildArchiveDownloadFileUrl(currentTask.download_id))
|
||||
}
|
||||
|
||||
const buttonContent = (() => {
|
||||
if (isPreparing) return t(($) => $['archives.action.preparing'], { ns: 'appLog' })
|
||||
if (isReady) return t(($) => $['operation.download'], { ns: 'common' })
|
||||
if (isFailed) return t(($) => $['operation.retry'], { ns: 'common' })
|
||||
return t(($) => $['archives.action.prepareDownload'], { ns: 'appLog' })
|
||||
})()
|
||||
|
||||
const buttonAriaLabel = isReady
|
||||
? t(($) => $['archives.action.downloadMonth'], { ns: 'appLog', month: archiveMonth })
|
||||
: t(($) => $['archives.action.prepareMonth'], { ns: 'appLog', month: archiveMonth })
|
||||
const buttonIconClassName = isReady ? 'i-ri-download-2-line' : 'i-ri-inbox-archive-line'
|
||||
const onAction = isReady ? downloadArchive : prepareDownload
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-h-15 items-center gap-3 border-b border-divider-subtle px-4 py-3 last:border-b-0',
|
||||
tableGridClassName,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 text-center">
|
||||
<span className="truncate system-sm-semibold text-text-primary">{archiveMonth}</span>
|
||||
</div>
|
||||
<div className="text-center system-sm-medium text-text-secondary tabular-nums">
|
||||
{formatNumber(archive.workflow_run_count)}
|
||||
</div>
|
||||
<div className="text-center system-sm-medium text-text-secondary tabular-nums">
|
||||
{formatBytes(archive.archive_bytes)}
|
||||
</div>
|
||||
<div className="flex min-w-0 justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
loading={isPreparing}
|
||||
disabled={isPreparing}
|
||||
className="gap-1 px-2"
|
||||
aria-label={buttonAriaLabel}
|
||||
onClick={onAction}
|
||||
>
|
||||
{!isPreparing && (
|
||||
<span className={cn(buttonIconClassName, 'size-3.5')} aria-hidden="true" />
|
||||
)}
|
||||
{buttonContent}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent
|
||||
placement="top"
|
||||
className={cn(
|
||||
'max-w-[260px] text-center text-text-tertiary',
|
||||
isFailed && 'max-w-[300px] text-start [overflow-wrap:anywhere] whitespace-pre-wrap',
|
||||
)}
|
||||
>
|
||||
{downloadHint}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "تكرار",
|
||||
"agentLogDetail.iterations": "التكرارات",
|
||||
"agentLogDetail.toolUsed": "الأداة المستخدمة",
|
||||
"archives.action.downloadMonth": "تنزيل أرشيف {{month}}",
|
||||
"archives.action.downloadReady": "تنزيل الأرشيف جاهز.",
|
||||
"archives.action.prepareDownload": "تحضير التنزيل",
|
||||
"archives.action.prepareFailed": "فشل تحضير تنزيل الأرشيف.",
|
||||
"archives.action.prepareMonth": "تحضير تنزيل أرشيف {{month}}",
|
||||
"archives.action.prepareStarted": "جارٍ تحضير تنزيل الأرشيف.",
|
||||
"archives.action.preparing": "جارٍ التحضير",
|
||||
"archives.description": "نزّل صادرات CSV شهرية لسجلات تشغيل سير العمل المؤرشفة في مساحة العمل هذه.",
|
||||
"archives.downloadHint.failed": "فشل تحضير هذا الأرشيف. أعد المحاولة لبدء مهمة تنزيل جديدة.",
|
||||
"archives.downloadHint.failedWithReason": "فشل تحضير هذا الأرشيف: {{reason}}",
|
||||
"archives.downloadHint.prepare": "يبدأ مهمة في الخلفية لتحضير تصدير CSV هذا. يمكنك مغادرة هذه الصفحة بعد بدئها.",
|
||||
"archives.downloadHint.preparing": "يتم تحضير تصدير CSV في الخلفية. يمكنك مغادرة هذه الصفحة والعودة لاحقًا.",
|
||||
"archives.downloadHint.ready": "تصدير CSV هذا جاهز للتنزيل.",
|
||||
"archives.empty.description": "ستظهر سجلات تشغيل سير العمل المؤرشفة هنا بعد انتهاء مهمة الاحتفاظ.",
|
||||
"archives.empty.title": "لا توجد سجلات مؤرشفة",
|
||||
"archives.error.description": "حدّث الصفحة أو حاول مرة أخرى لاحقًا.",
|
||||
"archives.error.title": "تعذّر تحميل السجلات المؤرشفة",
|
||||
"archives.notice.action": "عرض السجلات المؤرشفة",
|
||||
"archives.notice.description": "قد تكون بعض السجلات ضمن هذا النطاق الزمني قد أُرشفت.",
|
||||
"archives.summary.latest": "أحدث أرشيف",
|
||||
"archives.summary.months": "الأشهر المؤرشفة",
|
||||
"archives.summary.runs": "تشغيلات سير العمل",
|
||||
"archives.summary.size": "الحجم المخزّن",
|
||||
"archives.table.action": "الإجراء",
|
||||
"archives.table.month": "الشهر",
|
||||
"archives.table.runs": "التشغيلات",
|
||||
"archives.table.size": "الحجم",
|
||||
"archives.title": "السجلات المؤرشفة",
|
||||
"archives.upgradeTip.description": "رقِّ خطتك للحفاظ على سجلات تشغيل سير العمل من الحذف مستقبلًا. لا يمكن استعادة السجلات التي حُذفت بالفعل.",
|
||||
"archives.upgradeTip.title": "رقِّ خطتك للاحتفاظ بسجلات سير العمل المستقبلية",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm A",
|
||||
"description": "تسجل السجلات حالة تشغيل التطبيق، بما في ذلك مدخلات المستخدم واستجابات الذكاء الاصطناعي.",
|
||||
"detail.annotationTip": "تحسينات تم وضع علامة عليها بواسطة {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Iteration",
|
||||
"agentLogDetail.iterations": "Iterationen",
|
||||
"agentLogDetail.toolUsed": "Verwendetes Werkzeug",
|
||||
"archives.action.downloadMonth": "{{month}}-Archiv herunterladen",
|
||||
"archives.action.downloadReady": "Der Archivdownload ist bereit.",
|
||||
"archives.action.prepareDownload": "Download vorbereiten",
|
||||
"archives.action.prepareFailed": "Archivdownload konnte nicht vorbereitet werden.",
|
||||
"archives.action.prepareMonth": "{{month}}-Archivdownload vorbereiten",
|
||||
"archives.action.prepareStarted": "Der Archivdownload wird vorbereitet.",
|
||||
"archives.action.preparing": "Wird vorbereitet",
|
||||
"archives.description": "Monatliche CSV-Exporte archivierter Workflow-Ausführungsprotokolle in diesem Workspace herunterladen.",
|
||||
"archives.downloadHint.failed": "Die Vorbereitung dieses Archivs ist fehlgeschlagen. Versuchen Sie es erneut, um eine neue Downloadaufgabe zu starten.",
|
||||
"archives.downloadHint.failedWithReason": "Die Vorbereitung dieses Archivs ist fehlgeschlagen: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Startet eine Hintergrundaufgabe zur Vorbereitung dieses CSV-Exports. Sie können diese Seite nach dem Start verlassen.",
|
||||
"archives.downloadHint.preparing": "Der CSV-Export wird im Hintergrund vorbereitet. Sie können diese Seite verlassen und später zurückkehren.",
|
||||
"archives.downloadHint.ready": "Dieser CSV-Export ist zum Herunterladen bereit.",
|
||||
"archives.empty.description": "Archivierte Workflow-Ausführungsprotokolle werden hier angezeigt, nachdem der Aufbewahrungsjob abgeschlossen ist.",
|
||||
"archives.empty.title": "Keine archivierten Protokolle",
|
||||
"archives.error.description": "Aktualisieren Sie die Seite oder versuchen Sie es später erneut.",
|
||||
"archives.error.title": "Archivierte Protokolle konnten nicht geladen werden",
|
||||
"archives.notice.action": "Archivierte Protokolle anzeigen",
|
||||
"archives.notice.description": "Einige Protokolle in diesem Zeitraum wurden möglicherweise archiviert.",
|
||||
"archives.summary.latest": "Neueste Archivierung",
|
||||
"archives.summary.months": "Archivierte Monate",
|
||||
"archives.summary.runs": "Workflow-Ausführungen",
|
||||
"archives.summary.size": "Gespeicherte Größe",
|
||||
"archives.table.action": "Aktion",
|
||||
"archives.table.month": "Monat",
|
||||
"archives.table.runs": "Ausführungen",
|
||||
"archives.table.size": "Größe",
|
||||
"archives.title": "Archivierte Protokolle",
|
||||
"archives.upgradeTip.description": "Führen Sie ein Upgrade Ihres Plans durch, damit Workflow-Ausführungsprotokolle künftig nicht gelöscht werden. Bereits gelöschte Protokolle können nicht wiederhergestellt werden.",
|
||||
"archives.upgradeTip.title": "Upgrade durchführen, um zukünftige Workflow-Protokolle aufzubewahren",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "Die Protokolle zeichnen den Betriebsstatus der Anwendung auf, einschließlich Benutzereingaben und KI-Antworten.",
|
||||
"detail.annotationTip": "Verbesserungen markiert von {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Iteration",
|
||||
"agentLogDetail.iterations": "Iterations",
|
||||
"agentLogDetail.toolUsed": "Tool Used",
|
||||
"archives.action.downloadMonth": "Download {{month}} archive",
|
||||
"archives.action.downloadReady": "Archive download is ready.",
|
||||
"archives.action.prepareDownload": "Prepare download",
|
||||
"archives.action.prepareFailed": "Failed to prepare archive download.",
|
||||
"archives.action.prepareMonth": "Prepare {{month}} archive download",
|
||||
"archives.action.prepareStarted": "Archive download is being prepared.",
|
||||
"archives.action.preparing": "Preparing",
|
||||
"archives.description": "Download monthly CSV exports of archived workflow run logs across this workspace.",
|
||||
"archives.downloadHint.failed": "Preparing this archive failed. Retry to start a new download task.",
|
||||
"archives.downloadHint.failedWithReason": "Preparing this archive failed: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Starts a background task to prepare this CSV export. You can leave this page after it starts.",
|
||||
"archives.downloadHint.preparing": "The CSV export is being prepared in the background. You can leave this page and come back later.",
|
||||
"archives.downloadHint.ready": "This CSV export is ready to download.",
|
||||
"archives.empty.description": "Archived workflow run logs will appear here after the retention job finishes.",
|
||||
"archives.empty.title": "No archived logs",
|
||||
"archives.error.description": "Refresh the page or try again later.",
|
||||
"archives.error.title": "Could not load archived logs",
|
||||
"archives.notice.action": "View archived logs",
|
||||
"archives.notice.description": "Some logs in this time range may have been archived.",
|
||||
"archives.summary.latest": "Latest archive",
|
||||
"archives.summary.months": "Archived months",
|
||||
"archives.summary.runs": "Workflow runs",
|
||||
"archives.summary.size": "Stored size",
|
||||
"archives.table.action": "Action",
|
||||
"archives.table.month": "Month",
|
||||
"archives.table.runs": "Runs",
|
||||
"archives.table.size": "Size",
|
||||
"archives.title": "Archived logs",
|
||||
"archives.upgradeTip.description": "Upgrade your plan to keep workflow run logs from being deleted going forward. Logs that have already been deleted cannot be recovered.",
|
||||
"archives.upgradeTip.title": "Upgrade to retain future workflow logs",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "The logs record the running status of the application, including user inputs and AI replies.",
|
||||
"detail.annotationTip": "Improvements Marked by {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Iteración",
|
||||
"agentLogDetail.iterations": "Iteraciones",
|
||||
"agentLogDetail.toolUsed": "Herramienta Utilizada",
|
||||
"archives.action.downloadMonth": "Descargar archivo de {{month}}",
|
||||
"archives.action.downloadReady": "La descarga del archivo está lista.",
|
||||
"archives.action.prepareDownload": "Preparar descarga",
|
||||
"archives.action.prepareFailed": "No se pudo preparar la descarga del archivo.",
|
||||
"archives.action.prepareMonth": "Preparar la descarga del archivo de {{month}}",
|
||||
"archives.action.prepareStarted": "Se está preparando la descarga del archivo.",
|
||||
"archives.action.preparing": "Preparando",
|
||||
"archives.description": "Descarga exportaciones CSV mensuales de los registros archivados de ejecuciones de workflow en este espacio de trabajo.",
|
||||
"archives.downloadHint.failed": "No se pudo preparar este archivo. Vuelve a intentarlo para iniciar una nueva tarea de descarga.",
|
||||
"archives.downloadHint.failedWithReason": "No se pudo preparar este archivo: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Inicia una tarea en segundo plano para preparar esta exportación CSV. Puedes salir de esta página después de iniciarla.",
|
||||
"archives.downloadHint.preparing": "La exportación CSV se está preparando en segundo plano. Puedes salir de esta página y volver más tarde.",
|
||||
"archives.downloadHint.ready": "Esta exportación CSV está lista para descargar.",
|
||||
"archives.empty.description": "Los registros archivados de ejecuciones de workflow aparecerán aquí cuando finalice la tarea de retención.",
|
||||
"archives.empty.title": "No hay registros archivados",
|
||||
"archives.error.description": "Actualiza la página o inténtalo de nuevo más tarde.",
|
||||
"archives.error.title": "No se pudieron cargar los registros archivados",
|
||||
"archives.notice.action": "Ver registros archivados",
|
||||
"archives.notice.description": "Es posible que algunos registros de este intervalo de tiempo se hayan archivado.",
|
||||
"archives.summary.latest": "Último archivo",
|
||||
"archives.summary.months": "Meses archivados",
|
||||
"archives.summary.runs": "Ejecuciones de workflow",
|
||||
"archives.summary.size": "Tamaño almacenado",
|
||||
"archives.table.action": "Acción",
|
||||
"archives.table.month": "Mes",
|
||||
"archives.table.runs": "Ejecuciones",
|
||||
"archives.table.size": "Tamaño",
|
||||
"archives.title": "Registros archivados",
|
||||
"archives.upgradeTip.description": "Mejora tu plan para evitar que los registros de ejecuciones de workflow se eliminen en adelante. Los registros que ya se hayan eliminado no se pueden recuperar.",
|
||||
"archives.upgradeTip.title": "Mejora tu plan para conservar futuros registros de workflow",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "Los registros registran el estado de ejecución de la aplicación, incluyendo las entradas de usuario y las respuestas de la IA.",
|
||||
"detail.annotationTip": "Mejoras Marcadas por {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "تکرار",
|
||||
"agentLogDetail.iterations": "تکرارها",
|
||||
"agentLogDetail.toolUsed": "ابزار استفاده شده",
|
||||
"archives.action.downloadMonth": "دانلود بایگانی {{month}}",
|
||||
"archives.action.downloadReady": "دانلود بایگانی آماده است.",
|
||||
"archives.action.prepareDownload": "آمادهسازی دانلود",
|
||||
"archives.action.prepareFailed": "آمادهسازی دانلود بایگانی ناموفق بود.",
|
||||
"archives.action.prepareMonth": "آمادهسازی دانلود بایگانی {{month}}",
|
||||
"archives.action.prepareStarted": "دانلود بایگانی در حال آمادهسازی است.",
|
||||
"archives.action.preparing": "در حال آمادهسازی",
|
||||
"archives.description": "خروجیهای CSV ماهانه از لاگهای بایگانیشده اجرای workflow در این فضای کاری را دانلود کنید.",
|
||||
"archives.downloadHint.failed": "آمادهسازی این بایگانی ناموفق بود. برای شروع یک وظیفه دانلود جدید دوباره تلاش کنید.",
|
||||
"archives.downloadHint.failedWithReason": "آمادهسازی این بایگانی ناموفق بود: {{reason}}",
|
||||
"archives.downloadHint.prepare": "یک وظیفه پسزمینه برای آمادهسازی این خروجی CSV شروع میکند. پس از شروع میتوانید این صفحه را ترک کنید.",
|
||||
"archives.downloadHint.preparing": "خروجی CSV در پسزمینه در حال آمادهسازی است. میتوانید این صفحه را ترک کنید و بعداً بازگردید.",
|
||||
"archives.downloadHint.ready": "این خروجی CSV آماده دانلود است.",
|
||||
"archives.empty.description": "لاگهای بایگانیشده اجرای workflow پس از پایان کار نگهداشت در اینجا نمایش داده میشوند.",
|
||||
"archives.empty.title": "لاگ بایگانیشدهای وجود ندارد",
|
||||
"archives.error.description": "صفحه را تازهسازی کنید یا بعداً دوباره تلاش کنید.",
|
||||
"archives.error.title": "امکان بارگیری لاگهای بایگانیشده وجود ندارد",
|
||||
"archives.notice.action": "مشاهده لاگهای بایگانیشده",
|
||||
"archives.notice.description": "ممکن است برخی لاگها در این بازه زمانی بایگانی شده باشند.",
|
||||
"archives.summary.latest": "آخرین بایگانی",
|
||||
"archives.summary.months": "ماههای بایگانیشده",
|
||||
"archives.summary.runs": "اجراهای workflow",
|
||||
"archives.summary.size": "اندازه ذخیرهشده",
|
||||
"archives.table.action": "عملیات",
|
||||
"archives.table.month": "ماه",
|
||||
"archives.table.runs": "اجراها",
|
||||
"archives.table.size": "اندازه",
|
||||
"archives.title": "لاگهای بایگانیشده",
|
||||
"archives.upgradeTip.description": "طرح خود را ارتقا دهید تا لاگهای اجرای workflow از این پس حذف نشوند. لاگهایی که قبلاً حذف شدهاند قابل بازیابی نیستند.",
|
||||
"archives.upgradeTip.title": "برای نگهداری لاگهای آینده workflow ارتقا دهید",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "لاگها وضعیت اجرایی برنامه را ثبت میکنند، شامل ورودیهای کاربر و پاسخهای هوش مصنوعی.",
|
||||
"detail.annotationTip": "بهبودها توسط {{user}} علامتگذاری شده است",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Itération",
|
||||
"agentLogDetail.iterations": "Itérations",
|
||||
"agentLogDetail.toolUsed": "Outil utilisé",
|
||||
"archives.action.downloadMonth": "Télécharger l’archive de {{month}}",
|
||||
"archives.action.downloadReady": "Le téléchargement de l’archive est prêt.",
|
||||
"archives.action.prepareDownload": "Préparer le téléchargement",
|
||||
"archives.action.prepareFailed": "Impossible de préparer le téléchargement de l’archive.",
|
||||
"archives.action.prepareMonth": "Préparer le téléchargement de l’archive de {{month}}",
|
||||
"archives.action.prepareStarted": "Le téléchargement de l’archive est en cours de préparation.",
|
||||
"archives.action.preparing": "Préparation",
|
||||
"archives.description": "Téléchargez des exports CSV mensuels des journaux archivés d’exécutions de workflow dans cet espace de travail.",
|
||||
"archives.downloadHint.failed": "La préparation de cette archive a échoué. Réessayez pour démarrer une nouvelle tâche de téléchargement.",
|
||||
"archives.downloadHint.failedWithReason": "La préparation de cette archive a échoué : {{reason}}",
|
||||
"archives.downloadHint.prepare": "Démarre une tâche en arrière-plan pour préparer cet export CSV. Vous pouvez quitter cette page après son démarrage.",
|
||||
"archives.downloadHint.preparing": "L’export CSV est en cours de préparation en arrière-plan. Vous pouvez quitter cette page et revenir plus tard.",
|
||||
"archives.downloadHint.ready": "Cet export CSV est prêt à être téléchargé.",
|
||||
"archives.empty.description": "Les journaux archivés d’exécutions de workflow apparaîtront ici une fois la tâche de rétention terminée.",
|
||||
"archives.empty.title": "Aucun journal archivé",
|
||||
"archives.error.description": "Actualisez la page ou réessayez plus tard.",
|
||||
"archives.error.title": "Impossible de charger les journaux archivés",
|
||||
"archives.notice.action": "Voir les journaux archivés",
|
||||
"archives.notice.description": "Certains journaux de cette période peuvent avoir été archivés.",
|
||||
"archives.summary.latest": "Dernière archive",
|
||||
"archives.summary.months": "Mois archivés",
|
||||
"archives.summary.runs": "Exécutions de workflow",
|
||||
"archives.summary.size": "Taille stockée",
|
||||
"archives.table.action": "Action",
|
||||
"archives.table.month": "Mois",
|
||||
"archives.table.runs": "Exécutions",
|
||||
"archives.table.size": "Taille",
|
||||
"archives.title": "Journaux archivés",
|
||||
"archives.upgradeTip.description": "Passez à une offre supérieure pour empêcher la suppression des journaux d’exécutions de workflow à l’avenir. Les journaux déjà supprimés ne peuvent pas être récupérés.",
|
||||
"archives.upgradeTip.title": "Passez à une offre supérieure pour conserver les futurs journaux de workflow",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "Les journaux enregistrent l'état d'exécution de l'application, y compris les entrées utilisateur et les réponses de l'IA.",
|
||||
"detail.annotationTip": "Améliorations marquées par {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "चलना",
|
||||
"agentLogDetail.iterations": "पुनरूक्तियाँ",
|
||||
"agentLogDetail.toolUsed": "प्रयुक्त उपकरण",
|
||||
"archives.action.downloadMonth": "{{month}} का आर्काइव डाउनलोड करें",
|
||||
"archives.action.downloadReady": "आर्काइव डाउनलोड तैयार है।",
|
||||
"archives.action.prepareDownload": "डाउनलोड तैयार करें",
|
||||
"archives.action.prepareFailed": "आर्काइव डाउनलोड तैयार करने में विफल रहा।",
|
||||
"archives.action.prepareMonth": "{{month}} का आर्काइव डाउनलोड तैयार करें",
|
||||
"archives.action.prepareStarted": "आर्काइव डाउनलोड तैयार किया जा रहा है।",
|
||||
"archives.action.preparing": "तैयार किया जा रहा है",
|
||||
"archives.description": "इस वर्कस्पेस में आर्काइव किए गए workflow रन लॉग के मासिक CSV निर्यात डाउनलोड करें।",
|
||||
"archives.downloadHint.failed": "इस आर्काइव को तैयार करना विफल रहा। नया डाउनलोड कार्य शुरू करने के लिए फिर से प्रयास करें।",
|
||||
"archives.downloadHint.failedWithReason": "इस आर्काइव को तैयार करना विफल रहा: {{reason}}",
|
||||
"archives.downloadHint.prepare": "इस CSV निर्यात को तैयार करने के लिए बैकग्राउंड कार्य शुरू करता है। शुरू होने के बाद आप यह पेज छोड़ सकते हैं।",
|
||||
"archives.downloadHint.preparing": "CSV निर्यात बैकग्राउंड में तैयार किया जा रहा है। आप यह पेज छोड़कर बाद में वापस आ सकते हैं।",
|
||||
"archives.downloadHint.ready": "यह CSV निर्यात डाउनलोड के लिए तैयार है।",
|
||||
"archives.empty.description": "रिटेंशन जॉब पूरी होने के बाद आर्काइव किए गए workflow रन लॉग यहां दिखाई देंगे।",
|
||||
"archives.empty.title": "कोई आर्काइव लॉग नहीं",
|
||||
"archives.error.description": "पेज रीफ्रेश करें या बाद में फिर से प्रयास करें।",
|
||||
"archives.error.title": "आर्काइव लॉग लोड नहीं हो सके",
|
||||
"archives.notice.action": "आर्काइव लॉग देखें",
|
||||
"archives.notice.description": "इस समय सीमा के कुछ लॉग आर्काइव किए गए हो सकते हैं।",
|
||||
"archives.summary.latest": "नवीनतम आर्काइव",
|
||||
"archives.summary.months": "आर्काइव किए गए महीने",
|
||||
"archives.summary.runs": "Workflow रन",
|
||||
"archives.summary.size": "संग्रहीत आकार",
|
||||
"archives.table.action": "कार्रवाई",
|
||||
"archives.table.month": "महीना",
|
||||
"archives.table.runs": "रन",
|
||||
"archives.table.size": "आकार",
|
||||
"archives.title": "आर्काइव लॉग",
|
||||
"archives.upgradeTip.description": "आगे से workflow रन लॉग हटने से बचाने के लिए अपनी योजना अपग्रेड करें। पहले से हटाए गए लॉग पुनर्प्राप्त नहीं किए जा सकते।",
|
||||
"archives.upgradeTip.title": "भविष्य के workflow लॉग सुरक्षित रखने के लिए अपग्रेड करें",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "लॉग्स एप्लिकेशन के रनिंग स्टेटस को रिकॉर्ड करते हैं, जिसमें यूजर इनपुट और एआई रिप्लाईज़ शामिल हैं।",
|
||||
"detail.annotationTip": "{{user}} द्वारा सुधार चिह्नित",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Iterasi",
|
||||
"agentLogDetail.iterations": "Iterasi",
|
||||
"agentLogDetail.toolUsed": "Alat yang Digunakan",
|
||||
"archives.action.downloadMonth": "Unduh arsip {{month}}",
|
||||
"archives.action.downloadReady": "Unduhan arsip sudah siap.",
|
||||
"archives.action.prepareDownload": "Siapkan unduhan",
|
||||
"archives.action.prepareFailed": "Gagal menyiapkan unduhan arsip.",
|
||||
"archives.action.prepareMonth": "Siapkan unduhan arsip {{month}}",
|
||||
"archives.action.prepareStarted": "Unduhan arsip sedang disiapkan.",
|
||||
"archives.action.preparing": "Menyiapkan",
|
||||
"archives.description": "Unduh ekspor CSV bulanan dari log workflow run yang diarsipkan di workspace ini.",
|
||||
"archives.downloadHint.failed": "Gagal menyiapkan arsip ini. Coba lagi untuk memulai tugas unduhan baru.",
|
||||
"archives.downloadHint.failedWithReason": "Gagal menyiapkan arsip ini: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Memulai tugas latar belakang untuk menyiapkan ekspor CSV ini. Anda dapat meninggalkan halaman ini setelah tugas dimulai.",
|
||||
"archives.downloadHint.preparing": "Ekspor CSV sedang disiapkan di latar belakang. Anda dapat meninggalkan halaman ini dan kembali nanti.",
|
||||
"archives.downloadHint.ready": "Ekspor CSV ini siap diunduh.",
|
||||
"archives.empty.description": "Log workflow run yang diarsipkan akan muncul di sini setelah tugas retensi selesai.",
|
||||
"archives.empty.title": "Tidak ada log yang diarsipkan",
|
||||
"archives.error.description": "Muat ulang halaman atau coba lagi nanti.",
|
||||
"archives.error.title": "Tidak dapat memuat log yang diarsipkan",
|
||||
"archives.notice.action": "Lihat log yang diarsipkan",
|
||||
"archives.notice.description": "Beberapa log dalam rentang waktu ini mungkin telah diarsipkan.",
|
||||
"archives.summary.latest": "Arsip terbaru",
|
||||
"archives.summary.months": "Bulan yang diarsipkan",
|
||||
"archives.summary.runs": "Workflow run",
|
||||
"archives.summary.size": "Ukuran tersimpan",
|
||||
"archives.table.action": "Tindakan",
|
||||
"archives.table.month": "Bulan",
|
||||
"archives.table.runs": "Run",
|
||||
"archives.table.size": "Ukuran",
|
||||
"archives.title": "Log yang diarsipkan",
|
||||
"archives.upgradeTip.description": "Tingkatkan paket Anda agar log workflow run tidak dihapus ke depannya. Log yang sudah dihapus tidak dapat dipulihkan.",
|
||||
"archives.upgradeTip.title": "Tingkatkan paket untuk menyimpan log workflow mendatang",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "Log mencatat status berjalan aplikasi, termasuk input pengguna dan balasan AI.",
|
||||
"detail.annotationTip": "Peningkatan Ditandai oleh {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Iterazione",
|
||||
"agentLogDetail.iterations": "Iterazioni",
|
||||
"agentLogDetail.toolUsed": "Strumento Usato",
|
||||
"archives.action.downloadMonth": "Scarica l’archivio di {{month}}",
|
||||
"archives.action.downloadReady": "Il download dell’archivio è pronto.",
|
||||
"archives.action.prepareDownload": "Prepara download",
|
||||
"archives.action.prepareFailed": "Impossibile preparare il download dell’archivio.",
|
||||
"archives.action.prepareMonth": "Prepara il download dell’archivio di {{month}}",
|
||||
"archives.action.prepareStarted": "Il download dell’archivio è in preparazione.",
|
||||
"archives.action.preparing": "Preparazione",
|
||||
"archives.description": "Scarica esportazioni CSV mensili dei log archiviati delle esecuzioni workflow in questo workspace.",
|
||||
"archives.downloadHint.failed": "La preparazione di questo archivio non è riuscita. Riprova per avviare una nuova attività di download.",
|
||||
"archives.downloadHint.failedWithReason": "La preparazione di questo archivio non è riuscita: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Avvia un’attività in background per preparare questa esportazione CSV. Puoi lasciare questa pagina dopo l’avvio.",
|
||||
"archives.downloadHint.preparing": "L’esportazione CSV viene preparata in background. Puoi lasciare questa pagina e tornare più tardi.",
|
||||
"archives.downloadHint.ready": "Questa esportazione CSV è pronta per il download.",
|
||||
"archives.empty.description": "I log archiviati delle esecuzioni workflow appariranno qui al termine del job di retention.",
|
||||
"archives.empty.title": "Nessun log archiviato",
|
||||
"archives.error.description": "Aggiorna la pagina o riprova più tardi.",
|
||||
"archives.error.title": "Impossibile caricare i log archiviati",
|
||||
"archives.notice.action": "Visualizza log archiviati",
|
||||
"archives.notice.description": "Alcuni log in questo intervallo di tempo potrebbero essere stati archiviati.",
|
||||
"archives.summary.latest": "Archivio più recente",
|
||||
"archives.summary.months": "Mesi archiviati",
|
||||
"archives.summary.runs": "Esecuzioni workflow",
|
||||
"archives.summary.size": "Dimensione archiviata",
|
||||
"archives.table.action": "Azione",
|
||||
"archives.table.month": "Mese",
|
||||
"archives.table.runs": "Esecuzioni",
|
||||
"archives.table.size": "Dimensione",
|
||||
"archives.title": "Log archiviati",
|
||||
"archives.upgradeTip.description": "Aggiorna il piano per evitare che i log delle esecuzioni workflow vengano eliminati in futuro. I log già eliminati non possono essere recuperati.",
|
||||
"archives.upgradeTip.title": "Aggiorna per conservare i log workflow futuri",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "I registri registrano lo stato di esecuzione dell'applicazione, inclusi input degli utenti e risposte AI.",
|
||||
"detail.annotationTip": "Miglioramenti Segnalati da {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "反復",
|
||||
"agentLogDetail.iterations": "反復",
|
||||
"agentLogDetail.toolUsed": "使用したツール",
|
||||
"archives.action.downloadMonth": "{{month}} のアーカイブをダウンロード",
|
||||
"archives.action.downloadReady": "アーカイブのダウンロード準備が完了しました。",
|
||||
"archives.action.prepareDownload": "ダウンロードを準備",
|
||||
"archives.action.prepareFailed": "アーカイブのダウンロード準備に失敗しました。",
|
||||
"archives.action.prepareMonth": "{{month}} のアーカイブダウンロードを準備",
|
||||
"archives.action.prepareStarted": "アーカイブのダウンロードを準備しています。",
|
||||
"archives.action.preparing": "準備中",
|
||||
"archives.description": "このワークスペース内のアーカイブされたワークフロー実行ログを月別 CSV エクスポートとしてダウンロードします。",
|
||||
"archives.downloadHint.failed": "このアーカイブの準備に失敗しました。再試行すると新しいダウンロードタスクを開始します。",
|
||||
"archives.downloadHint.failedWithReason": "このアーカイブの準備に失敗しました: {{reason}}",
|
||||
"archives.downloadHint.prepare": "この CSV エクスポートを準備するバックグラウンドタスクを開始します。開始後はこのページを離れてもかまいません。",
|
||||
"archives.downloadHint.preparing": "CSV エクスポートをバックグラウンドで準備しています。このページを離れて後で戻ることができます。",
|
||||
"archives.downloadHint.ready": "この CSV エクスポートはダウンロードできます。",
|
||||
"archives.empty.description": "保持ジョブが完了すると、アーカイブされたワークフロー実行ログがここに表示されます。",
|
||||
"archives.empty.title": "アーカイブされたログはありません",
|
||||
"archives.error.description": "ページを更新するか、後でもう一度お試しください。",
|
||||
"archives.error.title": "アーカイブされたログを読み込めませんでした",
|
||||
"archives.notice.action": "アーカイブされたログを表示",
|
||||
"archives.notice.description": "この期間の一部のログはアーカイブされている可能性があります。",
|
||||
"archives.summary.latest": "最新のアーカイブ",
|
||||
"archives.summary.months": "アーカイブ済みの月",
|
||||
"archives.summary.runs": "ワークフロー実行",
|
||||
"archives.summary.size": "保存サイズ",
|
||||
"archives.table.action": "操作",
|
||||
"archives.table.month": "月",
|
||||
"archives.table.runs": "実行",
|
||||
"archives.table.size": "サイズ",
|
||||
"archives.title": "アーカイブされたログ",
|
||||
"archives.upgradeTip.description": "プランをアップグレードすると、今後のワークフロー実行ログが削除されないようになります。すでに削除されたログは復元できません。",
|
||||
"archives.upgradeTip.title": "今後のワークフローログを保持するにはアップグレードしてください",
|
||||
"dateTimeFormat": "YYYY/MM/DD hh:mm:ss A",
|
||||
"description": "ログは、アプリケーションの実行状態を記録します。ユーザーの入力や AI の応答などが含まれます。",
|
||||
"detail.annotationTip": "{{user}} によってマークされた改善",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "반복",
|
||||
"agentLogDetail.iterations": "반복",
|
||||
"agentLogDetail.toolUsed": "사용된 도구",
|
||||
"archives.action.downloadMonth": "{{month}} 아카이브 다운로드",
|
||||
"archives.action.downloadReady": "아카이브 다운로드가 준비되었습니다.",
|
||||
"archives.action.prepareDownload": "다운로드 준비",
|
||||
"archives.action.prepareFailed": "아카이브 다운로드를 준비하지 못했습니다.",
|
||||
"archives.action.prepareMonth": "{{month}} 아카이브 다운로드 준비",
|
||||
"archives.action.prepareStarted": "아카이브 다운로드를 준비하는 중입니다.",
|
||||
"archives.action.preparing": "준비 중",
|
||||
"archives.description": "이 워크스페이스의 보관된 워크플로 실행 로그를 월별 CSV 내보내기로 다운로드합니다.",
|
||||
"archives.downloadHint.failed": "이 아카이브를 준비하지 못했습니다. 새 다운로드 작업을 시작하려면 다시 시도하세요.",
|
||||
"archives.downloadHint.failedWithReason": "이 아카이브를 준비하지 못했습니다: {{reason}}",
|
||||
"archives.downloadHint.prepare": "이 CSV 내보내기를 준비하는 백그라운드 작업을 시작합니다. 시작 후에는 이 페이지를 떠나도 됩니다.",
|
||||
"archives.downloadHint.preparing": "CSV 내보내기가 백그라운드에서 준비 중입니다. 이 페이지를 떠났다가 나중에 다시 돌아올 수 있습니다.",
|
||||
"archives.downloadHint.ready": "이 CSV 내보내기를 다운로드할 수 있습니다.",
|
||||
"archives.empty.description": "보존 작업이 완료되면 보관된 워크플로 실행 로그가 여기에 표시됩니다.",
|
||||
"archives.empty.title": "보관된 로그가 없습니다",
|
||||
"archives.error.description": "페이지를 새로 고치거나 나중에 다시 시도하세요.",
|
||||
"archives.error.title": "보관된 로그를 불러올 수 없습니다",
|
||||
"archives.notice.action": "보관된 로그 보기",
|
||||
"archives.notice.description": "이 시간 범위의 일부 로그가 보관되었을 수 있습니다.",
|
||||
"archives.summary.latest": "최신 아카이브",
|
||||
"archives.summary.months": "보관된 월",
|
||||
"archives.summary.runs": "워크플로 실행",
|
||||
"archives.summary.size": "저장된 크기",
|
||||
"archives.table.action": "작업",
|
||||
"archives.table.month": "월",
|
||||
"archives.table.runs": "실행",
|
||||
"archives.table.size": "크기",
|
||||
"archives.title": "보관된 로그",
|
||||
"archives.upgradeTip.description": "플랜을 업그레이드하면 앞으로 워크플로 실행 로그가 삭제되지 않도록 보관할 수 있습니다. 이미 삭제된 로그는 복구할 수 없습니다.",
|
||||
"archives.upgradeTip.title": "향후 워크플로 로그를 보관하려면 업그레이드하세요",
|
||||
"dateTimeFormat": "YYYY/MM/DD HH:mm:ss",
|
||||
"description": "로그는 애플리케이션 실행 상태를 기록합니다. 사용자 입력 및 AI 응답이 포함됩니다.",
|
||||
"detail.annotationTip": "{{user}}에 의해 향상됨",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Iteration",
|
||||
"agentLogDetail.iterations": "Iterations",
|
||||
"agentLogDetail.toolUsed": "Tool Used",
|
||||
"archives.action.downloadMonth": "Archief van {{month}} downloaden",
|
||||
"archives.action.downloadReady": "De archiefdownload is klaar.",
|
||||
"archives.action.prepareDownload": "Download voorbereiden",
|
||||
"archives.action.prepareFailed": "Kan archiefdownload niet voorbereiden.",
|
||||
"archives.action.prepareMonth": "Archiefdownload van {{month}} voorbereiden",
|
||||
"archives.action.prepareStarted": "De archiefdownload wordt voorbereid.",
|
||||
"archives.action.preparing": "Voorbereiden",
|
||||
"archives.description": "Download maandelijkse CSV-exports van gearchiveerde workflow-runlogs in deze werkruimte.",
|
||||
"archives.downloadHint.failed": "Het voorbereiden van dit archief is mislukt. Probeer opnieuw om een nieuwe downloadtaak te starten.",
|
||||
"archives.downloadHint.failedWithReason": "Het voorbereiden van dit archief is mislukt: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Start een achtergrondtaak om deze CSV-export voor te bereiden. U kunt deze pagina verlaten nadat de taak is gestart.",
|
||||
"archives.downloadHint.preparing": "De CSV-export wordt op de achtergrond voorbereid. U kunt deze pagina verlaten en later terugkomen.",
|
||||
"archives.downloadHint.ready": "Deze CSV-export is klaar om te downloaden.",
|
||||
"archives.empty.description": "Gearchiveerde workflow-runlogs verschijnen hier nadat de bewaartaak is voltooid.",
|
||||
"archives.empty.title": "Geen gearchiveerde logs",
|
||||
"archives.error.description": "Vernieuw de pagina of probeer het later opnieuw.",
|
||||
"archives.error.title": "Kan gearchiveerde logs niet laden",
|
||||
"archives.notice.action": "Gearchiveerde logs bekijken",
|
||||
"archives.notice.description": "Sommige logs in deze periode zijn mogelijk gearchiveerd.",
|
||||
"archives.summary.latest": "Nieuwste archief",
|
||||
"archives.summary.months": "Gearchiveerde maanden",
|
||||
"archives.summary.runs": "Workflow-runs",
|
||||
"archives.summary.size": "Opgeslagen grootte",
|
||||
"archives.table.action": "Actie",
|
||||
"archives.table.month": "Maand",
|
||||
"archives.table.runs": "Runs",
|
||||
"archives.table.size": "Grootte",
|
||||
"archives.title": "Gearchiveerde logs",
|
||||
"archives.upgradeTip.description": "Upgrade uw abonnement om workflow-runlogs voortaan te bewaren en niet te laten verwijderen. Logs die al zijn verwijderd, kunnen niet worden hersteld.",
|
||||
"archives.upgradeTip.title": "Upgrade om toekomstige workflowlogs te bewaren",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "The logs record the running status of the application, including user inputs and AI replies.",
|
||||
"detail.annotationTip": "Improvements Marked by {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Iteracja",
|
||||
"agentLogDetail.iterations": "Iteracje",
|
||||
"agentLogDetail.toolUsed": "Użyte narzędzia",
|
||||
"archives.action.downloadMonth": "Pobierz archiwum z {{month}}",
|
||||
"archives.action.downloadReady": "Pobieranie archiwum jest gotowe.",
|
||||
"archives.action.prepareDownload": "Przygotuj pobieranie",
|
||||
"archives.action.prepareFailed": "Nie udało się przygotować pobierania archiwum.",
|
||||
"archives.action.prepareMonth": "Przygotuj pobieranie archiwum z {{month}}",
|
||||
"archives.action.prepareStarted": "Pobieranie archiwum jest przygotowywane.",
|
||||
"archives.action.preparing": "Przygotowywanie",
|
||||
"archives.description": "Pobierz miesięczne eksporty CSV zarchiwizowanych logów uruchomień workflow w tym obszarze roboczym.",
|
||||
"archives.downloadHint.failed": "Przygotowanie tego archiwum nie powiodło się. Spróbuj ponownie, aby uruchomić nowe zadanie pobierania.",
|
||||
"archives.downloadHint.failedWithReason": "Przygotowanie tego archiwum nie powiodło się: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Uruchamia zadanie w tle przygotowujące ten eksport CSV. Po jego rozpoczęciu możesz opuścić tę stronę.",
|
||||
"archives.downloadHint.preparing": "Eksport CSV jest przygotowywany w tle. Możesz opuścić tę stronę i wrócić później.",
|
||||
"archives.downloadHint.ready": "Ten eksport CSV jest gotowy do pobrania.",
|
||||
"archives.empty.description": "Zarchiwizowane logi uruchomień workflow pojawią się tutaj po zakończeniu zadania retencji.",
|
||||
"archives.empty.title": "Brak zarchiwizowanych logów",
|
||||
"archives.error.description": "Odśwież stronę lub spróbuj ponownie później.",
|
||||
"archives.error.title": "Nie można załadować zarchiwizowanych logów",
|
||||
"archives.notice.action": "Wyświetl zarchiwizowane logi",
|
||||
"archives.notice.description": "Niektóre logi z tego zakresu czasu mogły zostać zarchiwizowane.",
|
||||
"archives.summary.latest": "Najnowsze archiwum",
|
||||
"archives.summary.months": "Zarchiwizowane miesiące",
|
||||
"archives.summary.runs": "Uruchomienia workflow",
|
||||
"archives.summary.size": "Zapisany rozmiar",
|
||||
"archives.table.action": "Akcja",
|
||||
"archives.table.month": "Miesiąc",
|
||||
"archives.table.runs": "Uruchomienia",
|
||||
"archives.table.size": "Rozmiar",
|
||||
"archives.title": "Zarchiwizowane logi",
|
||||
"archives.upgradeTip.description": "Uaktualnij plan, aby logi uruchomień workflow nie były usuwane w przyszłości. Logów, które zostały już usunięte, nie można odzyskać.",
|
||||
"archives.upgradeTip.title": "Uaktualnij, aby zachowywać przyszłe logi workflow",
|
||||
"dateTimeFormat": "DD/MM/YYYY HH:mm:ss",
|
||||
"description": "Dzienniki rejestrują stan działania aplikacji, w tym dane wejściowe użytkowników i odpowiedzi AI.",
|
||||
"detail.annotationTip": "Usprawnienia oznaczone przez {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Iteração",
|
||||
"agentLogDetail.iterations": "Iterações",
|
||||
"agentLogDetail.toolUsed": "Ferramenta usada",
|
||||
"archives.action.downloadMonth": "Baixar arquivo de {{month}}",
|
||||
"archives.action.downloadReady": "O download do arquivo está pronto.",
|
||||
"archives.action.prepareDownload": "Preparar download",
|
||||
"archives.action.prepareFailed": "Falha ao preparar o download do arquivo.",
|
||||
"archives.action.prepareMonth": "Preparar download do arquivo de {{month}}",
|
||||
"archives.action.prepareStarted": "O download do arquivo está sendo preparado.",
|
||||
"archives.action.preparing": "Preparando",
|
||||
"archives.description": "Baixe exportações CSV mensais dos logs arquivados de execuções de workflow neste workspace.",
|
||||
"archives.downloadHint.failed": "Falha ao preparar este arquivo. Tente novamente para iniciar uma nova tarefa de download.",
|
||||
"archives.downloadHint.failedWithReason": "Falha ao preparar este arquivo: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Inicia uma tarefa em segundo plano para preparar esta exportação CSV. Você pode sair desta página depois que ela começar.",
|
||||
"archives.downloadHint.preparing": "A exportação CSV está sendo preparada em segundo plano. Você pode sair desta página e voltar mais tarde.",
|
||||
"archives.downloadHint.ready": "Esta exportação CSV está pronta para download.",
|
||||
"archives.empty.description": "Os logs arquivados de execuções de workflow aparecerão aqui depois que a tarefa de retenção terminar.",
|
||||
"archives.empty.title": "Nenhum log arquivado",
|
||||
"archives.error.description": "Atualize a página ou tente novamente mais tarde.",
|
||||
"archives.error.title": "Não foi possível carregar os logs arquivados",
|
||||
"archives.notice.action": "Ver logs arquivados",
|
||||
"archives.notice.description": "Alguns logs neste intervalo de tempo podem ter sido arquivados.",
|
||||
"archives.summary.latest": "Arquivo mais recente",
|
||||
"archives.summary.months": "Meses arquivados",
|
||||
"archives.summary.runs": "Execuções de workflow",
|
||||
"archives.summary.size": "Tamanho armazenado",
|
||||
"archives.table.action": "Ação",
|
||||
"archives.table.month": "Mês",
|
||||
"archives.table.runs": "Execuções",
|
||||
"archives.table.size": "Tamanho",
|
||||
"archives.title": "Logs arquivados",
|
||||
"archives.upgradeTip.description": "Faça upgrade do seu plano para evitar que logs de execuções de workflow sejam excluídos daqui em diante. Logs que já foram excluídos não podem ser recuperados.",
|
||||
"archives.upgradeTip.title": "Faça upgrade para manter logs futuros de workflow",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "Os registros registram o status de execução do aplicativo, incluindo entradas do usuário e respostas do AI.",
|
||||
"detail.annotationTip": "Melhorias Marcadas por {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Iterație",
|
||||
"agentLogDetail.iterations": "Iterații",
|
||||
"agentLogDetail.toolUsed": "Instrument utilizat",
|
||||
"archives.action.downloadMonth": "Descarcă arhiva {{month}}",
|
||||
"archives.action.downloadReady": "Descărcarea arhivei este gata.",
|
||||
"archives.action.prepareDownload": "Pregătește descărcarea",
|
||||
"archives.action.prepareFailed": "Pregătirea descărcării arhivei a eșuat.",
|
||||
"archives.action.prepareMonth": "Pregătește descărcarea arhivei {{month}}",
|
||||
"archives.action.prepareStarted": "Descărcarea arhivei este în pregătire.",
|
||||
"archives.action.preparing": "Se pregătește",
|
||||
"archives.description": "Descarcă exporturi CSV lunare ale jurnalelor arhivate de rulări workflow din acest spațiu de lucru.",
|
||||
"archives.downloadHint.failed": "Pregătirea acestei arhive a eșuat. Reîncearcă pentru a porni o nouă sarcină de descărcare.",
|
||||
"archives.downloadHint.failedWithReason": "Pregătirea acestei arhive a eșuat: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Pornește o sarcină în fundal pentru pregătirea acestui export CSV. Poți părăsi pagina după ce pornește.",
|
||||
"archives.downloadHint.preparing": "Exportul CSV este pregătit în fundal. Poți părăsi pagina și reveni mai târziu.",
|
||||
"archives.downloadHint.ready": "Acest export CSV este gata de descărcare.",
|
||||
"archives.empty.description": "Jurnalele arhivate de rulări workflow vor apărea aici după finalizarea sarcinii de retenție.",
|
||||
"archives.empty.title": "Nu există jurnale arhivate",
|
||||
"archives.error.description": "Reîmprospătează pagina sau încearcă din nou mai târziu.",
|
||||
"archives.error.title": "Jurnalele arhivate nu au putut fi încărcate",
|
||||
"archives.notice.action": "Vezi jurnalele arhivate",
|
||||
"archives.notice.description": "Este posibil ca unele jurnale din acest interval de timp să fi fost arhivate.",
|
||||
"archives.summary.latest": "Cea mai recentă arhivă",
|
||||
"archives.summary.months": "Luni arhivate",
|
||||
"archives.summary.runs": "Rulări workflow",
|
||||
"archives.summary.size": "Dimensiune stocată",
|
||||
"archives.table.action": "Acțiune",
|
||||
"archives.table.month": "Lună",
|
||||
"archives.table.runs": "Rulări",
|
||||
"archives.table.size": "Dimensiune",
|
||||
"archives.title": "Jurnale arhivate",
|
||||
"archives.upgradeTip.description": "Fă upgrade planului pentru ca jurnalele rulărilor workflow să nu mai fie șterse pe viitor. Jurnalele care au fost deja șterse nu pot fi recuperate.",
|
||||
"archives.upgradeTip.title": "Fă upgrade pentru a păstra viitoarele jurnale workflow",
|
||||
"dateTimeFormat": "DD/MM/YYYY hh:mm:ss A",
|
||||
"description": "Jurnalele înregistrează starea de funcționare a aplicației, inclusiv intrările utilizatorilor și răspunsurile AI.",
|
||||
"detail.annotationTip": "Îmbunătățiri marcate de {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Итерация",
|
||||
"agentLogDetail.iterations": "Итерации",
|
||||
"agentLogDetail.toolUsed": "Использованный инструмент",
|
||||
"archives.action.downloadMonth": "Скачать архив за {{month}}",
|
||||
"archives.action.downloadReady": "Архив готов к скачиванию.",
|
||||
"archives.action.prepareDownload": "Подготовить скачивание",
|
||||
"archives.action.prepareFailed": "Не удалось подготовить скачивание архива.",
|
||||
"archives.action.prepareMonth": "Подготовить скачивание архива за {{month}}",
|
||||
"archives.action.prepareStarted": "Скачивание архива подготавливается.",
|
||||
"archives.action.preparing": "Подготовка",
|
||||
"archives.description": "Скачивайте ежемесячные CSV-экспорты архивных логов запусков workflow в этом рабочем пространстве.",
|
||||
"archives.downloadHint.failed": "Не удалось подготовить этот архив. Повторите попытку, чтобы запустить новую задачу скачивания.",
|
||||
"archives.downloadHint.failedWithReason": "Не удалось подготовить этот архив: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Запускает фоновую задачу для подготовки этого CSV-экспорта. После запуска можно покинуть эту страницу.",
|
||||
"archives.downloadHint.preparing": "CSV-экспорт подготавливается в фоновом режиме. Можно покинуть эту страницу и вернуться позже.",
|
||||
"archives.downloadHint.ready": "Этот CSV-экспорт готов к скачиванию.",
|
||||
"archives.empty.description": "Архивные логи запусков workflow появятся здесь после завершения задания хранения.",
|
||||
"archives.empty.title": "Нет архивных логов",
|
||||
"archives.error.description": "Обновите страницу или повторите попытку позже.",
|
||||
"archives.error.title": "Не удалось загрузить архивные логи",
|
||||
"archives.notice.action": "Посмотреть архивные логи",
|
||||
"archives.notice.description": "Некоторые логи в этом временном диапазоне могли быть заархивированы.",
|
||||
"archives.summary.latest": "Последний архив",
|
||||
"archives.summary.months": "Архивные месяцы",
|
||||
"archives.summary.runs": "Запуски workflow",
|
||||
"archives.summary.size": "Размер в хранилище",
|
||||
"archives.table.action": "Действие",
|
||||
"archives.table.month": "Месяц",
|
||||
"archives.table.runs": "Запуски",
|
||||
"archives.table.size": "Размер",
|
||||
"archives.title": "Архивные логи",
|
||||
"archives.upgradeTip.description": "Обновите тариф, чтобы в дальнейшем логи запусков workflow не удалялись. Логи, которые уже были удалены, восстановить нельзя.",
|
||||
"archives.upgradeTip.title": "Обновите тариф, чтобы сохранять будущие логи workflow",
|
||||
"dateTimeFormat": "DD.MM.YYYY HH:mm:ss",
|
||||
"description": "В логах записывается состояние работы приложения, включая пользовательский ввод и ответы ИИ.",
|
||||
"detail.annotationTip": "Улучшения, отмеченные {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Iteracija",
|
||||
"agentLogDetail.iterations": "Iteracije",
|
||||
"agentLogDetail.toolUsed": "Uporabljeno orodje",
|
||||
"archives.action.downloadMonth": "Prenesi arhiv za {{month}}",
|
||||
"archives.action.downloadReady": "Prenos arhiva je pripravljen.",
|
||||
"archives.action.prepareDownload": "Pripravi prenos",
|
||||
"archives.action.prepareFailed": "Priprava prenosa arhiva ni uspela.",
|
||||
"archives.action.prepareMonth": "Pripravi prenos arhiva za {{month}}",
|
||||
"archives.action.prepareStarted": "Prenos arhiva se pripravlja.",
|
||||
"archives.action.preparing": "Pripravljanje",
|
||||
"archives.description": "Prenesite mesečne izvoze CSV arhiviranih dnevnikov zagonov workflow v tem delovnem prostoru.",
|
||||
"archives.downloadHint.failed": "Priprava tega arhiva ni uspela. Poskusite znova, da zaženete novo opravilo prenosa.",
|
||||
"archives.downloadHint.failedWithReason": "Priprava tega arhiva ni uspela: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Zažene opravilo v ozadju za pripravo tega izvoza CSV. Po začetku lahko zapustite to stran.",
|
||||
"archives.downloadHint.preparing": "Izvoz CSV se pripravlja v ozadju. To stran lahko zapustite in se vrnete pozneje.",
|
||||
"archives.downloadHint.ready": "Ta izvoz CSV je pripravljen za prenos.",
|
||||
"archives.empty.description": "Arhivirani dnevniki zagonov workflow bodo prikazani tukaj po zaključku opravila hrambe.",
|
||||
"archives.empty.title": "Ni arhiviranih dnevnikov",
|
||||
"archives.error.description": "Osvežite stran ali poskusite znova pozneje.",
|
||||
"archives.error.title": "Arhiviranih dnevnikov ni bilo mogoče naložiti",
|
||||
"archives.notice.action": "Prikaži arhivirane dnevnike",
|
||||
"archives.notice.description": "Nekateri dnevniki v tem časovnem obdobju so bili morda arhivirani.",
|
||||
"archives.summary.latest": "Najnovejši arhiv",
|
||||
"archives.summary.months": "Arhivirani meseci",
|
||||
"archives.summary.runs": "Zagoni workflow",
|
||||
"archives.summary.size": "Shranjena velikost",
|
||||
"archives.table.action": "Dejanje",
|
||||
"archives.table.month": "Mesec",
|
||||
"archives.table.runs": "Zagoni",
|
||||
"archives.table.size": "Velikost",
|
||||
"archives.title": "Arhivirani dnevniki",
|
||||
"archives.upgradeTip.description": "Nadgradite paket, da se dnevniki zagonov workflow v prihodnje ne bodo brisali. Dnevnikov, ki so že izbrisani, ni mogoče obnoviti.",
|
||||
"archives.upgradeTip.title": "Nadgradite za hranjenje prihodnjih dnevnikov workflow",
|
||||
"dateTimeFormat": "DD.MM.YYYY hh:mm:ss A",
|
||||
"description": "Dnevniki beležijo stanje delovanja aplikacije, vključno z vnosi uporabnikov in odgovori umetne inteligence.",
|
||||
"detail.annotationTip": "Izboljšave, ki jih je označil {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "เกิด ซ้ำ",
|
||||
"agentLogDetail.iterations": "เกิด ซ้ำ",
|
||||
"agentLogDetail.toolUsed": "เครื่องมือที่ใช้",
|
||||
"archives.action.downloadMonth": "ดาวน์โหลดไฟล์เก็บถาวรของ {{month}}",
|
||||
"archives.action.downloadReady": "ไฟล์เก็บถาวรพร้อมดาวน์โหลดแล้ว",
|
||||
"archives.action.prepareDownload": "เตรียมดาวน์โหลด",
|
||||
"archives.action.prepareFailed": "เตรียมดาวน์โหลดไฟล์เก็บถาวรไม่สำเร็จ",
|
||||
"archives.action.prepareMonth": "เตรียมดาวน์โหลดไฟล์เก็บถาวรของ {{month}}",
|
||||
"archives.action.prepareStarted": "กำลังเตรียมดาวน์โหลดไฟล์เก็บถาวร",
|
||||
"archives.action.preparing": "กำลังเตรียม",
|
||||
"archives.description": "ดาวน์โหลดไฟล์ส่งออก CSV รายเดือนของบันทึกการรัน workflow ที่เก็บถาวรในเวิร์กสเปซนี้",
|
||||
"archives.downloadHint.failed": "เตรียมไฟล์เก็บถาวรนี้ไม่สำเร็จ ลองอีกครั้งเพื่อเริ่มงานดาวน์โหลดใหม่",
|
||||
"archives.downloadHint.failedWithReason": "เตรียมไฟล์เก็บถาวรนี้ไม่สำเร็จ: {{reason}}",
|
||||
"archives.downloadHint.prepare": "เริ่มงานเบื้องหลังเพื่อเตรียมไฟล์ส่งออก CSV นี้ คุณสามารถออกจากหน้านี้ได้หลังจากเริ่มงานแล้ว",
|
||||
"archives.downloadHint.preparing": "กำลังเตรียมไฟล์ส่งออก CSV ในเบื้องหลัง คุณสามารถออกจากหน้านี้และกลับมาใหม่ภายหลังได้",
|
||||
"archives.downloadHint.ready": "ไฟล์ส่งออก CSV นี้พร้อมดาวน์โหลดแล้ว",
|
||||
"archives.empty.description": "บันทึกการรัน workflow ที่เก็บถาวรจะแสดงที่นี่หลังจากงานเก็บรักษาเสร็จสิ้น",
|
||||
"archives.empty.title": "ไม่มีบันทึกที่เก็บถาวร",
|
||||
"archives.error.description": "รีเฟรชหน้า หรือลองอีกครั้งในภายหลัง",
|
||||
"archives.error.title": "ไม่สามารถโหลดบันทึกที่เก็บถาวรได้",
|
||||
"archives.notice.action": "ดูบันทึกที่เก็บถาวร",
|
||||
"archives.notice.description": "บันทึกบางส่วนในช่วงเวลานี้อาจถูกเก็บถาวรแล้ว",
|
||||
"archives.summary.latest": "ไฟล์เก็บถาวรล่าสุด",
|
||||
"archives.summary.months": "เดือนที่เก็บถาวร",
|
||||
"archives.summary.runs": "การรัน workflow",
|
||||
"archives.summary.size": "ขนาดที่จัดเก็บ",
|
||||
"archives.table.action": "การดำเนินการ",
|
||||
"archives.table.month": "เดือน",
|
||||
"archives.table.runs": "การรัน",
|
||||
"archives.table.size": "ขนาด",
|
||||
"archives.title": "บันทึกที่เก็บถาวร",
|
||||
"archives.upgradeTip.description": "อัปเกรดแพ็กเกจของคุณเพื่อป้องกันไม่ให้บันทึกการรัน workflow ถูกลบในอนาคต บันทึกที่ถูกลบไปแล้วไม่สามารถกู้คืนได้",
|
||||
"archives.upgradeTip.title": "อัปเกรดเพื่อเก็บบันทึก workflow ในอนาคต",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "บันทึกบันทึกสถานะการทํางานของแอปพลิเคชัน รวมถึงการป้อนข้อมูลของผู้ใช้และการตอบกลับ AI",
|
||||
"detail.annotationTip": "การปรับปรุงที่ทําเครื่องหมายโดย {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Yineleme",
|
||||
"agentLogDetail.iterations": "Yinelemeler",
|
||||
"agentLogDetail.toolUsed": "Kullanılan Araç",
|
||||
"archives.action.downloadMonth": "{{month}} arşivini indir",
|
||||
"archives.action.downloadReady": "Arşiv indirmesi hazır.",
|
||||
"archives.action.prepareDownload": "İndirmeyi hazırla",
|
||||
"archives.action.prepareFailed": "Arşiv indirmesi hazırlanamadı.",
|
||||
"archives.action.prepareMonth": "{{month}} arşiv indirmesini hazırla",
|
||||
"archives.action.prepareStarted": "Arşiv indirmesi hazırlanıyor.",
|
||||
"archives.action.preparing": "Hazırlanıyor",
|
||||
"archives.description": "Bu çalışma alanındaki arşivlenmiş workflow çalıştırma günlüklerinin aylık CSV dışa aktarımlarını indirin.",
|
||||
"archives.downloadHint.failed": "Bu arşiv hazırlanamadı. Yeni bir indirme görevi başlatmak için tekrar deneyin.",
|
||||
"archives.downloadHint.failedWithReason": "Bu arşiv hazırlanamadı: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Bu CSV dışa aktarımını hazırlamak için arka planda bir görev başlatır. Başladıktan sonra bu sayfadan ayrılabilirsiniz.",
|
||||
"archives.downloadHint.preparing": "CSV dışa aktarımı arka planda hazırlanıyor. Bu sayfadan ayrılıp daha sonra geri dönebilirsiniz.",
|
||||
"archives.downloadHint.ready": "Bu CSV dışa aktarımı indirilmeye hazır.",
|
||||
"archives.empty.description": "Arşivlenmiş workflow çalıştırma günlükleri, saklama işi tamamlandıktan sonra burada görünür.",
|
||||
"archives.empty.title": "Arşivlenmiş günlük yok",
|
||||
"archives.error.description": "Sayfayı yenileyin veya daha sonra tekrar deneyin.",
|
||||
"archives.error.title": "Arşivlenmiş günlükler yüklenemedi",
|
||||
"archives.notice.action": "Arşivlenmiş günlükleri görüntüle",
|
||||
"archives.notice.description": "Bu zaman aralığındaki bazı günlükler arşivlenmiş olabilir.",
|
||||
"archives.summary.latest": "En son arşiv",
|
||||
"archives.summary.months": "Arşivlenen aylar",
|
||||
"archives.summary.runs": "Workflow çalıştırmaları",
|
||||
"archives.summary.size": "Depolanan boyut",
|
||||
"archives.table.action": "Eylem",
|
||||
"archives.table.month": "Ay",
|
||||
"archives.table.runs": "Çalıştırmalar",
|
||||
"archives.table.size": "Boyut",
|
||||
"archives.title": "Arşivlenmiş günlükler",
|
||||
"archives.upgradeTip.description": "Workflow çalıştırma günlüklerinin bundan sonra silinmesini önlemek için planınızı yükseltin. Zaten silinmiş günlükler kurtarılamaz.",
|
||||
"archives.upgradeTip.title": "Gelecekteki workflow günlüklerini saklamak için yükseltin",
|
||||
"dateTimeFormat": "GG/AA/YYYY ss:dd ÖÖ/ÖS",
|
||||
"description": "Günlükler, kullanıcının girdileri ve AI tepkileri dahil olmak üzere uygulamanın çalışma durumunu kaydeder.",
|
||||
"detail.annotationTip": "{{user}} tarafından işaretlenen iyileştirmeler",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Ітерація",
|
||||
"agentLogDetail.iterations": "Ітерації",
|
||||
"agentLogDetail.toolUsed": "Використаний інструмент",
|
||||
"archives.action.downloadMonth": "Завантажити архів за {{month}}",
|
||||
"archives.action.downloadReady": "Архів готовий до завантаження.",
|
||||
"archives.action.prepareDownload": "Підготувати завантаження",
|
||||
"archives.action.prepareFailed": "Не вдалося підготувати завантаження архіву.",
|
||||
"archives.action.prepareMonth": "Підготувати завантаження архіву за {{month}}",
|
||||
"archives.action.prepareStarted": "Завантаження архіву готується.",
|
||||
"archives.action.preparing": "Підготовка",
|
||||
"archives.description": "Завантажуйте щомісячні CSV-експорти архівних логів запусків workflow у цьому робочому просторі.",
|
||||
"archives.downloadHint.failed": "Не вдалося підготувати цей архів. Спробуйте ще раз, щоб запустити нове завдання завантаження.",
|
||||
"archives.downloadHint.failedWithReason": "Не вдалося підготувати цей архів: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Запускає фонове завдання для підготовки цього CSV-експорту. Після запуску можна залишити цю сторінку.",
|
||||
"archives.downloadHint.preparing": "CSV-експорт готується у фоновому режимі. Можна залишити цю сторінку й повернутися пізніше.",
|
||||
"archives.downloadHint.ready": "Цей CSV-експорт готовий до завантаження.",
|
||||
"archives.empty.description": "Архівні логи запусків workflow з’являться тут після завершення завдання зберігання.",
|
||||
"archives.empty.title": "Немає архівних логів",
|
||||
"archives.error.description": "Оновіть сторінку або спробуйте ще раз пізніше.",
|
||||
"archives.error.title": "Не вдалося завантажити архівні логи",
|
||||
"archives.notice.action": "Переглянути архівні логи",
|
||||
"archives.notice.description": "Деякі логи в цьому часовому діапазоні могли бути заархівовані.",
|
||||
"archives.summary.latest": "Останній архів",
|
||||
"archives.summary.months": "Архівні місяці",
|
||||
"archives.summary.runs": "Запуски workflow",
|
||||
"archives.summary.size": "Розмір у сховищі",
|
||||
"archives.table.action": "Дія",
|
||||
"archives.table.month": "Місяць",
|
||||
"archives.table.runs": "Запуски",
|
||||
"archives.table.size": "Розмір",
|
||||
"archives.title": "Архівні логи",
|
||||
"archives.upgradeTip.description": "Оновіть тариф, щоб надалі логи запусків workflow не видалялися. Логи, які вже було видалено, неможливо відновити.",
|
||||
"archives.upgradeTip.title": "Оновіть тариф, щоб зберігати майбутні логи workflow",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "Журнали фіксують робочий статус додатка, включаючи введення користувачів та відповіді штучного інтелекту.",
|
||||
"detail.annotationTip": "Покращення Позначені Користувачем {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "Lần lặp",
|
||||
"agentLogDetail.iterations": "Số lần lặp",
|
||||
"agentLogDetail.toolUsed": "Công cụ đã sử dụng",
|
||||
"archives.action.downloadMonth": "Tải xuống bản lưu trữ {{month}}",
|
||||
"archives.action.downloadReady": "Bản lưu trữ đã sẵn sàng để tải xuống.",
|
||||
"archives.action.prepareDownload": "Chuẩn bị tải xuống",
|
||||
"archives.action.prepareFailed": "Không thể chuẩn bị bản lưu trữ để tải xuống.",
|
||||
"archives.action.prepareMonth": "Chuẩn bị tải xuống bản lưu trữ {{month}}",
|
||||
"archives.action.prepareStarted": "Đang chuẩn bị bản lưu trữ để tải xuống.",
|
||||
"archives.action.preparing": "Đang chuẩn bị",
|
||||
"archives.description": "Tải xuống các bản xuất CSV hằng tháng của log workflow run đã lưu trữ trong workspace này.",
|
||||
"archives.downloadHint.failed": "Không thể chuẩn bị bản lưu trữ này. Thử lại để bắt đầu một tác vụ tải xuống mới.",
|
||||
"archives.downloadHint.failedWithReason": "Không thể chuẩn bị bản lưu trữ này: {{reason}}",
|
||||
"archives.downloadHint.prepare": "Bắt đầu một tác vụ nền để chuẩn bị bản xuất CSV này. Bạn có thể rời trang này sau khi tác vụ bắt đầu.",
|
||||
"archives.downloadHint.preparing": "Bản xuất CSV đang được chuẩn bị trong nền. Bạn có thể rời trang này và quay lại sau.",
|
||||
"archives.downloadHint.ready": "Bản xuất CSV này đã sẵn sàng để tải xuống.",
|
||||
"archives.empty.description": "Log workflow run đã lưu trữ sẽ xuất hiện ở đây sau khi tác vụ lưu giữ hoàn tất.",
|
||||
"archives.empty.title": "Không có log đã lưu trữ",
|
||||
"archives.error.description": "Làm mới trang hoặc thử lại sau.",
|
||||
"archives.error.title": "Không thể tải log đã lưu trữ",
|
||||
"archives.notice.action": "Xem log đã lưu trữ",
|
||||
"archives.notice.description": "Một số log trong khoảng thời gian này có thể đã được lưu trữ.",
|
||||
"archives.summary.latest": "Bản lưu trữ mới nhất",
|
||||
"archives.summary.months": "Tháng đã lưu trữ",
|
||||
"archives.summary.runs": "Workflow run",
|
||||
"archives.summary.size": "Dung lượng đã lưu",
|
||||
"archives.table.action": "Hành động",
|
||||
"archives.table.month": "Tháng",
|
||||
"archives.table.runs": "Lượt chạy",
|
||||
"archives.table.size": "Dung lượng",
|
||||
"archives.title": "Log đã lưu trữ",
|
||||
"archives.upgradeTip.description": "Nâng cấp gói của bạn để log workflow run không bị xóa trong tương lai. Không thể khôi phục các log đã bị xóa.",
|
||||
"archives.upgradeTip.title": "Nâng cấp để lưu giữ log workflow trong tương lai",
|
||||
"dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
|
||||
"description": "Nhật ký ghi lại trạng thái hoạt động của ứng dụng, bao gồm đầu vào của người dùng và phản hồi của trí tuệ nhân tạo.",
|
||||
"detail.annotationTip": "Cải thiện được đánh dấu bởi {{user}}",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "迭代",
|
||||
"agentLogDetail.iterations": "迭代次数",
|
||||
"agentLogDetail.toolUsed": "使用工具",
|
||||
"archives.action.downloadMonth": "下载 {{month}} 归档",
|
||||
"archives.action.downloadReady": "归档下载已准备好。",
|
||||
"archives.action.prepareDownload": "准备下载",
|
||||
"archives.action.prepareFailed": "归档下载准备失败。",
|
||||
"archives.action.prepareMonth": "准备下载 {{month}} 归档",
|
||||
"archives.action.prepareStarted": "正在准备归档下载。",
|
||||
"archives.action.preparing": "准备中",
|
||||
"archives.description": "下载此工作空间中已归档工作流运行日志的月度 CSV 导出。",
|
||||
"archives.downloadHint.failed": "此归档准备失败。重试将启动新的下载任务。",
|
||||
"archives.downloadHint.failedWithReason": "此归档准备失败:{{reason}}",
|
||||
"archives.downloadHint.prepare": "启动后台任务来准备此 CSV 导出。任务开始后你可以离开此页面。",
|
||||
"archives.downloadHint.preparing": "CSV 导出正在后台准备中。你可以离开此页面,稍后再回来。",
|
||||
"archives.downloadHint.ready": "此 CSV 导出已可下载。",
|
||||
"archives.empty.description": "保留任务完成后,已归档的工作流运行日志会显示在这里。",
|
||||
"archives.empty.title": "暂无归档日志",
|
||||
"archives.error.description": "请刷新页面或稍后重试。",
|
||||
"archives.error.title": "无法加载归档日志",
|
||||
"archives.notice.action": "查看归档日志",
|
||||
"archives.notice.description": "此时间范围内的部分日志可能已被归档。",
|
||||
"archives.summary.latest": "最新归档",
|
||||
"archives.summary.months": "归档月份",
|
||||
"archives.summary.runs": "工作流运行",
|
||||
"archives.summary.size": "存储大小",
|
||||
"archives.table.action": "操作",
|
||||
"archives.table.month": "月份",
|
||||
"archives.table.runs": "运行次数",
|
||||
"archives.table.size": "大小",
|
||||
"archives.title": "归档日志",
|
||||
"archives.upgradeTip.description": "升级计划后,未来的工作流运行日志将不会被删除。已经删除的日志无法恢复。",
|
||||
"archives.upgradeTip.title": "升级以保留未来的工作流日志",
|
||||
"dateTimeFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"description": "日志记录了应用的运行情况,包括用户的输入和 AI 的回复。",
|
||||
"detail.annotationTip": "{{user}} 标记的改进回复",
|
||||
|
||||
@@ -4,6 +4,36 @@
|
||||
"agentLogDetail.iteration": "迭代",
|
||||
"agentLogDetail.iterations": "迭代次數",
|
||||
"agentLogDetail.toolUsed": "使用工具",
|
||||
"archives.action.downloadMonth": "下載 {{month}} 封存",
|
||||
"archives.action.downloadReady": "封存下載已準備好。",
|
||||
"archives.action.prepareDownload": "準備下載",
|
||||
"archives.action.prepareFailed": "封存下載準備失敗。",
|
||||
"archives.action.prepareMonth": "準備下載 {{month}} 封存",
|
||||
"archives.action.prepareStarted": "正在準備封存下載。",
|
||||
"archives.action.preparing": "準備中",
|
||||
"archives.description": "下載此工作區中已封存工作流程執行記錄的每月 CSV 匯出。",
|
||||
"archives.downloadHint.failed": "此封存準備失敗。重試將啟動新的下載任務。",
|
||||
"archives.downloadHint.failedWithReason": "此封存準備失敗:{{reason}}",
|
||||
"archives.downloadHint.prepare": "啟動背景任務來準備此 CSV 匯出。任務開始後你可以離開此頁面。",
|
||||
"archives.downloadHint.preparing": "CSV 匯出正在背景準備中。你可以離開此頁面,稍後再回來。",
|
||||
"archives.downloadHint.ready": "此 CSV 匯出已可下載。",
|
||||
"archives.empty.description": "保留任務完成後,已封存的工作流程執行記錄會顯示在這裡。",
|
||||
"archives.empty.title": "沒有封存記錄",
|
||||
"archives.error.description": "請重新整理頁面或稍後再試。",
|
||||
"archives.error.title": "無法載入封存記錄",
|
||||
"archives.notice.action": "查看封存記錄",
|
||||
"archives.notice.description": "此時間範圍內的部分記錄可能已被封存。",
|
||||
"archives.summary.latest": "最新封存",
|
||||
"archives.summary.months": "封存月份",
|
||||
"archives.summary.runs": "工作流程執行",
|
||||
"archives.summary.size": "儲存大小",
|
||||
"archives.table.action": "操作",
|
||||
"archives.table.month": "月份",
|
||||
"archives.table.runs": "執行次數",
|
||||
"archives.table.size": "大小",
|
||||
"archives.title": "封存記錄",
|
||||
"archives.upgradeTip.description": "升級方案後,未來的工作流程執行記錄將不會被刪除。已刪除的記錄無法復原。",
|
||||
"archives.upgradeTip.title": "升級以保留未來的工作流程記錄",
|
||||
"dateTimeFormat": "YYYY-MM-DD HH:mm:ss",
|
||||
"description": "日誌記錄了應用的執行情況,包括使用者的輸入和 AI 的回覆。",
|
||||
"detail.annotationTip": "{{user}} 標記的改進回覆",
|
||||
|
||||
Reference in New Issue
Block a user