From f09d2b191ff6128e10913c56ebe53873e43a2540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Tue, 14 Jul 2026 16:25:37 +0800 Subject: [PATCH] feat: archive logs export (#38207) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/commands/__init__.py | 2 + api/commands/retention.py | 85 ++++ api/controllers/console/__init__.py | 2 + .../console/workflow_run_archive.py | 236 ++++++++++ api/controllers/console/wraps.py | 16 + api/extensions/ext_celery.py | 1 + api/extensions/ext_commands.py | 2 + api/libs/archive_storage.py | 19 +- api/libs/token.py | 1 + ...0_add_workflow_run_archive_index_tables.py | 61 +++ api/models/__init__.py | 2 + api/models/workflow.py | 34 ++ api/openapi/markdown/console-openapi.md | 120 +++++ .../workflow_run/archive_bundle_index.py | 347 +++++++++++++++ .../archive_download_preparation.py | 354 +++++++++++++++ .../archive_download_task_cache.py | 177 ++++++++ .../workflow_run/archive_log_service.py | 298 +++++++++++++ .../archive_paid_plan_workflow_run.py | 35 +- .../bundle_archive_maintenance.py | 7 +- .../workflow_run_archive_download_tasks.py | 18 + .../retention/test_workflow_run_archiver.py | 32 ++ .../console/test_workflow_run_archive.py | 30 ++ .../controllers/console/test_wraps.py | 48 ++ .../unit_tests/libs/test_archive_storage.py | 26 ++ api/tests/unit_tests/libs/test_token.py | 31 +- .../workflow_run/test_archive_bundle_index.py | 172 +++++++ .../test_archive_download_preparation.py | 292 ++++++++++++ .../test_archive_download_task_cache.py | 189 ++++++++ .../workflow_run/test_archive_log_service.py | 380 ++++++++++++++++ .../generated/api/console/orpc.gen.ts | 4 + .../generated/api/console/router.gen.ts | 2 + .../console/workflow-run-archives/orpc.gen.ts | 74 +++ .../workflow-run-archives/types.gen.ts | 96 ++++ .../console/workflow-run-archives/zod.gen.ts | 99 +++++ .../__tests__/archived-logs-notice.spec.tsx | 82 ++++ .../app/log/archived-logs-notice-utils.ts | 11 + .../app/log/archived-logs-notice.tsx | 50 +++ web/app/components/app/workflow-log/index.tsx | 7 + .../__tests__/constants.spec.ts | 2 + .../account-setting/__tests__/index.spec.tsx | 85 +++- .../header/account-setting/constants.ts | 2 + .../header/account-setting/index.tsx | 22 + .../__tests__/index.spec.tsx | 134 ++++++ .../workflow-log-archives-page/index.tsx | 420 ++++++++++++++++++ web/i18n/ar-TN/app-log.json | 30 ++ web/i18n/de-DE/app-log.json | 30 ++ web/i18n/en-US/app-log.json | 30 ++ web/i18n/es-ES/app-log.json | 30 ++ web/i18n/fa-IR/app-log.json | 30 ++ web/i18n/fr-FR/app-log.json | 30 ++ web/i18n/hi-IN/app-log.json | 30 ++ web/i18n/id-ID/app-log.json | 30 ++ web/i18n/it-IT/app-log.json | 30 ++ web/i18n/ja-JP/app-log.json | 30 ++ web/i18n/ko-KR/app-log.json | 30 ++ web/i18n/nl-NL/app-log.json | 30 ++ web/i18n/pl-PL/app-log.json | 30 ++ web/i18n/pt-BR/app-log.json | 30 ++ web/i18n/ro-RO/app-log.json | 30 ++ web/i18n/ru-RU/app-log.json | 30 ++ web/i18n/sl-SI/app-log.json | 30 ++ web/i18n/th-TH/app-log.json | 30 ++ web/i18n/tr-TR/app-log.json | 30 ++ web/i18n/uk-UA/app-log.json | 30 ++ web/i18n/vi-VN/app-log.json | 30 ++ web/i18n/zh-Hans/app-log.json | 30 ++ web/i18n/zh-Hant/app-log.json | 30 ++ 67 files changed, 4779 insertions(+), 18 deletions(-) create mode 100644 api/controllers/console/workflow_run_archive.py create mode 100644 api/migrations/versions/2026_06_25_1500-7a1c2d9e4b60_add_workflow_run_archive_index_tables.py create mode 100644 api/services/retention/workflow_run/archive_bundle_index.py create mode 100644 api/services/retention/workflow_run/archive_download_preparation.py create mode 100644 api/services/retention/workflow_run/archive_download_task_cache.py create mode 100644 api/services/retention/workflow_run/archive_log_service.py create mode 100644 api/tasks/workflow_run_archive_download_tasks.py create mode 100644 api/tests/unit_tests/controllers/console/test_workflow_run_archive.py create mode 100644 api/tests/unit_tests/services/retention/workflow_run/test_archive_bundle_index.py create mode 100644 api/tests/unit_tests/services/retention/workflow_run/test_archive_download_preparation.py create mode 100644 api/tests/unit_tests/services/retention/workflow_run/test_archive_download_task_cache.py create mode 100644 api/tests/unit_tests/services/retention/workflow_run/test_archive_log_service.py create mode 100644 packages/contracts/generated/api/console/workflow-run-archives/orpc.gen.ts create mode 100644 packages/contracts/generated/api/console/workflow-run-archives/types.gen.ts create mode 100644 packages/contracts/generated/api/console/workflow-run-archives/zod.gen.ts create mode 100644 web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx create mode 100644 web/app/components/app/log/archived-logs-notice-utils.ts create mode 100644 web/app/components/app/log/archived-logs-notice.tsx create mode 100644 web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx create mode 100644 web/app/components/header/account-setting/workflow-log-archives-page/index.tsx diff --git a/api/commands/__init__.py b/api/commands/__init__.py index 1e40d3f7928..08ce86368d7 100644 --- a/api/commands/__init__.py +++ b/api/commands/__init__.py @@ -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", diff --git a/api/commands/retention.py b/api/commands/retention.py index 841f18ac874..47eaa76c957 100644 --- a/api/commands/retention.py +++ b/api/commands/retention.py @@ -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" diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index f5dd5407df3..e2d35354424 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -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", diff --git a/api/controllers/console/workflow_run_archive.py b/api/controllers/console/workflow_run_archive.py new file mode 100644 index 00000000000..c8a43594af9 --- /dev/null +++ b/api/controllers/console/workflow_run_archive.py @@ -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/") +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//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) diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 37d7239170c..4e22d334bec 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -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) diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index 9be77df955d..5824b8339fb 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -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 diff --git a/api/extensions/ext_commands.py b/api/extensions/ext_commands.py index ac96e3031a8..92c47f630f1 100644 --- a/api/extensions/ext_commands.py +++ b/api/extensions/ext_commands.py @@ -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, diff --git a/api/libs/archive_storage.py b/api/libs/archive_storage.py index 66b57ac6615..fb35a52948a 100644 --- a/api/libs/archive_storage.py +++ b/api/libs/archive_storage.py @@ -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: diff --git a/api/libs/token.py b/api/libs/token.py index 68048d8c7d6..ce868dc3d8a 100644 --- a/api/libs/token.py +++ b/api/libs/token.py @@ -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"), ] diff --git a/api/migrations/versions/2026_06_25_1500-7a1c2d9e4b60_add_workflow_run_archive_index_tables.py b/api/migrations/versions/2026_06_25_1500-7a1c2d9e4b60_add_workflow_run_archive_index_tables.py new file mode 100644 index 00000000000..91de1b8eeb6 --- /dev/null +++ b/api/migrations/versions/2026_06_25_1500-7a1c2d9e4b60_add_workflow_run_archive_index_tables.py @@ -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") diff --git a/api/models/__init__.py b/api/models/__init__.py index ac90eef0962..d29f75ac4bb 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -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", diff --git a/api/models/workflow.py b/api/models/workflow.py index bec5df45cbf..2923dfdb022 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -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" diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index f56b7aa3825..f033d7808b3 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -9721,6 +9721,61 @@ Suggest example workflow-generator instructions for the tenant | 200 | Suggestions generated successfully | **application/json**: [WorkflowInstructionSuggestionsResponse](#workflowinstructionsuggestionsresponse)
| | 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)
| + +### [POST] /workflow-run-archives/downloads +Create or return a temporary workflow-run archive download task + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowRunArchiveDownloadPayload](#workflowrunarchivedownloadpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 202 | Download task accepted | **application/json**: [WorkflowRunArchiveDownloadTaskResponse](#workflowrunarchivedownloadtaskresponse)
| + +### [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)
| + +### [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)
| +| 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 | diff --git a/api/services/retention/workflow_run/archive_bundle_index.py b/api/services/retention/workflow_run/archive_bundle_index.py new file mode 100644 index 00000000000..8eb9723a7f8 --- /dev/null +++ b/api/services/retention/workflow_run/archive_bundle_index.py @@ -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 diff --git a/api/services/retention/workflow_run/archive_download_preparation.py b/api/services/retention/workflow_run/archive_download_preparation.py new file mode 100644 index 00000000000..510a20a5319 --- /dev/null +++ b/api/services/retention/workflow_run/archive_download_preparation.py @@ -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() diff --git a/api/services/retention/workflow_run/archive_download_task_cache.py b/api/services/retention/workflow_run/archive_download_task_cache.py new file mode 100644 index 00000000000..a508ee18804 --- /dev/null +++ b/api/services/retention/workflow_run/archive_download_task_cache.py @@ -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] diff --git a/api/services/retention/workflow_run/archive_log_service.py b/api/services/retention/workflow_run/archive_log_service.py new file mode 100644 index 00000000000..42377c658cc --- /dev/null +++ b/api/services/retention/workflow_run/archive_log_service.py @@ -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 diff --git a/api/services/retention/workflow_run/archive_paid_plan_workflow_run.py b/api/services/retention/workflow_run/archive_paid_plan_workflow_run.py index af2bb023612..31435d2f7ea 100644 --- a/api/services/retention/workflow_run/archive_paid_plan_workflow_run.py +++ b/api/services/retention/workflow_run/archive_paid_plan_workflow_run.py @@ -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, diff --git a/api/services/retention/workflow_run/bundle_archive_maintenance.py b/api/services/retention/workflow_run/bundle_archive_maintenance.py index 8678e044c5e..8d90a1d4ac7 100644 --- a/api/services/retention/workflow_run/bundle_archive_maintenance.py +++ b/api/services/retention/workflow_run/bundle_archive_maintenance.py @@ -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 diff --git a/api/tasks/workflow_run_archive_download_tasks.py b/api/tasks/workflow_run_archive_download_tasks.py new file mode 100644 index 00000000000..85efbf60e64 --- /dev/null +++ b/api/tasks/workflow_run_archive_download_tasks.py @@ -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) diff --git a/api/tests/integration_tests/services/retention/test_workflow_run_archiver.py b/api/tests/integration_tests/services/retention/test_workflow_run_archiver.py index 4d6ff7bd7ec..0269e99779b 100644 --- a/api/tests/integration_tests/services/retention/test_workflow_run_archiver.py +++ b/api/tests/integration_tests/services/retention/test_workflow_run_archiver.py @@ -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() diff --git a/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py b/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py new file mode 100644 index 00000000000..6c01e378ca5 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py @@ -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(".")[0]) + method = method.__wrapped__ + + assert { + "only_edition_cloud", + "cloud_edition_billing_enabled", + "cloud_edition_billing_paid_plan_required", + } <= decorator_names diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index 172125f4635..07ac57b1ede 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -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""" diff --git a/api/tests/unit_tests/libs/test_archive_storage.py b/api/tests/unit_tests/libs/test_archive_storage.py index 966e75f1f9e..659f3cdc6a6 100644 --- a/api/tests/unit_tests/libs/test_archive_storage.py +++ b/api/tests/unit_tests/libs/test_archive_storage.py @@ -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) diff --git a/api/tests/unit_tests/libs/test_token.py b/api/tests/unit_tests/libs/test_token.py index 6bd794b24d7..97d156478d6 100644 --- a/api/tests/unit_tests/libs/test_token.py +++ b/api/tests/unit_tests/libs/test_token.py @@ -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") diff --git a/api/tests/unit_tests/services/retention/workflow_run/test_archive_bundle_index.py b/api/tests/unit_tests/services/retention/workflow_run/test_archive_bundle_index.py new file mode 100644 index 00000000000..0f17f96f571 --- /dev/null +++ b/api/tests/unit_tests/services/retention/workflow_run/test_archive_bundle_index.py @@ -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() diff --git a/api/tests/unit_tests/services/retention/workflow_run/test_archive_download_preparation.py b/api/tests/unit_tests/services/retention/workflow_run/test_archive_download_preparation.py new file mode 100644 index 00000000000..2bf96dc9701 --- /dev/null +++ b/api/tests/unit_tests/services/retention/workflow_run/test_archive_download_preparation.py @@ -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 == [] diff --git a/api/tests/unit_tests/services/retention/workflow_run/test_archive_download_task_cache.py b/api/tests/unit_tests/services/retention/workflow_run/test_archive_download_task_cache.py new file mode 100644 index 00000000000..79852028615 --- /dev/null +++ b/api/tests/unit_tests/services/retention/workflow_run/test_archive_download_task_cache.py @@ -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 diff --git a/api/tests/unit_tests/services/retention/workflow_run/test_archive_log_service.py b/api/tests/unit_tests/services/retention/workflow_run/test_archive_log_service.py new file mode 100644 index 00000000000..6fdf9d8358e --- /dev/null +++ b/api/tests/unit_tests/services/retention/workflow_run/test_archive_log_service.py @@ -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 + ) diff --git a/packages/contracts/generated/api/console/orpc.gen.ts b/packages/contracts/generated/api/console/orpc.gen.ts index 1102f11e301..5c5e288753b 100644 --- a/packages/contracts/generated/api/console/orpc.gen.ts +++ b/packages/contracts/generated/api/console/orpc.gen.ts @@ -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 })), } diff --git a/packages/contracts/generated/api/console/router.gen.ts b/packages/contracts/generated/api/console/router.gen.ts index 4342f9121ca..d8591d31833 100644 --- a/packages/contracts/generated/api/console/router.gen.ts +++ b/packages/contracts/generated/api/console/router.gen.ts @@ -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, } diff --git a/packages/contracts/generated/api/console/workflow-run-archives/orpc.gen.ts b/packages/contracts/generated/api/console/workflow-run-archives/orpc.gen.ts new file mode 100644 index 00000000000..61837a81d98 --- /dev/null +++ b/packages/contracts/generated/api/console/workflow-run-archives/orpc.gen.ts @@ -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, +} diff --git a/packages/contracts/generated/api/console/workflow-run-archives/types.gen.ts b/packages/contracts/generated/api/console/workflow-run-archives/types.gen.ts new file mode 100644 index 00000000000..9616c2d0684 --- /dev/null +++ b/packages/contracts/generated/api/console/workflow-run-archives/types.gen.ts @@ -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 + 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] diff --git a/packages/contracts/generated/api/console/workflow-run-archives/zod.gen.ts b/packages/contracts/generated/api/console/workflow-run-archives/zod.gen.ts new file mode 100644 index 00000000000..895205a0ca3 --- /dev/null +++ b/packages/contracts/generated/api/console/workflow-run-archives/zod.gen.ts @@ -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 diff --git a/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx b/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx new file mode 100644 index 00000000000..c4623c1a30e --- /dev/null +++ b/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx @@ -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() + 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() + return { + ...actual, + useProviderContext: vi.fn(), + } +}) + +vi.mock('@/context/modal-context', async (importOriginal) => { + const actual = await importOriginal() + 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[0]), + ) + }) + + it('should show notice for paid workspace managers', () => { + render() + + 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() + + expect(screen.queryByText('appLog.archives.notice.description')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/log/archived-logs-notice-utils.ts b/web/app/components/app/log/archived-logs-notice-utils.ts new file mode 100644 index 00000000000..3180424fbac --- /dev/null +++ b/web/app/components/app/log/archived-logs-notice-utils.ts @@ -0,0 +1,11 @@ +const LAST_THREE_MONTHS_PERIOD = '4' + +type TimePeriodMapping = Record + +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 +} diff --git a/web/app/components/app/log/archived-logs-notice.tsx b/web/app/components/app/log/archived-logs-notice.tsx new file mode 100644 index 00000000000..f5e75d37a6e --- /dev/null +++ b/web/app/components/app/log/archived-logs-notice.tsx @@ -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 ( +
+ +
+ {t(($) => $['archives.notice.description'], { ns: 'appLog' })} + +
+
+ ) +} diff --git a/web/app/components/app/workflow-log/index.tsx b/web/app/components/app/workflow-log/index.tsx index 36ff5c26b1a..24eb42eed45 100644 --- a/web/app/components/app/workflow-log/index.tsx +++ b/web/app/components/app/workflow-log/index.tsx @@ -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 = ({ 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 (
@@ -78,6 +84,7 @@ const Logs: FC = ({ appDetail }) => { />
+ {showArchivedLogsNotice && } {/* workflow log */} {total === undefined ? ( diff --git a/web/app/components/header/account-setting/__tests__/constants.spec.ts b/web/app/components/header/account-setting/__tests__/constants.spec.ts index a37d1ed2e0f..982cf101f21 100644 --- a/web/app/components/header/account-setting/__tests__/constants.spec.ts +++ b/web/app/components/header/account-setting/__tests__/constants.spec.ts @@ -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) diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index 37dbb6e7b25..0fd6b6a78d8 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -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() + return { + ...actual, + get IS_CLOUD_EDITION() { + return mockConfig.IS_CLOUD_EDITION + }, + } +}) vi.mock('@/context/provider-context', async (importOriginal) => { const actual = await importOriginal() @@ -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) diff --git a/web/app/components/header/account-setting/constants.ts b/web/app/components/header/account-setting/constants.ts index bf7d2c19acf..c433f6e0ac1 100644 --- a/web/app/components/header/account-setting/constants.ts +++ b/web/app/components/header/account-setting/constants.ts @@ -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 diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index a0f7ac8b386..80ac759f2df 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -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: , activeIcon: , }, + { + key: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES, + name: t(($) => $['archives.title'], { ns: 'appLog' }), + description: t(($) => $['archives.description'], { ns: 'appLog' }), + icon: , + activeIcon: , + }, { 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 && } {activeMenu === ACCOUNT_SETTING_TAB.BILLING && } + {activeMenu === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && ( + + )} {activeMenu === ACCOUNT_SETTING_TAB.DATA_SOURCE && } {activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && } {activeMenu === ACCOUNT_SETTING_TAB.CUSTOM && } diff --git a/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx new file mode 100644 index 00000000000..b5d49cb033a --- /dev/null +++ b/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx @@ -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() + return { + ...actual, + IS_CLOUD_EDITION: true, + } +}) + +vi.mock('@/context/provider-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useProviderContext: vi.fn(), + } +}) + +vi.mock('@/context/modal-context', async (importOriginal) => { + const actual = await importOriginal() + 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(, { + queryClient, + }) +} + +describe('WorkflowLogArchivesPage', () => { + const setShowPricingModal = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockPlan(Plan.professional) + mockUseModalContext.mockReturnValue({ + setShowPricingModal, + } as unknown as ReturnType) + }) + + 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) + }) + }) +}) diff --git a/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx b/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx new file mode 100644 index 00000000000..06f3e85671c --- /dev/null +++ b/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx @@ -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(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 ( +
+ +
+ ) + } + + return ( +
+
+
+ {summaryItems.map((item) => ( +
+
+ ))} +
+
+ +
+
+
+
+
+ {t(($) => $['archives.table.month'], { ns: 'appLog' })} +
+
+ {t(($) => $['archives.table.runs'], { ns: 'appLog' })} +
+
+ {t(($) => $['archives.table.size'], { ns: 'appLog' })} +
+
+ {t(($) => $['archives.table.action'], { ns: 'appLog' })} +
+
+ {isLoading && ( + <> + {['first', 'second', 'third'].map((key) => ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ))} + + )} + {!isLoading && archiveListQuery.isError && ( +
+
+ )} + {!isLoading && !archiveListQuery.isError && archiveMonths.length === 0 && ( +
+
+ )} + {!isLoading && + !archiveListQuery.isError && + visibleArchiveMonths.map((archive) => { + const archiveMonth = formatMonth(archive.year, archive.month) + + return + })} + {!isLoading && !archiveListQuery.isError && hasMoreArchives && ( + + )} +
+
+
+
+ ) +} + +function ArchivedLogsUpgradeBanner() { + const { t } = useTranslation() + const { setShowPricingModal } = useModalContext() + + return ( +
+
+
+ {t(($) => $['archives.upgradeTip.title'], { ns: 'appLog' })} +
+
+ {t(($) => $['archives.upgradeTip.description'], { ns: 'appLog' })} +
+
+ +
+ ) +} + +function WorkflowArchiveMonthRow({ archive }: { archive: WorkflowRunArchiveMonthResponse }) { + const { t } = useTranslation() + const [downloadTask, setDownloadTask] = useState( + 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 ( +
+
+ {archiveMonth} +
+
+ {formatNumber(archive.workflow_run_count)} +
+
+ {formatBytes(archive.archive_bytes)} +
+
+ + + {!isPreparing && ( + +
+
+ ) +} diff --git a/web/i18n/ar-TN/app-log.json b/web/i18n/ar-TN/app-log.json index 535fc3652a4..4eb315694bf 100644 --- a/web/i18n/ar-TN/app-log.json +++ b/web/i18n/ar-TN/app-log.json @@ -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}}", diff --git a/web/i18n/de-DE/app-log.json b/web/i18n/de-DE/app-log.json index 1c00e91fafc..d85a9d89ccd 100644 --- a/web/i18n/de-DE/app-log.json +++ b/web/i18n/de-DE/app-log.json @@ -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}}", diff --git a/web/i18n/en-US/app-log.json b/web/i18n/en-US/app-log.json index e84f3b65b1f..9bc35200e3c 100644 --- a/web/i18n/en-US/app-log.json +++ b/web/i18n/en-US/app-log.json @@ -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}}", diff --git a/web/i18n/es-ES/app-log.json b/web/i18n/es-ES/app-log.json index 1efd79b82b4..195382d5f8d 100644 --- a/web/i18n/es-ES/app-log.json +++ b/web/i18n/es-ES/app-log.json @@ -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}}", diff --git a/web/i18n/fa-IR/app-log.json b/web/i18n/fa-IR/app-log.json index ffa8f6c1536..fb93f738729 100644 --- a/web/i18n/fa-IR/app-log.json +++ b/web/i18n/fa-IR/app-log.json @@ -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}} علامت‌گذاری شده است", diff --git a/web/i18n/fr-FR/app-log.json b/web/i18n/fr-FR/app-log.json index 09f834de227..5ef8cb5ff3e 100644 --- a/web/i18n/fr-FR/app-log.json +++ b/web/i18n/fr-FR/app-log.json @@ -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}}", diff --git a/web/i18n/hi-IN/app-log.json b/web/i18n/hi-IN/app-log.json index 5fe74a54ff0..a78bc82a474 100644 --- a/web/i18n/hi-IN/app-log.json +++ b/web/i18n/hi-IN/app-log.json @@ -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}} द्वारा सुधार चिह्नित", diff --git a/web/i18n/id-ID/app-log.json b/web/i18n/id-ID/app-log.json index f84f6110697..6e784ddc5e1 100644 --- a/web/i18n/id-ID/app-log.json +++ b/web/i18n/id-ID/app-log.json @@ -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}}", diff --git a/web/i18n/it-IT/app-log.json b/web/i18n/it-IT/app-log.json index 45978e5d285..f735e2efc6a 100644 --- a/web/i18n/it-IT/app-log.json +++ b/web/i18n/it-IT/app-log.json @@ -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}}", diff --git a/web/i18n/ja-JP/app-log.json b/web/i18n/ja-JP/app-log.json index 8c72261633e..f692843ef4b 100644 --- a/web/i18n/ja-JP/app-log.json +++ b/web/i18n/ja-JP/app-log.json @@ -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}} によってマークされた改善", diff --git a/web/i18n/ko-KR/app-log.json b/web/i18n/ko-KR/app-log.json index c3a3ea672f9..49c01088249 100644 --- a/web/i18n/ko-KR/app-log.json +++ b/web/i18n/ko-KR/app-log.json @@ -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}}에 의해 향상됨", diff --git a/web/i18n/nl-NL/app-log.json b/web/i18n/nl-NL/app-log.json index e95b63dcb1a..79fd680f33b 100644 --- a/web/i18n/nl-NL/app-log.json +++ b/web/i18n/nl-NL/app-log.json @@ -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}}", diff --git a/web/i18n/pl-PL/app-log.json b/web/i18n/pl-PL/app-log.json index e4690b62c7b..53f77bd828f 100644 --- a/web/i18n/pl-PL/app-log.json +++ b/web/i18n/pl-PL/app-log.json @@ -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}}", diff --git a/web/i18n/pt-BR/app-log.json b/web/i18n/pt-BR/app-log.json index c47e88a0143..0d980632c24 100644 --- a/web/i18n/pt-BR/app-log.json +++ b/web/i18n/pt-BR/app-log.json @@ -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}}", diff --git a/web/i18n/ro-RO/app-log.json b/web/i18n/ro-RO/app-log.json index 5afd8d3f497..a656cbb2598 100644 --- a/web/i18n/ro-RO/app-log.json +++ b/web/i18n/ro-RO/app-log.json @@ -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}}", diff --git a/web/i18n/ru-RU/app-log.json b/web/i18n/ru-RU/app-log.json index 675e86b1b83..1437e895a99 100644 --- a/web/i18n/ru-RU/app-log.json +++ b/web/i18n/ru-RU/app-log.json @@ -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}}", diff --git a/web/i18n/sl-SI/app-log.json b/web/i18n/sl-SI/app-log.json index ac99befc35a..f48767f2e7a 100644 --- a/web/i18n/sl-SI/app-log.json +++ b/web/i18n/sl-SI/app-log.json @@ -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}}", diff --git a/web/i18n/th-TH/app-log.json b/web/i18n/th-TH/app-log.json index ee31250d11a..8972325ebcc 100644 --- a/web/i18n/th-TH/app-log.json +++ b/web/i18n/th-TH/app-log.json @@ -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}}", diff --git a/web/i18n/tr-TR/app-log.json b/web/i18n/tr-TR/app-log.json index f741505634f..630801a894a 100644 --- a/web/i18n/tr-TR/app-log.json +++ b/web/i18n/tr-TR/app-log.json @@ -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", diff --git a/web/i18n/uk-UA/app-log.json b/web/i18n/uk-UA/app-log.json index 7fb339246ba..d6fb56e1df6 100644 --- a/web/i18n/uk-UA/app-log.json +++ b/web/i18n/uk-UA/app-log.json @@ -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}}", diff --git a/web/i18n/vi-VN/app-log.json b/web/i18n/vi-VN/app-log.json index de4e69c9fee..18ed93a95a2 100644 --- a/web/i18n/vi-VN/app-log.json +++ b/web/i18n/vi-VN/app-log.json @@ -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}}", diff --git a/web/i18n/zh-Hans/app-log.json b/web/i18n/zh-Hans/app-log.json index ae660225307..0e5005f60d1 100644 --- a/web/i18n/zh-Hans/app-log.json +++ b/web/i18n/zh-Hans/app-log.json @@ -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}} 标记的改进回复", diff --git a/web/i18n/zh-Hant/app-log.json b/web/i18n/zh-Hant/app-log.json index 043e6b89e52..b53201f82d7 100644 --- a/web/i18n/zh-Hant/app-log.json +++ b/web/i18n/zh-Hant/app-log.json @@ -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}} 標記的改進回覆",