diff --git a/clawpal-cli/src/main.rs b/clawpal-cli/src/main.rs index 96f95033..97417578 100644 --- a/clawpal-cli/src/main.rs +++ b/clawpal-cli/src/main.rs @@ -307,6 +307,9 @@ fn run_profile_command(command: ProfileCommands) -> Result, pub base_url: Option, pub description: Option, + #[serde(default)] + pub sync_source_device_name: Option, + #[serde(default)] + pub sync_source_host_id: Option, + #[serde(default)] + pub sync_synced_at: Option, pub enabled: bool, } @@ -415,6 +421,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, } } @@ -580,6 +589,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }; let content = serde_json::json!({ "profiles": [donor], "version": 1 }).to_string(); @@ -603,6 +615,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }; let second = ModelProfile { @@ -614,6 +629,9 @@ mod tests { api_key: None, base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }; diff --git a/clawpal-core/tests/oauth_e2e.rs b/clawpal-core/tests/oauth_e2e.rs index ba93c3e6..f728a698 100644 --- a/clawpal-core/tests/oauth_e2e.rs +++ b/clawpal-core/tests/oauth_e2e.rs @@ -73,6 +73,9 @@ fn e2e_create_oauth_profile_and_probe() { api_key: Some(oauth_token.clone()), base_url: None, description: Some("E2E OAuth token test profile".to_string()), + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }; diff --git a/clawpal-core/tests/profile_e2e.rs b/clawpal-core/tests/profile_e2e.rs index 6a2e89ab..cb6c8469 100644 --- a/clawpal-core/tests/profile_e2e.rs +++ b/clawpal-core/tests/profile_e2e.rs @@ -235,6 +235,9 @@ fn run_case(case: &ModelCase) -> CaseResult { api_key: Some(api_key.clone()), base_url: None, description: Some(format!("E2E — {}/{}", case.provider, case.model)), + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }; diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs index 5dcb247a..f4964d63 100644 --- a/src-tauri/src/commands/profiles.rs +++ b/src-tauri/src/commands/profiles.rs @@ -227,6 +227,9 @@ fn merge_remote_profile_into_local( remote: &ModelProfile, resolved_api_key: Option, resolved_base_url: Option, + source_device_name: &str, + source_host_id: &str, + synced_at: &str, ) -> bool { let remote_key = normalize_profile_key(remote); let target_idx = local_profiles @@ -282,6 +285,9 @@ fn merge_remote_profile_into_local( if !existing.enabled && remote.enabled { existing.enabled = true; } + existing.sync_source_device_name = Some(source_device_name.to_string()); + existing.sync_source_host_id = Some(source_host_id.to_string()); + existing.sync_synced_at = Some(synced_at.to_string()); return false; } @@ -292,6 +298,9 @@ fn merge_remote_profile_into_local( if !is_non_empty(merged.base_url.as_deref()) && is_non_empty(resolved_base_url.as_deref()) { merged.base_url = resolved_base_url; } + merged.sync_source_device_name = Some(source_device_name.to_string()); + merged.sync_source_host_id = Some(source_host_id.to_string()); + merged.sync_synced_at = Some(synced_at.to_string()); local_profiles.push(merged); true } @@ -352,6 +361,9 @@ fn extract_profiles_from_openclaw_config( api_key: None, base_url, description: Some(format!("Extracted from config ({scope_label})")), + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }; let key = profile_to_model_value(&profile); @@ -573,6 +585,7 @@ pub struct RemoteAuthSyncResult { pub async fn remote_sync_profiles_to_local_auth( pool: State<'_, SshConnectionPool>, host_id: String, + source_device_name: Option, ) -> Result { let (remote_profiles, _) = collect_remote_profiles_from_openclaw(&pool, &host_id, true).await?; if remote_profiles.is_empty() { @@ -589,6 +602,13 @@ pub async fn remote_sync_profiles_to_local_auth( let paths = resolve_paths(); let mut local_profiles = dedupe_profiles_by_model_key(load_model_profiles(&paths)); + let source_name = source_device_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(host_id.as_str()) + .to_string(); + let synced_at = chrono::Utc::now().to_rfc3339(); let mut created_profiles = 0usize; let mut updated_profiles = 0usize; @@ -652,6 +672,9 @@ pub async fn remote_sync_profiles_to_local_auth( remote, resolved_api_key, resolved_base_url, + &source_name, + &host_id, + &synced_at, ) { created_profiles += 1; } else { @@ -1310,6 +1333,9 @@ mod tests { api_key: api_key.map(|v| v.to_string()), base_url: None, description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, } } @@ -1576,6 +1602,9 @@ mod tests { api_key: None, base_url: Some("https://openrouter.example/v1".to_string()), description: None, + sync_source_device_name: None, + sync_source_host_id: None, + sync_synced_at: None, enabled: true, }, provider_key: "openrouter".to_string(), @@ -1719,6 +1748,9 @@ pub fn resolve_provider_auth(provider: String) -> Result ( + connectWithPassphraseFallback(hostId) + .then(() => true) + .catch(() => false) + )} onDataChange={bumpConfigVersion} /> )} @@ -481,6 +484,11 @@ export function App() { globalMode section="preferences" onOpenDoctor={openDoctor} + onConnectDevice={(hostId) => ( + connectWithPassphraseFallback(hostId) + .then(() => true) + .catch(() => false) + )} onDataChange={bumpConfigVersion} hasAppUpdate={appUpdateAvailable} onAppUpdateSeen={() => setAppUpdateAvailable(false)} diff --git a/src/components/SidebarFooter.tsx b/src/components/SidebarFooter.tsx index 3c2dc56c..4d1b42a4 100644 --- a/src/components/SidebarFooter.tsx +++ b/src/components/SidebarFooter.tsx @@ -1,20 +1,13 @@ import { Suspense, lazy } from "react"; import { useTranslation } from "react-i18next"; import { shouldShowPendingChangesBar } from "@/lib/route-ui"; -import { cn, formatBytes } from "@/lib/utils"; +import { formatBytes } from "@/lib/utils"; import { api } from "../lib/api"; import type { SshTransferStats } from "../lib/types"; const PendingChangesBar = lazy(() => import("./PendingChangesBar").then((m) => ({ default: m.PendingChangesBar }))); -interface ProfileSyncStatus { - phase: "idle" | "syncing" | "success" | "error"; - message: string; - instanceId: string | null; -} - interface SidebarFooterProps { - profileSyncStatus: ProfileSyncStatus; showSshTransferSpeedUi: boolean; isRemote: boolean; isConnected: boolean; @@ -26,33 +19,15 @@ interface SidebarFooterProps { } export function SidebarFooter({ - profileSyncStatus, showSshTransferSpeedUi, isRemote, isConnected, + showSshTransferSpeedUi, isRemote, isConnected, sshTransferStats, inStart, route, showToast, bumpConfigVersion, }: SidebarFooterProps) { const { t } = useTranslation(); return ( <>
-
- - - {profileSyncStatus.phase === "idle" - ? t("doctor.profileSyncIdle") - : profileSyncStatus.phase === "syncing" - ? t("doctor.profileSyncSyncing", { instance: profileSyncStatus.instanceId || t("instance.current") }) - : profileSyncStatus.phase === "success" - ? t("doctor.profileSyncSuccessStatus", { instance: profileSyncStatus.instanceId || t("instance.current") }) - : t("doctor.profileSyncErrorStatus", { instance: profileSyncStatus.instanceId || t("instance.current") })} - -
{showSshTransferSpeedUi && isRemote && isConnected && ( -
+
{t("doctor.sshTransferSpeedTitle")}
{t("doctor.sshTransferSpeedDown", { speed: `${formatBytes(Math.max(0, Math.round(sshTransferStats?.downloadBytesPerSec ?? 0)))} /s` })} diff --git a/src/hooks/useSshConnection.ts b/src/hooks/useSshConnection.ts index a6313948..927a6e04 100644 --- a/src/hooks/useSshConnection.ts +++ b/src/hooks/useSshConnection.ts @@ -175,6 +175,7 @@ export function useSshConnection(params: UseSshConnectionParams) { }, [activeInstance, requestPassphrase, sshHosts, t, setPersistenceScope, setPersistenceResolved]); const syncRemoteAuthAfterConnect = useCallback(async (hostId: string) => { + const hostLabel = sshHosts.find((host) => host.id === hostId)?.label || hostId; const now = Date.now(); const last = remoteAuthSyncAtRef.current[hostId] || 0; if (now - last < 30_000) return; @@ -182,10 +183,10 @@ export function useSshConnection(params: UseSshConnectionParams) { setProfileSyncStatus({ phase: "syncing", message: t("doctor.profileSyncStarted"), - instanceId: hostId, + instanceId: hostLabel, }); try { - const result = await api.remoteSyncProfilesToLocalAuth(hostId); + const result = await api.remoteSyncProfilesToLocalAuth(hostId, hostLabel); invalidateGlobalReadCache(["listModelProfiles", "resolveApiKeys"]); const localProfiles = await api.listModelProfiles().catch((error) => { logDevIgnoredError("syncRemoteAuthAfterConnect listModelProfiles", error); @@ -198,27 +199,27 @@ export function useSshConnection(params: UseSshConnectionParams) { resolvedKeys: result.resolvedKeys, }); showToast(message, "success"); - setProfileSyncStatus({ phase: "success", message, instanceId: hostId }); + setProfileSyncStatus({ phase: "success", message, instanceId: hostLabel }); } else { const message = t("doctor.profileSyncNoLocalProfiles"); showToast(message, "error"); - setProfileSyncStatus({ phase: "error", message, instanceId: hostId }); + setProfileSyncStatus({ phase: "error", message, instanceId: hostLabel }); } } else if (result.totalRemoteProfiles > 0) { const message = t("doctor.profileSyncNoUsableKeys"); showToast(message, "error"); - setProfileSyncStatus({ phase: "error", message, instanceId: hostId }); + setProfileSyncStatus({ phase: "error", message, instanceId: hostLabel }); } else { const message = t("doctor.profileSyncNoProfiles"); showToast(message, "error"); - setProfileSyncStatus({ phase: "error", message, instanceId: hostId }); + setProfileSyncStatus({ phase: "error", message, instanceId: hostLabel }); } } catch (e) { const message = t("doctor.profileSyncFailed", { error: String(e) }); showToast(message, "error"); - setProfileSyncStatus({ phase: "error", message, instanceId: hostId }); + setProfileSyncStatus({ phase: "error", message, instanceId: hostLabel }); } - }, [showToast, t]); + }, [showToast, sshHosts, t]); // SSH self-healing: detect dropped connections and reconnect useEffect(() => { diff --git a/src/lib/api.ts b/src/lib/api.ts index a967bb60..def3def6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -380,8 +380,11 @@ export const api = { invoke("remote_test_model_profile", { hostId, profileId }), remoteResolveApiKeys: (hostId: string): Promise => invoke("remote_resolve_api_keys", { hostId }), - remoteSyncProfilesToLocalAuth: (hostId: string): Promise => - invoke("remote_sync_profiles_to_local_auth", { hostId }), + remoteSyncProfilesToLocalAuth: (hostId: string, sourceDeviceName?: string): Promise => + invoke("remote_sync_profiles_to_local_auth", { + hostId, + sourceDeviceName: sourceDeviceName ?? null, + }), pushModelProfilesToLocalOpenclaw: (profileIds: string[]): Promise => invoke("push_model_profiles_to_local_openclaw", { profileIds }), pushModelProfilesToRemoteOpenclaw: (hostId: string, profileIds: string[]): Promise => diff --git a/src/lib/types.ts b/src/lib/types.ts index 2f8445b6..461dd9ad 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -529,6 +529,9 @@ export interface ModelProfile { apiKey?: string; baseUrl?: string; description?: string; + syncSourceDeviceName?: string; + syncSourceHostId?: string; + syncSyncedAt?: string; enabled: boolean; } diff --git a/src/locales/en.json b/src/locales/en.json index 7b0e2763..093f61aa 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -274,6 +274,22 @@ "settings.pushProfileBlockedMissingCredential": "This profile has no resolved static credential to push.", "settings.loadingProfiles": "Loading profiles...", "settings.noProfiles": "No model profiles yet.", + "settings.syncedDevicesCount": "Synced {{count}} devices", + "settings.syncDevicesTitle": "Device sync", + "settings.noSyncDevices": "No syncable devices", + "settings.syncFromDevices": "Sync from devices", + "settings.syncFromDevicesAction": "Sync selected devices ({{count}})", + "settings.syncStarted": "Started syncing {{count}} devices", + "settings.syncFailedForDevice": "Sync failed for {{device}}: {{error}}", + "settings.connectDevice": "Connect", + "settings.connecting": "Connecting", + "settings.disconnected": "Disconnected", + "settings.connectDeviceFirst": "Please connect device first: {{device}}", + "settings.syncStatusIdle": "Idle", + "settings.syncStatusSyncing": "Syncing", + "settings.syncStatusSuccess": "Success", + "settings.syncStatusFailed": "Failed", + "settings.profileSyncSource": "Source: {{device}} · Synced at: {{syncedAt}}", "settings.enabled": "enabled", "settings.disabled": "disabled", "settings.enable": "Enable", diff --git a/src/locales/zh.json b/src/locales/zh.json index 1cc64fff..e87a096a 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -273,6 +273,22 @@ "settings.pushProfileBlockedMissingCredential": "这个配置没有可解析的静态凭据可供推送。", "settings.loadingProfiles": "加载配置中...", "settings.noProfiles": "暂无模型配置。", + "settings.syncedDevicesCount": "已同步 {{count}} 个设备", + "settings.syncDevicesTitle": "设备同步", + "settings.noSyncDevices": "暂无可同步设备", + "settings.syncFromDevices": "从设备同步", + "settings.syncFromDevicesAction": "同步所选设备({{count}})", + "settings.syncStarted": "已开始同步 {{count}} 个设备", + "settings.syncFailedForDevice": "{{device}} 同步失败:{{error}}", + "settings.connectDevice": "连接", + "settings.connecting": "连接中", + "settings.disconnected": "未连接", + "settings.connectDeviceFirst": "请先连接设备:{{device}}", + "settings.syncStatusIdle": "未同步", + "settings.syncStatusSyncing": "同步中", + "settings.syncStatusSuccess": "同步成功", + "settings.syncStatusFailed": "同步失败", + "settings.profileSyncSource": "来源设备:{{device}} · 同步时间:{{syncedAt}}", "settings.enabled": "已启用", "settings.disabled": "已禁用", "settings.enable": "启用", diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index fc63ccf8..12d43ae6 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -11,6 +11,7 @@ import type { FormEvent } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { hasGuidanceEmitted, useApi } from "@/lib/use-api"; +import { api } from "@/lib/api"; import { isAlreadyExplainedGuidanceError } from "@/lib/guidance"; import { useTheme } from "@/lib/use-theme"; import { useFont } from "@/lib/use-font"; @@ -21,6 +22,7 @@ import type { ModelProfile, ProviderAuthSuggestion, ResolvedApiKey, + SshHost, } from "@/lib/types"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { BugReportSettings } from "@/components/BugReportSettings"; @@ -56,11 +58,14 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; +import { PlusIcon, RefreshCwIcon } from "lucide-react"; const MODEL_CATALOG_CACHE_TTL_MS = 5 * 60_000; let modelCatalogCache: { value: ModelCatalogProvider[]; expiresAt: number } | null = null; let profilesExtractedOnce = false; +let syncSelectionSessionCache: string[] = []; +let syncStatusSessionCache: Record = {}; const PROVIDER_FALLBACK_OPTIONS = [ "openai", "openai-codex", @@ -83,6 +88,7 @@ export function Settings({ globalMode = false, section = "all", onOpenDoctor, + onConnectDevice, }: { onDataChange?: () => void; hasAppUpdate?: boolean; @@ -90,6 +96,7 @@ export function Settings({ globalMode?: boolean; section?: "all" | "profiles" | "preferences"; onOpenDoctor?: () => void; + onConnectDevice?: (hostId: string) => Promise; }) { const { t, i18n } = useTranslation(); const ua = useApi(); @@ -101,10 +108,15 @@ export function Settings({ const [form, setForm] = useState(emptyForm()); const [credentialSource, setCredentialSource] = useState("manual"); const [profileDialogOpen, setProfileDialogOpen] = useState(false); - const [message, setMessage] = useState(""); const [authSuggestion, setAuthSuggestion] = useState(null); const [testingProfileId, setTestingProfileId] = useState(null); const [showSshTransferSpeedUi, setShowSshTransferSpeedUi] = useState(false); + const [remoteDevices, setRemoteDevices] = useState([]); + const [syncDialogOpen, setSyncDialogOpen] = useState(false); + const [selectedSyncHostIds, setSelectedSyncHostIds] = useState(() => [...syncSelectionSessionCache]); + const [syncStatusByHostId, setSyncStatusByHostId] = useState>(() => ({ ...syncStatusSessionCache })); + const [hostConnectionById, setHostConnectionById] = useState>({}); + const [hostConnectingById, setHostConnectingById] = useState>({}); const [catalogRefreshed, setCatalogRefreshed] = useState(false); @@ -145,6 +157,48 @@ export function Settings({ useEffect(refreshProfiles, [ua]); + useEffect(() => { + ua.listSshHosts() + .then((hosts) => setRemoteDevices(hosts)) + .catch((e) => { + console.error("Failed to load SSH hosts:", e); + setRemoteDevices([]); + }); + }, [ua]); + + useEffect(() => { + if (!syncDialogOpen || remoteDevices.length === 0) return; + let cancelled = false; + Promise.all( + remoteDevices.map(async (device) => { + try { + const status = await ua.sshStatus(device.id); + const text = String(status).toLowerCase(); + const connected = text.includes("connected") && !text.includes("disconnected") && !text.includes("no connection"); + return [device.id, connected] as const; + } catch { + return [device.id, false] as const; + } + }), + ).then((pairs) => { + if (cancelled) return; + const next = Object.fromEntries(pairs); + setHostConnectionById(next); + setSelectedSyncHostIds((prev) => prev.filter((id) => next[id])); + }); + return () => { + cancelled = true; + }; + }, [remoteDevices, syncDialogOpen, ua]); + + useEffect(() => { + syncSelectionSessionCache = selectedSyncHostIds; + }, [selectedSyncHostIds]); + + useEffect(() => { + syncStatusSessionCache = syncStatusByHostId; + }, [syncStatusByHostId]); + useEffect(() => { ua.getAppPreferences() .then((prefs) => { @@ -258,7 +312,7 @@ export function Settings({ const saveProfile = async (authRefOverride?: string): Promise => { if (!form.provider || !form.model) { - setMessage(t('settings.providerModelRequired')); + toast.error(t('settings.providerModelRequired')); return false; } const apiKeyOptional = form.useCustomUrl || providerSupportsOptionalApiKey(form.provider); @@ -266,7 +320,7 @@ export function Settings({ const envSource = credentialSource === "env"; const manualSource = credentialSource === "manual"; if (!ua.isRemote && manualSource && !form.apiKey && !form.id && !apiKeyOptional) { - setMessage(t('settings.apiKeyRequired')); + toast.error(t('settings.apiKeyRequired')); return false; } const overrideAuthRef = (authRefOverride || "").trim(); @@ -293,14 +347,15 @@ export function Settings({ }; try { await ua.upsertModelProfile(profileData); - setMessage(t('settings.profileSaved')); + toast.success(t('settings.profileSaved')); setForm(emptyForm()); setProfileDialogOpen(false); refreshProfiles(); onDataChange?.(); return true; } catch (e) { - setMessage(t('settings.saveFailed', { error: String(e) })); + const errorText = e instanceof Error ? e.message : String(e); + toast.error(t('settings.saveFailed', { error: errorText })); return false; } }; @@ -334,14 +389,17 @@ export function Settings({ const deleteProfile = (id: string) => { ua.deleteModelProfile(id) .then(() => { - setMessage(t('settings.profileDeleted')); + toast.success(t('settings.profileDeleted')); if (form.id === id) { setForm(emptyForm()); } refreshProfiles(); onDataChange?.(); }) - .catch((e) => setMessage(t('settings.deleteFailed', { error: String(e) }))); + .catch((e) => { + const errorText = e instanceof Error ? e.message : String(e); + toast.error(t('settings.deleteFailed', { error: errorText })); + }); }; const toggleProfileEnabled = (profile: ModelProfile) => { @@ -429,6 +487,59 @@ export function Settings({ const showProfiles = section !== "preferences"; const showPreferences = section !== "profiles"; + const syncedDeviceCount = useMemo(() => { + const ids = new Set(); + for (const profile of profiles || []) { + const source = profile.syncSourceHostId?.trim(); + if (source) ids.add(source); + } + return ids.size; + }, [profiles]); + + const syncButtonText = remoteDevices.length > 0 && selectedSyncHostIds.length === 0 + ? t("settings.syncFromDevices") + : t("settings.syncFromDevicesAction", { count: selectedSyncHostIds.length }); + + const isDeviceSyncing = useMemo( + () => Object.values(syncStatusByHostId).some((status) => status === "syncing"), + [syncStatusByHostId], + ); + + const formatSyncedAt = (value?: string) => { + if (!value) return "-"; + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) return value; + return new Date(timestamp).toLocaleString(); + }; + + const runDeviceSync = useCallback(async () => { + if (selectedSyncHostIds.length === 0) { + toast.message(t("settings.syncFromDevices")); + return; + } + + toast.success(t("settings.syncStarted", { count: selectedSyncHostIds.length })); + setSyncDialogOpen(false); + + for (const hostId of selectedSyncHostIds) { + const device = remoteDevices.find((item) => item.id === hostId); + const deviceName = device?.label || hostId; + syncStatusSessionCache = { ...syncStatusSessionCache, [hostId]: "syncing" }; + setSyncStatusByHostId((prev) => ({ ...prev, [hostId]: "syncing" })); + try { + await api.remoteSyncProfilesToLocalAuth(hostId, deviceName); + syncStatusSessionCache = { ...syncStatusSessionCache, [hostId]: "success" }; + setSyncStatusByHostId((prev) => ({ ...prev, [hostId]: "success" })); + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); + syncStatusSessionCache = { ...syncStatusSessionCache, [hostId]: "failed" }; + setSyncStatusByHostId((prev) => ({ ...prev, [hostId]: "failed" })); + toast.error(t("settings.syncFailedForDevice", { device: deviceName, error: errorText })); + } + } + refreshProfiles(); + }, [remoteDevices, selectedSyncHostIds, ua]); + const handleSshTransferSpeedUiToggle = useCallback((nextChecked: boolean) => { setShowSshTransferSpeedUi(nextChecked); ua.setSshTransferSpeedUiPreference(nextChecked) @@ -562,7 +673,23 @@ export function Settings({
{t('settings.modelProfiles')}
- + +
@@ -605,24 +732,43 @@ export function Settings({ ) : null}
-
- {t('settings.credential')}: {t(`settings.credentialKind.${credential.kind}`)} +
+ + {t(`settings.credentialKind.${credential.kind}`)} + + {showCredentialRef && ( + + Ref + + )} + {showCredentialStatus && ( + + {credentialStatusText} + + )} + {profile.baseUrl && ( + + URL + + )} + {(profile.syncSourceDeviceName || profile.syncSyncedAt) && ( + + {profile.syncSourceDeviceName || "-"} + + )}
- {showCredentialRef && ( -
- {t("settings.credentialRef")}: {credential.authRef || "-"} -
- )} - {showCredentialStatus && ( -
- {t("settings.credentialStatus")}: {credentialStatusText} -
- )} - {profile.baseUrl && ( -
- URL: {profile.baseUrl} -
- )}
{profileUi.actions.includes("edit") ? (
- {message && ( -

{message}

- )} + + + + {t("settings.syncDevicesTitle")} + +
+ {remoteDevices.length === 0 ? ( +

{t("settings.noSyncDevices")}

+ ) : remoteDevices.map((device) => { + const checked = selectedSyncHostIds.includes(device.id); + const connected = hostConnectionById[device.id] ?? false; + const connecting = hostConnectingById[device.id] ?? false; + const status = syncStatusByHostId[device.id] || "idle"; + const statusText = connecting + ? t("settings.connecting") + : status === "syncing" + ? t("settings.syncStatusSyncing") + : status === "success" + ? t("settings.syncStatusSuccess") + : status === "failed" + ? t("settings.syncStatusFailed") + : t("settings.syncStatusIdle"); + return ( + + ); + })} +
+ + + + +
+
{/* Add / Edit Profile Dialog */} {