fustor-demo 0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: fustor-demo
3
+ Version: 0.2
4
+ Summary: Fustor Bio-Fusion Demo for unified directory service
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: fastapi[all]>=0.104.1
7
+ Requires-Dist: uvicorn>=0.23.2
8
+ Requires-Dist: fustor-core
9
+ Requires-Dist: fustor-event-model
10
+ Requires-Dist: pydantic>=2.0.0
11
+ Requires-Dist: httpx>=0.25.0
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "fustor-demo"
3
+ dynamic = ["version"]
4
+ description = "Fustor Bio-Fusion Demo for unified directory service"
5
+ dependencies = [ "fastapi[all]>=0.104.1", "uvicorn>=0.23.2", "fustor-core", "fustor-event-model", "pydantic>=2.0.0", "httpx>=0.25.0",]
6
+ requires-python = ">=3.11"
7
+
8
+ [build-system]
9
+ requires = [ "setuptools>=61.0", "setuptools-scm>=8.0"]
10
+ build-backend = "setuptools.build_meta"
11
+
12
+ [tool.setuptools_scm]
13
+ root = "../.."
14
+ version_scheme = "post-release"
15
+ local_scheme = "dirty-tag"
16
+
17
+ ["project.urls"]
18
+ Homepage = "https://github.com/excelwang/fustor/tree/master/packages/demo_bio_fusion"
19
+ "Bug Tracker" = "https://github.com/excelwang/fustor/issues"
20
+
21
+ license = "MIT"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = [ "src",]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,128 @@
1
+ import asyncio
2
+ import random
3
+ import logging
4
+ from typing import List
5
+ from .mock_agents import (
6
+ mock_mysql_create_project,
7
+ mock_nfs_hot_add_file,
8
+ mock_nfs_cold_add_file,
9
+ mock_oss_add_dataset_link,
10
+ mock_es_add_publication
11
+ )
12
+
13
+ logger = logging.getLogger("fustor_demo.generator")
14
+
15
+ # Sample data pools for random generation
16
+ PROJECT_NAMES = [
17
+ "Cancer_Genomics_2024", "Viral_Study_Beta", "Plant_Diversity_X",
18
+ "Rare_Disease_Gamma", "Microbiome_Gut_Health", "Neuro_Science_Alpha"
19
+ ]
20
+
21
+ FILE_PREFIXES = ["seq_run", "analysis_result", "raw_image", "patient_data", "gene_expression"]
22
+ FILE_EXTENSIONS = [".fastq", ".bam", ".vcf", ".tiff", ".csv", ".json"]
23
+
24
+ DATASET_NAMES = ["1000_Genomes", "TCGA_Public", "GSA_Reference", "OMIX_Baseline", "UniProt_Dump"]
25
+
26
+ TITLES = [
27
+ "Novel findings in gene X", "Study of protein Y interaction",
28
+ "Large scale population analysis", "Review of viral vectors", "Methodology for fast sequencing"
29
+ ]
30
+
31
+ class AutoDataGenerator:
32
+ def __init__(self, interval_min: float = 2.0, interval_max: float = 5.0):
33
+ self.interval_min = interval_min
34
+ self.interval_max = interval_max
35
+ self._running = False
36
+ self._task = None
37
+ self._generated_projects: List[str] = [] # Keep track of created projects to add files to them
38
+
39
+ async def start(self):
40
+ if self._running:
41
+ return
42
+ self._running = True
43
+ self._task = asyncio.create_task(self._loop())
44
+ logger.info("Auto data generator started.")
45
+
46
+ async def stop(self):
47
+ if not self._running:
48
+ return
49
+ self._running = False
50
+ if self._task:
51
+ self._task.cancel()
52
+ try:
53
+ await self._task
54
+ except asyncio.CancelledError:
55
+ pass
56
+ logger.info("Auto data generator stopped.")
57
+
58
+ async def _loop(self):
59
+ # Initial seeding: Create a couple of projects
60
+ for name in PROJECT_NAMES[:2]:
61
+ self._create_project(name)
62
+ await asyncio.sleep(0.5)
63
+
64
+ while self._running:
65
+ try:
66
+ # Pick a random action
67
+ action_type = random.choice([
68
+ "create_project",
69
+ "add_nfs_hot", "add_nfs_hot", "add_nfs_hot", # Higher weight for files
70
+ "add_nfs_cold",
71
+ "add_oss",
72
+ "add_es"
73
+ ])
74
+
75
+ if action_type == "create_project":
76
+ # Create a new project from the list if not all exist, or a random variant
77
+ available_names = [p for p in PROJECT_NAMES if p.lower() not in self._generated_projects]
78
+ if available_names:
79
+ self._create_project(random.choice(available_names))
80
+ else:
81
+ # Create a variant
82
+ base = random.choice(PROJECT_NAMES)
83
+ variant = f"{base}_v{random.randint(1, 99)}"
84
+ self._create_project(variant)
85
+
86
+ elif self._generated_projects: # Only add items if we have projects
87
+ project_id = random.choice(self._generated_projects)
88
+
89
+ if action_type == "add_nfs_hot":
90
+ fname = f"{random.choice(FILE_PREFIXES)}_{random.randint(100, 999)}{random.choice(FILE_EXTENSIONS)}"
91
+ size = random.randint(10, 5000)
92
+ mock_nfs_hot_add_file(project_id, fname, size)
93
+ logger.info(f"Auto-generated NFS Hot file '{fname}' for '{project_id}'")
94
+
95
+ elif action_type == "add_nfs_cold":
96
+ fname = f"archive_{random.choice(FILE_PREFIXES)}_{random.randint(2010, 2023)}{random.choice(FILE_EXTENSIONS)}"
97
+ size = random.randint(50, 200) # GB
98
+ mock_nfs_cold_add_file(project_id, fname, size)
99
+ logger.info(f"Auto-generated NFS Cold file '{fname}' for '{project_id}'")
100
+
101
+ elif action_type == "add_oss":
102
+ ds_name = f"{random.choice(DATASET_NAMES)}_{random.randint(1, 10)}.tar.gz"
103
+ mock_oss_add_dataset_link(project_id, ds_name, f"https://oss.example.com/{ds_name}")
104
+ logger.info(f"Auto-generated OSS link '{ds_name}' for '{project_id}'")
105
+
106
+ elif action_type == "add_es":
107
+ title = f"{random.choice(TITLES)} - {random.randint(1, 100)}"
108
+ pid = str(random.randint(10000000, 99999999))
109
+ mock_es_add_publication(project_id, title, pid)
110
+ logger.info(f"Auto-generated ES citation '{title}' for '{project_id}'")
111
+
112
+ # Sleep for random interval
113
+ delay = random.uniform(self.interval_min, self.interval_max)
114
+ await asyncio.sleep(delay)
115
+
116
+ except Exception as e:
117
+ logger.error(f"Error in auto generator loop: {e}")
118
+ await asyncio.sleep(5) # Retry delay
119
+
120
+ def _create_project(self, name: str):
121
+ mock_mysql_create_project(name)
122
+ project_id = name.lower().replace(" ", "_")
123
+ if project_id not in self._generated_projects:
124
+ self._generated_projects.append(project_id)
125
+ logger.info(f"Auto-generated Project '{name}'")
126
+
127
+ # Global instance
128
+ generator = AutoDataGenerator()
@@ -0,0 +1,114 @@
1
+ from fastapi import FastAPI, Request, HTTPException, status
2
+ from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
3
+ from fastapi.staticfiles import StaticFiles
4
+ from typing import List, Dict, Any, Optional
5
+ import os
6
+ import logging
7
+ import asyncio
8
+
9
+ from fustor_event_model.models import EventBase, EventType # For type hinting if needed
10
+
11
+ from fustor_demo.store import demo_store
12
+ from fustor_demo.mock_agents import (
13
+ mock_mysql_create_project,
14
+ mock_nfs_hot_add_file,
15
+ mock_nfs_cold_add_file,
16
+ mock_oss_add_dataset_link,
17
+ mock_es_add_publication
18
+ )
19
+
20
+ from fustor_demo.auto_generator import generator
21
+
22
+ logger = logging.getLogger("fustor_demo")
23
+
24
+ # Get the directory where main.py is located
25
+ current_dir = os.path.dirname(os.path.abspath(__file__))
26
+ static_dir = os.path.join(current_dir, "static")
27
+
28
+ # Ensure static directory exists
29
+ if not os.path.exists(static_dir):
30
+ os.makedirs(static_dir)
31
+
32
+ app = FastAPI(
33
+ title="Fustor Bio-Fusion Demo API",
34
+ description="Demonstrates unified directory service from 5 heterogeneous data sources.",
35
+ version="0.1.0",
36
+ )
37
+
38
+ @app.on_event("startup")
39
+ async def startup_event():
40
+ await generator.start()
41
+
42
+ @app.on_event("shutdown")
43
+ async def shutdown_event():
44
+ await generator.stop()
45
+
46
+ # Mount static files to serve the frontend UI
47
+ app.mount("/static", StaticFiles(directory=static_dir), name="static")
48
+
49
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
50
+ async def serve_demo_ui():
51
+ """Serve the main HTML page for the demo UI."""
52
+ html_file_path = os.path.join(static_dir, "index.html")
53
+ if not os.path.exists(html_file_path):
54
+ raise HTTPException(status_code=404, detail="Demo UI (index.html) not found. Please ensure it's in the static directory.")
55
+ return FileResponse(html_file_path)
56
+
57
+ @app.get("/api/unified_directory", response_model=List[Dict[str, Any]])
58
+ async def get_unified_directory(
59
+ project_id: Optional[str] = None
60
+ ):
61
+ """
62
+ Query the unified directory service.
63
+ Returns all projects if project_id is None or "ALL".
64
+ Returns items within a specific project if project_id is provided.
65
+ """
66
+ if project_id and project_id.upper() == "ALL":
67
+ return demo_store.get_unified_directory("ALL")
68
+ return demo_store.get_unified_directory(project_id)
69
+
70
+ @app.post("/api/mock/mysql_project")
71
+ async def trigger_mysql_project(project_name: str = "Project_Alpha"):
72
+ """Trigger a mock MySQL event to create a new project."""
73
+ event = mock_mysql_create_project(project_name)
74
+ return {"status": "success", "message": f"Mock MySQL project '{project_name}' created.", "event": event}
75
+
76
+ @app.post("/api/mock/nfs_hot_file")
77
+ async def trigger_nfs_hot_file(project_id: str = "project_alpha", filename: str = "sample_data.fastq", size_mb: int = 100):
78
+ """Trigger a mock NFS Hot event to add a file."""
79
+ event = mock_nfs_hot_add_file(project_id, filename, size_mb)
80
+ return {"status": "success", "message": f"Mock NFS hot file '{filename}' added to '{project_id}'.", "event": event}
81
+
82
+ @app.post("/api/mock/nfs_cold_file")
83
+ async def trigger_nfs_cold_file(project_id: str = "project_alpha", filename: str = "archive_data.bam", size_gb: int = 50):
84
+ """Trigger a mock NFS Cold event to add an archived file."""
85
+ event = mock_nfs_cold_add_file(project_id, filename, size_gb)
86
+ return {"status": "success", "message": f"Mock NFS cold file '{filename}' added to '{project_id}'.", "event": event}
87
+
88
+ @app.post("/api/mock/oss_dataset")
89
+ async def trigger_oss_dataset(project_id: str = "project_alpha", dataset_name: str = "gsa_public_dataset.zip", public_url: str = "https://oss.example.com/data.zip"):
90
+ """Trigger a mock OSS event to add a public dataset link."""
91
+ event = mock_oss_add_dataset_link(project_id, dataset_name, public_url)
92
+ return {"status": "success", "message": f"Mock OSS dataset '{dataset_name}' added to '{project_id}'.", "event": event}
93
+
94
+ @app.post("/api/mock/es_publication")
95
+ async def trigger_es_publication(project_id: str = "project_alpha", title: str = "Novel Gene Discovery", pubmed_id: str = "38000000"):
96
+ """Trigger a mock Elasticsearch event to add a publication metadata."""
97
+ event = mock_es_add_publication(project_id, title, pubmed_id)
98
+ return {"status": "success", "message": f"Mock ES publication '{title}' added to '{project_id}'.", "event": event}
99
+
100
+ @app.post("/api/clear_store")
101
+ async def clear_store():
102
+ """Clears all data in the demo store."""
103
+ demo_store.clear()
104
+ return {"status": "success", "message": "Demo store cleared."}
105
+
106
+ # Main entry point for uvicorn
107
+ if __name__ == "__main__":
108
+ import logging
109
+ import uvicorn
110
+ # Configure uvicorn to use DEBUG level for access logs to reduce verbosity
111
+ uvicorn_logger = logging.getLogger("uvicorn.access")
112
+ uvicorn_logger.setLevel(logging.DEBUG)
113
+
114
+ uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
@@ -0,0 +1,128 @@
1
+ from typing import Dict, Any, Optional
2
+ from datetime import datetime, timedelta, timezone
3
+ import uuid
4
+ import random
5
+
6
+ from fustor_core.models.config import SourceConfig, PasswdCredential # Used for type hinting config in mock
7
+ from fustor_event_model.models import EventType # Used for event types in mock
8
+ from fustor_demo.store import demo_store
9
+
10
+ # --- Helper to generate common event structure ---
11
+ def _generate_base_event(
12
+ project_id: str,
13
+ source_type: str,
14
+ item_type: str, # "file", "directory", "link", "metadata"
15
+ name: str,
16
+ path: str,
17
+ size: Optional[int] = None,
18
+ url: Optional[str] = None,
19
+ extra_metadata: Optional[Dict[str, Any]] = None
20
+ ) -> Dict[str, Any]:
21
+ """Generates a base event dictionary for the demo store."""
22
+ unique_id = f"{source_type}-{project_id}-{uuid.uuid4().hex[:8]}"
23
+ return {
24
+ "id": unique_id,
25
+ "name": name,
26
+ "type": item_type,
27
+ "source_type": source_type,
28
+ "project_id": project_id,
29
+ "path": path,
30
+ "size": size,
31
+ "last_modified": datetime.now(timezone.utc).isoformat(),
32
+ "url": url,
33
+ "extra_metadata": extra_metadata or {}
34
+ }
35
+
36
+ # --- Mock Agent Functions ---
37
+
38
+ def mock_mysql_create_project(project_name: str) -> Dict[str, Any]:
39
+ """Simulates a MySQL Agent creating a new project."""
40
+ project_id = project_name.lower().replace(" ", "_")
41
+ event = _generate_base_event(
42
+ project_id=project_id,
43
+ source_type="mysql",
44
+ item_type="directory",
45
+ name=project_name,
46
+ path=f"/{project_name}",
47
+ extra_metadata={
48
+ "description": f"Research project {project_name}.",
49
+ "pi": f"Dr. {random.choice(['Smith', 'Jones', 'Li', 'Chen'])}",
50
+ "created_by": "System"
51
+ }
52
+ )
53
+ demo_store.add_event(event)
54
+ return event
55
+
56
+ def mock_nfs_hot_add_file(project_id: str, filename: str, size_mb: int) -> Dict[str, Any]:
57
+ """Simulates an NFS Hot Agent detecting a new file."""
58
+ event = _generate_base_event(
59
+ project_id=project_id,
60
+ source_type="nfs_hot",
61
+ item_type="file",
62
+ name=filename,
63
+ path=f"/{project_id}/hot_data/{filename}",
64
+ size=size_mb * 1024 * 1024,
65
+ extra_metadata={
66
+ "local_path": f"/mnt/nfs_hot/{project_id}/{filename}",
67
+ "last_access": datetime.now(timezone.utc).isoformat()
68
+ }
69
+ )
70
+ demo_store.add_event(event)
71
+ return event
72
+
73
+ def mock_nfs_cold_add_file(project_id: str, filename: str, size_gb: int, age_days: int = 370) -> Dict[str, Any]:
74
+ """Simulates an NFS Cold Agent detecting an archived file."""
75
+ archived_time = datetime.now(timezone.utc) - timedelta(days=age_days)
76
+ event = _generate_base_event(
77
+ project_id=project_id,
78
+ source_type="nfs_cold",
79
+ item_type="file",
80
+ name=filename,
81
+ path=f"/{project_id}/cold_archive/{filename}",
82
+ size=size_gb * 1024 * 1024 * 1024,
83
+ extra_metadata={
84
+ "local_path": f"/mnt/nfs_cold_archive/{project_id}/{filename}",
85
+ "archived_on": archived_time.isoformat(),
86
+ "retrieval_status": "offline"
87
+ }
88
+ )
89
+ demo_store.add_event(event)
90
+ return event
91
+
92
+ def mock_oss_add_dataset_link(project_id: str, dataset_name: str, public_url: str) -> Dict[str, Any]:
93
+ """Simulates an OSS Agent detecting a new public dataset link."""
94
+ event = _generate_base_event(
95
+ project_id=project_id,
96
+ source_type="oss",
97
+ item_type="link",
98
+ name=dataset_name,
99
+ path=f"/{project_id}/public_datasets/{dataset_name}",
100
+ url=public_url,
101
+ extra_metadata={
102
+ "provider": "GSA/OMIX Public Data",
103
+ "version": "1.0",
104
+ "access_level": "public"
105
+ }
106
+ )
107
+ demo_store.add_event(event)
108
+ return event
109
+
110
+ def mock_es_add_publication(project_id: str, title: str, pubmed_id: str) -> Dict[str, Any]:
111
+ """Simulates an Elasticsearch Agent finding a new publication metadata."""
112
+ event = _generate_base_event(
113
+ project_id=project_id,
114
+ source_type="es",
115
+ item_type="metadata",
116
+ name=title,
117
+ path=f"/{project_id}/publications/{pubmed_id}",
118
+ url=f"https://pubmed.ncbi.nlm.nih.gov/{pubmed_id}/",
119
+ extra_metadata={
120
+ "pubmed_id": pubmed_id,
121
+ "journal": "Science",
122
+ "authors": ["J. Doe", "A. Smith"],
123
+ "abstract_snippet": "This study investigates...",
124
+ "publication_date": (datetime.now(timezone.utc) - timedelta(days=random.randint(10, 300))).isoformat()
125
+ }
126
+ )
127
+ demo_store.add_event(event)
128
+ return event
@@ -0,0 +1,386 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Fustor Bio-Fusion Demo - Unified Directory API Explorer</title>
7
+ <!-- Bootstrap CSS -->
8
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
9
+ <!-- Vue.js 3 -->
10
+ <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
11
+ <!-- Axios for HTTP requests -->
12
+ <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
13
+ <style>
14
+ body {
15
+ background-color: #212529; /* Dark background */
16
+ color: #f8f9fa; /* Light text */
17
+ }
18
+ .container-fluid {
19
+ padding-top: 20px;
20
+ }
21
+ .card {
22
+ background-color: #343a40; /* Darker card background */
23
+ border-color: #495057;
24
+ margin-bottom: 15px;
25
+ }
26
+ .card-header {
27
+ background-color: #495057;
28
+ color: #f8f9fa;
29
+ }
30
+ .btn-primary {
31
+ background-color: #007bff;
32
+ border-color: #007bff;
33
+ }
34
+ .btn-primary:hover {
35
+ background-color: #0056b3;
36
+ border-color: #0056b3;
37
+ }
38
+ .list-group-item {
39
+ background-color: #343a40;
40
+ color: #f8f9fa;
41
+ border-color: #495057;
42
+ }
43
+ .badge {
44
+ font-size: 0.75em;
45
+ padding: 0.4em 0.6em;
46
+ border-radius: 0.25rem;
47
+ }
48
+ .badge-mysql { background-color: #6c757d; } /* Grey */
49
+ .badge-nfs_hot { background-color: #dc3545; } /* Red */
50
+ .badge-nfs_cold { background-color: #17a2b8; } /* Cyan */
51
+ .badge-oss { background-color: #ffc107; } /* Yellow */
52
+ .badge-es { background-color: #28a745; } /* Green */
53
+ .badge-system { background-color: #007bff; } /* Blue */
54
+
55
+ .json-output {
56
+ background-color: #282c34;
57
+ color: #abb2bf;
58
+ padding: 15px;
59
+ border-radius: 5px;
60
+ overflow-x: auto;
61
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
62
+ }
63
+ pre { margin: 0; }
64
+ .log-entry {
65
+ font-size: 0.85em;
66
+ color: #adb5bd;
67
+ }
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <div id="app" class="container-fluid">
72
+ <h1 class="mb-4 text-center text-primary">Fustor Bio-Fusion Demo - Unified Directory API Explorer</h1>
73
+ <div class="row">
74
+ <!-- Left Panel: Source Controls -->
75
+ <div class="col-md-4">
76
+ <h3 class="mb-3">Source Controls (Simulated Agents)</h3>
77
+ <div class="card">
78
+ <div class="card-header d-flex justify-content-between align-items-center">
79
+ MySQL (Projects)
80
+ <span class="badge bg-success">Online</span>
81
+ </div>
82
+ <div class="card-body">
83
+ <div class="input-group mb-3">
84
+ <input type="text" class="form-control" v-model="mysqlProjectName" placeholder="Project Name">
85
+ <button class="btn btn-primary" @click="triggerMock('mysql_project', {project_name: mysqlProjectName})">Create Project</button>
86
+ </div>
87
+ <small class="text-muted">New project: {{ mysqlProjectName }}</small>
88
+ </div>
89
+ </div>
90
+
91
+ <div class="card">
92
+ <div class="card-header d-flex justify-content-between align-items-center">
93
+ NFS Hot (Raw Data)
94
+ <span class="badge bg-success">Online</span>
95
+ </div>
96
+ <div class="card-body">
97
+ <div class="input-group mb-3">
98
+ <input type="text" class="form-control" v-model="nfsHotProjectId" placeholder="Project ID">
99
+ <input type="text" class="form-control" v-model="nfsHotFilename" placeholder="Filename">
100
+ <input type="number" class="form-control" v-model.number="nfsHotSize" placeholder="Size (MB)">
101
+ <button class="btn btn-primary" @click="triggerMock('nfs_hot_file', {project_id: nfsHotProjectId, filename: nfsHotFilename, size_mb: nfsHotSize})">Add File</button>
102
+ </div>
103
+ </div>
104
+ </div>
105
+
106
+ <div class="card">
107
+ <div class="card-header d-flex justify-content-between align-items-center">
108
+ NFS Cold (Archived Data)
109
+ <span class="badge bg-success">Online</span>
110
+ </div>
111
+ <div class="card-body">
112
+ <div class="input-group mb-3">
113
+ <input type="text" class="form-control" v-model="nfsColdProjectId" placeholder="Project ID">
114
+ <input type="text" class="form-control" v-model="nfsColdFilename" placeholder="Filename">
115
+ <input type="number" class="form-control" v-model.number="nfsColdSize" placeholder="Size (GB)">
116
+ <button class="btn btn-primary" @click="triggerMock('nfs_cold_file', {project_id: nfsColdProjectId, filename: nfsColdFilename, size_gb: nfsColdSize})">Archive File</button>
117
+ </div>
118
+ </div>
119
+ </div>
120
+
121
+ <div class="card">
122
+ <div class="card-header d-flex justify-content-between align-items-center">
123
+ OSS (Public Datasets)
124
+ <span class="badge bg-success">Online</span>
125
+ </div>
126
+ <div class="card-body">
127
+ <div class="input-group mb-3">
128
+ <input type="text" class="form-control" v-model="ossProjectId" placeholder="Project ID">
129
+ <input type="text" class="form-control" v-model="ossDatasetName" placeholder="Dataset Name">
130
+ <input type="text" class="form-control" v-model="ossPublicUrl" placeholder="Public URL">
131
+ <button class="btn btn-primary" @click="triggerMock('oss_dataset', {project_id: ossProjectId, dataset_name: ossDatasetName, public_url: ossPublicUrl})">Add Dataset Link</button>
132
+ </div>
133
+ </div>
134
+ </div>
135
+
136
+ <div class="card">
137
+ <div class="card-header d-flex justify-content-between align-items-center">
138
+ ES (PubMed Publications)
139
+ <span class="badge bg-success">Online</span>
140
+ </div>
141
+ <div class="card-body">
142
+ <div class="input-group mb-3">
143
+ <input type="text" class="form-control" v-model="esProjectId" placeholder="Project ID">
144
+ <input type="text" class="form-control" v-model="esTitle" placeholder="Title">
145
+ <input type="text" class="form-control" v-model="esPubmedId" placeholder="PubMed ID">
146
+ <button class="btn btn-primary" @click="triggerMock('es_publication', {project_id: esProjectId, title: esTitle, pubmed_id: esPubmedId})">Add Publication</button>
147
+ </div>
148
+ </div>
149
+ </div>
150
+ <div class="d-grid gap-2">
151
+ <button class="btn btn-warning" @click="clearStore">Clear All Data</button>
152
+ </div>
153
+ </div>
154
+
155
+ <!-- Right Panel: Unified Directory API Explorer -->
156
+ <div class="col-md-8">
157
+ <h3 class="mb-3">Unified Directory API Explorer</h3>
158
+ <div class="card mb-3">
159
+ <div class="card-header">API Endpoint</div>
160
+ <div class="card-body">
161
+ <div class="input-group">
162
+ <span class="input-group-text">GET</span>
163
+ <input type="text" class="form-control" :value="apiEndpoint" readonly>
164
+ <select class="form-select" v-model="selectedProjectId" @change="fetchData">
165
+ <option value="ALL">ALL Projects</option>
166
+ <option v-for="proj in currentProjects" :value="proj.id">{{ proj.name }}</option>
167
+ </select>
168
+ <button class="btn btn-primary" @click="fetchData">Refresh</button>
169
+ </div>
170
+ </div>
171
+ </div>
172
+
173
+ <div class="d-flex justify-content-between align-items-center mb-3">
174
+ <h4>API Response</h4>
175
+ <div>
176
+ <button class="btn btn-sm btn-outline-light me-2" @click="toggleView('table')" :class="{'active': currentView === 'table'}">Table View</button>
177
+ <button class="btn btn-sm btn-outline-light" @click="toggleView('json')" :class="{'active': currentView === 'json'}">JSON View</button>
178
+ </div>
179
+ </div>
180
+
181
+ <div v-if="loading" class="text-center text-info mb-3">Loading...</div>
182
+ <div v-if="error" class="alert alert-danger">{{ error }}</div>
183
+
184
+ <div v-if="currentView === 'table'" class="table-responsive">
185
+ <table class="table table-dark table-striped table-hover">
186
+ <thead>
187
+ <tr>
188
+ <th>Name</th>
189
+ <th>Type</th>
190
+ <th>Logical Path</th>
191
+ <th>Source</th>
192
+ <th>Size</th>
193
+ <th>Last Modified</th>
194
+ <th>URL / Details</th>
195
+ </tr>
196
+ </thead>
197
+ <tbody>
198
+ <tr v-if="!unifiedDirectory.length && !loading">
199
+ <td colspan="7" class="text-center text-muted">No data available. Create some projects and items!</td>
200
+ </tr>
201
+ <template v-for="item in sortedDirectory" :key="item.id">
202
+ <tr :class="{'table-info': item.isNew}">
203
+ <td>
204
+ <i :class="getIcon(item.type)" class="me-2"></i>
205
+ {{ item.name }}
206
+ </td>
207
+ <td>
208
+ <span :class="getBadgeClass(item.type)">{{ item.type }}</span>
209
+ </td>
210
+ <td>{{ item.path }}</td>
211
+ <td>
212
+ <span :class="getBadgeClass(item.source_type)">{{ item.source_type }}</span>
213
+ </td>
214
+ <td>{{ formatSize(item.size) }}</td>
215
+ <td>{{ formatDateTime(item.last_modified) }}</td>
216
+ <td>
217
+ <a v-if="item.url" :href="item.url" target="_blank" class="text-info">{{ item.url.length > 30 ? item.url.substring(0,27) + '...' : item.url }}</a>
218
+ <span v-else-if="item.extra_metadata">{{ JSON.stringify(item.extra_metadata).substring(0, 50) + '...' }}</span>
219
+ <span v-else>-</span>
220
+ </td>
221
+ </tr>
222
+ </template>
223
+ </tbody>
224
+ </table>
225
+ </div>
226
+
227
+ <div v-if="currentView === 'json'" class="json-output">
228
+ <pre>{{ JSON.stringify(unifiedDirectory, null, 2) }}</pre>
229
+ </div>
230
+ </div>
231
+ </div>
232
+ </div>
233
+
234
+ <script>
235
+ const app = Vue.createApp({
236
+ data() {
237
+ return {
238
+ apiBaseUrl: window.location.origin,
239
+ unifiedDirectory: [],
240
+ currentProjects: [],
241
+ selectedProjectId: 'ALL',
242
+ loading: false,
243
+ error: null,
244
+ currentView: 'table', // 'table' or 'json'
245
+
246
+ // Mock agent input fields
247
+ mysqlProjectName: 'Project_Gamma',
248
+ nfsHotProjectId: 'project_alpha',
249
+ nfsHotFilename: 'sequencing_results.fastq',
250
+ nfsHotSize: 100,
251
+ nfsColdProjectId: 'project_alpha',
252
+ nfsColdFilename: 'archived_run_001.bam',
253
+ nfsColdSize: 50,
254
+ ossProjectId: 'project_alpha',
255
+ ossDatasetName: 'public_ref_genome.fasta',
256
+ ossPublicUrl: 'https://public-data.oss.com/ref_genome.fasta',
257
+ esProjectId: 'project_alpha',
258
+ esTitle: 'New Cancer Marker Discovery',
259
+ esPubmedId: '38123456',
260
+ };
261
+ },
262
+ computed: {
263
+ apiEndpoint() {
264
+ let endpoint = `${this.apiBaseUrl}/api/unified_directory`;
265
+ if (this.selectedProjectId) {
266
+ endpoint += `?project_id=${this.selectedProjectId}`;
267
+ }
268
+ return endpoint;
269
+ },
270
+ sortedDirectory() {
271
+ const projects = this.unifiedDirectory.filter(item => item.type === 'directory');
272
+ const items = this.unifiedDirectory.filter(item => item.type !== 'directory');
273
+
274
+ let sortedItems = items.sort((a, b) => {
275
+ const pathA = a.path || a.name;
276
+ const pathB = b.path || b.name;
277
+ return pathA.localeCompare(pathB);
278
+ });
279
+
280
+ // If "ALL Projects" is selected, group by project_id and display projects first
281
+ if (this.selectedProjectId === 'ALL') {
282
+ let result = [];
283
+ projects.forEach(proj => {
284
+ result.push(proj);
285
+ result.push(...sortedItems.filter(item => item.project_id === proj.id));
286
+ });
287
+ return result;
288
+ }
289
+ return sortedItems;
290
+ }
291
+ },
292
+ methods: {
293
+ async fetchData() {
294
+ this.loading = true;
295
+ this.error = null;
296
+ try {
297
+ const response = await axios.get(this.apiEndpoint);
298
+ this.unifiedDirectory = response.data;
299
+ this.updateProjectList();
300
+ } catch (err) {
301
+ this.error = 'Failed to fetch data: ' + (err.response?.data?.detail || err.message);
302
+ } finally {
303
+ this.loading = false;
304
+ }
305
+ },
306
+ async triggerMock(endpoint, data) {
307
+ this.loading = true;
308
+ this.error = null;
309
+ try {
310
+ const response = await axios.post(`${this.apiBaseUrl}/api/mock/${endpoint}`, data);
311
+ console.log(`Triggered mock ${endpoint}:`, response.data);
312
+ // Refresh data after trigger
313
+ await this.fetchData();
314
+ } catch (err) {
315
+ this.error = 'Failed to trigger mock event: ' + (err.response?.data?.detail || err.message);
316
+ } finally {
317
+ this.loading = false;
318
+ }
319
+ },
320
+ async clearStore() {
321
+ this.loading = true;
322
+ this.error = null;
323
+ try {
324
+ await axios.post(`${this.apiBaseUrl}/api/clear_store`);
325
+ await this.fetchData();
326
+ alert('Demo store cleared!');
327
+ } catch (err) {
328
+ this.error = 'Failed to clear store: ' + (err.response?.data?.detail || err.message);
329
+ } finally {
330
+ this.loading = false;
331
+ }
332
+ },
333
+ toggleView(view) {
334
+ this.currentView = view;
335
+ },
336
+ formatSize(bytes) {
337
+ if (bytes === null || bytes === undefined) return '-';
338
+ if (bytes === 0) return '0 Bytes';
339
+ const k = 1024;
340
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
341
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
342
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
343
+ },
344
+ formatDateTime(isoString) {
345
+ if (!isoString) return '-';
346
+ const date = new Date(isoString);
347
+ return date.toLocaleString();
348
+ },
349
+ getBadgeClass(sourceType) {
350
+ return `badge badge-${sourceType.toLowerCase().replace(/[^a-z0-9]/g, '_')}`;
351
+ },
352
+ getIcon(itemType) {
353
+ switch (itemType) {
354
+ case 'directory': return 'bi bi-folder-fill'; // Bootstrap Icons
355
+ case 'file': return 'bi bi-file-earmark';
356
+ case 'link': return 'bi bi-link-45deg';
357
+ case 'metadata': return 'bi bi-journal-richtext';
358
+ default: return 'bi bi-question-circle';
359
+ }
360
+ },
361
+ updateProjectList() {
362
+ // Filter out unique projects that are of type 'directory'
363
+ const uniqueProjects = {};
364
+ this.unifiedDirectory.forEach(item => {
365
+ if (item.type === 'directory') {
366
+ uniqueProjects[item.id] = { id: item.id, name: item.name };
367
+ }
368
+ });
369
+ this.currentProjects = Object.values(uniqueProjects);
370
+ }
371
+ },
372
+ mounted() {
373
+ this.fetchData();
374
+ // Fetch data every 2 seconds to simulate real-time updates
375
+ setInterval(this.fetchData, 2000);
376
+ }
377
+ });
378
+
379
+ app.mount('#app');
380
+ </script>
381
+ <!-- Bootstrap Icons (Optional, for better icons) -->
382
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.3/font/bootstrap-icons.css">
383
+ <!-- Bootstrap JS (Optional, if you need interactive components like modals) -->
384
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
385
+ </body>
386
+ </html>
@@ -0,0 +1,100 @@
1
+ from typing import Dict, Any, List, Optional
2
+ from datetime import datetime, timezone
3
+ import copy
4
+
5
+ class DemoStore:
6
+ """
7
+ A simple in-memory store for the demo.
8
+ Aggregates events into a unified directory structure, mimicking Fusion's role.
9
+ """
10
+ def __init__(self):
11
+ # Stores projects, indexed by project_id
12
+ # Example: { "project_alpha": { "id": "project_alpha", "name": "Project Alpha", "files": [], "metadata": [] } }
13
+ self._projects: Dict[str, Dict[str, Any]] = {}
14
+ self._lock = {} # Placeholder for thread-safety in a real app, simplified for demo
15
+
16
+ def add_event(self, event_data: Dict[str, Any]):
17
+ """
18
+ Adds an event to the store, aggregating it by project_id.
19
+ Expected event_data structure:
20
+ {
21
+ "id": "unique_id_for_this_item",
22
+ "name": "display_name",
23
+ "type": "file" | "directory" | "link" | "metadata",
24
+ "source_type": "mysql" | "nfs_hot" | "nfs_cold" | "oss" | "es",
25
+ "project_id": "bio_project_id",
26
+ "path": "/logical/path/to/item",
27
+ "size": "1024" | None,
28
+ "last_modified": "ISO_timestamp",
29
+ "url": "http://download.link" | None,
30
+ "extra_metadata": {}
31
+ }
32
+ """
33
+ project_id = event_data.get("project_id")
34
+ if not project_id:
35
+ # For events without a project_id, put them under a "Unassigned" project
36
+ project_id = "unassigned"
37
+ event_data["project_id"] = project_id
38
+
39
+ if project_id not in self._projects:
40
+ self._projects[project_id] = {
41
+ "id": project_id,
42
+ "name": event_data.get("project_name", project_id.replace("_", " ").title()),
43
+ "type": "directory",
44
+ "source_type": "mysql" if project_id != "unassigned" else "system", # Assume projects are from MySQL
45
+ "path": f"/{project_id}",
46
+ "items": [],
47
+ "last_modified": datetime.now(timezone.utc).isoformat()
48
+ }
49
+
50
+ # Add the item to the project's items list
51
+ # Ensure it's not a duplicate based on event_data['id']
52
+ # This is a very simplified deduplication. In a real app, you'd update existing items.
53
+ existing_item_ids = {item["id"] for item in self._projects[project_id]["items"]}
54
+ if event_data["id"] not in existing_item_ids:
55
+ self._projects[project_id]["items"].append(copy.deepcopy(event_data))
56
+ # Sort items for consistent display
57
+ self._projects[project_id]["items"].sort(key=lambda x: x.get("path", x.get("name", "")))
58
+ else:
59
+ # Update existing item
60
+ for i, item in enumerate(self._projects[project_id]["items"]):
61
+ if item["id"] == event_data["id"]:
62
+ self._projects[project_id]["items"][i] = copy.deepcopy(event_data)
63
+ break
64
+
65
+
66
+ def get_unified_directory(self, project_id: Optional[str] = None) -> List[Dict[str, Any]]:
67
+ """
68
+ Retrieves the unified directory structure.
69
+ If project_id is None, returns all top-level projects.
70
+ If project_id is specified, returns items within that project.
71
+ """
72
+ if project_id == "ALL": # Special case for all top-level projects
73
+ # Return top-level projects, sorted by name
74
+ return sorted(
75
+ [copy.deepcopy(proj) for proj in self._projects.values() if proj["type"] == "directory"],
76
+ key=lambda x: x["name"]
77
+ )
78
+ elif project_id and project_id in self._projects:
79
+ # Return items within a specific project
80
+ return sorted(
81
+ copy.deepcopy(self._projects[project_id]["items"]),
82
+ key=lambda x: x.get("path", x.get("name", ""))
83
+ )
84
+ else:
85
+ # Return all items if no project specified, or for invalid project_id
86
+ all_items = []
87
+ for proj in self._projects.values():
88
+ all_items.append(copy.deepcopy(proj))
89
+ all_items.extend(copy.deepcopy(proj["items"]))
90
+ return sorted(
91
+ all_items,
92
+ key=lambda x: x.get("path", x.get("name", ""))
93
+ )
94
+
95
+ def clear(self):
96
+ """Clears all stored data."""
97
+ self._projects = {}
98
+
99
+ # Instantiate a global store for the demo server
100
+ demo_store = DemoStore()
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: fustor-demo
3
+ Version: 0.2
4
+ Summary: Fustor Bio-Fusion Demo for unified directory service
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: fastapi[all]>=0.104.1
7
+ Requires-Dist: uvicorn>=0.23.2
8
+ Requires-Dist: fustor-core
9
+ Requires-Dist: fustor-event-model
10
+ Requires-Dist: pydantic>=2.0.0
11
+ Requires-Dist: httpx>=0.25.0
@@ -0,0 +1,11 @@
1
+ pyproject.toml
2
+ src/fustor_demo/auto_generator.py
3
+ src/fustor_demo/main.py
4
+ src/fustor_demo/mock_agents.py
5
+ src/fustor_demo/store.py
6
+ src/fustor_demo.egg-info/PKG-INFO
7
+ src/fustor_demo.egg-info/SOURCES.txt
8
+ src/fustor_demo.egg-info/dependency_links.txt
9
+ src/fustor_demo.egg-info/requires.txt
10
+ src/fustor_demo.egg-info/top_level.txt
11
+ src/fustor_demo/static/index.html
@@ -0,0 +1,6 @@
1
+ fastapi[all]>=0.104.1
2
+ uvicorn>=0.23.2
3
+ fustor-core
4
+ fustor-event-model
5
+ pydantic>=2.0.0
6
+ httpx>=0.25.0
@@ -0,0 +1 @@
1
+ fustor_demo