Highly ambitious ATProtocol AppView service and sdks

add sparklines to slice cards

+517 -286
-90
api/.sqlx/query-644745eb5e81daafd5f77110aea2c812d0d4cbd60d69f98d29c90af8ddb350f4.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT \n job_id, user_did, slice_uri, status, success, total_records,\n collections_synced, repos_processed, message, error_message,\n created_at, completed_at\n FROM job_results \n WHERE user_did = $1 AND slice_uri = $2\n ORDER BY created_at DESC\n LIMIT $3\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "job_id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "user_did", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "slice_uri", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "status", 24 - "type_info": "Text" 25 - }, 26 - { 27 - "ordinal": 4, 28 - "name": "success", 29 - "type_info": "Bool" 30 - }, 31 - { 32 - "ordinal": 5, 33 - "name": "total_records", 34 - "type_info": "Int8" 35 - }, 36 - { 37 - "ordinal": 6, 38 - "name": "collections_synced", 39 - "type_info": "Jsonb" 40 - }, 41 - { 42 - "ordinal": 7, 43 - "name": "repos_processed", 44 - "type_info": "Int8" 45 - }, 46 - { 47 - "ordinal": 8, 48 - "name": "message", 49 - "type_info": "Text" 50 - }, 51 - { 52 - "ordinal": 9, 53 - "name": "error_message", 54 - "type_info": "Text" 55 - }, 56 - { 57 - "ordinal": 10, 58 - "name": "created_at", 59 - "type_info": "Timestamptz" 60 - }, 61 - { 62 - "ordinal": 11, 63 - "name": "completed_at", 64 - "type_info": "Timestamptz" 65 - } 66 - ], 67 - "parameters": { 68 - "Left": [ 69 - "Text", 70 - "Text", 71 - "Int8" 72 - ] 73 - }, 74 - "nullable": [ 75 - false, 76 - false, 77 - false, 78 - false, 79 - false, 80 - false, 81 - false, 82 - false, 83 - false, 84 - true, 85 - false, 86 - false 87 - ] 88 - }, 89 - "hash": "644745eb5e81daafd5f77110aea2c812d0d4cbd60d69f98d29c90af8ddb350f4" 90 - }
+96
api/.sqlx/query-fed4c0570726ac2aef29d401e041c27fcdded2fc5a98438dd396b0c9d9b7f2fd.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n -- Completed jobs from job_results\n SELECT\n job_id, user_did, slice_uri, status, success, total_records,\n collections_synced, repos_processed, message, error_message,\n created_at, completed_at,\n 'completed' as job_type\n FROM job_results\n WHERE user_did = $1 AND slice_uri = $2\n\n UNION ALL\n\n -- Pending jobs from message queue\n SELECT\n (p.payload_json->>'job_id')::uuid as job_id,\n p.payload_json->>'user_did' as user_did,\n p.payload_json->>'slice_uri' as slice_uri,\n 'running' as status,\n NULL::boolean as success,\n NULL::bigint as total_records,\n '[]'::jsonb as collections_synced,\n NULL::bigint as repos_processed,\n 'Job in progress...' as message,\n NULL::text as error_message,\n m.created_at,\n NULL::timestamptz as completed_at,\n 'pending' as job_type\n FROM mq_msgs m\n JOIN mq_payloads p ON m.id = p.id\n WHERE m.channel_name = 'sync_queue'\n AND m.id != '00000000-0000-0000-0000-000000000000'\n AND p.payload_json->>'user_did' = $1\n AND p.payload_json->>'slice_uri' = $2\n AND NOT EXISTS (\n SELECT 1 FROM job_results jr\n WHERE jr.job_id = (p.payload_json->>'job_id')::uuid\n )\n\n ORDER BY created_at DESC\n LIMIT $3\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "job_id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "user_did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "slice_uri", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "status", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "success", 29 + "type_info": "Bool" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "total_records", 34 + "type_info": "Int8" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "collections_synced", 39 + "type_info": "Jsonb" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "repos_processed", 44 + "type_info": "Int8" 45 + }, 46 + { 47 + "ordinal": 8, 48 + "name": "message", 49 + "type_info": "Text" 50 + }, 51 + { 52 + "ordinal": 9, 53 + "name": "error_message", 54 + "type_info": "Text" 55 + }, 56 + { 57 + "ordinal": 10, 58 + "name": "created_at", 59 + "type_info": "Timestamptz" 60 + }, 61 + { 62 + "ordinal": 11, 63 + "name": "completed_at", 64 + "type_info": "Timestamptz" 65 + }, 66 + { 67 + "ordinal": 12, 68 + "name": "job_type", 69 + "type_info": "Text" 70 + } 71 + ], 72 + "parameters": { 73 + "Left": [ 74 + "Text", 75 + "Text", 76 + "Int8" 77 + ] 78 + }, 79 + "nullable": [ 80 + null, 81 + null, 82 + null, 83 + null, 84 + null, 85 + null, 86 + null, 87 + null, 88 + null, 89 + null, 90 + null, 91 + null, 92 + null 93 + ] 94 + }, 95 + "hash": "fed4c0570726ac2aef29d401e041c27fcdded2fc5a98438dd396b0c9d9b7f2fd" 96 + }
+31
api/scripts/generate_typescript.ts
··· 425 425 ], 426 426 }); 427 427 428 + 429 + sourceFile.addInterface({ 430 + name: "GetSparklinesParams", 431 + isExported: true, 432 + properties: [ 433 + { name: "slices", type: "string[]" }, 434 + { name: "interval", type: "string", hasQuestionToken: true }, 435 + { name: "duration", type: "string", hasQuestionToken: true }, 436 + ], 437 + }); 438 + 439 + sourceFile.addInterface({ 440 + name: "GetSparklinesOutput", 441 + isExported: true, 442 + properties: [ 443 + { name: "success", type: "boolean" }, 444 + { name: "sparklines", type: `Record<string, NetworkSlicesSliceDefs["SparklinePoint"][]>` }, 445 + { name: "message", type: "string", hasQuestionToken: true }, 446 + ], 447 + }); 448 + 428 449 // These base interfaces are now imported from @slices/client 429 450 430 451 // OAuth client interfaces ··· 1216 1237 isAsync: true, 1217 1238 statements: [ 1218 1239 `return await this.client.makeRequest<SliceStatsOutput>('network.slices.slice.stats', 'POST', params);`, 1240 + ], 1241 + }); 1242 + 1243 + classDeclaration.addMethod({ 1244 + name: "getSparklines", 1245 + parameters: [{ name: "params", type: "GetSparklinesParams" }], 1246 + returnType: "Promise<GetSparklinesOutput>", 1247 + isAsync: true, 1248 + statements: [ 1249 + `return await this.client.makeRequest<GetSparklinesOutput>('network.slices.slice.getSparklines', 'POST', params);`, 1219 1250 ], 1220 1251 }); 1221 1252
+54
api/src/database.rs
··· 1229 1229 1230 1230 Ok(()) 1231 1231 } 1232 + 1233 + 1234 + pub async fn get_batch_sparkline_data( 1235 + &self, 1236 + slice_uris: &[String], 1237 + interval: &str, 1238 + duration_hours: i32, 1239 + ) -> Result<std::collections::HashMap<String, Vec<crate::models::SparklinePoint>>, DatabaseError> { 1240 + use chrono::{Duration, Utc}; 1241 + let cutoff_time = Utc::now() - Duration::hours(duration_hours as i64); 1242 + 1243 + let mut sparklines = std::collections::HashMap::new(); 1244 + 1245 + for slice_uri in slice_uris { 1246 + // Validate interval to prevent SQL injection 1247 + let interval_validated = match interval { 1248 + "minute" => "minute", 1249 + "day" => "day", 1250 + _ => "hour", 1251 + }; 1252 + 1253 + let query = format!( 1254 + r#" 1255 + SELECT 1256 + date_trunc('{}', indexed_at) as bucket, 1257 + COUNT(*) as count 1258 + FROM record 1259 + WHERE indexed_at >= $1 1260 + AND slice_uri = $2 1261 + GROUP BY bucket 1262 + ORDER BY bucket 1263 + "#, 1264 + interval_validated 1265 + ); 1266 + 1267 + let rows = sqlx::query_as::<_, (Option<chrono::DateTime<chrono::Utc>>, Option<i64>)>(&query) 1268 + .bind(cutoff_time) 1269 + .bind(slice_uri) 1270 + .fetch_all(&self.pool) 1271 + .await?; 1272 + 1273 + let data_points = rows 1274 + .into_iter() 1275 + .map(|(bucket, count)| crate::models::SparklinePoint { 1276 + timestamp: bucket.unwrap().to_rfc3339(), 1277 + count: count.unwrap_or(0), 1278 + }) 1279 + .collect(); 1280 + 1281 + sparklines.insert(slice_uri.clone(), data_points); 1282 + } 1283 + 1284 + Ok(sparklines) 1285 + } 1232 1286 }
+50
api/src/handler_sparkline.rs
··· 1 + use axum::{ 2 + extract::State, 3 + http::StatusCode, 4 + response::Json, 5 + }; 6 + use crate::models::{GetSparklinesParams, GetSparklinesOutput}; 7 + use crate::AppState; 8 + 9 + pub async fn batch_sparkline( 10 + State(state): State<AppState>, 11 + axum::extract::Json(params): axum::extract::Json<GetSparklinesParams>, 12 + ) -> Result<Json<GetSparklinesOutput>, StatusCode> { 13 + match get_batch_sparkline_data(&state, &params).await { 14 + Ok(output) => Ok(Json(output)), 15 + Err(_) => Ok(Json(GetSparklinesOutput { 16 + success: false, 17 + sparklines: std::collections::HashMap::new(), 18 + message: Some("Failed to get batch sparkline data".to_string()), 19 + })), 20 + } 21 + } 22 + 23 + async fn get_batch_sparkline_data( 24 + state: &AppState, 25 + params: &GetSparklinesParams, 26 + ) -> Result<GetSparklinesOutput, Box<dyn std::error::Error + Send + Sync>> { 27 + let interval = params.interval.as_deref().unwrap_or("hour"); 28 + let duration = params.duration.as_deref().unwrap_or("24h"); 29 + 30 + // Parse duration 31 + let duration_hours = match duration { 32 + "1h" => 1, 33 + "24h" => 24, 34 + "7d" => 24 * 7, 35 + "30d" => 24 * 30, 36 + _ => 24, // default to 24h 37 + }; 38 + 39 + let sparklines = state.database.get_batch_sparkline_data( 40 + &params.slices, 41 + interval, 42 + duration_hours, 43 + ).await?; 44 + 45 + Ok(GetSparklinesOutput { 46 + success: true, 47 + sparklines, 48 + message: None, 49 + }) 50 + }
+5
api/src/main.rs
··· 10 10 mod handler_logs; 11 11 mod handler_oauth_clients; 12 12 mod handler_openapi_spec; 13 + mod handler_sparkline; 13 14 mod handler_stats; 14 15 mod handler_sync; 15 16 mod handler_sync_user_collections; ··· 346 347 .route( 347 348 "/xrpc/network.slices.slice.stats", 348 349 post(handler_stats::stats), 350 + ) 351 + .route( 352 + "/xrpc/network.slices.slice.getSparklines", 353 + post(handler_sparkline::batch_sparkline), 349 354 ) 350 355 .route( 351 356 "/xrpc/network.slices.slice.getSliceRecords",
+24
api/src/models.rs
··· 203 203 pub success: bool, 204 204 pub message: String, 205 205 } 206 + 207 + 208 + #[derive(Debug, Serialize, Deserialize)] 209 + #[serde(rename_all = "camelCase")] 210 + pub struct SparklinePoint { 211 + pub timestamp: String, // ISO 8601 212 + pub count: i64, 213 + } 214 + 215 + #[derive(Debug, Serialize, Deserialize)] 216 + #[serde(rename_all = "camelCase")] 217 + pub struct GetSparklinesParams { 218 + pub slices: Vec<String>, 219 + pub interval: Option<String>, // "hour", "day", "minute" - defaults to "hour" 220 + pub duration: Option<String>, // "24h", "7d", "30d" - defaults to "24h" 221 + } 222 + 223 + #[derive(Debug, Serialize, Deserialize)] 224 + #[serde(rename_all = "camelCase")] 225 + pub struct GetSparklinesOutput { 226 + pub success: bool, 227 + pub sparklines: std::collections::HashMap<String, Vec<SparklinePoint>>, 228 + pub message: Option<String>, 229 + }
+142 -153
frontend/src/client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-09-14 17:24:23 UTC 2 + // Generated at: 2025-09-15 21:45:37 UTC 3 3 // Lexicons: 9 4 4 5 5 /** ··· 198 198 message?: string; 199 199 } 200 200 201 + export interface GetSparklinesParams { 202 + slices: string[]; 203 + interval?: string; 204 + duration?: string; 205 + } 206 + 207 + export interface GetSparklinesOutput { 208 + success: boolean; 209 + sparklines: Record<string, NetworkSlicesSliceDefs["SparklinePoint"][]>; 210 + message?: string; 211 + } 212 + 201 213 export interface CreateOAuthClientRequest { 202 214 clientName: string; 203 215 redirectUris: string[]; ··· 255 267 labels?: 256 268 | ComAtprotoLabelDefs["SelfLabels"] 257 269 | { 258 - $type: string; 259 - [key: string]: unknown; 260 - }; 270 + $type: string; 271 + [key: string]: unknown; 272 + }; 261 273 createdAt?: string; 262 274 pinnedPost?: ComAtprotoRepoStrongRef; 263 275 /** Free-form profile description text. */ ··· 281 293 /** Profile of the slice creator */ 282 294 creator: NetworkSlicesActorDefs["ProfileViewBasic"]; 283 295 createdAt: string; 296 + /** Recent activity sparkline data points for the last 24 hours */ 297 + sparkline?: NetworkSlicesSliceDefs["SparklinePoint"][]; 298 + } 299 + 300 + export interface NetworkSlicesSliceDefsSparklinePoint { 301 + timestamp: string; 302 + count: number; 284 303 } 285 304 286 305 export interface NetworkSlicesSlice { ··· 404 423 405 424 export interface NetworkSlicesSliceDefs { 406 425 readonly SliceView: NetworkSlicesSliceDefsSliceView; 426 + readonly SparklinePoint: NetworkSlicesSliceDefsSparklinePoint; 407 427 } 408 428 409 429 export interface NetworkSlicesActorDefs { ··· 415 435 readonly SelfLabel: ComAtprotoLabelDefsSelfLabel; 416 436 readonly SelfLabels: ComAtprotoLabelDefsSelfLabels; 417 437 readonly LabelValueDefinition: ComAtprotoLabelDefsLabelValueDefinition; 418 - readonly LabelValueDefinitionStrings: 419 - ComAtprotoLabelDefsLabelValueDefinitionStrings; 438 + readonly LabelValueDefinitionStrings: ComAtprotoLabelDefsLabelValueDefinitionStrings; 420 439 } 421 440 422 441 class ProfileActorBskyAppClient { ··· 430 449 limit?: number; 431 450 cursor?: string; 432 451 where?: { 433 - [ 434 - K in 435 - | AppBskyActorProfileSortFields 436 - | IndexedRecordFields 437 - ]?: WhereCondition; 452 + [K in 453 + | AppBskyActorProfileSortFields 454 + | IndexedRecordFields]?: WhereCondition; 438 455 }; 439 456 orWhere?: { 440 - [ 441 - K in 442 - | AppBskyActorProfileSortFields 443 - | IndexedRecordFields 444 - ]?: WhereCondition; 457 + [K in 458 + | AppBskyActorProfileSortFields 459 + | IndexedRecordFields]?: WhereCondition; 445 460 }; 446 461 sortBy?: SortField<AppBskyActorProfileSortFields>[]; 447 462 }): Promise<GetRecordsResponse<AppBskyActorProfile>> { ··· 449 464 } 450 465 451 466 async getRecord( 452 - params: GetRecordParams, 467 + params: GetRecordParams 453 468 ): Promise<RecordResponse<AppBskyActorProfile>> { 454 469 return await this.client.getRecord("app.bsky.actor.profile", params); 455 470 } ··· 458 473 limit?: number; 459 474 cursor?: string; 460 475 where?: { 461 - [ 462 - K in 463 - | AppBskyActorProfileSortFields 464 - | IndexedRecordFields 465 - ]?: WhereCondition; 476 + [K in 477 + | AppBskyActorProfileSortFields 478 + | IndexedRecordFields]?: WhereCondition; 466 479 }; 467 480 orWhere?: { 468 - [ 469 - K in 470 - | AppBskyActorProfileSortFields 471 - | IndexedRecordFields 472 - ]?: WhereCondition; 481 + [K in 482 + | AppBskyActorProfileSortFields 483 + | IndexedRecordFields]?: WhereCondition; 473 484 }; 474 485 sortBy?: SortField<AppBskyActorProfileSortFields>[]; 475 486 }): Promise<CountRecordsResponse> { ··· 478 489 479 490 async createRecord( 480 491 record: AppBskyActorProfile, 481 - useSelfRkey?: boolean, 492 + useSelfRkey?: boolean 482 493 ): Promise<{ uri: string; cid: string }> { 483 494 return await this.client.createRecord( 484 495 "app.bsky.actor.profile", 485 496 record, 486 - useSelfRkey, 497 + useSelfRkey 487 498 ); 488 499 } 489 500 490 501 async updateRecord( 491 502 rkey: string, 492 - record: AppBskyActorProfile, 503 + record: AppBskyActorProfile 493 504 ): Promise<{ uri: string; cid: string }> { 494 505 return await this.client.updateRecord( 495 506 "app.bsky.actor.profile", 496 507 rkey, 497 - record, 508 + record 498 509 ); 499 510 } 500 511 ··· 544 555 limit?: number; 545 556 cursor?: string; 546 557 where?: { 547 - [ 548 - K in 549 - | NetworkSlicesSliceSortFields 550 - | IndexedRecordFields 551 - ]?: WhereCondition; 558 + [K in 559 + | NetworkSlicesSliceSortFields 560 + | IndexedRecordFields]?: WhereCondition; 552 561 }; 553 562 orWhere?: { 554 - [ 555 - K in 556 - | NetworkSlicesSliceSortFields 557 - | IndexedRecordFields 558 - ]?: WhereCondition; 563 + [K in 564 + | NetworkSlicesSliceSortFields 565 + | IndexedRecordFields]?: WhereCondition; 559 566 }; 560 567 sortBy?: SortField<NetworkSlicesSliceSortFields>[]; 561 568 }): Promise<GetRecordsResponse<NetworkSlicesSlice>> { ··· 563 570 } 564 571 565 572 async getRecord( 566 - params: GetRecordParams, 573 + params: GetRecordParams 567 574 ): Promise<RecordResponse<NetworkSlicesSlice>> { 568 575 return await this.client.getRecord("network.slices.slice", params); 569 576 } ··· 572 579 limit?: number; 573 580 cursor?: string; 574 581 where?: { 575 - [ 576 - K in 577 - | NetworkSlicesSliceSortFields 578 - | IndexedRecordFields 579 - ]?: WhereCondition; 582 + [K in 583 + | NetworkSlicesSliceSortFields 584 + | IndexedRecordFields]?: WhereCondition; 580 585 }; 581 586 orWhere?: { 582 - [ 583 - K in 584 - | NetworkSlicesSliceSortFields 585 - | IndexedRecordFields 586 - ]?: WhereCondition; 587 + [K in 588 + | NetworkSlicesSliceSortFields 589 + | IndexedRecordFields]?: WhereCondition; 587 590 }; 588 591 sortBy?: SortField<NetworkSlicesSliceSortFields>[]; 589 592 }): Promise<CountRecordsResponse> { ··· 592 595 593 596 async createRecord( 594 597 record: NetworkSlicesSlice, 595 - useSelfRkey?: boolean, 598 + useSelfRkey?: boolean 596 599 ): Promise<{ uri: string; cid: string }> { 597 600 return await this.client.createRecord( 598 601 "network.slices.slice", 599 602 record, 600 - useSelfRkey, 603 + useSelfRkey 601 604 ); 602 605 } 603 606 604 607 async updateRecord( 605 608 rkey: string, 606 - record: NetworkSlicesSlice, 609 + record: NetworkSlicesSlice 607 610 ): Promise<{ uri: string; cid: string }> { 608 611 return await this.client.updateRecord("network.slices.slice", rkey, record); 609 612 } ··· 616 619 return await this.client.makeRequest<CodegenXrpcResponse>( 617 620 "network.slices.slice.codegen", 618 621 "POST", 619 - request, 622 + request 620 623 ); 621 624 } 622 625 ··· 624 627 return await this.client.makeRequest<SliceStatsOutput>( 625 628 "network.slices.slice.stats", 626 629 "POST", 627 - params, 630 + params 631 + ); 632 + } 633 + 634 + async getSparklines( 635 + params: GetSparklinesParams 636 + ): Promise<GetSparklinesOutput> { 637 + return await this.client.makeRequest<GetSparklinesOutput>( 638 + "network.slices.slice.getSparklines", 639 + "POST", 640 + params 628 641 ); 629 642 } 630 643 631 644 async getSliceRecords<T = Record<string, unknown>>( 632 - params: Omit<SliceLevelRecordsParams<T>, "slice">, 645 + params: Omit<SliceLevelRecordsParams<T>, "slice"> 633 646 ): Promise<SliceRecordsOutput<T>> { 634 647 // Combine where and orWhere into the expected backend format 635 648 const whereClause: any = params?.where ? { ...params.where } : {}; ··· 646 659 return await this.client.makeRequest<SliceRecordsOutput<T>>( 647 660 "network.slices.slice.getSliceRecords", 648 661 "POST", 649 - requestParams, 662 + requestParams 650 663 ); 651 664 } 652 665 ··· 655 668 return await this.client.makeRequest<GetActorsResponse>( 656 669 "network.slices.slice.getActors", 657 670 "POST", 658 - requestParams, 671 + requestParams 659 672 ); 660 673 } 661 674 ··· 664 677 return await this.client.makeRequest<SyncJobResponse>( 665 678 "network.slices.slice.startSync", 666 679 "POST", 667 - requestParams, 680 + requestParams 668 681 ); 669 682 } 670 683 ··· 672 685 return await this.client.makeRequest<JobStatus>( 673 686 "network.slices.slice.getJobStatus", 674 687 "GET", 675 - params, 688 + params 676 689 ); 677 690 } 678 691 679 692 async getJobHistory( 680 - params: GetJobHistoryParams, 693 + params: GetJobHistoryParams 681 694 ): Promise<GetJobHistoryResponse> { 682 695 return await this.client.makeRequest<GetJobHistoryResponse>( 683 696 "network.slices.slice.getJobHistory", 684 697 "GET", 685 - params, 698 + params 686 699 ); 687 700 } 688 701 ··· 690 703 return await this.client.makeRequest<GetJobLogsResponse>( 691 704 "network.slices.slice.getJobLogs", 692 705 "GET", 693 - params, 706 + params 694 707 ); 695 708 } 696 709 697 710 async getJetstreamStatus(): Promise<JetstreamStatusResponse> { 698 711 return await this.client.makeRequest<JetstreamStatusResponse>( 699 712 "network.slices.slice.getJetstreamStatus", 700 - "GET", 713 + "GET" 701 714 ); 702 715 } 703 716 704 717 async getJetstreamLogs( 705 - params: GetJetstreamLogsParams, 718 + params: GetJetstreamLogsParams 706 719 ): Promise<GetJetstreamLogsResponse> { 707 720 return await this.client.makeRequest<GetJetstreamLogsResponse>( 708 721 "network.slices.slice.getJetstreamLogs", 709 722 "GET", 710 - params, 723 + params 711 724 ); 712 725 } 713 726 714 727 async syncUserCollections( 715 - params?: SyncUserCollectionsRequest, 728 + params?: SyncUserCollectionsRequest 716 729 ): Promise<SyncUserCollectionsResult> { 717 730 const requestParams = { slice: this.client.sliceUri, ...params }; 718 731 return await this.client.makeRequest<SyncUserCollectionsResult>( 719 732 "network.slices.slice.syncUserCollections", 720 733 "POST", 721 - requestParams, 734 + requestParams 722 735 ); 723 736 } 724 737 725 738 async createOAuthClient( 726 - params: CreateOAuthClientRequest, 739 + params: CreateOAuthClientRequest 727 740 ): Promise<OAuthClientDetails> { 728 741 const requestParams = { ...params, sliceUri: this.client.sliceUri }; 729 742 return await this.client.makeRequest<OAuthClientDetails>( 730 743 "network.slices.slice.createOAuthClient", 731 744 "POST", 732 - requestParams, 745 + requestParams 733 746 ); 734 747 } 735 748 ··· 738 751 return await this.client.makeRequest<ListOAuthClientsResponse>( 739 752 "network.slices.slice.getOAuthClients", 740 753 "GET", 741 - requestParams, 754 + requestParams 742 755 ); 743 756 } 744 757 745 758 async updateOAuthClient( 746 - params: UpdateOAuthClientRequest, 759 + params: UpdateOAuthClientRequest 747 760 ): Promise<OAuthClientDetails> { 748 761 const requestParams = { ...params, sliceUri: this.client.sliceUri }; 749 762 return await this.client.makeRequest<OAuthClientDetails>( 750 763 "network.slices.slice.updateOAuthClient", 751 764 "POST", 752 - requestParams, 765 + requestParams 753 766 ); 754 767 } 755 768 756 769 async deleteOAuthClient( 757 - clientId: string, 770 + clientId: string 758 771 ): Promise<DeleteOAuthClientResponse> { 759 772 return await this.client.makeRequest<DeleteOAuthClientResponse>( 760 773 "network.slices.slice.deleteOAuthClient", 761 774 "POST", 762 - { clientId }, 775 + { clientId } 763 776 ); 764 777 } 765 778 } ··· 775 788 limit?: number; 776 789 cursor?: string; 777 790 where?: { 778 - [ 779 - K in 780 - | NetworkSlicesWaitingSortFields 781 - | IndexedRecordFields 782 - ]?: WhereCondition; 791 + [K in 792 + | NetworkSlicesWaitingSortFields 793 + | IndexedRecordFields]?: WhereCondition; 783 794 }; 784 795 orWhere?: { 785 - [ 786 - K in 787 - | NetworkSlicesWaitingSortFields 788 - | IndexedRecordFields 789 - ]?: WhereCondition; 796 + [K in 797 + | NetworkSlicesWaitingSortFields 798 + | IndexedRecordFields]?: WhereCondition; 790 799 }; 791 800 sortBy?: SortField<NetworkSlicesWaitingSortFields>[]; 792 801 }): Promise<GetRecordsResponse<NetworkSlicesWaiting>> { ··· 794 803 } 795 804 796 805 async getRecord( 797 - params: GetRecordParams, 806 + params: GetRecordParams 798 807 ): Promise<RecordResponse<NetworkSlicesWaiting>> { 799 808 return await this.client.getRecord("network.slices.waiting", params); 800 809 } ··· 803 812 limit?: number; 804 813 cursor?: string; 805 814 where?: { 806 - [ 807 - K in 808 - | NetworkSlicesWaitingSortFields 809 - | IndexedRecordFields 810 - ]?: WhereCondition; 815 + [K in 816 + | NetworkSlicesWaitingSortFields 817 + | IndexedRecordFields]?: WhereCondition; 811 818 }; 812 819 orWhere?: { 813 - [ 814 - K in 815 - | NetworkSlicesWaitingSortFields 816 - | IndexedRecordFields 817 - ]?: WhereCondition; 820 + [K in 821 + | NetworkSlicesWaitingSortFields 822 + | IndexedRecordFields]?: WhereCondition; 818 823 }; 819 824 sortBy?: SortField<NetworkSlicesWaitingSortFields>[]; 820 825 }): Promise<CountRecordsResponse> { ··· 823 828 824 829 async createRecord( 825 830 record: NetworkSlicesWaiting, 826 - useSelfRkey?: boolean, 831 + useSelfRkey?: boolean 827 832 ): Promise<{ uri: string; cid: string }> { 828 833 return await this.client.createRecord( 829 834 "network.slices.waiting", 830 835 record, 831 - useSelfRkey, 836 + useSelfRkey 832 837 ); 833 838 } 834 839 835 840 async updateRecord( 836 841 rkey: string, 837 - record: NetworkSlicesWaiting, 842 + record: NetworkSlicesWaiting 838 843 ): Promise<{ uri: string; cid: string }> { 839 844 return await this.client.updateRecord( 840 845 "network.slices.waiting", 841 846 rkey, 842 - record, 847 + record 843 848 ); 844 849 } 845 850 ··· 859 864 limit?: number; 860 865 cursor?: string; 861 866 where?: { 862 - [ 863 - K in 864 - | NetworkSlicesLexiconSortFields 865 - | IndexedRecordFields 866 - ]?: WhereCondition; 867 + [K in 868 + | NetworkSlicesLexiconSortFields 869 + | IndexedRecordFields]?: WhereCondition; 867 870 }; 868 871 orWhere?: { 869 - [ 870 - K in 871 - | NetworkSlicesLexiconSortFields 872 - | IndexedRecordFields 873 - ]?: WhereCondition; 872 + [K in 873 + | NetworkSlicesLexiconSortFields 874 + | IndexedRecordFields]?: WhereCondition; 874 875 }; 875 876 sortBy?: SortField<NetworkSlicesLexiconSortFields>[]; 876 877 }): Promise<GetRecordsResponse<NetworkSlicesLexicon>> { ··· 878 879 } 879 880 880 881 async getRecord( 881 - params: GetRecordParams, 882 + params: GetRecordParams 882 883 ): Promise<RecordResponse<NetworkSlicesLexicon>> { 883 884 return await this.client.getRecord("network.slices.lexicon", params); 884 885 } ··· 887 888 limit?: number; 888 889 cursor?: string; 889 890 where?: { 890 - [ 891 - K in 892 - | NetworkSlicesLexiconSortFields 893 - | IndexedRecordFields 894 - ]?: WhereCondition; 891 + [K in 892 + | NetworkSlicesLexiconSortFields 893 + | IndexedRecordFields]?: WhereCondition; 895 894 }; 896 895 orWhere?: { 897 - [ 898 - K in 899 - | NetworkSlicesLexiconSortFields 900 - | IndexedRecordFields 901 - ]?: WhereCondition; 896 + [K in 897 + | NetworkSlicesLexiconSortFields 898 + | IndexedRecordFields]?: WhereCondition; 902 899 }; 903 900 sortBy?: SortField<NetworkSlicesLexiconSortFields>[]; 904 901 }): Promise<CountRecordsResponse> { ··· 907 904 908 905 async createRecord( 909 906 record: NetworkSlicesLexicon, 910 - useSelfRkey?: boolean, 907 + useSelfRkey?: boolean 911 908 ): Promise<{ uri: string; cid: string }> { 912 909 return await this.client.createRecord( 913 910 "network.slices.lexicon", 914 911 record, 915 - useSelfRkey, 912 + useSelfRkey 916 913 ); 917 914 } 918 915 919 916 async updateRecord( 920 917 rkey: string, 921 - record: NetworkSlicesLexicon, 918 + record: NetworkSlicesLexicon 922 919 ): Promise<{ uri: string; cid: string }> { 923 920 return await this.client.updateRecord( 924 921 "network.slices.lexicon", 925 922 rkey, 926 - record, 923 + record 927 924 ); 928 925 } 929 926 ··· 943 940 limit?: number; 944 941 cursor?: string; 945 942 where?: { 946 - [ 947 - K in 948 - | NetworkSlicesActorProfileSortFields 949 - | IndexedRecordFields 950 - ]?: WhereCondition; 943 + [K in 944 + | NetworkSlicesActorProfileSortFields 945 + | IndexedRecordFields]?: WhereCondition; 951 946 }; 952 947 orWhere?: { 953 - [ 954 - K in 955 - | NetworkSlicesActorProfileSortFields 956 - | IndexedRecordFields 957 - ]?: WhereCondition; 948 + [K in 949 + | NetworkSlicesActorProfileSortFields 950 + | IndexedRecordFields]?: WhereCondition; 958 951 }; 959 952 sortBy?: SortField<NetworkSlicesActorProfileSortFields>[]; 960 953 }): Promise<GetRecordsResponse<NetworkSlicesActorProfile>> { ··· 962 955 } 963 956 964 957 async getRecord( 965 - params: GetRecordParams, 958 + params: GetRecordParams 966 959 ): Promise<RecordResponse<NetworkSlicesActorProfile>> { 967 960 return await this.client.getRecord("network.slices.actor.profile", params); 968 961 } ··· 971 964 limit?: number; 972 965 cursor?: string; 973 966 where?: { 974 - [ 975 - K in 976 - | NetworkSlicesActorProfileSortFields 977 - | IndexedRecordFields 978 - ]?: WhereCondition; 967 + [K in 968 + | NetworkSlicesActorProfileSortFields 969 + | IndexedRecordFields]?: WhereCondition; 979 970 }; 980 971 orWhere?: { 981 - [ 982 - K in 983 - | NetworkSlicesActorProfileSortFields 984 - | IndexedRecordFields 985 - ]?: WhereCondition; 972 + [K in 973 + | NetworkSlicesActorProfileSortFields 974 + | IndexedRecordFields]?: WhereCondition; 986 975 }; 987 976 sortBy?: SortField<NetworkSlicesActorProfileSortFields>[]; 988 977 }): Promise<CountRecordsResponse> { 989 978 return await this.client.countRecords( 990 979 "network.slices.actor.profile", 991 - params, 980 + params 992 981 ); 993 982 } 994 983 995 984 async createRecord( 996 985 record: NetworkSlicesActorProfile, 997 - useSelfRkey?: boolean, 986 + useSelfRkey?: boolean 998 987 ): Promise<{ uri: string; cid: string }> { 999 988 return await this.client.createRecord( 1000 989 "network.slices.actor.profile", 1001 990 record, 1002 - useSelfRkey, 991 + useSelfRkey 1003 992 ); 1004 993 } 1005 994 1006 995 async updateRecord( 1007 996 rkey: string, 1008 - record: NetworkSlicesActorProfile, 997 + record: NetworkSlicesActorProfile 1009 998 ): Promise<{ uri: string; cid: string }> { 1010 999 return await this.client.updateRecord( 1011 1000 "network.slices.actor.profile", 1012 1001 rkey, 1013 - record, 1002 + record 1014 1003 ); 1015 1004 } 1016 1005
+5
frontend/src/features/dashboard/templates/DashboardPage.tsx
··· 64 64 {slices.length > 0 65 65 ? ( 66 66 <div className="space-y-4"> 67 + <div className="flex items-center justify-between mb-4"> 68 + <p className="text-sm text-zinc-500"> 69 + Activity shows records indexed in the past 24 hours 70 + </p> 71 + </div> 67 72 {slices.map((slice) => ( 68 73 <SliceCard 69 74 key={slice.uri}
+5
frontend/src/features/landing/templates/LandingPage.tsx
··· 26 26 {slices.length > 0 27 27 ? ( 28 28 <div className="space-y-4"> 29 + <div className="flex items-center justify-between mb-4"> 30 + <p className="text-sm text-zinc-500"> 31 + Activity shows records indexed in the past 24 hours 32 + </p> 33 + </div> 29 34 {slices.map((slice) => ( 30 35 <SliceCard 31 36 key={slice.uri}
+62 -13
frontend/src/lib/api.ts
··· 4 4 NetworkSlicesActorProfile, 5 5 NetworkSlicesSlice, 6 6 NetworkSlicesSliceDefsSliceView, 7 + NetworkSlicesSliceDefsSparklinePoint, 7 8 } from "../client.ts"; 8 9 import { recordBlobToCdnUrl, RecordResponse } from "@slices/client"; 9 10 11 + async function fetchSparklinesForSlices( 12 + client: AtProtoClient, 13 + sliceUris: string[] 14 + ): Promise<Record<string, NetworkSlicesSliceDefsSparklinePoint[]>> { 15 + if (sliceUris.length === 0) return {}; 16 + 17 + try { 18 + const sparklinesResponse = await client.network.slices.slice.getSparklines({ 19 + slices: sliceUris, 20 + duration: "24h", 21 + }); 22 + if (sparklinesResponse.success) { 23 + return sparklinesResponse.sparklines; 24 + } 25 + } catch (error) { 26 + console.warn("Failed to fetch batch sparkline data:", error); 27 + } 28 + return {}; 29 + } 30 + 10 31 export async function getSlice( 11 32 client: AtProtoClient, 12 - uri: string, 33 + uri: string 13 34 ): Promise<NetworkSlicesSliceDefsSliceView | null> { 14 35 try { 15 36 const sliceRecord = await client.network.slices.slice.getRecord({ uri }); ··· 18 39 const creatorProfile = await getSliceActor(client, sliceRecord.did); 19 40 if (!creatorProfile) return null; 20 41 21 - return sliceToView(sliceRecord, creatorProfile); 42 + const sparklinesMap = await fetchSparklinesForSlices(client, [uri]); 43 + const sparklineData = sparklinesMap[uri]; 44 + 45 + return sliceToView(sliceRecord, creatorProfile, sparklineData); 22 46 } catch (error) { 23 47 console.error("Failed to get slice:", error); 24 48 return null; ··· 27 51 28 52 export async function getSliceActor( 29 53 client: AtProtoClient, 30 - did: string, 54 + did: string 31 55 ): Promise<NetworkSlicesActorDefsProfileViewBasic | null> { 32 56 try { 33 57 const profileUri = `at://${did}/network.slices.actor.profile/self`; ··· 42 66 limit: 1, 43 67 }); 44 68 45 - const handle = actorsResponse.actors.length > 0 46 - ? actorsResponse.actors[0].handle ?? did 47 - : did; // fallback to DID if no handle found 69 + const handle = 70 + actorsResponse.actors.length > 0 71 + ? actorsResponse.actors[0].handle ?? did 72 + : did; // fallback to DID if no handle found 48 73 49 74 return actorToView(profileRecord, handle); 50 75 } catch (error) { ··· 56 81 export function sliceToView( 57 82 sliceRecord: RecordResponse<NetworkSlicesSlice>, 58 83 creator: NetworkSlicesActorDefsProfileViewBasic, 84 + sparkline?: NetworkSlicesSliceDefsSparklinePoint[] 59 85 ): NetworkSlicesSliceDefsSliceView { 60 86 return { 61 87 uri: sliceRecord.uri, ··· 64 90 domain: sliceRecord.value.domain, 65 91 creator, 66 92 createdAt: sliceRecord.value.createdAt, 93 + sparkline, 67 94 }; 68 95 } 69 96 70 97 export function actorToView( 71 98 profileRecord: RecordResponse<NetworkSlicesActorProfile>, 72 - handle: string, 99 + handle: string 73 100 ): NetworkSlicesActorDefsProfileViewBasic { 74 101 return { 75 102 did: profileRecord.did, ··· 84 111 85 112 export async function getSlicesForActor( 86 113 client: AtProtoClient, 87 - did: string, 114 + did: string 88 115 ): Promise<NetworkSlicesSliceDefsSliceView[]> { 89 116 try { 90 117 const slicesResponse = await client.network.slices.slice.getRecords({ 91 118 where: { 92 119 did: { eq: did }, 93 120 }, 121 + sortBy: [{ field: "createdAt", direction: "desc" }], 94 122 }); 95 123 124 + // Collect slice URIs for batch sparkline fetch 125 + const sliceUris = slicesResponse.records.map((record) => record.uri); 126 + 127 + // Fetch sparklines for all slices at once 128 + const sparklinesMap = await fetchSparklinesForSlices(client, sliceUris); 129 + 96 130 const sliceViews: NetworkSlicesSliceDefsSliceView[] = []; 97 131 for (const sliceRecord of slicesResponse.records) { 98 132 const creator = await getSliceActor(client, sliceRecord.did); 99 133 if (creator) { 100 - sliceViews.push(sliceToView(sliceRecord, creator)); 134 + const sparklineData = sparklinesMap[sliceRecord.uri]; 135 + sliceViews.push(sliceToView(sliceRecord, creator, sparklineData)); 101 136 } 102 137 } 103 138 ··· 111 146 export async function searchSlices( 112 147 client: AtProtoClient, 113 148 query: string, 114 - limit = 20, 149 + limit = 20 115 150 ): Promise<NetworkSlicesSliceDefsSliceView[]> { 116 151 try { 117 152 const slicesResponse = await client.network.slices.slice.getRecords({ ··· 121 156 limit, 122 157 }); 123 158 159 + // Collect slice URIs for batch sparkline fetch 160 + const sliceUris = slicesResponse.records.map((record) => record.uri); 161 + 162 + // Fetch sparklines for all slices at once 163 + const sparklinesMap = await fetchSparklinesForSlices(client, sliceUris); 164 + 124 165 const sliceViews: NetworkSlicesSliceDefsSliceView[] = []; 125 166 for (const sliceRecord of slicesResponse.records) { 126 167 const creator = await getSliceActor(client, sliceRecord.did); 127 168 if (creator) { 128 - sliceViews.push(sliceToView(sliceRecord, creator)); 169 + const sparklineData = sparklinesMap[sliceRecord.uri]; 170 + sliceViews.push(sliceToView(sliceRecord, creator, sparklineData)); 129 171 } 130 172 } 131 173 ··· 138 180 139 181 export async function getTimeline( 140 182 client: AtProtoClient, 141 - limit = 50, 183 + limit = 50 142 184 ): Promise<NetworkSlicesSliceDefsSliceView[]> { 143 185 try { 144 186 const slicesResponse = await client.network.slices.slice.getRecords({ ··· 146 188 sortBy: [{ field: "createdAt", direction: "desc" }], 147 189 }); 148 190 191 + // Collect slice URIs for batch sparkline fetch 192 + const sliceUris = slicesResponse.records.map((record) => record.uri); 193 + 194 + // Fetch sparklines for all slices at once 195 + const sparklinesMap = await fetchSparklinesForSlices(client, sliceUris); 196 + 149 197 const sliceViews: NetworkSlicesSliceDefsSliceView[] = []; 150 198 for (const sliceRecord of slicesResponse.records) { 151 199 const creator = await getSliceActor(client, sliceRecord.did); 152 200 if (creator) { 153 - sliceViews.push(sliceToView(sliceRecord, creator)); 201 + const sparklineData = sparklinesMap[sliceRecord.uri]; 202 + sliceViews.push(sliceToView(sliceRecord, creator, sparklineData)); 154 203 } 155 204 } 156 205
+18 -25
frontend/src/shared/fragments/ActivitySparkline.tsx
··· 1 + import type { NetworkSlicesSliceDefsSparklinePoint } from "../../client.ts"; 2 + 1 3 interface ActivitySparklineProps { 2 - data?: number[]; 4 + sparklineData?: NetworkSlicesSliceDefsSparklinePoint[]; 3 5 width?: number; 4 6 height?: number; 5 7 className?: string; 6 8 } 7 9 8 10 export function ActivitySparkline({ 9 - data = [], 11 + sparklineData, 10 12 width = 100, 11 13 height = 30, 12 14 className = "", 13 15 }: ActivitySparklineProps) { 14 - // Generate mock data if none provided (for demo purposes) 15 - const sparklineData = data.length > 0 ? data : generateMockData(); 16 + // Convert sparkline data to numbers, or create flat line for no data 17 + let dataPoints: number[]; 18 + if (!sparklineData || sparklineData.length === 0) { 19 + // Create a flat line with 24 zero points (24 hours) 20 + dataPoints = Array(24).fill(0); 21 + } else { 22 + dataPoints = sparklineData.map(point => point.count); 23 + } 16 24 17 25 // Calculate the path for the sparkline 18 - const max = Math.max(...sparklineData, 1); 19 - const min = Math.min(...sparklineData, 0); 26 + const max = Math.max(...dataPoints, 1); 27 + const min = Math.min(...dataPoints, 0); 20 28 const range = max - min || 1; 21 29 22 30 // Create SVG path points 23 - const points = sparklineData.map((value, index) => { 24 - const x = (index / (sparklineData.length - 1)) * width; 31 + const points = dataPoints.map((value, index) => { 32 + const x = (index / (dataPoints.length - 1)) * width; 25 33 const y = height - ((value - min) / range) * height; 26 34 return `${x},${y}`; 27 35 }).join(" "); ··· 78 86 <circle 79 87 cx={width} 80 88 cy={height - 81 - ((sparklineData[sparklineData.length - 1] - min) / range) * height} 89 + ((dataPoints[dataPoints.length - 1] - min) / range) * height} 82 90 r="2" 83 91 fill="#3b82f6" 84 92 /> ··· 86 94 87 95 {/* Activity label */} 88 96 <div className="ml-2 text-xs text-zinc-500"> 89 - {sparklineData[sparklineData.length - 1]} ops/min 97 + {dataPoints[dataPoints.length - 1]} records 90 98 </div> 91 99 </div> 92 100 ); 93 101 } 94 102 95 - // Generate mock data for demonstration 96 - function generateMockData(): number[] { 97 - const points = 20; 98 - const data: number[] = []; 99 - let lastValue = Math.random() * 50 + 10; 100 - 101 - for (let i = 0; i < points; i++) { 102 - // Random walk with some smoothing 103 - const change = (Math.random() - 0.5) * 20; 104 - lastValue = Math.max(0, Math.min(100, lastValue + change)); 105 - data.push(Math.round(lastValue)); 106 - } 107 - 108 - return data; 109 - }
+3 -5
frontend/src/shared/fragments/SliceCard.tsx
··· 44 44 </div> 45 45 46 46 {/* Right side - activity sparkline */} 47 - { 48 - /* <div className="flex items-center ml-4 self-center"> 49 - <ActivitySparkline /> 50 - </div> */ 51 - } 47 + <div className="flex items-center ml-4 self-center"> 48 + <ActivitySparkline sparklineData={slice.sparkline} /> 49 + </div> 52 50 </div> 53 51 </div> 54 52 </a>
+22
lexicons/network/slices/slice/defs.json
··· 30 30 "createdAt": { 31 31 "type": "string", 32 32 "format": "datetime" 33 + }, 34 + "sparkline": { 35 + "type": "array", 36 + "items": { 37 + "type": "ref", 38 + "ref": "#sparklinePoint" 39 + }, 40 + "description": "Recent activity sparkline data points for the last 24 hours" 41 + } 42 + } 43 + }, 44 + "sparklinePoint": { 45 + "type": "object", 46 + "required": ["timestamp", "count"], 47 + "properties": { 48 + "timestamp": { 49 + "type": "string", 50 + "format": "datetime" 51 + }, 52 + "count": { 53 + "type": "integer", 54 + "minimum": 0 33 55 } 34 56 } 35 57 }