Highly ambitious ATProtocol AppView service and sdks

add network.slices.slice.getSyncSummary xrpc and corresponding ui, shows a summary of the sync before syncing, limits to 5000 repos but can change via env var

Changed files
+610 -15
api
src
xrpc
network
frontend
src
features
slices
lexicon
sync
lexicons
network
slices
+3
api/.env.example
··· 19 # System slice URI 20 SYSTEM_SLICE_URI=at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z 21 22 # Logging level 23 RUST_LOG=debug 24
··· 19 # System slice URI 20 SYSTEM_SLICE_URI=at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z 21 22 + # Default maximum repositories per sync operation 23 + DEFAULT_MAX_SYNC_REPOS=5000 24 + 25 # Logging level 26 RUST_LOG=debug 27
+11
api/src/main.rs
··· 36 pub auth_base_url: String, 37 pub relay_endpoint: String, 38 pub system_slice_uri: String, 39 } 40 41 #[derive(Clone)] ··· 86 "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z".to_string() 87 }); 88 89 let config = Config { 90 auth_base_url, 91 relay_endpoint, 92 system_slice_uri, 93 }; 94 95 // Initialize global logger ··· 377 .route( 378 "/xrpc/network.slices.slice.deleteOAuthClient", 379 post(xrpc::network::slices::slice::delete_oauth_client::handler), 380 ) 381 // Dynamic collection-specific XRPC endpoints (wildcard routes must come last) 382 .route(
··· 36 pub auth_base_url: String, 37 pub relay_endpoint: String, 38 pub system_slice_uri: String, 39 + pub default_max_sync_repos: i32, 40 } 41 42 #[derive(Clone)] ··· 87 "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z".to_string() 88 }); 89 90 + let default_max_sync_repos = env::var("DEFAULT_MAX_SYNC_REPOS") 91 + .unwrap_or_else(|_| "5000".to_string()) 92 + .parse::<i32>() 93 + .unwrap_or(5000); 94 + 95 let config = Config { 96 auth_base_url, 97 relay_endpoint, 98 system_slice_uri, 99 + default_max_sync_repos, 100 }; 101 102 // Initialize global logger ··· 384 .route( 385 "/xrpc/network.slices.slice.deleteOAuthClient", 386 post(xrpc::network::slices::slice::delete_oauth_client::handler), 387 + ) 388 + .route( 389 + "/xrpc/network.slices.slice.getSyncSummary", 390 + get(xrpc::network::slices::slice::get_sync_summary::handler), 391 ) 392 // Dynamic collection-specific XRPC endpoints (wildcard routes must come last) 393 .route(
+1 -1
api/src/sync.rs
··· 667 /// Fetch all repositories that have records in a given collection. 668 /// 669 /// Uses cursor-based pagination to fetch all repos from the relay. 670 - async fn get_repos_for_collection( 671 &self, 672 collection: &str, 673 slice_uri: &str,
··· 667 /// Fetch all repositories that have records in a given collection. 668 /// 669 /// Uses cursor-based pagination to fetch all repos from the relay. 670 + pub async fn get_repos_for_collection( 671 &self, 672 collection: &str, 673 slice_uri: &str,
+208
api/src/xrpc/network/slices/slice/get_sync_summary.rs
···
··· 1 + use crate::{AppState, auth, errors::AppError, sync::SyncService}; 2 + use axum::{extract::{Query, State}, http::HeaderMap, response::Json}; 3 + use serde::{Deserialize, Serialize}; 4 + use std::collections::HashMap; 5 + 6 + #[derive(Debug, Deserialize)] 7 + #[serde(rename_all = "camelCase")] 8 + pub struct Params { 9 + pub slice: String, 10 + #[serde(default, deserialize_with = "deserialize_string_or_vec")] 11 + pub collections: Option<Vec<String>>, 12 + #[serde(default, deserialize_with = "deserialize_string_or_vec")] 13 + pub external_collections: Option<Vec<String>>, 14 + #[serde(default, deserialize_with = "deserialize_string_or_vec")] 15 + pub repos: Option<Vec<String>>, 16 + } 17 + 18 + fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error> 19 + where 20 + D: serde::Deserializer<'de>, 21 + { 22 + use serde::de::{self, Visitor}; 23 + use std::fmt; 24 + 25 + struct StringOrVec; 26 + 27 + impl<'de> Visitor<'de> for StringOrVec { 28 + type Value = Option<Vec<String>>; 29 + 30 + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 31 + formatter.write_str("string or list of strings") 32 + } 33 + 34 + fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> 35 + where 36 + E: de::Error, 37 + { 38 + Ok(Some(vec![value.to_string()])) 39 + } 40 + 41 + fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> 42 + where 43 + A: de::SeqAccess<'de>, 44 + { 45 + let mut vec = Vec::new(); 46 + while let Some(item) = seq.next_element::<String>()? { 47 + vec.push(item); 48 + } 49 + Ok(if vec.is_empty() { None } else { Some(vec) }) 50 + } 51 + 52 + fn visit_none<E>(self) -> Result<Self::Value, E> 53 + where 54 + E: de::Error, 55 + { 56 + Ok(None) 57 + } 58 + 59 + fn visit_unit<E>(self) -> Result<Self::Value, E> 60 + where 61 + E: de::Error, 62 + { 63 + Ok(None) 64 + } 65 + } 66 + 67 + deserializer.deserialize_any(StringOrVec) 68 + } 69 + 70 + #[derive(Debug, Serialize)] 71 + #[serde(rename_all = "camelCase")] 72 + pub struct Output { 73 + pub total_repos: i64, 74 + pub capped_repos: i64, 75 + pub collections_summary: Vec<CollectionSummary>, 76 + pub would_be_capped: bool, 77 + pub applied_limit: i32, 78 + } 79 + 80 + #[derive(Debug, Serialize)] 81 + #[serde(rename_all = "camelCase")] 82 + pub struct CollectionSummary { 83 + pub collection: String, 84 + pub estimated_repos: i64, 85 + pub is_external: bool, 86 + } 87 + 88 + pub async fn handler( 89 + State(state): State<AppState>, 90 + headers: HeaderMap, 91 + Query(params): Query<Params>, 92 + ) -> Result<Json<Output>, AppError> { 93 + tracing::info!("getSyncSummary called with params: {:?}", params); 94 + 95 + let token = auth::extract_bearer_token(&headers)?; 96 + let _user_info = auth::verify_oauth_token_cached( 97 + &token, 98 + &state.config.auth_base_url, 99 + Some(state.auth_cache.clone()), 100 + ) 101 + .await?; 102 + 103 + let slice_uri = &params.slice; 104 + let primary_collections = params.collections.unwrap_or_default(); 105 + let external_collections = params.external_collections.unwrap_or_default(); 106 + let user_provided_repos = params.repos; 107 + 108 + // Use the system default limit 109 + let applied_limit = state.config.default_max_sync_repos; 110 + 111 + // Get slice domain for categorizing collections 112 + let slice_domain = state 113 + .database 114 + .get_slice_domain(slice_uri) 115 + .await 116 + .map_err(|e| AppError::Internal(format!("Failed to get slice domain: {}", e)))? 117 + .ok_or_else(|| AppError::NotFound(format!("Slice not found: {}", slice_uri)))?; 118 + 119 + // Create sync service for repo discovery 120 + let sync_service = SyncService::with_cache( 121 + state.database.clone(), 122 + state.config.relay_endpoint.clone(), 123 + state.auth_cache.clone(), 124 + ); 125 + 126 + // Discover repos if not provided 127 + let all_repos = if let Some(provided_repos) = user_provided_repos { 128 + provided_repos 129 + } else { 130 + // Discover repos from collections 131 + let mut discovered_repos = std::collections::HashSet::new(); 132 + 133 + // Get repos from primary collections 134 + for collection in &primary_collections { 135 + match sync_service.get_repos_for_collection(collection, slice_uri).await { 136 + Ok(repos) => { 137 + discovered_repos.extend(repos); 138 + } 139 + Err(e) => { 140 + tracing::warn!("Failed to get repos for collection {}: {}", collection, e); 141 + } 142 + } 143 + } 144 + 145 + // Get repos from external collections 146 + for collection in &external_collections { 147 + match sync_service.get_repos_for_collection(collection, slice_uri).await { 148 + Ok(repos) => { 149 + discovered_repos.extend(repos); 150 + } 151 + Err(e) => { 152 + tracing::warn!("Failed to get repos for collection {}: {}", collection, e); 153 + } 154 + } 155 + } 156 + 157 + discovered_repos.into_iter().collect() 158 + }; 159 + 160 + let total_repos = all_repos.len() as i64; 161 + let capped_repos = std::cmp::min(total_repos, applied_limit as i64); 162 + let would_be_capped = total_repos > applied_limit as i64; 163 + 164 + // Build collections summary 165 + let mut collections_summary = Vec::new(); 166 + let mut collection_repo_counts: HashMap<String, i64> = HashMap::new(); 167 + 168 + // Count repos per collection (this is an approximation) 169 + for collection in &primary_collections { 170 + let is_external = !collection.starts_with(&slice_domain); 171 + let estimated_repos = if let Ok(repos) = sync_service.get_repos_for_collection(collection, slice_uri).await { 172 + repos.len() as i64 173 + } else { 174 + 0 175 + }; 176 + 177 + collection_repo_counts.insert(collection.clone(), estimated_repos); 178 + collections_summary.push(CollectionSummary { 179 + collection: collection.clone(), 180 + estimated_repos, 181 + is_external, 182 + }); 183 + } 184 + 185 + for collection in &external_collections { 186 + let is_external = !collection.starts_with(&slice_domain); 187 + let estimated_repos = if let Ok(repos) = sync_service.get_repos_for_collection(collection, slice_uri).await { 188 + repos.len() as i64 189 + } else { 190 + 0 191 + }; 192 + 193 + collection_repo_counts.insert(collection.clone(), estimated_repos); 194 + collections_summary.push(CollectionSummary { 195 + collection: collection.clone(), 196 + estimated_repos, 197 + is_external, 198 + }); 199 + } 200 + 201 + Ok(Json(Output { 202 + total_repos, 203 + capped_repos, 204 + collections_summary, 205 + would_be_capped, 206 + applied_limit, 207 + })) 208 + }
+1
api/src/xrpc/network/slices/slice/mod.rs
··· 10 pub mod get_oauth_clients; 11 pub mod get_slice_records; 12 pub mod get_sparklines; 13 pub mod openapi; 14 pub mod start_sync; 15 pub mod stats;
··· 10 pub mod get_oauth_clients; 11 pub mod get_slice_records; 12 pub mod get_sparklines; 13 + pub mod get_sync_summary; 14 pub mod openapi; 15 pub mod start_sync; 16 pub mod stats;
+36 -2
frontend/src/client.ts
··· 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-09-28 21:47:20 UTC 3 - // Lexicons: 41 4 5 /** 6 * @example Usage ··· 1046 connected: boolean; 1047 } 1048 1049 export interface NetworkSlicesSlice { 1050 /** Name of the slice */ 1051 name: string; ··· 1552 1553 export interface NetworkSlicesSliceGetJobLogs { 1554 readonly LogEntry: NetworkSlicesSliceGetJobLogsLogEntry; 1555 } 1556 1557 export interface NetworkSlicesSliceGetJobStatus { ··· 2243 return await this.client.makeRequest< 2244 NetworkSlicesSliceGetJetstreamStatusOutput 2245 >("network.slices.slice.getJetstreamStatus", "GET", {}); 2246 } 2247 2248 async getJobStatus(
··· 1 // Generated TypeScript client for AT Protocol records 2 + // Generated at: 2025-09-29 01:19:06 UTC 3 + // Lexicons: 42 4 5 /** 6 * @example Usage ··· 1046 connected: boolean; 1047 } 1048 1049 + export interface NetworkSlicesSliceGetSyncSummaryParams { 1050 + slice: string; 1051 + collections?: string[]; 1052 + externalCollections?: string[]; 1053 + repos?: string[]; 1054 + maxRepos?: number; 1055 + } 1056 + 1057 + export interface NetworkSlicesSliceGetSyncSummaryOutput { 1058 + totalRepos: number; 1059 + cappedRepos: number; 1060 + collectionsSummary: NetworkSlicesSliceGetSyncSummary["CollectionSummary"][]; 1061 + wouldBeCapped: boolean; 1062 + appliedLimit: number; 1063 + } 1064 + 1065 + export interface NetworkSlicesSliceGetSyncSummaryCollectionSummary { 1066 + collection: string; 1067 + estimatedRepos: number; 1068 + isExternal: boolean; 1069 + } 1070 + 1071 export interface NetworkSlicesSlice { 1072 /** Name of the slice */ 1073 name: string; ··· 1574 1575 export interface NetworkSlicesSliceGetJobLogs { 1576 readonly LogEntry: NetworkSlicesSliceGetJobLogsLogEntry; 1577 + } 1578 + 1579 + export interface NetworkSlicesSliceGetSyncSummary { 1580 + readonly CollectionSummary: NetworkSlicesSliceGetSyncSummaryCollectionSummary; 1581 } 1582 1583 export interface NetworkSlicesSliceGetJobStatus { ··· 2269 return await this.client.makeRequest< 2270 NetworkSlicesSliceGetJetstreamStatusOutput 2271 >("network.slices.slice.getJetstreamStatus", "GET", {}); 2272 + } 2273 + 2274 + async getSyncSummary( 2275 + params?: NetworkSlicesSliceGetSyncSummaryParams, 2276 + ): Promise<NetworkSlicesSliceGetSyncSummaryOutput> { 2277 + return await this.client.makeRequest< 2278 + NetworkSlicesSliceGetSyncSummaryOutput 2279 + >("network.slices.slice.getSyncSummary", "GET", params); 2280 } 2281 2282 async getJobStatus(
+1 -1
frontend/src/features/slices/lexicon/templates/LexiconDetailPage.tsx
··· 118 className="leading-relaxed" 119 > 120 When enabled, records for this lexicon will not be 121 - synced from the AT Protocol firehose or during bulk sync 122 operations. 123 </Text> 124 </div>
··· 118 className="leading-relaxed" 119 > 120 When enabled, records for this lexicon will not be 121 + synced from the AT Protocol firehose or during sync 122 operations. 123 </Text> 124 </div>
+75 -1
frontend/src/features/slices/sync/handlers.tsx
··· 15 import { SliceSyncPage } from "./templates/SliceSyncPage.tsx"; 16 import { hxRedirect } from "../../../utils/htmx.ts"; 17 import { SyncFormModal } from "./templates/fragments/SyncFormModal.tsx"; 18 import { SyncResult } from "./templates/fragments/SyncResult.tsx"; 19 import { JobHistory } from "./templates/fragments/JobHistory.tsx"; 20 ··· 35 try { 36 const formData = await req.formData(); 37 const collections = formData.getAll("collections") as string[]; 38 - const externalCollections = formData.getAll("external_collections") as string[]; 39 const reposText = (formData.get("repos") as string) || ""; 40 41 const repos = reposText ··· 249 } 250 } 251 252 export const syncRoutes: Route[] = [ 253 { 254 method: "GET", ··· 264 method: "POST", 265 pattern: new URLPattern({ pathname: "/api/slices/:id/sync" }), 266 handler: handleSliceSync, 267 }, 268 { 269 method: "GET",
··· 15 import { SliceSyncPage } from "./templates/SliceSyncPage.tsx"; 16 import { hxRedirect } from "../../../utils/htmx.ts"; 17 import { SyncFormModal } from "./templates/fragments/SyncFormModal.tsx"; 18 + import { SyncSummaryModal } from "./templates/fragments/SyncSummaryModal.tsx"; 19 import { SyncResult } from "./templates/fragments/SyncResult.tsx"; 20 import { JobHistory } from "./templates/fragments/JobHistory.tsx"; 21 ··· 36 try { 37 const formData = await req.formData(); 38 const collections = formData.getAll("collections") as string[]; 39 + const externalCollections = formData.getAll( 40 + "external_collections" 41 + ) as string[]; 42 const reposText = (formData.get("repos") as string) || ""; 43 44 const repos = reposText ··· 252 } 253 } 254 255 + async function handleSyncSummary( 256 + req: Request, 257 + params?: URLPatternResult 258 + ): Promise<Response> { 259 + const context = await withAuth(req); 260 + const authResponse = requireAuth(context); 261 + if (authResponse) return authResponse; 262 + 263 + const sliceId = params?.pathname.groups.id; 264 + if (!sliceId) { 265 + return new Response("Invalid slice ID", { status: 400 }); 266 + } 267 + 268 + try { 269 + const formData = await req.formData(); 270 + const collections = formData.getAll("collections") as string[]; 271 + const externalCollections = formData.getAll( 272 + "external_collections" 273 + ) as string[]; 274 + const reposText = (formData.get("repos") as string) || ""; 275 + 276 + const repos = reposText 277 + .split("\n") 278 + .map((line) => line.trim()) 279 + .filter((line) => line.length > 0); 280 + 281 + if (collections.length === 0 && externalCollections.length === 0) { 282 + return renderHTML( 283 + <SyncResult 284 + success={false} 285 + error="Please specify at least one collection (primary or external) to sync" 286 + /> 287 + ); 288 + } 289 + 290 + const sliceClient = getSliceClient(context, sliceId); 291 + const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 292 + 293 + // Call the getSyncSummary endpoint 294 + const requestParams = { 295 + slice: sliceUri, 296 + collections: collections.length > 0 ? collections : undefined, 297 + externalCollections: 298 + externalCollections.length > 0 ? externalCollections : undefined, 299 + repos: repos.length > 0 ? repos : undefined, 300 + }; 301 + 302 + const summaryResponse = 303 + await sliceClient.network.slices.slice.getSyncSummary(requestParams); 304 + 305 + return renderHTML( 306 + <SyncSummaryModal 307 + sliceId={sliceId} 308 + summary={summaryResponse} 309 + collections={collections} 310 + externalCollections={externalCollections} 311 + repos={reposText} 312 + /> 313 + ); 314 + } catch (error) { 315 + console.error("Failed to get sync summary:", error); 316 + const errorMessage = error instanceof Error ? error.message : String(error); 317 + return renderHTML(<SyncResult success={false} error={errorMessage} />); 318 + } 319 + } 320 + 321 export const syncRoutes: Route[] = [ 322 { 323 method: "GET", ··· 333 method: "POST", 334 pattern: new URLPattern({ pathname: "/api/slices/:id/sync" }), 335 handler: handleSliceSync, 336 + }, 337 + { 338 + method: "POST", 339 + pattern: new URLPattern({ pathname: "/api/slices/:id/sync/summary" }), 340 + handler: handleSyncSummary, 341 }, 342 { 343 method: "GET",
+7 -9
frontend/src/features/slices/sync/templates/fragments/SyncFormModal.tsx
··· 19 title="Sync Collections" 20 description="Sync entire collections from AT Protocol network to this slice." 21 > 22 - <form 23 - hx-post={`/api/slices/${sliceId}/sync`} 24 - hx-target="#sync-result" 25 - hx-swap="innerHTML" 26 - className="space-y-4" 27 - > 28 <div className="space-y-2"> 29 <label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"> 30 Primary Collections ··· 97 </Button> 98 <Button 99 type="submit" 100 - variant="success" 101 className="flex items-center justify-center" 102 > 103 <i 104 data-lucide="loader-2" ··· 107 _="on load js lucide.createIcons() end" 108 > 109 </i> 110 - <span className="htmx-indicator">Syncing...</span> 111 - <span className="default-text">Start Sync</span> 112 </Button> 113 </div> 114 </form>
··· 19 title="Sync Collections" 20 description="Sync entire collections from AT Protocol network to this slice." 21 > 22 + <form className="space-y-4"> 23 <div className="space-y-2"> 24 <label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"> 25 Primary Collections ··· 92 </Button> 93 <Button 94 type="submit" 95 + variant="primary" 96 className="flex items-center justify-center" 97 + hx-post={`/api/slices/${sliceId}/sync/summary`} 98 + hx-target="#modal-container" 99 + hx-swap="innerHTML" 100 > 101 <i 102 data-lucide="loader-2" ··· 105 _="on load js lucide.createIcons() end" 106 > 107 </i> 108 + <span className="htmx-indicator">Loading...</span> 109 + <span className="default-text">Preview Sync</span> 110 </Button> 111 </div> 112 </form>
+172
frontend/src/features/slices/sync/templates/fragments/SyncSummaryModal.tsx
···
··· 1 + import { Button } from "../../../../../shared/fragments/Button.tsx"; 2 + import { Modal } from "../../../../../shared/fragments/Modal.tsx"; 3 + import type { NetworkSlicesSliceGetSyncSummaryOutput } from "../../../../../client.ts"; 4 + 5 + interface SyncSummaryModalProps { 6 + sliceId: string; 7 + summary: NetworkSlicesSliceGetSyncSummaryOutput; 8 + collections: string[]; 9 + externalCollections: string[]; 10 + repos?: string; 11 + } 12 + 13 + export function SyncSummaryModal({ 14 + sliceId, 15 + summary, 16 + collections, 17 + externalCollections, 18 + repos, 19 + }: SyncSummaryModalProps) { 20 + return ( 21 + <Modal 22 + title="Sync Summary" 23 + description="Review what will be synced before starting the operation." 24 + > 25 + <div className="space-y-6"> 26 + {/* Summary Stats */} 27 + <div className="bg-zinc-50 dark:bg-zinc-800 rounded-lg p-4"> 28 + {summary.wouldBeCapped ? ( 29 + <div className="grid grid-cols-2 gap-4"> 30 + <div> 31 + <p className="text-sm font-medium text-zinc-700 dark:text-zinc-300"> 32 + Total Repositories 33 + </p> 34 + <p className="text-2xl font-bold text-zinc-900 dark:text-zinc-100"> 35 + {summary.totalRepos.toLocaleString()} 36 + </p> 37 + </div> 38 + <div> 39 + <p className="text-sm font-medium text-zinc-700 dark:text-zinc-300"> 40 + Will Sync 41 + </p> 42 + <p className="text-2xl font-bold text-zinc-900 dark:text-zinc-100"> 43 + {summary.cappedRepos.toLocaleString()} 44 + </p> 45 + </div> 46 + </div> 47 + ) : ( 48 + <div className="text-center"> 49 + <p className="text-sm font-medium text-zinc-700 dark:text-zinc-300"> 50 + Repositories to Sync 51 + </p> 52 + <p className="text-2xl font-bold text-zinc-900 dark:text-zinc-100"> 53 + {summary.totalRepos.toLocaleString()} 54 + </p> 55 + </div> 56 + )} 57 + 58 + {summary.wouldBeCapped && ( 59 + <div className="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md"> 60 + <div className="flex"> 61 + <i 62 + data-lucide="alert-triangle" 63 + className="h-5 w-5 text-yellow-400 mr-2 flex-shrink-0" 64 + /* @ts-ignore - Hyperscript attribute */ 65 + _="on load js lucide.createIcons() end" 66 + ></i> 67 + <div> 68 + <p className="text-sm font-medium text-yellow-800 dark:text-yellow-200"> 69 + Sync Limited 70 + </p> 71 + <p className="text-sm text-yellow-700 dark:text-yellow-300"> 72 + This sync has been limited to{" "} 73 + {summary.appliedLimit.toLocaleString()} repositories. 74 + </p> 75 + </div> 76 + </div> 77 + </div> 78 + )} 79 + </div> 80 + 81 + {/* Collections Breakdown */} 82 + <div> 83 + <h3 className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3"> 84 + Collections Breakdown 85 + </h3> 86 + <div className="space-y-2"> 87 + {summary.collectionsSummary.map((collection) => ( 88 + <div 89 + key={collection.collection} 90 + className="flex items-center justify-between p-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-md" 91 + > 92 + <div className="flex items-center space-x-3"> 93 + <div 94 + className={`w-2 h-2 rounded-full ${ 95 + collection.isExternal ? "bg-blue-500" : "bg-green-500" 96 + }`} 97 + ></div> 98 + <div> 99 + <p className="text-sm font-medium text-zinc-900 dark:text-zinc-100"> 100 + {collection.collection} 101 + </p> 102 + <p className="text-xs text-zinc-500"> 103 + {collection.isExternal ? "External" : "Primary"}{" "} 104 + Collection 105 + </p> 106 + </div> 107 + </div> 108 + <span className="text-sm font-medium text-zinc-600 dark:text-zinc-400"> 109 + {collection.estimatedRepos.toLocaleString()} repos 110 + </span> 111 + </div> 112 + ))} 113 + </div> 114 + </div> 115 + 116 + {/* Actions */} 117 + <form 118 + hx-post={`/api/slices/${sliceId}/sync`} 119 + hx-target="#sync-result" 120 + hx-swap="innerHTML" 121 + className="space-y-4" 122 + > 123 + {/* Hidden inputs to pass the sync parameters */} 124 + {collections.map((collection) => ( 125 + <input 126 + key={collection} 127 + type="hidden" 128 + name="collections" 129 + value={collection} 130 + /> 131 + ))} 132 + {externalCollections.map((collection) => ( 133 + <input 134 + key={collection} 135 + type="hidden" 136 + name="external_collections" 137 + value={collection} 138 + /> 139 + ))} 140 + {repos && <input type="hidden" name="repos" value={repos} />} 141 + 142 + <div id="sync-result" className="mt-4"></div> 143 + 144 + <div className="flex justify-end gap-3"> 145 + <Button 146 + type="button" 147 + variant="secondary" 148 + /* @ts-ignore - Hyperscript attribute */ 149 + _="on click set #modal-container's innerHTML to ''" 150 + > 151 + Cancel 152 + </Button> 153 + <Button 154 + type="submit" 155 + variant="success" 156 + className="flex items-center justify-center" 157 + > 158 + <i 159 + data-lucide="loader-2" 160 + className="htmx-indicator animate-spin mr-2 h-4 w-4" 161 + /* @ts-ignore - Hyperscript attribute */ 162 + _="on load js lucide.createIcons() end" 163 + ></i> 164 + <span className="htmx-indicator">Starting...</span> 165 + <span className="default-text">Start Sync</span> 166 + </Button> 167 + </div> 168 + </form> 169 + </div> 170 + </Modal> 171 + ); 172 + }
+94
lexicons/network/slices/slice/getSyncSummary.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "network.slices.slice.getSyncSummary", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get a summary of what would be synced before running a sync operation", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["slice"], 11 + "properties": { 12 + "slice": { 13 + "type": "string", 14 + "description": "URI of the slice to sync" 15 + }, 16 + "collections": { 17 + "type": "array", 18 + "items": { 19 + "type": "string" 20 + }, 21 + "description": "Primary collections to sync" 22 + }, 23 + "externalCollections": { 24 + "type": "array", 25 + "items": { 26 + "type": "string" 27 + }, 28 + "description": "External collections to sync" 29 + }, 30 + "repos": { 31 + "type": "array", 32 + "items": { 33 + "type": "string" 34 + }, 35 + "description": "Specific repositories to sync (DIDs)" 36 + } 37 + } 38 + }, 39 + "output": { 40 + "encoding": "application/json", 41 + "schema": { 42 + "type": "object", 43 + "required": [ 44 + "totalRepos", 45 + "cappedRepos", 46 + "collectionsSummary", 47 + "wouldBeCapped", 48 + "appliedLimit" 49 + ], 50 + "properties": { 51 + "totalRepos": { 52 + "type": "integer", 53 + "description": "Total number of repositories that would be synced" 54 + }, 55 + "cappedRepos": { 56 + "type": "integer", 57 + "description": "Number of repositories after applying limit" 58 + }, 59 + "collectionsSummary": { 60 + "type": "array", 61 + "items": { 62 + "type": "ref", 63 + "ref": "#collectionSummary" 64 + } 65 + }, 66 + "wouldBeCapped": { 67 + "type": "boolean", 68 + "description": "Whether the sync would be limited by maxRepos" 69 + }, 70 + "appliedLimit": { 71 + "type": "integer", 72 + "description": "The actual limit applied (user-specified or default)" 73 + } 74 + } 75 + } 76 + } 77 + }, 78 + "collectionSummary": { 79 + "type": "object", 80 + "required": ["collection", "estimatedRepos", "isExternal"], 81 + "properties": { 82 + "collection": { 83 + "type": "string" 84 + }, 85 + "estimatedRepos": { 86 + "type": "integer" 87 + }, 88 + "isExternal": { 89 + "type": "boolean" 90 + } 91 + } 92 + } 93 + } 94 + }
+1 -1
slices.json
··· 1 { 2 "slice": "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z", 3 "lexiconPath": "./lexicons", 4 - "clientOutputPath": "./frontend/src/generated_client.ts" 5 }
··· 1 { 2 "slice": "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z", 3 "lexiconPath": "./lexicons", 4 + "clientOutputPath": "./frontend/src/client.ts" 5 }