Skip to content

Commit 71aa967

Browse files
JayantDevkarclaude
andcommitted
fix: sync page bugs — pending actions, team counts, setup wizard, watch start
- Filter already-configured folders from pending UI + dismiss stale offers - Add dismiss_pending_folder() to SyncthingClient - Pass through member_count/project_count in teamsList mapping - Add SyncStatusTeamEntry type, clean up unsafe casts in TeamSelector - Fix SetupWizard sending wrong body to add-project API - Fix watch start sending JSON body instead of query param - Fix $derived() → $derived.by() for member/project count derivations - Remove dead ternary and redundant $effect - Fix encode_project_path('') when path is empty Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent da8dc68 commit 71aa967

9 files changed

Lines changed: 524 additions & 35 deletions

File tree

api/routers/sync_status.py

Lines changed: 431 additions & 2 deletions
Large diffs are not rendered by default.

api/services/syncthing_proxy.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,40 @@ def rescan_all(self) -> dict:
262262
pass
263263
return {"ok": True, "scanned": scanned}
264264

265+
def get_pending_folders_for_ui(
266+
self, known_devices: dict[str, tuple[str, str]]
267+
) -> list[dict]:
268+
"""Get pending folder offers filtered for known team members.
269+
270+
Args:
271+
known_devices: {device_id: (member_name, team_name)}
272+
273+
Returns:
274+
List of pending offers from known members with karma- prefix only.
275+
"""
276+
client = self._require_client()
277+
pending = client.get_pending_folders()
278+
existing_ids = {f["id"] for f in client.get_folders()}
279+
result = []
280+
281+
for folder_id, info in pending.items():
282+
if not folder_id.startswith("karma-"):
283+
continue
284+
if folder_id in existing_ids:
285+
continue
286+
for device_id, offer in info.get("offeredBy", {}).items():
287+
if device_id not in known_devices:
288+
continue
289+
member_name, team_name = known_devices[device_id]
290+
result.append({
291+
"folder_id": folder_id,
292+
"from_device": device_id,
293+
"from_member": member_name,
294+
"from_team": team_name,
295+
"offered_at": offer.get("time"),
296+
})
297+
return result
298+
265299
def get_bandwidth(self) -> dict:
266300
"""Return current bandwidth totals from connections endpoint."""
267301
client = self._require_client()

cli/karma/main.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,33 @@ def _accept_pending_folders(st, config):
8282

8383
accepted = 0
8484

85+
# Get existing folder IDs to avoid duplicates
86+
existing_folder_ids = {f["id"] for f in st.get_folders()}
87+
own_device_id = config.syncthing.device_id if config.syncthing else None
88+
8589
for folder_id, folder_info in pending.items():
8690
# Security: only accept karma-prefixed folders
8791
if not folder_id.startswith("karma-"):
8892
click.echo(f" Skipped non-karma folder offer '{folder_id}' (security policy)")
8993
continue
9094

95+
# Skip folders we already have configured (e.g. our own outbox)
96+
if folder_id in existing_folder_ids:
97+
# Dismiss the pending offer so it doesn't reappear
98+
for device_id in folder_info.get("offeredBy", {}):
99+
try:
100+
st.dismiss_pending_folder(folder_id, device_id)
101+
except Exception:
102+
pass
103+
click.echo(f" Dismissed '{folder_id}' (already configured locally)")
104+
continue
105+
91106
offered_by = folder_info.get("offeredBy", {})
92107
for device_id, _offer in offered_by.items():
108+
# Skip offers from our own device
109+
if own_device_id and device_id == own_device_id:
110+
continue
111+
93112
if device_id not in known_devices:
94113
short_id = device_id[:20] + "..."
95114
click.echo(f" Skipped folder '{folder_id}' from unknown device {short_id}")
@@ -128,10 +147,11 @@ def _accept_pending_folders(st, config):
128147

129148
# Accept: create the folder as receiveonly
130149
inbox_devices = [device_id]
131-
if config.syncthing and config.syncthing.device_id:
132-
inbox_devices.append(config.syncthing.device_id)
150+
if own_device_id:
151+
inbox_devices.append(own_device_id)
133152
Path(inbox_path).mkdir(parents=True, exist_ok=True)
134153
st.add_folder(folder_id, inbox_path, inbox_devices, folder_type="receiveonly")
154+
existing_folder_ids.add(folder_id)
135155

136156
click.echo(
137157
f" Accepted '{folder_id}' from {member_name} "

cli/karma/syncthing.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,15 @@ def get_pending_folders(self) -> dict:
142142
resp.raise_for_status()
143143
return resp.json()
144144

145+
def dismiss_pending_folder(self, folder_id: str, device_id: str) -> None:
146+
"""Dismiss a pending folder offer so it no longer appears."""
147+
requests.delete(
148+
f"{self.api_url}/rest/cluster/pending/folders",
149+
headers=self.headers,
150+
params={"folder": folder_id, "device": device_id},
151+
timeout=10,
152+
)
153+
145154
def get_folders(self) -> list[dict]:
146155
"""Get all configured folders."""
147156
config = self._get_config()

frontend/src/lib/api-types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1697,11 +1697,17 @@ export interface SyncDetect {
16971697
device_id: string | null;
16981698
}
16991699

1700+
export interface SyncStatusTeamEntry {
1701+
backend: 'syncthing' | 'ipfs';
1702+
member_count: number;
1703+
project_count: number;
1704+
}
1705+
17001706
export interface SyncStatusResponse {
17011707
configured: boolean;
17021708
user_id?: string;
17031709
machine_id?: string;
1704-
teams?: Record<string, unknown>;
1710+
teams?: Record<string, SyncStatusTeamEntry>;
17051711
}
17061712

17071713
export interface SyncDevice {
@@ -1734,6 +1740,8 @@ export interface SyncTeam {
17341740
backend: 'syncthing' | 'ipfs';
17351741
projects: SyncTeamProject[];
17361742
members: SyncTeamMember[];
1743+
member_count?: number;
1744+
project_count?: number;
17371745
}
17381746

17391747
export interface SyncTeamProject {

frontend/src/lib/components/sync/OverviewTab.svelte

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,9 @@
4141
if (!teamName) return;
4242
watchActing = true;
4343
try {
44-
const res = await fetch(`${API_BASE}/sync/watch/start`, {
45-
method: 'POST',
46-
headers: { 'Content-Type': 'application/json' },
47-
body: JSON.stringify({ team: teamName })
48-
}).catch(() => null);
44+
const url = new URL(`${API_BASE}/sync/watch/start`, window.location.origin);
45+
if (teamName) url.searchParams.set('team_name', teamName);
46+
const res = await fetch(url.toString(), { method: 'POST' }).catch(() => null);
4947
if (res?.ok) {
5048
watchStatus = await res.json();
5149
}
@@ -108,7 +106,7 @@
108106
109107
type TeamEntry = { member_count?: number; project_count?: number; members?: unknown[] };
110108
111-
let derivedMemberCount = $derived(() => {
109+
let derivedMemberCount = $derived.by(() => {
112110
if (!status?.teams) return 0;
113111
let count = 0;
114112
for (const team of Object.values(status.teams) as TeamEntry[]) {
@@ -117,7 +115,7 @@
117115
return count;
118116
});
119117
120-
let derivedProjectCount = $derived(() => {
118+
let derivedProjectCount = $derived.by(() => {
121119
if (!status?.teams) return 0;
122120
let count = 0;
123121
for (const team of Object.values(status.teams) as TeamEntry[]) {
@@ -128,8 +126,8 @@
128126
129127
async function loadStats() {
130128
statsLoading = true;
131-
memberCount = derivedMemberCount();
132-
projectCount = derivedProjectCount();
129+
memberCount = derivedMemberCount;
130+
projectCount = derivedProjectCount;
133131
try {
134132
const res = await fetch(`${API_BASE}/sync/projects`).catch(() => null);
135133
if (res?.ok) {

frontend/src/lib/components/sync/SetupWizard.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@
218218
fetch(`${API_BASE}/sync/teams/${encodeURIComponent(name)}/projects`, {
219219
method: 'POST',
220220
headers: { 'Content-Type': 'application/json' },
221-
body: JSON.stringify({ encoded_name: encodedName })
221+
body: JSON.stringify({ name: encodedName, path: '' })
222222
})
223223
)
224224
);

frontend/src/lib/components/sync/TeamSelector.svelte

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,12 @@
1818
1919
let memberCount = $derived(
2020
activeTeamObj
21-
? ((activeTeamObj as unknown as Record<string, unknown>).member_count as number) ??
22-
(Array.isArray(activeTeamObj.members) ? activeTeamObj.members.length : 0)
21+
? activeTeamObj.member_count ?? activeTeamObj.members.length
2322
: 0
2423
);
2524
let projectCount = $derived(
2625
activeTeamObj
27-
? ((activeTeamObj as unknown as Record<string, unknown>).project_count as number) ??
28-
(Array.isArray(activeTeamObj.projects) ? activeTeamObj.projects.length : 0)
26+
? activeTeamObj.project_count ?? activeTeamObj.projects.length
2927
: 0
3028
);
3129
@@ -123,8 +121,8 @@
123121
aria-label="Select team"
124122
>
125123
{#each teams as team (team.name)}
126-
{@const tMembers = (team as unknown as Record<string, unknown>).member_count as number ?? (Array.isArray(team.members) ? team.members.length : 0)}
127-
{@const tProjects = (team as unknown as Record<string, unknown>).project_count as number ?? (Array.isArray(team.projects) ? team.projects.length : 0)}
124+
{@const tMembers = team.member_count ?? team.members.length}
125+
{@const tProjects = team.project_count ?? team.projects.length}
128126
<button
129127
onclick={() => selectTeam(team.name)}
130128
role="option"

frontend/src/routes/sync/+page.svelte

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,21 @@
2020
let syncDetect = $state<SyncDetect | null>(data.detect ?? null);
2121
let syncStatus = $state<SyncStatusResponse | null>(data.status ?? null);
2222
23-
let activeTab = $state(
24-
data.activeTab ?? (data.status?.configured ? 'overview' : 'overview')
25-
);
23+
let activeTab = $state(data.activeTab ?? 'overview');
2624
2725
// ── Team selection ───────────────────────────────────────────────────────
2826
let activeTeamName = $state('');
2927
3028
// Derive teams array from syncStatus.teams Record
3129
let teamsList = $derived.by<SyncTeam[]>(() => {
3230
if (!syncStatus?.teams) return [];
33-
return Object.entries(syncStatus.teams).map(([name, value]) => ({
31+
return Object.entries(syncStatus.teams).map(([name, entry]) => ({
3432
name,
35-
backend: ((value as Record<string, unknown>).backend as 'syncthing' | 'ipfs') ?? 'syncthing',
36-
projects: ((value as Record<string, unknown>).projects as SyncTeam['projects']) ?? [],
37-
members: ((value as Record<string, unknown>).members as SyncTeam['members']) ?? []
33+
backend: entry.backend ?? 'syncthing',
34+
projects: [],
35+
members: [],
36+
member_count: entry.member_count ?? 0,
37+
project_count: entry.project_count ?? 0
3838
}));
3939
});
4040
@@ -75,13 +75,6 @@
7575
history.replaceState(null, '', url.toString());
7676
});
7777
78-
// Update seconds since last update
79-
$effect(() => {
80-
if (lastUpdated) {
81-
secondsSinceUpdate = Math.floor((Date.now() - lastUpdated.getTime()) / 1000);
82-
}
83-
});
84-
8578
async function refreshData() {
8679
if (isFetching) return;
8780
isFetching = true;

0 commit comments

Comments
 (0)