From 74246638f59cd6250b83811eaa4e6b450e54d5fa Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 21 Aug 2026 04:22:59 +0800 Subject: [PATCH 1/5] refactor(remote): rename probe and tunnel telemetry fields --- crates/aster_drive_migration/src/lib.rs | 2 + ...821_000001_rename_remote_node_telemetry.rs | 92 +++++++++++++++++++ .../src/entities/managed_follower.rs | 34 +++---- developer-docs/en/api/admin.md | 9 +- developer-docs/zh-CN/api/admin.md | 9 +- .../RemoteNodeDialog.test.tsx | 4 +- .../RemoteNodeDialogCards.tsx | 19 ++-- .../RemoteNodesTable.test.tsx | 18 ++-- .../RemoteNodesTable.tsx | 8 +- .../admin-remote-nodes-page/shared.test.tsx | 48 +++++----- .../admin/admin-remote-nodes-page/shared.tsx | 8 +- .../admin/remoteNodeDialogShared.test.ts | 8 +- .../StorageConnectorActionsPanel.test.tsx | 8 +- .../i18n/locales/en/admin/remote-nodes.json | 10 +- .../i18n/locales/zh/admin/remote-nodes.json | 10 +- .../pages/admin/AdminPoliciesPage.test.tsx | 8 +- .../pages/admin/AdminRemoteNodesPage.test.tsx | 10 +- .../useAdminRemoteNodesPageController.ts | 2 +- frontend-panel/src/services/api.generated.ts | 36 ++++---- src/api/pagination.rs | 2 +- src/db/repository/managed_follower_repo.rs | 24 ++--- src/services/ops/deployment.rs | 8 +- src/services/remote/remote_node.rs | 20 ++-- .../storage_policy/policy/policies.rs | 4 +- src/storage/connectors/remote.rs | 8 +- src/storage/drivers/remote/tests.rs | 8 +- src/storage/policy_snapshot.rs | 4 +- src/storage/registry.rs | 8 +- src/storage/remote_protocol/runtime.rs | 8 +- src/storage/remote_protocol/transport.rs | 8 +- .../remote_protocol/tunnel/server/mod.rs | 22 ++--- .../remote_protocol/tunnel/server/owner.rs | 8 +- .../remote_protocol/tunnel/server/proxy.rs | 16 ++-- .../tunnel/server/registry/mod.rs | 49 +++++----- .../tunnel/server/registry/persistence.rs | 2 +- .../tunnel/server/registry/polling.rs | 2 +- .../tunnel/server/registry/streaming.rs | 2 +- .../remote_protocol/tunnel/server/tests.rs | 10 +- tests/files/upload.rs | 4 +- tests/multi_primary/cluster.rs | 8 +- tests/operations/cli.rs | 26 +++++- tests/storage/remote_storage.rs | 54 +++++------ 42 files changed, 385 insertions(+), 263 deletions(-) create mode 100644 crates/aster_drive_migration/src/m20260821_000001_rename_remote_node_telemetry.rs diff --git a/crates/aster_drive_migration/src/lib.rs b/crates/aster_drive_migration/src/lib.rs index 8a1475988..d671289cb 100644 --- a/crates/aster_drive_migration/src/lib.rs +++ b/crates/aster_drive_migration/src/lib.rs @@ -70,6 +70,7 @@ mod m20260813_000001_canonical_file_revision_ledger; mod m20260815_000001_virtual_empty_file_blobs; mod m20260817_000001_add_remote_binding_control_state; mod m20260820_000001_remove_storage_policy_legacy; +mod m20260821_000001_rename_remote_node_telemetry; pub const BASELINE_MIGRATION_NAME: &str = "m20260512_000001_baseline_schema"; const MIGRATION_TABLE: &str = "seaql_migrations"; @@ -213,6 +214,7 @@ impl MigratorTrait for CurrentMigrator { m20260817_000001_add_remote_binding_control_state::Migration, ), Box::new(m20260820_000001_remove_storage_policy_legacy::Migration), + Box::new(m20260821_000001_rename_remote_node_telemetry::Migration), ] } } diff --git a/crates/aster_drive_migration/src/m20260821_000001_rename_remote_node_telemetry.rs b/crates/aster_drive_migration/src/m20260821_000001_rename_remote_node_telemetry.rs new file mode 100644 index 000000000..fa4d49a3c --- /dev/null +++ b/crates/aster_drive_migration/src/m20260821_000001_rename_remote_node_telemetry.rs @@ -0,0 +1,92 @@ +//! 数据库迁移:明确区分 remote-node probe 与 reverse-tunnel 运行态字段。 + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // SQLite 对单条 ALTER TABLE 的支持最稳定;四个列逐一重命名并保留原值。 + rename_column( + manager, + ManagedFollowers::LastError, + ManagedFollowers::LastProbeError, + ) + .await?; + rename_column( + manager, + ManagedFollowers::LastCheckedAt, + ManagedFollowers::LastProbeAt, + ) + .await?; + rename_column( + manager, + ManagedFollowers::TunnelLastError, + ManagedFollowers::TunnelRuntimeError, + ) + .await?; + rename_column( + manager, + ManagedFollowers::TunnelLastSeenAt, + ManagedFollowers::TunnelLastHandshakeAt, + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + rename_column( + manager, + ManagedFollowers::LastProbeError, + ManagedFollowers::LastError, + ) + .await?; + rename_column( + manager, + ManagedFollowers::LastProbeAt, + ManagedFollowers::LastCheckedAt, + ) + .await?; + rename_column( + manager, + ManagedFollowers::TunnelRuntimeError, + ManagedFollowers::TunnelLastError, + ) + .await?; + rename_column( + manager, + ManagedFollowers::TunnelLastHandshakeAt, + ManagedFollowers::TunnelLastSeenAt, + ) + .await + } +} + +async fn rename_column( + manager: &SchemaManager<'_>, + from: ManagedFollowers, + to: ManagedFollowers, +) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(ManagedFollowers::Table) + .rename_column(from, to) + .to_owned(), + ) + .await +} + +#[derive(DeriveIden)] +enum ManagedFollowers { + Table, + LastError, + LastCheckedAt, + TunnelLastError, + TunnelLastSeenAt, + LastProbeError, + LastProbeAt, + TunnelRuntimeError, + TunnelLastHandshakeAt, +} diff --git a/crates/aster_drive_model/src/entities/managed_follower.rs b/crates/aster_drive_model/src/entities/managed_follower.rs index ffcc6e3e7..cb604a57e 100644 --- a/crates/aster_drive_model/src/entities/managed_follower.rs +++ b/crates/aster_drive_model/src/entities/managed_follower.rs @@ -31,19 +31,19 @@ pub struct Model { /// Capabilities returned by the most recent explicit remote-node probe. pub last_capabilities: String, /// Error from the most recent explicit remote-node probe or connection test. - pub last_error: String, + pub last_probe_error: String, #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = Option))] - /// Time at which `last_capabilities` and `last_error` were recorded. - pub last_checked_at: Option, + /// Time at which `last_capabilities` and `last_probe_error` were recorded. + pub last_probe_at: Option, /// Transient reverse-tunnel runtime error; a successful poll or stream handshake clears it. - pub tunnel_last_error: String, + pub tunnel_runtime_error: String, /// Desired binding-control revision generated by the primary. pub binding_revision: i64, /// Latest binding-control revision applied by the follower. pub binding_applied_revision: i64, #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = Option))] /// Last successful reverse-tunnel poll or stream handshake observed by this primary. - pub tunnel_last_seen_at: Option, + pub tunnel_last_handshake_at: Option, #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = String))] /// Time at which the remote node record was created. pub created_at: DateTimeUtc, @@ -63,12 +63,12 @@ impl fmt::Debug for Model { .field("is_enabled", &self.is_enabled) .field("transport_mode", &self.transport_mode) .field("last_capabilities", &self.last_capabilities) - .field("last_error", &self.last_error) - .field("last_checked_at", &self.last_checked_at) - .field("tunnel_last_error", &self.tunnel_last_error) + .field("last_probe_error", &self.last_probe_error) + .field("last_probe_at", &self.last_probe_at) + .field("tunnel_runtime_error", &self.tunnel_runtime_error) .field("binding_revision", &self.binding_revision) .field("binding_applied_revision", &self.binding_applied_revision) - .field("tunnel_last_seen_at", &self.tunnel_last_seen_at) + .field("tunnel_last_handshake_at", &self.tunnel_last_handshake_at) .field("created_at", &self.created_at) .field("updated_at", &self.updated_at) .finish() @@ -97,11 +97,11 @@ impl ActiveModelBehavior for ActiveModel { if !self.transport_mode.is_set() { self.transport_mode = Set(RemoteNodeTransportMode::Direct); } - if !self.tunnel_last_error.is_set() { - self.tunnel_last_error = Set(String::new()); + if !self.tunnel_runtime_error.is_set() { + self.tunnel_runtime_error = Set(String::new()); } - if !self.tunnel_last_seen_at.is_set() { - self.tunnel_last_seen_at = Set(None); + if !self.tunnel_last_handshake_at.is_set() { + self.tunnel_last_handshake_at = Set(None); } } Ok(self) @@ -124,12 +124,12 @@ mod tests { is_enabled: true, transport_mode: RemoteNodeTransportMode::Direct, last_capabilities: "{}".to_string(), - last_error: String::new(), - last_checked_at: None, - tunnel_last_error: String::new(), + last_probe_error: String::new(), + last_probe_at: None, + tunnel_runtime_error: String::new(), binding_revision: 1, binding_applied_revision: 0, - tunnel_last_seen_at: None, + tunnel_last_handshake_at: None, created_at: now, updated_at: now, }; diff --git a/developer-docs/en/api/admin.md b/developer-docs/en/api/admin.md index c34eaa044..7ebf58ea9 100644 --- a/developer-docs/en/api/admin.md +++ b/developer-docs/en/api/admin.md @@ -352,10 +352,11 @@ Notes: - `reverse_tunnel` requires the follower to actively connect back to `/api/v1/internal/remote-tunnel/*` - `auto` uses direct when `base_url` is non-empty and reverse tunnel otherwise - empty `base_url` usually means the enrollment flow will complete binding later -- remote-node details include `transport_mode`, `enrollment_status`, `last_error`, `capabilities`, `last_checked_at`, and `tunnel` -- `last_error` and `last_checked_at` belong to the explicit capability probe or connection test; they describe the most recent probe rather than reverse-tunnel runtime health -- `tunnel.status` is derived from recent poll / stream handshakes and the online TTL; `tunnel.last_seen_at` is the primary's most recent successful handshake time -- `tunnel.last_error` is transient reverse-tunnel runtime telemetry. The next successful poll / stream handshake clears it; it is not a historical error log and must not be conflated with the node probe `last_error` +- remote-node details include `transport_mode`, `enrollment_status`, `last_probe_error`, `capabilities`, `last_probe_at`, and `tunnel` +- `last_probe_error` and `last_probe_at` belong to the explicit capability probe or connection test; they describe the most recent probe rather than reverse-tunnel runtime health +- `tunnel.status` is derived from recent poll / stream handshakes and the online TTL; `tunnel.last_handshake_at` is the primary's most recent successful handshake time +- `tunnel.runtime_error` is transient reverse-tunnel runtime telemetry. The next successful poll / stream handshake clears it; it is not a historical error log and must not be conflated with the node probe `last_probe_error` +- The renamed fields are the public management contract: the old names are removed without response aliases, so API clients must migrate to `last_probe_error`, `last_probe_at`, `tunnel.runtime_error`, and `tunnel.last_handshake_at` together. The database migration renames columns in place and preserves their values. - reverse tunnel cannot be combined with remote browser presigned upload / download strategies - remote storage target request bodies match the follower internal storage protocol; see [Internal storage protocol](./internal-storage.md) diff --git a/developer-docs/zh-CN/api/admin.md b/developer-docs/zh-CN/api/admin.md index 7a994b45d..57e23e747 100644 --- a/developer-docs/zh-CN/api/admin.md +++ b/developer-docs/zh-CN/api/admin.md @@ -373,10 +373,11 @@ POST /api/v1/admin/policies/action - `base_url` 为空时通常走 enrollment 流程,由 follower 兑换绑定信息后再完成实际接入 - `/enrollment-token` 返回给 CLI 使用的命令信息;follower 会再调用公开 enrollment 接口完成 redeem / ack - `GET /admin/remote-nodes` 支持 `limit`、`offset`、`sort_by`、`sort_order` -- 远端节点详情会返回 `transport_mode`、`enrollment_status`、`last_error`、`capabilities`、`last_checked_at` 和 `tunnel` -- `last_error` / `last_checked_at` 属于显式 capability probe 或 connection test;它们描述最近一次探测,不是 reverse tunnel 的运行态 -- `tunnel.status` 根据最近 poll / stream handshake 和在线 TTL 推导;`tunnel.last_seen_at` 是 primary 最近一次成功握手时间 -- `tunnel.last_error` 是 reverse tunnel 运行态的暂时错误。下一次成功 poll / stream handshake 会清空它;它不是历史错误日志,也不应与节点探测的 `last_error` 混用 +- 远端节点详情会返回 `transport_mode`、`enrollment_status`、`last_probe_error`、`capabilities`、`last_probe_at` 和 `tunnel` +- `last_probe_error` / `last_probe_at` 属于显式 capability probe 或 connection test;它们描述最近一次探测,不是 reverse tunnel 的运行态 +- `tunnel.status` 根据最近 poll / stream handshake 和在线 TTL 推导;`tunnel.last_handshake_at` 是 primary 最近一次成功握手时间 +- `tunnel.runtime_error` 是 reverse tunnel 运行态的暂时错误。下一次成功 poll / stream handshake 会清空它;它不是历史错误日志,也不应与节点探测的 `last_probe_error` 混用 +- 这些重命名后的字段属于公开管理契约:旧名称不再返回,也不保留响应 alias,API 客户端需要一次性迁移到 `last_probe_error`、`last_probe_at`、`tunnel.runtime_error` 和 `tunnel.last_handshake_at`。数据库迁移只重命名列并保留原值。 - reverse tunnel 模式不能配合 remote 浏览器预签名上传 / 下载策略使用。创建或更新远端策略、切换远端节点传输模式时,如果引用该节点的策略使用 `remote_upload_strategy = "presigned"` 或 `remote_download_strategy = "presigned"`,服务端会拒绝这个组合 - 远程存储目标的请求体和 follower 内部协议一致,见 [内部存储协议](./internal-storage.md) diff --git a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialog.test.tsx b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialog.test.tsx index d649e36e3..7b7e0a69e 100644 --- a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialog.test.tsx +++ b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialog.test.tsx @@ -181,8 +181,8 @@ const remoteNode = ( base_url: "https://edge.example.com", is_enabled: true, enrollment_status: "not_started", - last_error: "", - last_checked_at: null, + last_probe_error: "", + last_probe_at: null, capabilities: { protocol_version: "v1", supports_list: true, diff --git a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialogCards.tsx b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialogCards.tsx index 100bfd5e7..504569ab4 100644 --- a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialogCards.tsx +++ b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialogCards.tsx @@ -245,30 +245,31 @@ export function RemoteNodeDiagnosticsCard({ {getRemoteNodeTunnelLabel(t, editingNode)}
- {t("remote_node_tunnel_last_seen")}:{" "} - {formatLastChecked(t, editingNode.tunnel?.last_seen_at)} + {t("remote_node_tunnel_last_handshake")}:{" "} + {formatLastChecked(t, editingNode.tunnel?.last_handshake_at)}
- {t("remote_node_tunnel_last_error")}:{" "} - {editingNode.tunnel?.last_error || - t("remote_node_last_error_empty")} + {t("remote_node_tunnel_runtime_error")}:{" "} + {editingNode.tunnel?.runtime_error || + t("remote_node_last_probe_error_empty")}
- {t("remote_node_last_checked")} + {t("remote_node_last_probe_at")}
- {formatLastChecked(t, editingNode.last_checked_at)} + {formatLastChecked(t, editingNode.last_probe_at)}
- {t("remote_node_last_error")} + {t("remote_node_last_probe_error")}
- {editingNode.last_error || t("remote_node_last_error_empty")} + {editingNode.last_probe_error || + t("remote_node_last_probe_error_empty")}
diff --git a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodesTable.test.tsx b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodesTable.test.tsx index de2f9e2d1..596c0a733 100644 --- a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodesTable.test.tsx +++ b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodesTable.test.tsx @@ -66,12 +66,12 @@ const remoteNode = ( transport_mode: "direct", is_enabled: true, enrollment_status: "not_started", - last_error: "", - last_checked_at: null, + last_probe_error: "", + last_probe_at: null, tunnel: { status: "offline", - last_error: "", - last_seen_at: null, + runtime_error: "", + last_handshake_at: null, }, capabilities: { protocol_version: "v1", @@ -190,8 +190,8 @@ describe("RemoteNodesTable", () => { it("shows deleting and generating states without firing disabled actions", () => { const node = remoteNode({ enrollment_status: "pending", - last_error: "storage unavailable", - last_checked_at: "2026-05-29T08:00:00Z", + last_probe_error: "storage unavailable", + last_probe_at: "2026-05-29T08:00:00Z", }); const onGenerateEnrollmentCommand = vi.fn(); const onRequestDelete = vi.fn(); @@ -235,11 +235,11 @@ describe("RemoteNodesTable", () => { renderTable({ items: [ remoteNode({ - last_error: nodeError, + last_probe_error: nodeError, tunnel: { status: "offline", - last_error: tunnelError, - last_seen_at: null, + runtime_error: tunnelError, + last_handshake_at: null, }, }), ], diff --git a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodesTable.tsx b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodesTable.tsx index 4470dea12..38fecf35f 100644 --- a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodesTable.tsx +++ b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodesTable.tsx @@ -100,7 +100,7 @@ export function RemoteNodesTable({ {t("remote_node_transport_mode")}
- {node.tunnel?.last_error ? ( + {node.tunnel?.runtime_error ? (
- {node.tunnel.last_error} + {node.tunnel.runtime_error}
) : null} @@ -231,7 +231,7 @@ export function RemoteNodesTable({
- {formatLastChecked(t, node.last_checked_at)} + {formatLastChecked(t, node.last_probe_at)}
diff --git a/frontend-panel/src/components/admin/admin-remote-nodes-page/shared.test.tsx b/frontend-panel/src/components/admin/admin-remote-nodes-page/shared.test.tsx index e6cb773cc..693fcdc57 100644 --- a/frontend-panel/src/components/admin/admin-remote-nodes-page/shared.test.tsx +++ b/frontend-panel/src/components/admin/admin-remote-nodes-page/shared.test.tsx @@ -36,13 +36,13 @@ describe("admin remote nodes shared helpers", () => { const node = (overrides: Partial = {}) => ({ is_enabled: true, - last_checked_at: "2026-05-29T08:00:00Z", - last_error: "", + last_probe_at: "2026-05-29T08:00:00Z", + last_probe_error: "", transport_mode: "direct", tunnel: { status: "offline", - last_error: "", - last_seen_at: null, + runtime_error: "", + last_handshake_at: null, }, enrollment_status: "not_started", ...overrides, @@ -77,18 +77,18 @@ describe("admin remote nodes shared helpers", () => { expect(getRemoteNodeStatusTone(node({ is_enabled: false }))).toContain( "border-slate", ); - expect(getRemoteNodeStatusLabel(t, node({ last_checked_at: null }))).toBe( + expect(getRemoteNodeStatusLabel(t, node({ last_probe_at: null }))).toBe( "remote_node_status_pending", ); - expect(getRemoteNodeStatusTone(node({ last_checked_at: null }))).toContain( + expect(getRemoteNodeStatusTone(node({ last_probe_at: null }))).toContain( "border-blue", ); - expect(getRemoteNodeStatusLabel(t, node({ last_error: "timeout" }))).toBe( - "remote_node_status_degraded", - ); - expect(getRemoteNodeStatusTone(node({ last_error: "timeout" }))).toContain( - "border-amber", - ); + expect( + getRemoteNodeStatusLabel(t, node({ last_probe_error: "timeout" })), + ).toBe("remote_node_status_degraded"); + expect( + getRemoteNodeStatusTone(node({ last_probe_error: "timeout" })), + ).toContain("border-amber"); expect(getRemoteNodeStatusLabel(t, node())).toBe( "remote_node_status_enabled", ); @@ -182,8 +182,8 @@ describe("admin remote nodes shared helpers", () => { transport_mode: "direct", tunnel: { status: "online", - last_error: "", - last_seen_at: "2026-05-29T08:00:00Z", + runtime_error: "", + last_handshake_at: "2026-05-29T08:00:00Z", }, } as RemoteNodeInfo), ).toBe("remote_node_tunnel_not_used"); @@ -192,8 +192,8 @@ describe("admin remote nodes shared helpers", () => { transport_mode: "reverse_tunnel", tunnel: { status: "online", - last_error: "", - last_seen_at: "2026-05-29T08:00:00Z", + runtime_error: "", + last_handshake_at: "2026-05-29T08:00:00Z", }, } as RemoteNodeInfo), ).toBe("remote_node_tunnel_online"); @@ -202,8 +202,8 @@ describe("admin remote nodes shared helpers", () => { transport_mode: "auto", tunnel: { status: "offline", - last_error: "poll timeout", - last_seen_at: null, + runtime_error: "poll timeout", + last_handshake_at: null, }, } as RemoteNodeInfo), ).toBe("remote_node_tunnel_offline"); @@ -216,8 +216,8 @@ describe("admin remote nodes shared helpers", () => { transport_mode: "direct", tunnel: { status: "online", - last_error: "", - last_seen_at: "2026-05-29T08:00:00Z", + runtime_error: "", + last_handshake_at: "2026-05-29T08:00:00Z", }, }), ), @@ -228,8 +228,8 @@ describe("admin remote nodes shared helpers", () => { transport_mode: "reverse_tunnel", tunnel: { status: "online", - last_error: "", - last_seen_at: "2026-05-29T08:00:00Z", + runtime_error: "", + last_handshake_at: "2026-05-29T08:00:00Z", }, }), ), @@ -240,8 +240,8 @@ describe("admin remote nodes shared helpers", () => { transport_mode: "auto", tunnel: { status: "offline", - last_error: "poll timeout", - last_seen_at: null, + runtime_error: "poll timeout", + last_handshake_at: null, }, }), ), diff --git a/frontend-panel/src/components/admin/admin-remote-nodes-page/shared.tsx b/frontend-panel/src/components/admin/admin-remote-nodes-page/shared.tsx index 01cf6e95c..55b043541 100644 --- a/frontend-panel/src/components/admin/admin-remote-nodes-page/shared.tsx +++ b/frontend-panel/src/components/admin/admin-remote-nodes-page/shared.tsx @@ -54,11 +54,11 @@ export function getRemoteNodeStatusTone(node: RemoteNodeInfo) { return "border-slate-500/40 bg-slate-500/10 text-slate-600 dark:text-slate-300"; } - if (!node.last_checked_at) { + if (!node.last_probe_at) { return "border-blue-500/60 bg-blue-500/10 text-blue-600 dark:text-blue-300"; } - if (node.last_error) { + if (node.last_probe_error) { return "border-amber-500/60 bg-amber-500/10 text-amber-600 dark:text-amber-300"; } @@ -70,11 +70,11 @@ export function getRemoteNodeStatusLabel(t: TFunction, node: RemoteNodeInfo) { return t("remote_node_status_disabled"); } - if (!node.last_checked_at) { + if (!node.last_probe_at) { return t("remote_node_status_pending"); } - if (node.last_error) { + if (node.last_probe_error) { return t("remote_node_status_degraded"); } diff --git a/frontend-panel/src/components/admin/remoteNodeDialogShared.test.ts b/frontend-panel/src/components/admin/remoteNodeDialogShared.test.ts index d73bd0488..0423b34eb 100644 --- a/frontend-panel/src/components/admin/remoteNodeDialogShared.test.ts +++ b/frontend-panel/src/components/admin/remoteNodeDialogShared.test.ts @@ -17,13 +17,13 @@ describe("remoteNodeDialogShared", () => { base_url: "https://remote.example.com", transport_mode: "reverse_tunnel", is_enabled: true, - last_error: "", - last_checked_at: null, + last_probe_error: "", + last_probe_at: null, enrollment_status: "completed", tunnel: { status: "online", - last_error: "", - last_seen_at: "2026-05-29T08:00:00Z", + runtime_error: "", + last_handshake_at: "2026-05-29T08:00:00Z", }, capabilities: { protocol_version: "v1", diff --git a/frontend-panel/src/components/admin/storage-policy-dialog/StorageConnectorActionsPanel.test.tsx b/frontend-panel/src/components/admin/storage-policy-dialog/StorageConnectorActionsPanel.test.tsx index b52ec3732..ce64244e0 100644 --- a/frontend-panel/src/components/admin/storage-policy-dialog/StorageConnectorActionsPanel.test.tsx +++ b/frontend-panel/src/components/admin/storage-policy-dialog/StorageConnectorActionsPanel.test.tsx @@ -154,11 +154,11 @@ function remoteNode(id: number, name: string): RemoteNodeInfo { enrollment_status: "completed", id, is_enabled: true, - last_checked_at: null, - last_error: "", + last_probe_at: null, + last_probe_error: "", name, transport_mode: "direct", - tunnel: { last_error: "", last_seen_at: null, status: "offline" }, + tunnel: { runtime_error: "", last_handshake_at: null, status: "offline" }, updated_at: "2026-08-05T00:00:00Z", }; } @@ -173,7 +173,7 @@ function remoteTarget(targetKey: string): RemoteStorageTargetInfo { driver_type: "local", endpoint: "", is_default: true, - last_error: "", + last_probe_error: "", name: "Archive", target_key: targetKey, updated_at: "2026-08-05T00:00:00Z", diff --git a/frontend-panel/src/i18n/locales/en/admin/remote-nodes.json b/frontend-panel/src/i18n/locales/en/admin/remote-nodes.json index 91d8712de..5ddf2b000 100644 --- a/frontend-panel/src/i18n/locales/en/admin/remote-nodes.json +++ b/frontend-panel/src/i18n/locales/en/admin/remote-nodes.json @@ -45,8 +45,8 @@ "remote_node_tunnel_online": "Online", "remote_node_tunnel_offline": "Offline", "remote_node_tunnel_not_used": "Not used", - "remote_node_tunnel_last_seen": "Last Seen", - "remote_node_tunnel_last_error": "Tunnel Error", + "remote_node_tunnel_last_handshake": "Last Handshake", + "remote_node_tunnel_runtime_error": "Tunnel Error", "remote_node_base_url_hint": "Optional for reverse tunnel. For direct transport, enter the follower URL that the primary can reach. Auto mode chooses direct only when this value is set; it does not retry through the tunnel if direct access fails.", "remote_node_base_url_invalid": "Enter a valid base URL that starts with http:// or https://.", "remote_node_base_url_empty": "No outbound base URL", @@ -54,9 +54,9 @@ "remote_node_status_settings_desc": "This switch only decides whether the primary should keep using this node. When disabled, the primary stops sending remote traffic to it and the peer can no longer push objects into this node. Which local policy receives those objects is still written on the follower during enroll, not here.", "remote_node_diagnostics_title": "Last Probe", "remote_node_diagnostics_desc": "Saved capability probe details from the latest connection test.", - "remote_node_last_checked": "Last Checked", - "remote_node_last_error": "Last Error", - "remote_node_last_error_empty": "No recorded error", + "remote_node_last_probe_at": "Last Probe", + "remote_node_last_probe_error": "Probe Error", + "remote_node_last_probe_error_empty": "No recorded error", "remote_node_capabilities": "Capabilities", "remote_node_protocol_version": "Protocol", "remote_node_supports_list": "Supports list", diff --git a/frontend-panel/src/i18n/locales/zh/admin/remote-nodes.json b/frontend-panel/src/i18n/locales/zh/admin/remote-nodes.json index c23329cea..b2ee839d6 100644 --- a/frontend-panel/src/i18n/locales/zh/admin/remote-nodes.json +++ b/frontend-panel/src/i18n/locales/zh/admin/remote-nodes.json @@ -45,8 +45,8 @@ "remote_node_tunnel_online": "在线", "remote_node_tunnel_offline": "离线", "remote_node_tunnel_not_used": "不使用", - "remote_node_tunnel_last_seen": "最近在线", - "remote_node_tunnel_last_error": "通道错误", + "remote_node_tunnel_last_handshake": "最近握手", + "remote_node_tunnel_runtime_error": "通道错误", "remote_node_base_url_hint": "反向通道下可以留空。直连模式需要填写主控端能访问到的 follower 地址;自动模式只按这里是否填写来选择传输方式,不会在直连失败后自动改走通道。", "remote_node_base_url_invalid": "基础地址格式不正确,请填写以 http:// 或 https:// 开头的完整地址。", "remote_node_base_url_empty": "未配置出站地址", @@ -54,9 +54,9 @@ "remote_node_status_settings_desc": "这个开关只表示主控端是否继续使用该节点。关闭后会视为禁用:主控端停止向它发送远程流量,对端也不能再向当前节点回推对象。对象最终写到哪条本地策略,仍由从节点在 enroll 时写入本地,不在这里设置。", "remote_node_diagnostics_title": "最近探测", "remote_node_diagnostics_desc": "展示最近一次连接测试保存下来的能力信息。", - "remote_node_last_checked": "最近测试时间", - "remote_node_last_error": "最近错误", - "remote_node_last_error_empty": "暂无记录错误", + "remote_node_last_probe_at": "最近探测", + "remote_node_last_probe_error": "探测错误", + "remote_node_last_probe_error_empty": "暂无记录错误", "remote_node_capabilities": "能力", "remote_node_protocol_version": "协议版本", "remote_node_supports_list": "支持列表扫描", diff --git a/frontend-panel/src/pages/admin/AdminPoliciesPage.test.tsx b/frontend-panel/src/pages/admin/AdminPoliciesPage.test.tsx index 919716bd6..5708dcbbe 100644 --- a/frontend-panel/src/pages/admin/AdminPoliciesPage.test.tsx +++ b/frontend-panel/src/pages/admin/AdminPoliciesPage.test.tsx @@ -517,11 +517,11 @@ function remoteNode(id: number, name: string): RemoteNodeInfo { enrollment_status: "completed", id, is_enabled: true, - last_checked_at: null, - last_error: "", + last_probe_at: null, + last_probe_error: "", name, transport_mode: "direct", - tunnel: { last_error: "", last_seen_at: null, status: "offline" }, + tunnel: { runtime_error: "", last_handshake_at: null, status: "offline" }, updated_at: "2026-08-04T00:00:00Z", }; } @@ -539,7 +539,7 @@ function remoteTarget( driver_type: "local", endpoint: "", is_default: true, - last_error: "", + last_probe_error: "", name: targetKey, target_key: targetKey, updated_at: "2026-08-04T00:00:00Z", diff --git a/frontend-panel/src/pages/admin/AdminRemoteNodesPage.test.tsx b/frontend-panel/src/pages/admin/AdminRemoteNodesPage.test.tsx index d32a9de77..a9f362ce8 100644 --- a/frontend-panel/src/pages/admin/AdminRemoteNodesPage.test.tsx +++ b/frontend-panel/src/pages/admin/AdminRemoteNodesPage.test.tsx @@ -504,7 +504,7 @@ describe("AdminRemoteNodesPage", () => { driver_type: "local", endpoint: "", is_default: true, - last_error: "", + last_probe_error: "", name: "Default ingress", target_key: "default", updated_at: "2026-05-02T00:00:00Z", @@ -718,8 +718,8 @@ describe("AdminRemoteNodesPage", () => { name: "Reverse Tunnel", transport_mode: "reverse_tunnel", tunnel: { - last_error: "", - last_seen_at: "2026-05-29T00:00:00Z", + last_probe_error: "", + last_handshake_at: "2026-05-29T00:00:00Z", status: "online", }, }, @@ -759,8 +759,8 @@ describe("AdminRemoteNodesPage", () => { name: "Auto Tunnel", transport_mode: "auto", tunnel: { - last_error: "", - last_seen_at: "2026-05-29T00:00:00Z", + last_probe_error: "", + last_handshake_at: "2026-05-29T00:00:00Z", status: "online", }, }, diff --git a/frontend-panel/src/pages/admin/useAdminRemoteNodesPageController.ts b/frontend-panel/src/pages/admin/useAdminRemoteNodesPageController.ts index 3d0a36f53..2eb096e33 100644 --- a/frontend-panel/src/pages/admin/useAdminRemoteNodesPageController.ts +++ b/frontend-panel/src/pages/admin/useAdminRemoteNodesPageController.ts @@ -51,7 +51,7 @@ const REMOTE_NODE_SORT_BY_OPTIONS = [ "name", "base_url", "is_enabled", - "last_checked_at", + "last_probe_at", "created_at", "updated_at", ] as const satisfies readonly AdminRemoteNodeSortBy[]; diff --git a/frontend-panel/src/services/api.generated.ts b/frontend-panel/src/services/api.generated.ts index f2ccf36d0..2abb65520 100644 --- a/frontend-panel/src/services/api.generated.ts +++ b/frontend-panel/src/services/api.generated.ts @@ -4557,7 +4557,7 @@ export interface components { sort_order?: null | components["schemas"]["SortOrder"]; }; /** @enum {string} */ - AdminRemoteNodeSortBy: "id" | "name" | "base_url" | "is_enabled" | "last_checked_at" | "created_at" | "updated_at"; + AdminRemoteNodeSortBy: "id" | "name" | "base_url" | "is_enabled" | "last_probe_at" | "created_at" | "updated_at"; AdminShareListQuery: { sort_by?: null | components["schemas"]["AdminShareSortBy"]; sort_order?: null | components["schemas"]["SortOrder"]; @@ -6653,9 +6653,9 @@ export interface components { * Format: date-time * @description Timestamp of the most recent explicit capability probe. */ - last_checked_at?: string | null; + last_probe_at?: string | null; /** @description Result of the most recent explicit capability probe or connection test. */ - last_error: string; + last_probe_error: string; /** @description Administrative display name. */ name: string; /** @description Configured transport choice; `auto` is resolved from `base_url` at runtime. */ @@ -7430,9 +7430,9 @@ export interface components { * Format: date-time * @description Timestamp of the most recent explicit capability probe. */ - last_checked_at?: string | null; + last_probe_at?: string | null; /** @description Result of the most recent explicit capability probe or connection test. */ - last_error: string; + last_probe_error: string; /** @description Administrative display name. */ name: string; /** @description Configured transport choice; `auto` is resolved from `base_url` at runtime. */ @@ -7524,13 +7524,13 @@ export interface components { updated_at: string; }; RemoteTunnelInfo: { + /** @description Last successful poll or stream handshake persisted by the primary. */ + last_handshake_at?: string | null; /** * @description Transient runtime error from the tunnel control/data path. The next successful poll or * stream handshake clears it; it is not a historical error log. */ - last_error: string; - /** @description Last successful poll or stream handshake persisted by the primary. */ - last_seen_at?: string | null; + runtime_error: string; /** @description Online status derived from recent successful tunnel handshakes and the online TTL. */ status: components["schemas"]["RemoteTunnelOnlineStatus"]; }; @@ -12490,9 +12490,9 @@ export interface operations { * Format: date-time * @description Timestamp of the most recent explicit capability probe. */ - last_checked_at?: string | null; + last_probe_at?: string | null; /** @description Result of the most recent explicit capability probe or connection test. */ - last_error: string; + last_probe_error: string; /** @description Administrative display name. */ name: string; /** @description Configured transport choice; `auto` is resolved from `base_url` at runtime. */ @@ -12580,9 +12580,9 @@ export interface operations { * Format: date-time * @description Timestamp of the most recent explicit capability probe. */ - last_checked_at?: string | null; + last_probe_at?: string | null; /** @description Result of the most recent explicit capability probe or connection test. */ - last_error: string; + last_probe_error: string; /** @description Administrative display name. */ name: string; /** @description Configured transport choice; `auto` is resolved from `base_url` at runtime. */ @@ -12715,9 +12715,9 @@ export interface operations { * Format: date-time * @description Timestamp of the most recent explicit capability probe. */ - last_checked_at?: string | null; + last_probe_at?: string | null; /** @description Result of the most recent explicit capability probe or connection test. */ - last_error: string; + last_probe_error: string; /** @description Administrative display name. */ name: string; /** @description Configured transport choice; `auto` is resolved from `base_url` at runtime. */ @@ -12841,9 +12841,9 @@ export interface operations { * Format: date-time * @description Timestamp of the most recent explicit capability probe. */ - last_checked_at?: string | null; + last_probe_at?: string | null; /** @description Result of the most recent explicit capability probe or connection test. */ - last_error: string; + last_probe_error: string; /** @description Administrative display name. */ name: string; /** @description Configured transport choice; `auto` is resolved from `base_url` at runtime. */ @@ -13293,9 +13293,9 @@ export interface operations { * Format: date-time * @description Timestamp of the most recent explicit capability probe. */ - last_checked_at?: string | null; + last_probe_at?: string | null; /** @description Result of the most recent explicit capability probe or connection test. */ - last_error: string; + last_probe_error: string; /** @description Administrative display name. */ name: string; /** @description Configured transport choice; `auto` is resolved from `base_url` at runtime. */ diff --git a/src/api/pagination.rs b/src/api/pagination.rs index f976c014c..b29dd5c7f 100644 --- a/src/api/pagination.rs +++ b/src/api/pagination.rs @@ -111,7 +111,7 @@ pub enum AdminRemoteNodeSortBy { Name, BaseUrl, IsEnabled, - LastCheckedAt, + LastProbeAt, CreatedAt, UpdatedAt, } diff --git a/src/db/repository/managed_follower_repo.rs b/src/db/repository/managed_follower_repo.rs index b4e60fde1..766cc78bd 100644 --- a/src/db/repository/managed_follower_repo.rs +++ b/src/db/repository/managed_follower_repo.rs @@ -80,9 +80,9 @@ fn apply_admin_remote_node_sort( sort_order, managed_follower::Column::Id, ), - AdminRemoteNodeSortBy::LastCheckedAt => order_by_column_with_id( + AdminRemoteNodeSortBy::LastProbeAt => order_by_column_with_id( query, - managed_follower::Column::LastCheckedAt, + managed_follower::Column::LastProbeAt, sort_order, managed_follower::Column::Id, ), @@ -132,14 +132,14 @@ pub async fn touch_probe_result( db: &DatabaseConnection, id: i64, last_capabilities: String, - last_error: String, - last_checked_at: Option>, + last_probe_error: String, + last_probe_at: Option>, ) -> Result { let existing = find_by_id(db, id).await?; let mut active: managed_follower::ActiveModel = existing.into(); active.last_capabilities = Set(last_capabilities); - active.last_error = Set(last_error); - active.last_checked_at = Set(last_checked_at); + active.last_probe_error = Set(last_probe_error); + active.last_probe_at = Set(last_probe_at); active.updated_at = Set(chrono::Utc::now()); update(db, active).await } @@ -147,16 +147,16 @@ pub async fn touch_probe_result( pub async fn touch_tunnel_result( db: &DatabaseConnection, id: i64, - tunnel_last_error: String, - tunnel_last_seen_at: Option>, + tunnel_runtime_error: String, + tunnel_last_handshake_at: Option>, ) -> Result { let existing = find_by_id(db, id).await?; let mut active: managed_follower::ActiveModel = existing.into(); - active.tunnel_last_error = Set(tunnel_last_error); - active.tunnel_last_seen_at = Set(tunnel_last_seen_at); + active.tunnel_runtime_error = Set(tunnel_runtime_error); + active.tunnel_last_handshake_at = Set(tunnel_last_handshake_at); // Tunnel 心跳和错误是运行态遥测,不代表远端节点配置被修改。 - // `tunnel_last_error` 是暂时的健康状态:成功 poll/stream handshake 会写入空字符串, - // 所以它不是历史错误日志;需要按 tunnel 活跃度排序时应显式使用 `tunnel_last_seen_at`。 + // `tunnel_runtime_error` 是暂时的健康状态:成功 poll/stream handshake 会写入空字符串, + // 所以它不是历史错误日志;需要按 tunnel 活跃度排序时应显式使用 `tunnel_last_handshake_at`。 // 保持 updated_at 只用于名称、base_url、transport_mode 等管理面变更。 update(db, active).await } diff --git a/src/services/ops/deployment.rs b/src/services/ops/deployment.rs index a44809482..d8922c2c5 100644 --- a/src/services/ops/deployment.rs +++ b/src/services/ops/deployment.rs @@ -309,10 +309,10 @@ mod tests { is_enabled: Set(true), transport_mode: Set(RemoteNodeTransportMode::ReverseTunnel), last_capabilities: Set("{}".to_string()), - last_error: Set(String::new()), - last_checked_at: Set(None), - tunnel_last_error: Set(String::new()), - tunnel_last_seen_at: Set(None), + last_probe_error: Set(String::new()), + last_probe_at: Set(None), + tunnel_runtime_error: Set(String::new()), + tunnel_last_handshake_at: Set(None), created_at: Set(now), updated_at: Set(now), ..Default::default() diff --git a/src/services/remote/remote_node.rs b/src/services/remote/remote_node.rs index 7029b9c7d..c06b08378 100644 --- a/src/services/remote/remote_node.rs +++ b/src/services/remote/remote_node.rs @@ -59,11 +59,11 @@ pub struct RemoteNodeInfo { /// Current enrollment lifecycle state. pub enrollment_status: RemoteNodeEnrollmentStatus, /// Result of the most recent explicit capability probe or connection test. - pub last_error: String, + pub last_probe_error: String, /// Capabilities returned by the most recent explicit probe. pub capabilities: RemoteStorageCapabilities, /// Timestamp of the most recent explicit capability probe. - pub last_checked_at: Option>, + pub last_probe_at: Option>, /// Runtime reverse-tunnel health telemetry, separate from probe state above. pub tunnel: crate::storage::remote_protocol::tunnel::server::RemoteTunnelInfo, #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = String))] @@ -87,9 +87,9 @@ impl RemoteNodeInfo { transport_mode: model.transport_mode, is_enabled: model.is_enabled, enrollment_status, - last_error: model.last_error.clone(), + last_probe_error: model.last_probe_error.clone(), capabilities: parse_capabilities(&model.last_capabilities), - last_checked_at: model.last_checked_at, + last_probe_at: model.last_probe_at, tunnel: crate::storage::remote_protocol::tunnel::server::tunnel_info_for_node( state, &model, ), @@ -203,10 +203,10 @@ pub async fn create( is_enabled: Set(normalized.is_enabled), transport_mode: Set(normalized.transport_mode), last_capabilities: Set("{}".to_string()), - last_error: Set(String::new()), - last_checked_at: Set(None), - tunnel_last_error: Set(String::new()), - tunnel_last_seen_at: Set(None), + last_probe_error: Set(String::new()), + last_probe_at: Set(None), + tunnel_runtime_error: Set(String::new()), + tunnel_last_handshake_at: Set(None), binding_revision: Set(1), binding_applied_revision: Set(0), created_at: Set(now), @@ -621,7 +621,7 @@ async fn probe_and_persist_node( .probe_capabilities() .await; - let (last_capabilities, last_error, probe_error) = match capabilities { + let (last_capabilities, last_probe_error, probe_error) = match capabilities { Ok(capabilities) => { let policy_requirements = policy_requirements_for_node(state, node.id).await?; let resolver = RemoteCapabilityResolver::from_capabilities(node.id, capabilities); @@ -661,7 +661,7 @@ async fn probe_and_persist_node( state.writer_db(), node.id, last_capabilities, - last_error, + last_probe_error, Some(Utc::now()), ) .await?; diff --git a/src/services/storage_policy/policy/policies.rs b/src/services/storage_policy/policy/policies.rs index 7c35b22e0..7e73d018f 100644 --- a/src/services/storage_policy/policy/policies.rs +++ b/src/services/storage_policy/policy/policies.rs @@ -824,8 +824,8 @@ mod tests { &crate::storage::remote_protocol::RemoteStorageCapabilities::current(), ) .unwrap()), - last_error: Set(String::new()), - last_checked_at: Set(Some(now)), + last_probe_error: Set(String::new()), + last_probe_at: Set(Some(now)), created_at: Set(now), updated_at: Set(now), ..Default::default() diff --git a/src/storage/connectors/remote.rs b/src/storage/connectors/remote.rs index 86be329ca..2338e87a6 100644 --- a/src/storage/connectors/remote.rs +++ b/src/storage/connectors/remote.rs @@ -417,10 +417,10 @@ impl StorageConnector for RemoteConnector { &remote, ) .await?, - last_error: String::new(), - last_checked_at: None, - tunnel_last_error: String::new(), - tunnel_last_seen_at: None, + last_probe_error: String::new(), + last_probe_at: None, + tunnel_runtime_error: String::new(), + tunnel_last_handshake_at: None, binding_revision: 1, binding_applied_revision: 0, created_at: Utc::now(), diff --git a/src/storage/drivers/remote/tests.rs b/src/storage/drivers/remote/tests.rs index 693af40c9..abc3e5589 100644 --- a/src/storage/drivers/remote/tests.rs +++ b/src/storage/drivers/remote/tests.rs @@ -59,10 +59,10 @@ fn build_follower_with_capabilities( is_enabled: true, transport_mode: aster_drive_model::types::RemoteNodeTransportMode::Direct, last_capabilities: last_capabilities.to_string(), - last_error: String::new(), - last_checked_at: None, - tunnel_last_error: String::new(), - tunnel_last_seen_at: None, + last_probe_error: String::new(), + last_probe_at: None, + tunnel_runtime_error: String::new(), + tunnel_last_handshake_at: None, binding_revision: 1, binding_applied_revision: 0, created_at: now, diff --git a/src/storage/policy_snapshot.rs b/src/storage/policy_snapshot.rs index 921b40f0b..5dcda427b 100644 --- a/src/storage/policy_snapshot.rs +++ b/src/storage/policy_snapshot.rs @@ -421,8 +421,8 @@ mod tests { secret_key: Set(format!("sk_{name}")), is_enabled: Set(is_enabled), last_capabilities: Set("{}".to_string()), - last_error: Set(String::new()), - last_checked_at: Set(None), + last_probe_error: Set(String::new()), + last_probe_at: Set(None), created_at: Set(now), updated_at: Set(now), ..Default::default() diff --git a/src/storage/registry.rs b/src/storage/registry.rs index b8c32d655..f9230cfd5 100644 --- a/src/storage/registry.rs +++ b/src/storage/registry.rs @@ -596,10 +596,10 @@ mod tests { &crate::storage::remote_protocol::RemoteStorageCapabilities::current(), ) .expect("current remote capabilities should serialize"), - last_error: String::new(), - last_checked_at: None, - tunnel_last_error: String::new(), - tunnel_last_seen_at: None, + last_probe_error: String::new(), + last_probe_at: None, + tunnel_runtime_error: String::new(), + tunnel_last_handshake_at: None, binding_revision: 1, binding_applied_revision: 0, created_at: now, diff --git a/src/storage/remote_protocol/runtime.rs b/src/storage/remote_protocol/runtime.rs index 135f13870..6504c9df4 100644 --- a/src/storage/remote_protocol/runtime.rs +++ b/src/storage/remote_protocol/runtime.rs @@ -119,10 +119,10 @@ mod tests { is_enabled: true, transport_mode, last_capabilities: "{}".to_string(), - last_error: String::new(), - last_checked_at: None, - tunnel_last_error: String::new(), - tunnel_last_seen_at: None, + last_probe_error: String::new(), + last_probe_at: None, + tunnel_runtime_error: String::new(), + tunnel_last_handshake_at: None, binding_revision: 1, binding_applied_revision: 0, created_at: now, diff --git a/src/storage/remote_protocol/transport.rs b/src/storage/remote_protocol/transport.rs index 6adf8457c..884637c4a 100644 --- a/src/storage/remote_protocol/transport.rs +++ b/src/storage/remote_protocol/transport.rs @@ -922,10 +922,10 @@ mod tests { is_enabled: true, transport_mode: RemoteNodeTransportMode::ReverseTunnel, last_capabilities: "{}".to_string(), - last_error: String::new(), - last_checked_at: None, - tunnel_last_error: String::new(), - tunnel_last_seen_at: None, + last_probe_error: String::new(), + last_probe_at: None, + tunnel_runtime_error: String::new(), + tunnel_last_handshake_at: None, binding_revision: 1, binding_applied_revision: 0, created_at: now, diff --git a/src/storage/remote_protocol/tunnel/server/mod.rs b/src/storage/remote_protocol/tunnel/server/mod.rs index 83e1e187e..e8e29103f 100644 --- a/src/storage/remote_protocol/tunnel/server/mod.rs +++ b/src/storage/remote_protocol/tunnel/server/mod.rs @@ -86,10 +86,10 @@ pub struct RemoteTunnelInfo { pub status: RemoteTunnelOnlineStatus, /// Transient runtime error from the tunnel control/data path. The next successful poll or /// stream handshake clears it; it is not a historical error log. - pub last_error: String, + pub runtime_error: String, #[cfg_attr(all(debug_assertions, feature = "openapi"), schema(value_type = Option))] /// Last successful poll or stream handshake persisted by the primary. - pub last_seen_at: Option>, + pub last_handshake_at: Option>, } pub async fn poll( @@ -114,7 +114,7 @@ pub async fn poll( ) .await?; // A successful control-plane handshake means the runtime path recovered. This clears only - // transient tunnel telemetry and leaves the separate probe `last_error` untouched. + // transient tunnel telemetry and leaves the separate probe `last_probe_error` untouched. registry.clear_error(remote_node.id); let request = tokio::time::timeout(REMOTE_TUNNEL_POLL_TIMEOUT, request_rx) @@ -351,7 +351,7 @@ async fn run_connected_stream( liveness.record_activity(Instant::now()); match decode_stream_frame(bytes) { Ok(frame) => { - registry.update_last_seen(remote_node.id); + registry.update_last_handshake(remote_node.id); if let Err(error) = registry .complete_stream_frame(&remote_node, &lane_id, frame) .await @@ -380,11 +380,11 @@ async fn run_connected_stream( break; } liveness.record_activity(Instant::now()); - registry.update_last_seen(remote_node.id); + registry.update_last_handshake(remote_node.id); } actix_ws::Message::Pong(_) => { liveness.record_activity(Instant::now()); - registry.update_last_seen(remote_node.id); + registry.update_last_handshake(remote_node.id); } actix_ws::Message::Close(reason) => { tracing::info!( @@ -572,12 +572,12 @@ pub fn tunnel_info_for_node( }, // Prefer the in-memory value so a newly observed runtime failure is visible before the // asynchronous persistence task commits it; the database value survives process restart. - last_error: state + runtime_error: state .remote_protocol() .tunnel_registry() - .last_error(node.id) - .unwrap_or_else(|| node.tunnel_last_error.clone()), - last_seen_at: node.tunnel_last_seen_at, + .runtime_error(node.id) + .unwrap_or_else(|| node.tunnel_runtime_error.clone()), + last_handshake_at: node.tunnel_last_handshake_at, } } @@ -608,7 +608,7 @@ pub async fn mark_tunnel_error( state.writer_db(), remote_node.id, error.to_string(), - remote_node.tunnel_last_seen_at, + remote_node.tunnel_last_handshake_at, ) .await?; Ok(()) diff --git a/src/storage/remote_protocol/tunnel/server/owner.rs b/src/storage/remote_protocol/tunnel/server/owner.rs index 783fd8aa6..818b0b9c2 100644 --- a/src/storage/remote_protocol/tunnel/server/owner.rs +++ b/src/storage/remote_protocol/tunnel/server/owner.rs @@ -455,10 +455,10 @@ mod tests { is_enabled: Set(true), transport_mode: Set(aster_drive_model::types::RemoteNodeTransportMode::ReverseTunnel), last_capabilities: Set("{}".to_string()), - last_error: Set(String::new()), - last_checked_at: Set(None), - tunnel_last_error: Set(String::new()), - tunnel_last_seen_at: Set(None), + last_probe_error: Set(String::new()), + last_probe_at: Set(None), + tunnel_runtime_error: Set(String::new()), + tunnel_last_handshake_at: Set(None), binding_revision: Set(1), binding_applied_revision: Set(0), created_at: Set(Utc::now()), diff --git a/src/storage/remote_protocol/tunnel/server/proxy.rs b/src/storage/remote_protocol/tunnel/server/proxy.rs index 7891cfddb..5fea07b7d 100644 --- a/src/storage/remote_protocol/tunnel/server/proxy.rs +++ b/src/storage/remote_protocol/tunnel/server/proxy.rs @@ -735,10 +735,10 @@ mod tests { is_enabled: Set(true), transport_mode: Set(aster_drive_model::types::RemoteNodeTransportMode::ReverseTunnel), last_capabilities: Set("{}".to_string()), - last_error: Set(String::new()), - last_checked_at: Set(None), - tunnel_last_error: Set(String::new()), - tunnel_last_seen_at: Set(None), + last_probe_error: Set(String::new()), + last_probe_at: Set(None), + tunnel_runtime_error: Set(String::new()), + tunnel_last_handshake_at: Set(None), binding_revision: Set(1), binding_applied_revision: Set(0), created_at: Set(now), @@ -771,10 +771,10 @@ mod tests { is_enabled: true, transport_mode: aster_drive_model::types::RemoteNodeTransportMode::ReverseTunnel, last_capabilities: "{}".to_string(), - last_error: String::new(), - last_checked_at: None, - tunnel_last_error: String::new(), - tunnel_last_seen_at: None, + last_probe_error: String::new(), + last_probe_at: None, + tunnel_runtime_error: String::new(), + tunnel_last_handshake_at: None, binding_revision: 1, binding_applied_revision: 0, created_at: now, diff --git a/src/storage/remote_protocol/tunnel/server/registry/mod.rs b/src/storage/remote_protocol/tunnel/server/registry/mod.rs index 1bbd92ceb..ae863e2e3 100644 --- a/src/storage/remote_protocol/tunnel/server/registry/mod.rs +++ b/src/storage/remote_protocol/tunnel/server/registry/mod.rs @@ -110,8 +110,8 @@ pub struct RemoteTunnelRegistry { stream_lanes: DashMap>>, pending: DashMap, stream_pending: DashMap, - last_errors: DashMap, - last_seen_at: DashMap>, + runtime_errors: DashMap, + last_handshake_at: DashMap>, lifecycle: DashMap, persistence_db: parking_lot::RwLock>, audit_runtime_config: parking_lot::RwLock>>, @@ -132,17 +132,18 @@ impl RemoteTunnelRegistry { } pub fn is_online(&self, remote_node: &managed_follower::Model) -> bool { - let local_last_seen = self - .last_seen_at + let local_last_handshake = self + .last_handshake_at .get(&remote_node.id) - .map(|last_seen_at| *last_seen_at.value()); - local_last_seen - .or(remote_node.tunnel_last_seen_at) - .is_some_and(is_recent_tunnel_seen_at) + .map(|last_handshake_at| *last_handshake_at.value()); + local_last_handshake + .or(remote_node.tunnel_last_handshake_at) + .is_some_and(is_recent_tunnel_handshake_at) } - pub(crate) fn update_last_seen(&self, remote_node_id: i64) { - self.last_seen_at.insert(remote_node_id, chrono::Utc::now()); + pub(crate) fn update_last_handshake(&self, remote_node_id: i64) { + self.last_handshake_at + .insert(remote_node_id, chrono::Utc::now()); } pub(crate) fn record_handshake( @@ -322,8 +323,8 @@ impl RemoteTunnelRegistry { }); } - pub fn last_error(&self, remote_node_id: i64) -> Option { - self.last_errors + pub fn runtime_error(&self, remote_node_id: i64) -> Option { + self.runtime_errors .get(&remote_node_id) .map(|entry| entry.value().clone()) } @@ -338,13 +339,13 @@ impl RemoteTunnelRegistry { if error.trim().is_empty() { self.clear_error(remote_node_id); } else { - self.last_errors.insert(remote_node_id, error); + self.runtime_errors.insert(remote_node_id, error); self.persist_error(remote_node_id); } } pub(super) fn clear_error(&self, remote_node_id: i64) { - if self.last_errors.remove(&remote_node_id).is_some() { + if self.runtime_errors.remove(&remote_node_id).is_some() { self.persist_error(remote_node_id); } } @@ -353,7 +354,7 @@ impl RemoteTunnelRegistry { let Some(db) = self.persistence_db.read().clone() else { return; }; - let error = self.last_error(remote_node_id).unwrap_or_default(); + let error = self.runtime_error(remote_node_id).unwrap_or_default(); tokio::spawn(async move { if let Err(persist_error) = persist_tunnel_error(&db, remote_node_id, error).await { tracing::warn!( @@ -365,10 +366,10 @@ impl RemoteTunnelRegistry { } } -fn is_recent_tunnel_seen_at(last_seen_at: chrono::DateTime) -> bool { +fn is_recent_tunnel_handshake_at(last_handshake_at: chrono::DateTime) -> bool { chrono::Duration::from_std(REMOTE_TUNNEL_ONLINE_TTL) .ok() - .is_some_and(|ttl| last_seen_at + ttl > chrono::Utc::now()) + .is_some_and(|ttl| last_handshake_at + ttl > chrono::Utc::now()) } pub fn reverse_tunnel_offline_error(remote_node_id: i64) -> crate::errors::AsterError { @@ -394,10 +395,10 @@ mod tests { is_enabled: true, transport_mode: RemoteNodeTransportMode::ReverseTunnel, last_capabilities: "{}".to_string(), - last_error: String::new(), - last_checked_at: None, - tunnel_last_error: String::new(), - tunnel_last_seen_at: None, + last_probe_error: String::new(), + last_probe_at: None, + tunnel_runtime_error: String::new(), + tunnel_last_handshake_at: None, binding_revision: 1, binding_applied_revision: 1, created_at: now, @@ -406,12 +407,12 @@ mod tests { } #[test] - fn persisted_tunnel_seen_time_obeys_online_ttl_boundary() { - assert!(is_recent_tunnel_seen_at(chrono::Utc::now())); + fn persisted_tunnel_handshake_time_obeys_online_ttl_boundary() { + assert!(is_recent_tunnel_handshake_at(chrono::Utc::now())); let expired = chrono::Utc::now() - chrono::Duration::from_std(REMOTE_TUNNEL_ONLINE_TTL).unwrap() - chrono::Duration::milliseconds(1); - assert!(!is_recent_tunnel_seen_at(expired)); + assert!(!is_recent_tunnel_handshake_at(expired)); } #[tokio::test] diff --git a/src/storage/remote_protocol/tunnel/server/registry/persistence.rs b/src/storage/remote_protocol/tunnel/server/registry/persistence.rs index ff8b4c7fd..73b2fe1db 100644 --- a/src/storage/remote_protocol/tunnel/server/registry/persistence.rs +++ b/src/storage/remote_protocol/tunnel/server/registry/persistence.rs @@ -13,7 +13,7 @@ pub(super) async fn persist_tunnel_error( db, remote_node_id, error, - remote_node.tunnel_last_seen_at, + remote_node.tunnel_last_handshake_at, ) .await?; Ok(()) diff --git a/src/storage/remote_protocol/tunnel/server/registry/polling.rs b/src/storage/remote_protocol/tunnel/server/registry/polling.rs index d93f927ee..fc413882f 100644 --- a/src/storage/remote_protocol/tunnel/server/registry/polling.rs +++ b/src/storage/remote_protocol/tunnel/server/registry/polling.rs @@ -83,7 +83,7 @@ impl RemoteTunnelRegistry { request_tx, }, ); - self.update_last_seen(remote_node.id); + self.update_last_handshake(remote_node.id); self.connection_notify.notify_waiters(); let guard = RemoteTunnelRegistrationGuard { registry: self, diff --git a/src/storage/remote_protocol/tunnel/server/registry/streaming.rs b/src/storage/remote_protocol/tunnel/server/registry/streaming.rs index d36efbc15..b2f20156e 100644 --- a/src/storage/remote_protocol/tunnel/server/registry/streaming.rs +++ b/src/storage/remote_protocol/tunnel/server/registry/streaming.rs @@ -163,7 +163,7 @@ impl RemoteTunnelRegistry { .entry(remote_node.access_key.clone()) .or_default() .push(lane); - self.update_last_seen(remote_node.id); + self.update_last_handshake(remote_node.id); self.connection_notify.notify_waiters(); let guard = RemoteTunnelStreamRegistrationGuard { registry: self.clone(), diff --git a/src/storage/remote_protocol/tunnel/server/tests.rs b/src/storage/remote_protocol/tunnel/server/tests.rs index f1d297a48..c61d68f26 100644 --- a/src/storage/remote_protocol/tunnel/server/tests.rs +++ b/src/storage/remote_protocol/tunnel/server/tests.rs @@ -21,10 +21,10 @@ fn build_remote_node(id: i64, access_key: &str) -> managed_follower::Model { is_enabled: true, transport_mode: aster_drive_model::types::RemoteNodeTransportMode::ReverseTunnel, last_capabilities: "{}".to_string(), - last_error: String::new(), - last_checked_at: None, - tunnel_last_error: String::new(), - tunnel_last_seen_at: None, + last_probe_error: String::new(), + last_probe_at: None, + tunnel_runtime_error: String::new(), + tunnel_last_handshake_at: None, binding_revision: 1, binding_applied_revision: 0, created_at: now, @@ -552,7 +552,7 @@ async fn registry_poll_rejects_oversized_metadata_before_dispatch() { } #[tokio::test] -async fn poll_last_seen_keeps_tunnel_online_between_poll_cycles() { +async fn poll_last_handshake_keeps_tunnel_online_between_poll_cycles() { let registry = RemoteTunnelRegistry::new(); let node = build_remote_node(43, "poll-online-gap"); let (_request_rx, registration) = registry.register_poll(&node); diff --git a/tests/files/upload.rs b/tests/files/upload.rs index 422df6c38..a5842a274 100644 --- a/tests/files/upload.rs +++ b/tests/files/upload.rs @@ -861,8 +861,8 @@ async fn create_dead_remote_policy( &aster_drive::storage::remote_protocol::RemoteStorageCapabilities::current(), ) .expect("current remote capabilities should serialize")), - last_error: Set(String::new()), - last_checked_at: Set(Some(now)), + last_probe_error: Set(String::new()), + last_probe_at: Set(Some(now)), created_at: Set(now), updated_at: Set(now), ..Default::default() diff --git a/tests/multi_primary/cluster.rs b/tests/multi_primary/cluster.rs index 04eca4b0c..5e66da517 100644 --- a/tests/multi_primary/cluster.rs +++ b/tests/multi_primary/cluster.rs @@ -1231,10 +1231,10 @@ async fn seed_reverse_tunnel_node( is_enabled: Set(true), transport_mode: Set(aster_drive_model::types::RemoteNodeTransportMode::ReverseTunnel), last_capabilities: Set("{}".to_string()), - last_error: Set(String::new()), - last_checked_at: Set(None), - tunnel_last_error: Set(String::new()), - tunnel_last_seen_at: Set(None), + last_probe_error: Set(String::new()), + last_probe_at: Set(None), + tunnel_runtime_error: Set(String::new()), + tunnel_last_handshake_at: Set(None), created_at: Set(now), updated_at: Set(now), ..Default::default() diff --git a/tests/operations/cli.rs b/tests/operations/cli.rs index c8931c1af..2cf45525e 100644 --- a/tests/operations/cli.rs +++ b/tests/operations/cli.rs @@ -441,8 +441,8 @@ async fn seed_remote_node_fixture(db: &DatabaseConnection) { &aster_drive::storage::remote_protocol::RemoteStorageCapabilities::current(), ) .expect("current remote capabilities should serialize")), - last_error: Set(String::new()), - last_checked_at: Set(Some(now)), + last_probe_error: Set(String::new()), + last_probe_at: Set(Some(now)), created_at: Set(now), updated_at: Set(now), ..Default::default() @@ -1388,6 +1388,28 @@ async fn test_migrations_use_current_baseline_for_fresh_install() { aster_drive_migration::current_migration_names(), "fresh install should stamp all current migrations" ); + for column in [ + "last_probe_error", + "last_probe_at", + "tunnel_runtime_error", + "tunnel_last_handshake_at", + ] { + assert!( + column_exists(&db, DbBackend::Sqlite, "managed_followers", column).await, + "fresh schema should contain renamed remote-node telemetry column {column}" + ); + } + for column in [ + "last_error", + "last_checked_at", + "tunnel_last_error", + "tunnel_last_seen_at", + ] { + assert!( + !column_exists(&db, DbBackend::Sqlite, "managed_followers", column).await, + "fresh schema should not retain legacy remote-node telemetry column {column}" + ); + } } #[tokio::test] diff --git a/tests/storage/remote_storage.rs b/tests/storage/remote_storage.rs index 5a649c84f..57d98b233 100644 --- a/tests/storage/remote_storage.rs +++ b/tests/storage/remote_storage.rs @@ -644,8 +644,8 @@ async fn seed_remote_capabilities( .into(); remote_node.last_capabilities = Set(serde_json::to_string(&capabilities).expect("remote capabilities should serialize")); - remote_node.last_error = Set(String::new()); - remote_node.last_checked_at = Set(Some(Utc::now())); + remote_node.last_probe_error = Set(String::new()); + remote_node.last_probe_at = Set(Some(Utc::now())); remote_node.updated_at = Set(Utc::now()); remote_node .update(state.writer_db()) @@ -707,7 +707,7 @@ async fn wait_for_tunnel_error_persisted( let remote_node = managed_follower_repo::find_by_id(state.writer_db(), node_id) .await .expect("remote node should be queryable while waiting for tunnel error"); - if remote_node.tunnel_last_error.contains(expected) { + if remote_node.tunnel_runtime_error.contains(expected) { return; } if attempt < 19 { @@ -720,7 +720,7 @@ async fn wait_for_tunnel_error_persisted( .expect("remote node should be queryable after waiting for tunnel error"); panic!( "expected persisted tunnel error to contain '{expected}', got '{}'", - remote_node.tunnel_last_error + remote_node.tunnel_runtime_error ); } @@ -2580,7 +2580,7 @@ async fn test_internal_storage_compose_rejects_expected_size_exceeding_ingress_l } #[actix_web::test] -async fn test_remote_node_connection_failure_returns_error_and_persists_last_error() { +async fn test_remote_node_connection_failure_returns_error_and_persists_last_probe_error() { let state = common::setup().await; let node = remote_node::create( &state, @@ -2603,7 +2603,7 @@ async fn test_remote_node_connection_failure_returns_error_and_persists_last_err let stored = managed_follower_repo::find_by_id(state.writer_db(), node.id) .await .expect("remote node should still exist after failed probe"); - assert!(!stored.last_error.is_empty()); + assert!(!stored.last_probe_error.is_empty()); } #[actix_web::test] @@ -2670,7 +2670,7 @@ async fn test_remote_node_failed_probe_preserves_cached_capabilities() { let stored = managed_follower_repo::find_by_id(consumer_state.writer_db(), consumer_node.id) .await .expect("remote node should remain queryable after failed probe"); - assert!(!stored.last_error.is_empty()); + assert!(!stored.last_probe_error.is_empty()); assert_eq!(stored.last_capabilities, cached_capabilities); } @@ -2715,7 +2715,7 @@ async fn test_remote_node_probe_rejects_incompatible_protocol_version() { let stored = managed_follower_repo::find_by_id(state.writer_db(), node.id) .await .expect("remote node should remain queryable"); - assert!(stored.last_error.contains("protocol incompatible")); + assert!(stored.last_probe_error.contains("protocol incompatible")); assert_eq!(stored.last_capabilities, "{}"); capabilities_server.stop().await; @@ -2784,7 +2784,7 @@ async fn test_remote_node_probe_rejects_presigned_download_when_range_cors_missi .expect("remote node should remain queryable"); assert!( stored - .last_error + .last_probe_error .contains("browser CORS contract is incomplete") ); let expected_protocol_marker = @@ -3943,7 +3943,7 @@ async fn test_reverse_tunnel_follower_worker_rejects_non_storage_paths() { response.body.as_ref(), b"reverse tunnel can only proxy internal storage paths" ); - // `tunnel.last_error` is transient runtime telemetry. The production follower can complete + // `tunnel.runtime_error` is transient runtime telemetry. The production follower can complete // the next successful poll before this test reads the node, which intentionally clears it. // The offline-error lifecycle test below covers immediate visibility and persistence. @@ -4250,12 +4250,14 @@ async fn test_reverse_tunnel_records_offline_error_and_clears_on_poll() { .await .expect("remote node info should load"); assert!( - info.tunnel.last_error.contains("reverse tunnel is offline"), + info.tunnel + .runtime_error + .contains("reverse tunnel is offline"), "runtime tunnel error should be visible immediately, got '{}'", - info.tunnel.last_error + info.tunnel.runtime_error ); assert_eq!( - info.last_error, "capability probe failed", + info.last_probe_error, "capability probe failed", "tunnel runtime errors must not overwrite probe errors" ); wait_for_tunnel_error_persisted(&state, node.id, "reverse tunnel is offline").await; @@ -4312,9 +4314,9 @@ async fn test_reverse_tunnel_records_offline_error_and_clears_on_poll() { let cleared = remote_node::get(&state, node.id) .await .expect("remote node info should load after poll"); - assert_eq!(cleared.tunnel.last_error, ""); + assert_eq!(cleared.tunnel.runtime_error, ""); assert_eq!( - cleared.last_error, "capability probe failed", + cleared.last_probe_error, "capability probe failed", "clearing tunnel runtime state must not clear probe state" ); } @@ -4395,7 +4397,7 @@ async fn test_reverse_tunnel_polls_do_not_touch_updated_at() { after.updated_at, before.updated_at, "tunnel heartbeat should not change configuration updated_at" ); - assert!(after.tunnel_last_seen_at.is_some()); + assert!(after.tunnel_last_handshake_at.is_some()); } #[actix_web::test] @@ -4468,7 +4470,7 @@ async fn test_effective_direct_nodes_reject_tunnel_http_endpoints_without_touchi let after = managed_follower_repo::find_by_id(state.writer_db(), node.id) .await .expect("effective direct node should remain queryable"); - assert_eq!(after.tunnel_last_seen_at, None); + assert_eq!(after.tunnel_last_handshake_at, None); primary_server.stop().await; } @@ -5855,7 +5857,7 @@ async fn test_disabled_remote_nodes_skip_network_during_health_checks() { managed_follower_repo::find_by_id(consumer_state.writer_db(), remote_node.id) .await .expect("disabled remote node should remain queryable"); - assert_eq!(remote_node_model.last_checked_at, None); + assert_eq!(remote_node_model.last_probe_at, None); provider_server.stop().await; } @@ -5897,9 +5899,9 @@ async fn test_pending_remote_nodes_skip_network_during_health_checks() { managed_follower_repo::find_by_id(consumer_state.writer_db(), remote_node.id) .await .expect("pending remote node should remain queryable"); - assert_eq!(remote_node_model.last_checked_at, None); + assert_eq!(remote_node_model.last_probe_at, None); assert_eq!( - remote_node_model.last_error, "", + remote_node_model.last_probe_error, "", "pending remote nodes should not record probe failures", ); @@ -5947,8 +5949,8 @@ async fn test_pending_remote_node_connection_test_requires_completed_enrollment_ managed_follower_repo::find_by_id(consumer_state.writer_db(), remote_node.id) .await .expect("pending remote node should remain queryable"); - assert_eq!(remote_node_model.last_checked_at, None); - assert_eq!(remote_node_model.last_error, ""); + assert_eq!(remote_node_model.last_probe_at, None); + assert_eq!(remote_node_model.last_probe_error, ""); provider_server.stop().await; } @@ -6082,8 +6084,8 @@ async fn test_health_checks_only_touch_enabled_remote_nodes_in_mixed_sets() { managed_follower_repo::find_by_id(consumer_state.writer_db(), disabled_node.id) .await .expect("disabled remote node should remain queryable"); - assert!(enabled_node_model.last_checked_at.is_some()); - assert_eq!(disabled_node_model.last_checked_at, None); + assert!(enabled_node_model.last_probe_at.is_some()); + assert_eq!(disabled_node_model.last_probe_at, None); enabled_server.stop().await; disabled_server.stop().await; @@ -6162,8 +6164,8 @@ async fn test_reverse_tunnel_remote_nodes_are_checked_by_health_tests_without_ba managed_follower_repo::find_by_id(consumer_state.writer_db(), remote_node.id) .await .expect("reverse remote node should remain queryable"); - assert!(checked_node.last_checked_at.is_some()); - assert_eq!(checked_node.last_error, ""); + assert!(checked_node.last_probe_at.is_some()); + assert_eq!(checked_node.last_probe_error, ""); stop_test_reverse_tunnel_worker(tunnel_shutdown, tunnel_handle).await; provider_server.stop().await; From 4eaad96deffdfb4a82bade89e4be7e20cfd65fec Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 21 Aug 2026 04:28:46 +0800 Subject: [PATCH 2/5] fix(remote): update benchmark follower telemetry fields --- benches/webdav_provider_range.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/benches/webdav_provider_range.rs b/benches/webdav_provider_range.rs index 9372cb992..d3ba060ca 100644 --- a/benches/webdav_provider_range.rs +++ b/benches/webdav_provider_range.rs @@ -690,10 +690,10 @@ fn build_remote_provider() -> BenchResult { is_enabled: true, transport_mode: RemoteNodeTransportMode::Direct, last_capabilities: stored_capabilities, - last_error: String::new(), - last_checked_at: None, - tunnel_last_error: String::new(), - tunnel_last_seen_at: None, + last_probe_error: String::new(), + last_probe_at: None, + tunnel_runtime_error: String::new(), + tunnel_last_handshake_at: None, binding_revision: 1, binding_applied_revision: 0, created_at: now, From cc6912c08fa003deba4ce207a3ba49320b62df5c Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 21 Aug 2026 05:52:41 +0800 Subject: [PATCH 3/5] fix(remote): address telemetry review findings --- .../RemoteNodeDialogCards.tsx | 2 +- .../RemoteNodesTable.test.tsx | 25 +++++ .../i18n/locales/en/admin/remote-nodes.json | 1 + .../i18n/locales/zh/admin/remote-nodes.json | 1 + .../pages/admin/AdminRemoteNodesPage.test.tsx | 6 +- src/db/repository/managed_follower_repo.rs | 71 ++++++++---- .../remote_protocol/tunnel/server/mod.rs | 21 +--- .../tunnel/server/registry/mod.rs | 38 ++++++- .../tunnel/server/registry/persistence.rs | 9 +- tests/operations/cli.rs | 105 ++++++++++++++++++ 10 files changed, 228 insertions(+), 51 deletions(-) diff --git a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialogCards.tsx b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialogCards.tsx index 504569ab4..6ccef6d9a 100644 --- a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialogCards.tsx +++ b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialogCards.tsx @@ -251,7 +251,7 @@ export function RemoteNodeDiagnosticsCard({
{t("remote_node_tunnel_runtime_error")}:{" "} {editingNode.tunnel?.runtime_error || - t("remote_node_last_probe_error_empty")} + t("remote_node_tunnel_runtime_error_empty")}
diff --git a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodesTable.test.tsx b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodesTable.test.tsx index 596c0a733..50611a06e 100644 --- a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodesTable.test.tsx +++ b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodesTable.test.tsx @@ -104,6 +104,31 @@ function renderTable( } describe("RemoteNodesTable", () => { + it("sorts by last probe time and toggles its direction", () => { + const onSortChange = vi.fn(); + const view = renderTable({ onSortChange }); + + fireEvent.click(screen.getByRole("button", { name: "remote_node_status" })); + expect(onSortChange).toHaveBeenLastCalledWith("last_probe_at", "asc"); + + view.rerender( + , + ); + fireEvent.click(screen.getByRole("button", { name: "remote_node_status" })); + expect(onSortChange).toHaveBeenLastCalledWith("last_probe_at", "desc"); + }); + it("disables the enrollment command action after enrollment completes", () => { const onGenerateEnrollmentCommand = vi.fn(); diff --git a/frontend-panel/src/i18n/locales/en/admin/remote-nodes.json b/frontend-panel/src/i18n/locales/en/admin/remote-nodes.json index 5ddf2b000..d5f1bb4ad 100644 --- a/frontend-panel/src/i18n/locales/en/admin/remote-nodes.json +++ b/frontend-panel/src/i18n/locales/en/admin/remote-nodes.json @@ -47,6 +47,7 @@ "remote_node_tunnel_not_used": "Not used", "remote_node_tunnel_last_handshake": "Last Handshake", "remote_node_tunnel_runtime_error": "Tunnel Error", + "remote_node_tunnel_runtime_error_empty": "No current tunnel error", "remote_node_base_url_hint": "Optional for reverse tunnel. For direct transport, enter the follower URL that the primary can reach. Auto mode chooses direct only when this value is set; it does not retry through the tunnel if direct access fails.", "remote_node_base_url_invalid": "Enter a valid base URL that starts with http:// or https://.", "remote_node_base_url_empty": "No outbound base URL", diff --git a/frontend-panel/src/i18n/locales/zh/admin/remote-nodes.json b/frontend-panel/src/i18n/locales/zh/admin/remote-nodes.json index b2ee839d6..11103ace7 100644 --- a/frontend-panel/src/i18n/locales/zh/admin/remote-nodes.json +++ b/frontend-panel/src/i18n/locales/zh/admin/remote-nodes.json @@ -47,6 +47,7 @@ "remote_node_tunnel_not_used": "不使用", "remote_node_tunnel_last_handshake": "最近握手", "remote_node_tunnel_runtime_error": "通道错误", + "remote_node_tunnel_runtime_error_empty": "当前没有通道错误", "remote_node_base_url_hint": "反向通道下可以留空。直连模式需要填写主控端能访问到的 follower 地址;自动模式只按这里是否填写来选择传输方式,不会在直连失败后自动改走通道。", "remote_node_base_url_invalid": "基础地址格式不正确,请填写以 http:// 或 https:// 开头的完整地址。", "remote_node_base_url_empty": "未配置出站地址", diff --git a/frontend-panel/src/pages/admin/AdminRemoteNodesPage.test.tsx b/frontend-panel/src/pages/admin/AdminRemoteNodesPage.test.tsx index a9f362ce8..2c169b9be 100644 --- a/frontend-panel/src/pages/admin/AdminRemoteNodesPage.test.tsx +++ b/frontend-panel/src/pages/admin/AdminRemoteNodesPage.test.tsx @@ -504,7 +504,7 @@ describe("AdminRemoteNodesPage", () => { driver_type: "local", endpoint: "", is_default: true, - last_probe_error: "", + last_error: "", name: "Default ingress", target_key: "default", updated_at: "2026-05-02T00:00:00Z", @@ -718,7 +718,7 @@ describe("AdminRemoteNodesPage", () => { name: "Reverse Tunnel", transport_mode: "reverse_tunnel", tunnel: { - last_probe_error: "", + runtime_error: "", last_handshake_at: "2026-05-29T00:00:00Z", status: "online", }, @@ -759,7 +759,7 @@ describe("AdminRemoteNodesPage", () => { name: "Auto Tunnel", transport_mode: "auto", tunnel: { - last_probe_error: "", + runtime_error: "", last_handshake_at: "2026-05-29T00:00:00Z", status: "online", }, diff --git a/src/db/repository/managed_follower_repo.rs b/src/db/repository/managed_follower_repo.rs index 766cc78bd..52d5c6ee5 100644 --- a/src/db/repository/managed_follower_repo.rs +++ b/src/db/repository/managed_follower_repo.rs @@ -6,9 +6,10 @@ use aster_drive_model::entities::managed_follower::{self, Entity as ManagedFollo use aster_forge_api::SortOrder; use aster_forge_db::pagination::fetch_offset_page; use aster_forge_db::sort::{order_by_column_with_id, order_by_id}; +use sea_orm::sea_query::Expr; use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, - QueryOrder, Select, Set, + QueryOrder, Select, }; pub async fn find_by_id(db: &C, id: i64) -> Result { @@ -135,30 +136,62 @@ pub async fn touch_probe_result( last_probe_error: String, last_probe_at: Option>, ) -> Result { - let existing = find_by_id(db, id).await?; - let mut active: managed_follower::ActiveModel = existing.into(); - active.last_capabilities = Set(last_capabilities); - active.last_probe_error = Set(last_probe_error); - active.last_probe_at = Set(last_probe_at); - active.updated_at = Set(chrono::Utc::now()); - update(db, active).await + ManagedFollower::update_many() + .col_expr( + managed_follower::Column::LastCapabilities, + Expr::value(last_capabilities), + ) + .col_expr( + managed_follower::Column::LastProbeError, + Expr::value(last_probe_error), + ) + .col_expr( + managed_follower::Column::LastProbeAt, + Expr::value(last_probe_at), + ) + .filter(managed_follower::Column::Id.eq(id)) + .exec(db) + .await + .map_err(AsterError::from)?; + find_by_id(db, id).await } -pub async fn touch_tunnel_result( +pub async fn touch_tunnel_success( + db: &DatabaseConnection, + id: i64, + tunnel_last_handshake_at: chrono::DateTime, +) -> Result { + ManagedFollower::update_many() + .col_expr( + managed_follower::Column::TunnelRuntimeError, + Expr::value(String::new()), + ) + .col_expr( + managed_follower::Column::TunnelLastHandshakeAt, + Expr::value(Some(tunnel_last_handshake_at)), + ) + .filter(managed_follower::Column::Id.eq(id)) + .exec(db) + .await + .map_err(AsterError::from)?; + find_by_id(db, id).await +} + +pub async fn touch_tunnel_runtime_error( db: &DatabaseConnection, id: i64, tunnel_runtime_error: String, - tunnel_last_handshake_at: Option>, ) -> Result { - let existing = find_by_id(db, id).await?; - let mut active: managed_follower::ActiveModel = existing.into(); - active.tunnel_runtime_error = Set(tunnel_runtime_error); - active.tunnel_last_handshake_at = Set(tunnel_last_handshake_at); - // Tunnel 心跳和错误是运行态遥测,不代表远端节点配置被修改。 - // `tunnel_runtime_error` 是暂时的健康状态:成功 poll/stream handshake 会写入空字符串, - // 所以它不是历史错误日志;需要按 tunnel 活跃度排序时应显式使用 `tunnel_last_handshake_at`。 - // 保持 updated_at 只用于名称、base_url、transport_mode 等管理面变更。 - update(db, active).await + ManagedFollower::update_many() + .col_expr( + managed_follower::Column::TunnelRuntimeError, + Expr::value(tunnel_runtime_error), + ) + .filter(managed_follower::Column::Id.eq(id)) + .exec(db) + .await + .map_err(AsterError::from)?; + find_by_id(db, id).await } pub async fn acknowledge_binding_revision( diff --git a/src/storage/remote_protocol/tunnel/server/mod.rs b/src/storage/remote_protocol/tunnel/server/mod.rs index e8e29103f..06c1eea4e 100644 --- a/src/storage/remote_protocol/tunnel/server/mod.rs +++ b/src/storage/remote_protocol/tunnel/server/mod.rs @@ -106,13 +106,8 @@ pub async fn poll( let registry = state.remote_protocol().tunnel_registry(); let (request_rx, _registration) = registry.register_poll(remote_node); registry.record_handshake(remote_node, None); - managed_follower_repo::touch_tunnel_result( - state.writer_db(), - remote_node.id, - String::new(), - Some(Utc::now()), - ) - .await?; + managed_follower_repo::touch_tunnel_success(state.writer_db(), remote_node.id, Utc::now()) + .await?; // A successful control-plane handshake means the runtime path recovered. This clears only // transient tunnel telemetry and leaves the separate probe `last_probe_error` untouched. registry.clear_error(remote_node.id); @@ -219,13 +214,8 @@ async fn run_connected_stream( lane_id = %lane_id, "reverse tunnel streaming lane connected" ); - managed_follower_repo::touch_tunnel_result( - state.writer_db(), - remote_node.id, - String::new(), - Some(Utc::now()), - ) - .await?; + managed_follower_repo::touch_tunnel_success(state.writer_db(), remote_node.id, Utc::now()) + .await?; // Stream registration is the same successful tunnel handshake as poll registration, so it // clears only transient tunnel telemetry and leaves capability-probe state untouched. registry.clear_error(remote_node.id); @@ -604,11 +594,10 @@ pub async fn mark_tunnel_error( else { return Ok(()); }; - managed_follower_repo::touch_tunnel_result( + managed_follower_repo::touch_tunnel_runtime_error( state.writer_db(), remote_node.id, error.to_string(), - remote_node.tunnel_last_handshake_at, ) .await?; Ok(()) diff --git a/src/storage/remote_protocol/tunnel/server/registry/mod.rs b/src/storage/remote_protocol/tunnel/server/registry/mod.rs index ae863e2e3..d1fceae7a 100644 --- a/src/storage/remote_protocol/tunnel/server/registry/mod.rs +++ b/src/storage/remote_protocol/tunnel/server/registry/mod.rs @@ -3,7 +3,7 @@ use std::time::Duration; use dashmap::DashMap; use sea_orm::DatabaseConnection; -use tokio::sync::Notify; +use tokio::sync::{Mutex, Notify}; use crate::config::RuntimeConfig; use crate::services::ops::audit::{self, AuditContext, AuditLogInput}; @@ -112,6 +112,7 @@ pub struct RemoteTunnelRegistry { stream_pending: DashMap, runtime_errors: DashMap, last_handshake_at: DashMap>, + persistence_locks: DashMap>>, lifecycle: DashMap, persistence_db: parking_lot::RwLock>, audit_runtime_config: parking_lot::RwLock>>, @@ -136,9 +137,12 @@ impl RemoteTunnelRegistry { .last_handshake_at .get(&remote_node.id) .map(|last_handshake_at| *last_handshake_at.value()); - local_last_handshake - .or(remote_node.tunnel_last_handshake_at) - .is_some_and(is_recent_tunnel_handshake_at) + match (local_last_handshake, remote_node.tunnel_last_handshake_at) { + (Some(local), Some(persisted)) => is_recent_tunnel_handshake_at(local.max(persisted)), + (Some(local), None) => is_recent_tunnel_handshake_at(local), + (None, Some(persisted)) => is_recent_tunnel_handshake_at(persisted), + (None, None) => false, + } } pub(crate) fn update_last_handshake(&self, remote_node_id: i64) { @@ -355,7 +359,13 @@ impl RemoteTunnelRegistry { return; }; let error = self.runtime_error(remote_node_id).unwrap_or_default(); + let lock = self + .persistence_locks + .entry(remote_node_id) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); tokio::spawn(async move { + let _guard = lock.lock().await; if let Err(persist_error) = persist_tunnel_error(&db, remote_node_id, error).await { tracing::warn!( remote_node_id, @@ -415,6 +425,26 @@ mod tests { assert!(!is_recent_tunnel_handshake_at(expired)); } + #[test] + fn online_status_uses_the_newer_local_or_persisted_handshake() { + let registry = RemoteTunnelRegistry::new(); + let mut node = test_remote_node(); + let now = chrono::Utc::now(); + node.tunnel_last_handshake_at = Some(now); + registry.last_handshake_at.insert( + node.id, + now - chrono::Duration::from_std(REMOTE_TUNNEL_ONLINE_TTL).unwrap(), + ); + assert!(registry.is_online(&node)); + + node.tunnel_last_handshake_at = Some( + now - chrono::Duration::from_std(REMOTE_TUNNEL_ONLINE_TTL).unwrap() + - chrono::Duration::milliseconds(1), + ); + registry.last_handshake_at.insert(node.id, now); + assert!(registry.is_online(&node)); + } + #[tokio::test] async fn lifecycle_aggregates_four_lanes_into_one_outage_generation() { let registry = Arc::new(RemoteTunnelRegistry::new()); diff --git a/src/storage/remote_protocol/tunnel/server/registry/persistence.rs b/src/storage/remote_protocol/tunnel/server/registry/persistence.rs index 73b2fe1db..f1f6850d3 100644 --- a/src/storage/remote_protocol/tunnel/server/registry/persistence.rs +++ b/src/storage/remote_protocol/tunnel/server/registry/persistence.rs @@ -8,13 +8,6 @@ pub(super) async fn persist_tunnel_error( remote_node_id: i64, error: String, ) -> Result<()> { - let remote_node = managed_follower_repo::find_by_id(db, remote_node_id).await?; - managed_follower_repo::touch_tunnel_result( - db, - remote_node_id, - error, - remote_node.tunnel_last_handshake_at, - ) - .await?; + managed_follower_repo::touch_tunnel_runtime_error(db, remote_node_id, error).await?; Ok(()) } diff --git a/tests/operations/cli.rs b/tests/operations/cli.rs index 2cf45525e..77b3b409b 100644 --- a/tests/operations/cli.rs +++ b/tests/operations/cli.rs @@ -1412,6 +1412,111 @@ async fn test_migrations_use_current_baseline_for_fresh_install() { } } +#[tokio::test] +async fn test_remote_node_telemetry_rename_preserves_values_on_sqlite_up_and_down() { + let database_url = + setup_empty_database_url("asterdrive-cli-remote-node-telemetry-rename-test").await; + let db = db::connect_with_metrics( + &DatabaseConfig { + url: database_url.into(), + pool_size: 1, + retry_count: 0, + }, + aster_drive_metrics::NoopMetrics::arc(), + ) + .await + .unwrap(); + let migrations = CurrentMigrator::migrations(); + let rename_index = migrations + .iter() + .position(|migration| migration.name() == "m20260821_000001_rename_remote_node_telemetry") + .expect("remote-node telemetry rename migration should be registered"); + CurrentMigrator::up( + &db, + Some(u32::try_from(rename_index).expect("migration index should fit u32")), + ) + .await + .unwrap(); + + db.execute_unprepared( + "INSERT INTO managed_followers \ + (name, base_url, access_key, secret_key, is_enabled, last_capabilities, \ + last_error, last_checked_at, created_at, updated_at) \ + VALUES ('legacy-node', '', 'legacy-access', 'legacy-secret', 1, '{\"v\":1}', \ + 'probe failed', '2026-08-20T01:02:03Z', \ + '2026-08-20T01:02:03Z', '2026-08-20T01:02:03Z')", + ) + .await + .unwrap(); + + CurrentMigrator::up(&db, None).await.unwrap(); + assert_eq!( + scalar_string( + &db, + DbBackend::Sqlite, + "SELECT last_probe_error FROM managed_followers WHERE name = 'legacy-node'", + ) + .await, + "probe failed" + ); + assert_eq!( + scalar_string( + &db, + DbBackend::Sqlite, + "SELECT tunnel_runtime_error FROM managed_followers WHERE name = 'legacy-node'", + ) + .await, + "" + ); + assert_eq!( + scalar_i64( + &db, + DbBackend::Sqlite, + "SELECT COUNT(*) FROM managed_followers WHERE name = 'legacy-node' AND last_probe_at IS NOT NULL", + ) + .await, + 1 + ); + assert_eq!( + scalar_i64( + &db, + DbBackend::Sqlite, + "SELECT COUNT(*) FROM managed_followers WHERE name = 'legacy-node' AND tunnel_last_handshake_at IS NULL", + ) + .await, + 1 + ); + + CurrentMigrator::down(&db, Some(1)).await.unwrap(); + assert_eq!( + scalar_string( + &db, + DbBackend::Sqlite, + "SELECT last_error FROM managed_followers WHERE name = 'legacy-node'", + ) + .await, + "probe failed" + ); + assert_eq!( + scalar_string( + &db, + DbBackend::Sqlite, + "SELECT tunnel_last_error FROM managed_followers WHERE name = 'legacy-node'", + ) + .await, + "" + ); + assert!( + column_exists( + &db, + DbBackend::Sqlite, + "managed_followers", + "last_checked_at" + ) + .await + ); +} + #[tokio::test] async fn test_migration_backfills_storage_migration_result_renamed_opaque_count() { let database_url = From 968112e1463a1e668552a7ac44ae6ae91fb494bc Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 21 Aug 2026 06:11:56 +0800 Subject: [PATCH 4/5] fix(remote): close follow-up telemetry review findings --- .../RemoteNodeDialogCards.tsx | 4 +- .../i18n/locales/en/admin/remote-nodes.json | 1 + .../i18n/locales/zh/admin/remote-nodes.json | 1 + src/db/repository/managed_follower_repo.rs | 12 +++++- .../remote_protocol/tunnel/server/mod.rs | 15 ++++---- .../tunnel/server/registry/mod.rs | 21 ++++++++++- tests/operations/cli.rs | 37 ++++++++++++++++--- 7 files changed, 75 insertions(+), 16 deletions(-) diff --git a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialogCards.tsx b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialogCards.tsx index 6ccef6d9a..ad4d8996f 100644 --- a/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialogCards.tsx +++ b/frontend-panel/src/components/admin/admin-remote-nodes-page/RemoteNodeDialogCards.tsx @@ -246,7 +246,9 @@ export function RemoteNodeDiagnosticsCard({
{t("remote_node_tunnel_last_handshake")}:{" "} - {formatLastChecked(t, editingNode.tunnel?.last_handshake_at)} + {editingNode.tunnel?.last_handshake_at + ? formatLastChecked(t, editingNode.tunnel.last_handshake_at) + : t("remote_node_tunnel_never_handshaken")}
{t("remote_node_tunnel_runtime_error")}:{" "} diff --git a/frontend-panel/src/i18n/locales/en/admin/remote-nodes.json b/frontend-panel/src/i18n/locales/en/admin/remote-nodes.json index d5f1bb4ad..d9c57d288 100644 --- a/frontend-panel/src/i18n/locales/en/admin/remote-nodes.json +++ b/frontend-panel/src/i18n/locales/en/admin/remote-nodes.json @@ -46,6 +46,7 @@ "remote_node_tunnel_offline": "Offline", "remote_node_tunnel_not_used": "Not used", "remote_node_tunnel_last_handshake": "Last Handshake", + "remote_node_tunnel_never_handshaken": "No handshake recorded", "remote_node_tunnel_runtime_error": "Tunnel Error", "remote_node_tunnel_runtime_error_empty": "No current tunnel error", "remote_node_base_url_hint": "Optional for reverse tunnel. For direct transport, enter the follower URL that the primary can reach. Auto mode chooses direct only when this value is set; it does not retry through the tunnel if direct access fails.", diff --git a/frontend-panel/src/i18n/locales/zh/admin/remote-nodes.json b/frontend-panel/src/i18n/locales/zh/admin/remote-nodes.json index 11103ace7..434bc5296 100644 --- a/frontend-panel/src/i18n/locales/zh/admin/remote-nodes.json +++ b/frontend-panel/src/i18n/locales/zh/admin/remote-nodes.json @@ -46,6 +46,7 @@ "remote_node_tunnel_offline": "离线", "remote_node_tunnel_not_used": "不使用", "remote_node_tunnel_last_handshake": "最近握手", + "remote_node_tunnel_never_handshaken": "尚未记录握手", "remote_node_tunnel_runtime_error": "通道错误", "remote_node_tunnel_runtime_error_empty": "当前没有通道错误", "remote_node_base_url_hint": "反向通道下可以留空。直连模式需要填写主控端能访问到的 follower 地址;自动模式只按这里是否填写来选择传输方式,不会在直连失败后自动改走通道。", diff --git a/src/db/repository/managed_follower_repo.rs b/src/db/repository/managed_follower_repo.rs index 52d5c6ee5..7d2a13a08 100644 --- a/src/db/repository/managed_follower_repo.rs +++ b/src/db/repository/managed_follower_repo.rs @@ -6,7 +6,7 @@ use aster_drive_model::entities::managed_follower::{self, Entity as ManagedFollo use aster_forge_api::SortOrder; use aster_forge_db::pagination::fetch_offset_page; use aster_forge_db::sort::{order_by_column_with_id, order_by_id}; -use sea_orm::sea_query::Expr; +use sea_orm::sea_query::{Condition, Expr}; use sea_orm::{ ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Select, @@ -166,11 +166,21 @@ pub async fn touch_tunnel_success( managed_follower::Column::TunnelRuntimeError, Expr::value(String::new()), ) + .filter(managed_follower::Column::Id.eq(id)) + .exec(db) + .await + .map_err(AsterError::from)?; + ManagedFollower::update_many() .col_expr( managed_follower::Column::TunnelLastHandshakeAt, Expr::value(Some(tunnel_last_handshake_at)), ) .filter(managed_follower::Column::Id.eq(id)) + .filter( + Condition::any() + .add(managed_follower::Column::TunnelLastHandshakeAt.is_null()) + .add(managed_follower::Column::TunnelLastHandshakeAt.lt(tunnel_last_handshake_at)), + ) .exec(db) .await .map_err(AsterError::from)?; diff --git a/src/storage/remote_protocol/tunnel/server/mod.rs b/src/storage/remote_protocol/tunnel/server/mod.rs index 06c1eea4e..fc5966bfe 100644 --- a/src/storage/remote_protocol/tunnel/server/mod.rs +++ b/src/storage/remote_protocol/tunnel/server/mod.rs @@ -2,7 +2,7 @@ use crate::db::repository::managed_follower_repo; use crate::errors::{AsterError, Result}; -use crate::runtime::{RemoteProtocolRuntimeState, SharedRuntimeState}; +use crate::runtime::RemoteProtocolRuntimeState; use aster_drive_model::entities::managed_follower; use aster_drive_storage::StorageErrorKind; use chrono::Utc; @@ -584,7 +584,7 @@ pub(crate) fn ensure_reverse_tunnel_transport(remote_node: &managed_follower::Mo } } -pub async fn mark_tunnel_error( +pub async fn mark_tunnel_error( state: &S, access_key: &str, error: impl std::fmt::Display, @@ -594,12 +594,11 @@ pub async fn mark_tunnel_error( else { return Ok(()); }; - managed_follower_repo::touch_tunnel_runtime_error( - state.writer_db(), - remote_node.id, - error.to_string(), - ) - .await?; + state + .remote_protocol() + .tunnel_registry() + .persist_runtime_error(state.writer_db(), remote_node.id, error.to_string()) + .await?; Ok(()) } diff --git a/src/storage/remote_protocol/tunnel/server/registry/mod.rs b/src/storage/remote_protocol/tunnel/server/registry/mod.rs index d1fceae7a..1a93d4c9a 100644 --- a/src/storage/remote_protocol/tunnel/server/registry/mod.rs +++ b/src/storage/remote_protocol/tunnel/server/registry/mod.rs @@ -358,14 +358,18 @@ impl RemoteTunnelRegistry { let Some(db) = self.persistence_db.read().clone() else { return; }; - let error = self.runtime_error(remote_node_id).unwrap_or_default(); let lock = self .persistence_locks .entry(remote_node_id) .or_insert_with(|| Arc::new(Mutex::new(()))) .clone(); + let runtime_errors = self.runtime_errors.clone(); tokio::spawn(async move { let _guard = lock.lock().await; + let error = runtime_errors + .get(&remote_node_id) + .map(|entry| entry.value().clone()) + .unwrap_or_default(); if let Err(persist_error) = persist_tunnel_error(&db, remote_node_id, error).await { tracing::warn!( remote_node_id, @@ -374,6 +378,21 @@ impl RemoteTunnelRegistry { } }); } + + pub(crate) async fn persist_runtime_error( + &self, + db: &DatabaseConnection, + remote_node_id: i64, + error: String, + ) -> crate::errors::Result<()> { + let lock = self + .persistence_locks + .entry(remote_node_id) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + let _guard = lock.lock().await; + persist_tunnel_error(db, remote_node_id, error).await + } } fn is_recent_tunnel_handshake_at(last_handshake_at: chrono::DateTime) -> bool { diff --git a/tests/operations/cli.rs b/tests/operations/cli.rs index 77b3b409b..795601df5 100644 --- a/tests/operations/cli.rs +++ b/tests/operations/cli.rs @@ -1441,9 +1441,9 @@ async fn test_remote_node_telemetry_rename_preserves_values_on_sqlite_up_and_dow db.execute_unprepared( "INSERT INTO managed_followers \ (name, base_url, access_key, secret_key, is_enabled, last_capabilities, \ - last_error, last_checked_at, created_at, updated_at) \ + last_error, last_checked_at, tunnel_last_error, tunnel_last_seen_at, created_at, updated_at) \ VALUES ('legacy-node', '', 'legacy-access', 'legacy-secret', 1, '{\"v\":1}', \ - 'probe failed', '2026-08-20T01:02:03Z', \ + 'probe failed', '2026-08-20T01:02:03Z', 'tunnel failed', '2026-08-20T02:03:04Z', \ '2026-08-20T01:02:03Z', '2026-08-20T01:02:03Z')", ) .await @@ -1466,7 +1466,7 @@ async fn test_remote_node_telemetry_rename_preserves_values_on_sqlite_up_and_dow "SELECT tunnel_runtime_error FROM managed_followers WHERE name = 'legacy-node'", ) .await, - "" + "tunnel failed" ); assert_eq!( scalar_i64( @@ -1477,6 +1477,15 @@ async fn test_remote_node_telemetry_rename_preserves_values_on_sqlite_up_and_dow .await, 1 ); + assert_eq!( + scalar_string( + &db, + DbBackend::Sqlite, + "SELECT tunnel_last_handshake_at FROM managed_followers WHERE name = 'legacy-node'", + ) + .await, + "2026-08-20T02:03:04Z" + ); assert_eq!( scalar_i64( &db, @@ -1484,7 +1493,7 @@ async fn test_remote_node_telemetry_rename_preserves_values_on_sqlite_up_and_dow "SELECT COUNT(*) FROM managed_followers WHERE name = 'legacy-node' AND tunnel_last_handshake_at IS NULL", ) .await, - 1 + 0 ); CurrentMigrator::down(&db, Some(1)).await.unwrap(); @@ -1504,7 +1513,25 @@ async fn test_remote_node_telemetry_rename_preserves_values_on_sqlite_up_and_dow "SELECT tunnel_last_error FROM managed_followers WHERE name = 'legacy-node'", ) .await, - "" + "tunnel failed" + ); + assert_eq!( + scalar_string( + &db, + DbBackend::Sqlite, + "SELECT last_checked_at FROM managed_followers WHERE name = 'legacy-node'", + ) + .await, + "2026-08-20T01:02:03Z" + ); + assert_eq!( + scalar_string( + &db, + DbBackend::Sqlite, + "SELECT tunnel_last_seen_at FROM managed_followers WHERE name = 'legacy-node'", + ) + .await, + "2026-08-20T02:03:04Z" ); assert!( column_exists( From 69263f3409ed8f5f161246cee5435e201506b276 Mon Sep 17 00:00:00 2001 From: AptS-1547 Date: Fri, 21 Aug 2026 06:16:47 +0800 Subject: [PATCH 5/5] fix(remote): complete telemetry review coverage --- tests/platform/database_backends.rs | 123 ++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/tests/platform/database_backends.rs b/tests/platform/database_backends.rs index 822b825ea..e9100afd3 100644 --- a/tests/platform/database_backends.rs +++ b/tests/platform/database_backends.rs @@ -1387,6 +1387,120 @@ async fn test_postgres_migrations_keep_bounded_backfills_with_single_connection_ .expect("single-connection PostgreSQL migration pool should close"); } +async fn assert_remote_node_telemetry_rename_round_trip(database_url: String, backend: DbBackend) { + let config = aster_drive::config::DatabaseConfig { + url: database_url.into(), + pool_size: 1, + retry_count: 0, + }; + let database = + aster_drive::db::connect_with_metrics(&config, aster_drive_metrics::NoopMetrics::arc()) + .await + .expect("database migration fixture should connect"); + let migrations = CurrentMigrator::migrations(); + let rename_index = migrations + .iter() + .position(|migration| migration.name() == "m20260821_000001_rename_remote_node_telemetry") + .expect("remote-node telemetry rename migration should be registered"); + CurrentMigrator::up( + &database, + Some(u32::try_from(rename_index).expect("migration index should fit u32")), + ) + .await + .expect("database should migrate to the legacy telemetry schema"); + + let enabled = if backend == DbBackend::Postgres { + "TRUE" + } else { + "1" + }; + let probe_at = if backend == DbBackend::MySql { + "2026-08-20 01:02:03" + } else { + "2026-08-20T01:02:03Z" + }; + let handshake_at = if backend == DbBackend::MySql { + "2026-08-20 02:03:04" + } else { + "2026-08-20T02:03:04Z" + }; + database + .execute_unprepared(&format!( + "INSERT INTO managed_followers \ + (name, base_url, access_key, secret_key, is_enabled, last_capabilities, \ + last_error, last_checked_at, tunnel_last_error, tunnel_last_seen_at, created_at, updated_at) \ + VALUES ('legacy-node', '', 'legacy-access', 'legacy-secret', {enabled}, '{{\"v\":1}}', \ + 'probe failed', '{probe_at}', 'tunnel failed', '{handshake_at}', \ + '{probe_at}', '{probe_at}')" + )) + .await + .expect("legacy telemetry fixture should insert"); + CurrentMigrator::up(&database, None) + .await + .expect("database should apply telemetry rename"); + + let row = database + .query_one_raw(Statement::from_string( + backend, + "SELECT last_probe_error, last_probe_at, tunnel_runtime_error, tunnel_last_handshake_at \ + FROM managed_followers WHERE name = 'legacy-node'", + )) + .await + .expect("renamed telemetry row should be queryable") + .expect("renamed telemetry row should exist"); + assert_eq!(row.try_get_by_index::(0).unwrap(), "probe failed"); + assert_eq!( + row.try_get_by_index::>(1) + .unwrap() + .to_rfc3339(), + "2026-08-20T01:02:03+00:00" + ); + assert_eq!(row.try_get_by_index::(2).unwrap(), "tunnel failed"); + assert_eq!( + row.try_get_by_index::>(3) + .unwrap() + .to_rfc3339(), + "2026-08-20T02:03:04+00:00" + ); + + CurrentMigrator::down(&database, Some(1)) + .await + .expect("database should roll back telemetry rename"); + let row = database + .query_one_raw(Statement::from_string( + backend, + "SELECT last_error, last_checked_at, tunnel_last_error, tunnel_last_seen_at \ + FROM managed_followers WHERE name = 'legacy-node'", + )) + .await + .expect("legacy telemetry row should be queryable after rollback") + .expect("legacy telemetry row should remain after rollback"); + assert_eq!(row.try_get_by_index::(0).unwrap(), "probe failed"); + assert_eq!( + row.try_get_by_index::>(1) + .unwrap() + .to_rfc3339(), + "2026-08-20T01:02:03+00:00" + ); + assert_eq!(row.try_get_by_index::(2).unwrap(), "tunnel failed"); + assert_eq!( + row.try_get_by_index::>(3) + .unwrap() + .to_rfc3339(), + "2026-08-20T02:03:04+00:00" + ); + database.close().await.expect("database should close"); +} + +#[tokio::test] +async fn test_postgres_remote_node_telemetry_rename_preserves_values() { + assert_remote_node_telemetry_rename_round_trip( + common::postgres_empty_test_database_url().await, + DbBackend::Postgres, + ) + .await; +} + #[actix_web::test] async fn test_mysql_smoke_search_and_admin_overview() { let database_url = common::mysql_test_database_url().await; @@ -1470,3 +1584,12 @@ async fn test_mysql_concurrent_fresh_database_migrations_are_serialized() { .await .expect("second MySQL migration connection should close cleanly"); } + +#[tokio::test] +async fn test_mysql_remote_node_telemetry_rename_preserves_values() { + assert_remote_node_telemetry_rename_round_trip( + common::mysql_empty_test_database_url().await, + DbBackend::MySql, + ) + .await; +}