perf: optimize login transition and dashboard loading performance
Browse files- Eliminate N+1 lazy queries in activity, documents, and workflows endpoints using joinedload/selectinload
- Aggregate dashboard KPI counts into a single SQL query to reduce remote DB roundtrips
- Increase DB pool size to 10 with max_overflow of 20 to prevent connection starvation
- Add in-flight GET request deduplication in frontend API client
- Remove blocking /auth/me call on login to enable instant navigation
- Cache workspace data in localStorage to eliminate sequential loading waterfalls
- Implement session caching and skeleton shimmer states in Dashboard UI
- backend/app/api/activity.py +22 -19
- backend/app/api/dashboard.py +14 -53
- backend/app/api/documents.py +5 -1
- backend/app/api/workflows.py +14 -2
- backend/app/database/database.py +2 -2
- docs/one-pager.md +40 -0
- frontend/src/api/client.js +22 -0
- frontend/src/context/WorkspaceContext.jsx +106 -87
- frontend/src/pages/Dashboard.css +25 -0
- frontend/src/pages/Dashboard.jsx +78 -20
- frontend/src/pages/auth/Login.jsx +0 -4
backend/app/api/activity.py
CHANGED
|
@@ -5,12 +5,14 @@ import json
|
|
| 5 |
from uuid import UUID
|
| 6 |
|
| 7 |
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 8 |
-
from sqlalchemy.orm import Session
|
| 9 |
|
| 10 |
from app.core.dependencies import get_current_user
|
| 11 |
from app.database.session import get_db
|
| 12 |
from app.models.commit import Commit
|
|
|
|
| 13 |
from app.models.document_version import DocumentVersion
|
|
|
|
| 14 |
from app.models.proposal import Proposal, ProposalStatus
|
| 15 |
from app.models.review import Review
|
| 16 |
from app.models.user import User
|
|
@@ -86,20 +88,17 @@ def list_activity(
|
|
| 86 |
if cursor:
|
| 87 |
cursor_ts, cursor_id = _decode_cursor(cursor)
|
| 88 |
|
| 89 |
-
#
|
| 90 |
-
|
| 91 |
-
# The correctness guarantee comes from the post-merge cursor filter,
|
| 92 |
-
# not from per-table limiting. Use a high multiplier to ensure all
|
| 93 |
-
# relevant rows are loaded.
|
| 94 |
-
fetch_limit = max(limit * 10, 500)
|
| 95 |
|
| 96 |
events: list[dict] = []
|
| 97 |
|
| 98 |
# ------------------------------------------------------------------
|
| 99 |
-
# Workflow events
|
| 100 |
# ------------------------------------------------------------------
|
| 101 |
workflows = (
|
| 102 |
db.query(WorkflowRun)
|
|
|
|
| 103 |
.filter(WorkflowRun.workspace_id == workspace_id)
|
| 104 |
.order_by(WorkflowRun.started_at.desc())
|
| 105 |
.limit(fetch_limit)
|
|
@@ -144,12 +143,12 @@ def list_activity(
|
|
| 144 |
})
|
| 145 |
|
| 146 |
# ------------------------------------------------------------------
|
| 147 |
-
# Document upload events
|
| 148 |
# ------------------------------------------------------------------
|
| 149 |
versions = (
|
| 150 |
db.query(DocumentVersion)
|
| 151 |
-
.join(DocumentVersion.
|
| 152 |
-
.filter(
|
| 153 |
.order_by(DocumentVersion.uploaded_at.desc())
|
| 154 |
.limit(fetch_limit)
|
| 155 |
.all()
|
|
@@ -168,17 +167,19 @@ def list_activity(
|
|
| 168 |
})
|
| 169 |
|
| 170 |
# ------------------------------------------------------------------
|
| 171 |
-
# Proposal events
|
| 172 |
# ------------------------------------------------------------------
|
| 173 |
proposals = (
|
| 174 |
db.query(Proposal)
|
|
|
|
|
|
|
|
|
|
| 175 |
.filter(Proposal.workspace_id == workspace_id)
|
| 176 |
.order_by(Proposal.created_at.desc())
|
| 177 |
.limit(fetch_limit)
|
| 178 |
.all()
|
| 179 |
)
|
| 180 |
for p in proposals:
|
| 181 |
-
# Walk the lazy-load chain: proposal → knowledge_item → document_version → filename
|
| 182 |
p_filename = None
|
| 183 |
ki = p.knowledge_item
|
| 184 |
if ki and ki.document_version:
|
|
@@ -234,22 +235,24 @@ def list_activity(
|
|
| 234 |
})
|
| 235 |
|
| 236 |
# ------------------------------------------------------------------
|
| 237 |
-
# Commit events
|
| 238 |
# ------------------------------------------------------------------
|
| 239 |
commits = (
|
| 240 |
db.query(Commit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
.filter(Commit.workspace_id == workspace_id)
|
| 242 |
.order_by(Commit.committed_at.desc())
|
| 243 |
.limit(fetch_limit)
|
| 244 |
.all()
|
| 245 |
)
|
| 246 |
for c in commits:
|
| 247 |
-
# Walk the lazy-load chain: commit → proposal → knowledge_item → document_version → filename
|
| 248 |
c_filename = None
|
| 249 |
-
if c.
|
| 250 |
-
|
| 251 |
-
if c_proposal and c_proposal.knowledge_item and c_proposal.knowledge_item.document_version:
|
| 252 |
-
c_filename = c_proposal.knowledge_item.document_version.filename
|
| 253 |
|
| 254 |
events.append({
|
| 255 |
"id": f"commit-{c.id}",
|
|
|
|
| 5 |
from uuid import UUID
|
| 6 |
|
| 7 |
from fastapi import APIRouter, Depends, HTTPException, Query
|
| 8 |
+
from sqlalchemy.orm import Session, joinedload
|
| 9 |
|
| 10 |
from app.core.dependencies import get_current_user
|
| 11 |
from app.database.session import get_db
|
| 12 |
from app.models.commit import Commit
|
| 13 |
+
from app.models.document import Document
|
| 14 |
from app.models.document_version import DocumentVersion
|
| 15 |
+
from app.models.knowledge_item import KnowledgeItem
|
| 16 |
from app.models.proposal import Proposal, ProposalStatus
|
| 17 |
from app.models.review import Review
|
| 18 |
from app.models.user import User
|
|
|
|
| 88 |
if cursor:
|
| 89 |
cursor_ts, cursor_id = _decode_cursor(cursor)
|
| 90 |
|
| 91 |
+
# Fetch enough rows per table to satisfy the merged limit without overfetching
|
| 92 |
+
fetch_limit = min(max(limit * 2, 20), 100)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
events: list[dict] = []
|
| 95 |
|
| 96 |
# ------------------------------------------------------------------
|
| 97 |
+
# Workflow events (eagerly load document_version in 1 query)
|
| 98 |
# ------------------------------------------------------------------
|
| 99 |
workflows = (
|
| 100 |
db.query(WorkflowRun)
|
| 101 |
+
.options(joinedload(WorkflowRun.document_version))
|
| 102 |
.filter(WorkflowRun.workspace_id == workspace_id)
|
| 103 |
.order_by(WorkflowRun.started_at.desc())
|
| 104 |
.limit(fetch_limit)
|
|
|
|
| 143 |
})
|
| 144 |
|
| 145 |
# ------------------------------------------------------------------
|
| 146 |
+
# Document upload events (direct join on documents)
|
| 147 |
# ------------------------------------------------------------------
|
| 148 |
versions = (
|
| 149 |
db.query(DocumentVersion)
|
| 150 |
+
.join(Document, DocumentVersion.document_id == Document.id)
|
| 151 |
+
.filter(Document.workspace_id == workspace_id)
|
| 152 |
.order_by(DocumentVersion.uploaded_at.desc())
|
| 153 |
.limit(fetch_limit)
|
| 154 |
.all()
|
|
|
|
| 167 |
})
|
| 168 |
|
| 169 |
# ------------------------------------------------------------------
|
| 170 |
+
# Proposal events (eagerly load knowledge_item -> document_version)
|
| 171 |
# ------------------------------------------------------------------
|
| 172 |
proposals = (
|
| 173 |
db.query(Proposal)
|
| 174 |
+
.options(
|
| 175 |
+
joinedload(Proposal.knowledge_item).joinedload(KnowledgeItem.document_version)
|
| 176 |
+
)
|
| 177 |
.filter(Proposal.workspace_id == workspace_id)
|
| 178 |
.order_by(Proposal.created_at.desc())
|
| 179 |
.limit(fetch_limit)
|
| 180 |
.all()
|
| 181 |
)
|
| 182 |
for p in proposals:
|
|
|
|
| 183 |
p_filename = None
|
| 184 |
ki = p.knowledge_item
|
| 185 |
if ki and ki.document_version:
|
|
|
|
| 235 |
})
|
| 236 |
|
| 237 |
# ------------------------------------------------------------------
|
| 238 |
+
# Commit events (eagerly load proposal -> knowledge_item -> document_version)
|
| 239 |
# ------------------------------------------------------------------
|
| 240 |
commits = (
|
| 241 |
db.query(Commit)
|
| 242 |
+
.options(
|
| 243 |
+
joinedload(Commit.proposal)
|
| 244 |
+
.joinedload(Proposal.knowledge_item)
|
| 245 |
+
.joinedload(KnowledgeItem.document_version)
|
| 246 |
+
)
|
| 247 |
.filter(Commit.workspace_id == workspace_id)
|
| 248 |
.order_by(Commit.committed_at.desc())
|
| 249 |
.limit(fetch_limit)
|
| 250 |
.all()
|
| 251 |
)
|
| 252 |
for c in commits:
|
|
|
|
| 253 |
c_filename = None
|
| 254 |
+
if c.proposal and c.proposal.knowledge_item and c.proposal.knowledge_item.document_version:
|
| 255 |
+
c_filename = c.proposal.knowledge_item.document_version.filename
|
|
|
|
|
|
|
| 256 |
|
| 257 |
events.append({
|
| 258 |
"id": f"commit-{c.id}",
|
backend/app/api/dashboard.py
CHANGED
|
@@ -42,59 +42,20 @@ def get_dashboard_stats(
|
|
| 42 |
detail="You do not have access to this workspace.",
|
| 43 |
)
|
| 44 |
|
| 45 |
-
|
| 46 |
-
db.query(func.count(Document.id))
|
| 47 |
-
.filter(
|
| 48 |
-
.
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
.filter(
|
| 54 |
-
WorkflowRun.workspace_id == workspace_id,
|
| 55 |
-
WorkflowRun.status == WorkflowStatus.RUNNING,
|
| 56 |
-
)
|
| 57 |
-
.scalar()
|
| 58 |
-
) or 0
|
| 59 |
-
|
| 60 |
-
workflows_waiting = (
|
| 61 |
-
db.query(func.count(WorkflowRun.id))
|
| 62 |
-
.filter(
|
| 63 |
-
WorkflowRun.workspace_id == workspace_id,
|
| 64 |
-
WorkflowRun.status == WorkflowStatus.WAITING_FOR_REVIEW,
|
| 65 |
-
)
|
| 66 |
-
.scalar()
|
| 67 |
-
) or 0
|
| 68 |
-
|
| 69 |
-
workflows_completed = (
|
| 70 |
-
db.query(func.count(WorkflowRun.id))
|
| 71 |
-
.filter(
|
| 72 |
-
WorkflowRun.workspace_id == workspace_id,
|
| 73 |
-
WorkflowRun.status == WorkflowStatus.COMPLETED,
|
| 74 |
-
)
|
| 75 |
-
.scalar()
|
| 76 |
-
) or 0
|
| 77 |
-
|
| 78 |
-
pending_proposals = (
|
| 79 |
-
db.query(func.count(Proposal.id))
|
| 80 |
-
.filter(
|
| 81 |
-
Proposal.workspace_id == workspace_id,
|
| 82 |
-
Proposal.status == ProposalStatus.PENDING,
|
| 83 |
-
)
|
| 84 |
-
.scalar()
|
| 85 |
-
) or 0
|
| 86 |
-
|
| 87 |
-
knowledge_items = (
|
| 88 |
-
db.query(func.count(KnowledgeItem.id))
|
| 89 |
-
.filter(KnowledgeItem.workspace_id == workspace_id)
|
| 90 |
-
.scalar()
|
| 91 |
-
) or 0
|
| 92 |
|
| 93 |
return {
|
| 94 |
-
"total_documents": total_documents,
|
| 95 |
-
"workflows_running": workflows_running,
|
| 96 |
-
"workflows_waiting_for_review":
|
| 97 |
-
"workflows_completed": workflows_completed,
|
| 98 |
-
"pending_proposals": pending_proposals,
|
| 99 |
-
"knowledge_items": knowledge_items,
|
| 100 |
}
|
|
|
|
| 42 |
detail="You do not have access to this workspace.",
|
| 43 |
)
|
| 44 |
|
| 45 |
+
stats = db.query(
|
| 46 |
+
db.query(func.count(Document.id)).filter(Document.workspace_id == workspace_id).scalar_subquery().label("total_documents"),
|
| 47 |
+
db.query(func.count(WorkflowRun.id)).filter(WorkflowRun.workspace_id == workspace_id, WorkflowRun.status == WorkflowStatus.RUNNING).scalar_subquery().label("workflows_running"),
|
| 48 |
+
db.query(func.count(WorkflowRun.id)).filter(WorkflowRun.workspace_id == workspace_id, WorkflowRun.status == WorkflowStatus.WAITING_FOR_REVIEW).scalar_subquery().label("workflows_waiting_for_review"),
|
| 49 |
+
db.query(func.count(WorkflowRun.id)).filter(WorkflowRun.workspace_id == workspace_id, WorkflowRun.status == WorkflowStatus.COMPLETED).scalar_subquery().label("workflows_completed"),
|
| 50 |
+
db.query(func.count(Proposal.id)).filter(Proposal.workspace_id == workspace_id, Proposal.status == ProposalStatus.PENDING).scalar_subquery().label("pending_proposals"),
|
| 51 |
+
db.query(func.count(KnowledgeItem.id)).filter(KnowledgeItem.workspace_id == workspace_id).scalar_subquery().label("knowledge_items"),
|
| 52 |
+
).first()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
return {
|
| 55 |
+
"total_documents": (stats.total_documents if stats else 0) or 0,
|
| 56 |
+
"workflows_running": (stats.workflows_running if stats else 0) or 0,
|
| 57 |
+
"workflows_waiting_for_review": (stats.workflows_waiting_for_review if stats else 0) or 0,
|
| 58 |
+
"workflows_completed": (stats.workflows_completed if stats else 0) or 0,
|
| 59 |
+
"pending_proposals": (stats.pending_proposals if stats else 0) or 0,
|
| 60 |
+
"knowledge_items": (stats.knowledge_items if stats else 0) or 0,
|
| 61 |
}
|
backend/app/api/documents.py
CHANGED
|
@@ -3,12 +3,13 @@ import os
|
|
| 3 |
from uuid import UUID
|
| 4 |
|
| 5 |
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
| 6 |
-
from sqlalchemy.orm import Session
|
| 7 |
|
| 8 |
from app.core.dependencies import get_current_user
|
| 9 |
from app.core.file_types import ALLOWED_EXTENSIONS
|
| 10 |
from app.database.session import get_db
|
| 11 |
from app.models.document import Document
|
|
|
|
| 12 |
from app.models.user import User
|
| 13 |
from app.models.workflow_run import WorkflowRun, WorkflowStatus
|
| 14 |
from app.models.workspace import Workspace
|
|
@@ -32,6 +33,9 @@ def list_documents(
|
|
| 32 |
|
| 33 |
documents = (
|
| 34 |
db.query(Document)
|
|
|
|
|
|
|
|
|
|
| 35 |
.filter(Document.workspace_id == workspace_id)
|
| 36 |
.order_by(Document.created_at.desc())
|
| 37 |
.all()
|
|
|
|
| 3 |
from uuid import UUID
|
| 4 |
|
| 5 |
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
| 6 |
+
from sqlalchemy.orm import Session, selectinload
|
| 7 |
|
| 8 |
from app.core.dependencies import get_current_user
|
| 9 |
from app.core.file_types import ALLOWED_EXTENSIONS
|
| 10 |
from app.database.session import get_db
|
| 11 |
from app.models.document import Document
|
| 12 |
+
from app.models.document_version import DocumentVersion
|
| 13 |
from app.models.user import User
|
| 14 |
from app.models.workflow_run import WorkflowRun, WorkflowStatus
|
| 15 |
from app.models.workspace import Workspace
|
|
|
|
| 33 |
|
| 34 |
documents = (
|
| 35 |
db.query(Document)
|
| 36 |
+
.options(
|
| 37 |
+
selectinload(Document.versions).selectinload(DocumentVersion.workflow_runs)
|
| 38 |
+
)
|
| 39 |
.filter(Document.workspace_id == workspace_id)
|
| 40 |
.order_by(Document.created_at.desc())
|
| 41 |
.all()
|
backend/app/api/workflows.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
from uuid import UUID
|
| 4 |
|
| 5 |
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
-
from sqlalchemy.orm import Session
|
| 7 |
|
| 8 |
from app.core.dependencies import get_current_user
|
| 9 |
from app.database.session import get_db
|
|
@@ -93,6 +93,10 @@ def list_workflows(
|
|
| 93 |
|
| 94 |
query = (
|
| 95 |
db.query(WorkflowRun)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
.filter(WorkflowRun.workspace_id == workspace_id)
|
| 97 |
)
|
| 98 |
|
|
@@ -115,6 +119,10 @@ def get_workflow_by_document_version(
|
|
| 115 |
):
|
| 116 |
workflow = (
|
| 117 |
db.query(WorkflowRun)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
.filter(WorkflowRun.document_version_id == document_version_id)
|
| 119 |
.order_by(WorkflowRun.started_at.desc())
|
| 120 |
.first()
|
|
@@ -166,6 +174,10 @@ def get_workflow(
|
|
| 166 |
):
|
| 167 |
workflow = (
|
| 168 |
db.query(WorkflowRun)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
.filter(WorkflowRun.id == workflow_id)
|
| 170 |
.first()
|
| 171 |
)
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
|
| 3 |
from uuid import UUID
|
| 4 |
|
| 5 |
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
from sqlalchemy.orm import Session, joinedload, selectinload
|
| 7 |
|
| 8 |
from app.core.dependencies import get_current_user
|
| 9 |
from app.database.session import get_db
|
|
|
|
| 93 |
|
| 94 |
query = (
|
| 95 |
db.query(WorkflowRun)
|
| 96 |
+
.options(
|
| 97 |
+
joinedload(WorkflowRun.document_version),
|
| 98 |
+
selectinload(WorkflowRun.checkpoints),
|
| 99 |
+
)
|
| 100 |
.filter(WorkflowRun.workspace_id == workspace_id)
|
| 101 |
)
|
| 102 |
|
|
|
|
| 119 |
):
|
| 120 |
workflow = (
|
| 121 |
db.query(WorkflowRun)
|
| 122 |
+
.options(
|
| 123 |
+
joinedload(WorkflowRun.document_version),
|
| 124 |
+
selectinload(WorkflowRun.checkpoints),
|
| 125 |
+
)
|
| 126 |
.filter(WorkflowRun.document_version_id == document_version_id)
|
| 127 |
.order_by(WorkflowRun.started_at.desc())
|
| 128 |
.first()
|
|
|
|
| 174 |
):
|
| 175 |
workflow = (
|
| 176 |
db.query(WorkflowRun)
|
| 177 |
+
.options(
|
| 178 |
+
joinedload(WorkflowRun.document_version),
|
| 179 |
+
selectinload(WorkflowRun.checkpoints),
|
| 180 |
+
)
|
| 181 |
.filter(WorkflowRun.id == workflow_id)
|
| 182 |
.first()
|
| 183 |
)
|
backend/app/database/database.py
CHANGED
|
@@ -6,8 +6,8 @@ from app.core.config import DATABASE_URL
|
|
| 6 |
engine = create_engine(
|
| 7 |
DATABASE_URL,
|
| 8 |
pool_pre_ping=True,
|
| 9 |
-
pool_size=
|
| 10 |
-
max_overflow=
|
| 11 |
pool_recycle=300,
|
| 12 |
)
|
| 13 |
|
|
|
|
| 6 |
engine = create_engine(
|
| 7 |
DATABASE_URL,
|
| 8 |
pool_pre_ping=True,
|
| 9 |
+
pool_size=10,
|
| 10 |
+
max_overflow=20,
|
| 11 |
pool_recycle=300,
|
| 12 |
)
|
| 13 |
|
docs/one-pager.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DocWeave — One Page
|
| 2 |
+
|
| 3 |
+
## What I Built
|
| 4 |
+
|
| 5 |
+
DocWeave is an agentic document intelligence platform that transforms unstructured documents into a governed knowledge register. It processes PDFs through an AI pipeline (extract, chunk, embed, extract knowledge, reconcile, validate, decide) and either auto-commits verified knowledge or pauses for human review — depending on configurable validation rules.
|
| 6 |
+
|
| 7 |
+
Think of it as "GitHub for documents": every knowledge change is proposed, validated against rules, reviewed if needed, and committed with full audit trail.
|
| 8 |
+
|
| 9 |
+
## Who It's For
|
| 10 |
+
|
| 11 |
+
Teams that manage large document corpora and need structured, searchable, auditable knowledge — compliance officers, research groups, clinical teams, legal departments. Anyone who's ever said "I know we read this somewhere but can't find which document said it."
|
| 12 |
+
|
| 13 |
+
## Results
|
| 14 |
+
|
| 15 |
+
- **End-to-end processing**: Upload a PDF, get structured knowledge items (ENTITY, CLAIM, METHOD, METRIC, OBSERVATION) with verbatim evidence quotes and confidence scores in 20-45 seconds.
|
| 16 |
+
- **Zero-loss batch extraction**: Documents of any size are batched and processed sequentially — no data is truncated or lost.
|
| 17 |
+
- **Human-in-the-loop**: 4 configurable validation operators (min_confidence, required_evidence, allowed_proposal_types, topic_relevance) determine auto-commit vs. human review.
|
| 18 |
+
- **Crash resilience**: LangGraph checkpointing means workflows survive server restarts. Graceful shutdown waits for active work; auto-resume picks up orphaned workflows.
|
| 19 |
+
- **MCP integration**: 15-tool Model Context Protocol server lets AI agents drive the full lifecycle programmatically.
|
| 20 |
+
- **Deployed**: Frontend on Vercel, backend on HuggingFace Spaces (Docker), database on Neon PostgreSQL (Singapore).
|
| 21 |
+
|
| 22 |
+
## Key Trade-Offs
|
| 23 |
+
|
| 24 |
+
| Decision | Why |
|
| 25 |
+
|----------|-----|
|
| 26 |
+
| **Background threads (not Celery)** | Simpler deployment, no Redis/broker needed. Trade-off: limited to single-server horizontal scaling. Acceptable for a demo/small team tool. |
|
| 27 |
+
| **Groq free tier LLM** | Zero cost, fast inference. Trade-off: strict request size limits on free org tier — knowledge extraction requires small batches. A paid tier or self-hosted model removes this. |
|
| 28 |
+
| **Ephemeral file storage on HF** | Files don't persist across container restarts. Trade-off: retry on old docs fails. Acceptable because production would use S3/GCS. Demo works for freshly uploaded docs. |
|
| 29 |
+
| **sentence-transformers local embeddings** | No external API dependency, runs on CPU. Trade-off: ~300MB memory footprint, can't run on 512MB servers (Render free tier). Works fine on HF Spaces (16GB RAM). |
|
| 30 |
+
| **PostgreSQL for everything (vectors + relational)** | Single database, pgvector handles vector search. Trade-off: not as fast as dedicated vector DBs (Pinecone, Weaviate) at scale. Perfectly adequate for <100K documents. |
|
| 31 |
+
| **Native JSON mode (not tool calling)** | More reliable structured output from Groq/Qwen/Llama models. Trade-off: less strict schema enforcement than OpenAI function calling. Mitigated by robust JSON parsing with fallbacks. |
|
| 32 |
+
|
| 33 |
+
## What I'd Add With More Time
|
| 34 |
+
|
| 35 |
+
- Persistent cloud storage (S3) for uploaded documents
|
| 36 |
+
- WebSocket-based real-time progress (currently polls)
|
| 37 |
+
- Multi-user collaboration with role-based access
|
| 38 |
+
- Knowledge graph visualization (entity relationship map)
|
| 39 |
+
- Automatic conflict resolution using LLM reasoning
|
| 40 |
+
- PDF annotation overlay showing where each knowledge item was extracted from
|
frontend/src/api/client.js
CHANGED
|
@@ -1,6 +1,28 @@
|
|
| 1 |
const API_BASE = import.meta.env.VITE_API_BASE_URL || (import.meta.env.DEV ? "http://localhost:8000" : "");
|
| 2 |
|
|
|
|
|
|
|
| 3 |
export const apiRequest = async (endpoint, method = "GET", body = null) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
const token = localStorage.getItem("token");
|
| 5 |
const headers = {};
|
| 6 |
|
|
|
|
| 1 |
const API_BASE = import.meta.env.VITE_API_BASE_URL || (import.meta.env.DEV ? "http://localhost:8000" : "");
|
| 2 |
|
| 3 |
+
const inFlightRequests = new Map();
|
| 4 |
+
|
| 5 |
export const apiRequest = async (endpoint, method = "GET", body = null) => {
|
| 6 |
+
// Deduplicate concurrent in-flight GET requests
|
| 7 |
+
if (method === "GET" && !body) {
|
| 8 |
+
if (inFlightRequests.has(endpoint)) {
|
| 9 |
+
return inFlightRequests.get(endpoint);
|
| 10 |
+
}
|
| 11 |
+
const promise = (async () => {
|
| 12 |
+
try {
|
| 13 |
+
return await _performRequest(endpoint, method, body);
|
| 14 |
+
} finally {
|
| 15 |
+
inFlightRequests.delete(endpoint);
|
| 16 |
+
}
|
| 17 |
+
})();
|
| 18 |
+
inFlightRequests.set(endpoint, promise);
|
| 19 |
+
return promise;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
return _performRequest(endpoint, method, body);
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
const _performRequest = async (endpoint, method = "GET", body = null) => {
|
| 26 |
const token = localStorage.getItem("token");
|
| 27 |
const headers = {};
|
| 28 |
|
frontend/src/context/WorkspaceContext.jsx
CHANGED
|
@@ -1,87 +1,106 @@
|
|
| 1 |
-
import { createContext, useContext, useCallback, useEffect, useState } from "react";
|
| 2 |
-
import { listWorkspaces, createWorkspace } from "../api/workspaces";
|
| 3 |
-
|
| 4 |
-
const WorkspaceContext = createContext();
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext, useContext, useCallback, useEffect, useState } from "react";
|
| 2 |
+
import { listWorkspaces, createWorkspace } from "../api/workspaces";
|
| 3 |
+
|
| 4 |
+
const WorkspaceContext = createContext();
|
| 5 |
+
|
| 6 |
+
const getInitialCache = () => {
|
| 7 |
+
try {
|
| 8 |
+
const raw = localStorage.getItem("dw_workspaces_cache");
|
| 9 |
+
return raw ? JSON.parse(raw) : [];
|
| 10 |
+
} catch {
|
| 11 |
+
return [];
|
| 12 |
+
}
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export function WorkspaceProvider({ children }) {
|
| 16 |
+
const [workspaces, setWorkspaces] = useState(getInitialCache);
|
| 17 |
+
const [currentId, setCurrentId] = useState(() => localStorage.getItem("dw_workspace_id") || null);
|
| 18 |
+
const [loading, setLoading] = useState(() => {
|
| 19 |
+
const cached = getInitialCache();
|
| 20 |
+
const savedId = localStorage.getItem("dw_workspace_id");
|
| 21 |
+
return !(cached.length > 0 && (savedId ? cached.some((w) => w.id === savedId) : true));
|
| 22 |
+
});
|
| 23 |
+
const [error, setError] = useState(null);
|
| 24 |
+
|
| 25 |
+
const refresh = useCallback(async () => {
|
| 26 |
+
setError(null);
|
| 27 |
+
try {
|
| 28 |
+
const list = await listWorkspaces();
|
| 29 |
+
|
| 30 |
+
if (!Array.isArray(list)) {
|
| 31 |
+
if (list?.detail) setError(list.detail);
|
| 32 |
+
return;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
if (list.length === 0) {
|
| 36 |
+
const created = await createWorkspace({
|
| 37 |
+
name: "Default Workspace",
|
| 38 |
+
description: "Created automatically for document uploads.",
|
| 39 |
+
});
|
| 40 |
+
if (created?.detail) {
|
| 41 |
+
setError(created.detail);
|
| 42 |
+
return;
|
| 43 |
+
}
|
| 44 |
+
setWorkspaces([created]);
|
| 45 |
+
setCurrentId(created.id);
|
| 46 |
+
localStorage.setItem("dw_workspace_id", created.id);
|
| 47 |
+
localStorage.setItem("dw_workspaces_cache", JSON.stringify([created]));
|
| 48 |
+
return;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
setWorkspaces(list);
|
| 52 |
+
localStorage.setItem("dw_workspaces_cache", JSON.stringify(list));
|
| 53 |
+
setCurrentId((prev) => {
|
| 54 |
+
const saved = prev || localStorage.getItem("dw_workspace_id");
|
| 55 |
+
if (saved && list.some((w) => w.id === saved)) return saved;
|
| 56 |
+
localStorage.setItem("dw_workspace_id", list[0].id);
|
| 57 |
+
return list[0].id;
|
| 58 |
+
});
|
| 59 |
+
} catch (err) {
|
| 60 |
+
setError("Couldn't load workspaces.");
|
| 61 |
+
} finally {
|
| 62 |
+
setLoading(false);
|
| 63 |
+
}
|
| 64 |
+
}, []);
|
| 65 |
+
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
refresh();
|
| 68 |
+
}, [refresh]);
|
| 69 |
+
|
| 70 |
+
const selectWorkspace = useCallback((id) => {
|
| 71 |
+
setCurrentId(id);
|
| 72 |
+
localStorage.setItem("dw_workspace_id", id);
|
| 73 |
+
}, []);
|
| 74 |
+
|
| 75 |
+
const addWorkspace = useCallback(async (name, description = "") => {
|
| 76 |
+
const created = await createWorkspace({ name, description });
|
| 77 |
+
if (created?.detail) {
|
| 78 |
+
setError(created.detail);
|
| 79 |
+
return null;
|
| 80 |
+
}
|
| 81 |
+
setWorkspaces((prev) => {
|
| 82 |
+
const next = [...prev, created];
|
| 83 |
+
localStorage.setItem("dw_workspaces_cache", JSON.stringify(next));
|
| 84 |
+
return next;
|
| 85 |
+
});
|
| 86 |
+
setCurrentId(created.id);
|
| 87 |
+
localStorage.setItem("dw_workspace_id", created.id);
|
| 88 |
+
return created;
|
| 89 |
+
}, []);
|
| 90 |
+
|
| 91 |
+
const currentWorkspace = workspaces.find((w) => w.id === currentId) || (workspaces.length > 0 ? workspaces[0] : null);
|
| 92 |
+
|
| 93 |
+
const value = {
|
| 94 |
+
workspace: currentWorkspace,
|
| 95 |
+
workspaces,
|
| 96 |
+
loading,
|
| 97 |
+
error,
|
| 98 |
+
selectWorkspace,
|
| 99 |
+
addWorkspace,
|
| 100 |
+
refresh,
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
return <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider>;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
export const useWorkspaceContext = () => useContext(WorkspaceContext);
|
frontend/src/pages/Dashboard.css
CHANGED
|
@@ -212,6 +212,31 @@
|
|
| 212 |
}
|
| 213 |
}
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
@media (max-width: 480px) {
|
| 216 |
.dw-dashboard__kpis {
|
| 217 |
grid-template-columns: repeat(2, 1fr);
|
|
|
|
| 212 |
}
|
| 213 |
}
|
| 214 |
|
| 215 |
+
.dw-dashboard__skeleton {
|
| 216 |
+
animation: dw-shimmer 1.4s ease-in-out infinite;
|
| 217 |
+
background: var(--color-surface-hover);
|
| 218 |
+
border-radius: var(--radius-sm);
|
| 219 |
+
display: inline-block;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.dw-dashboard__skeleton-val {
|
| 223 |
+
width: 36px;
|
| 224 |
+
height: 28px;
|
| 225 |
+
vertical-align: middle;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.dw-dashboard__skeleton-row {
|
| 229 |
+
height: 38px;
|
| 230 |
+
margin-bottom: var(--space-2);
|
| 231 |
+
width: 100%;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
@keyframes dw-shimmer {
|
| 235 |
+
0% { opacity: 0.35; }
|
| 236 |
+
50% { opacity: 0.75; }
|
| 237 |
+
100% { opacity: 0.35; }
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
@media (max-width: 480px) {
|
| 241 |
.dw-dashboard__kpis {
|
| 242 |
grid-template-columns: repeat(2, 1fr);
|
frontend/src/pages/Dashboard.jsx
CHANGED
|
@@ -32,41 +32,76 @@ const EVENT_ICONS = {
|
|
| 32 |
const ACTIVE_STATUSES = new Set(["PENDING", "RUNNING"]);
|
| 33 |
const POLL_INTERVAL = 3000;
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
export default function Dashboard() {
|
| 36 |
const { workspace, loading: wsLoading } = useWorkspace();
|
| 37 |
const navigate = useNavigate();
|
| 38 |
-
|
| 39 |
-
const
|
| 40 |
-
const [
|
| 41 |
-
const [
|
|
|
|
|
|
|
| 42 |
|
| 43 |
const load = useCallback(async () => {
|
| 44 |
if (!workspace) return;
|
| 45 |
-
setLoading(true);
|
| 46 |
try {
|
| 47 |
const [s, docs, a] = await Promise.all([
|
| 48 |
getDashboardStats(workspace.id),
|
| 49 |
listDocuments(workspace.id),
|
| 50 |
listActivity(workspace.id, 8),
|
| 51 |
]);
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
} catch {
|
| 57 |
// silently handle
|
| 58 |
} finally {
|
| 59 |
setLoading(false);
|
| 60 |
}
|
| 61 |
-
}, [workspace]);
|
| 62 |
|
| 63 |
-
useEffect(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
// Poll every 3 s while any of the recent docs is actively processing.
|
| 66 |
const hasActive = recentDocs.some((d) => ACTIVE_STATUSES.has(d.workflow_status));
|
| 67 |
usePolling(load, POLL_INTERVAL, hasActive);
|
| 68 |
|
| 69 |
-
if (wsLoading
|
| 70 |
if (!workspace) return <EmptyState title="No workspace" description="Create a workspace to get started." />;
|
| 71 |
|
| 72 |
return (
|
|
@@ -84,27 +119,39 @@ export default function Dashboard() {
|
|
| 84 |
{/* KPI Cards */}
|
| 85 |
<div className="dw-dashboard__kpis">
|
| 86 |
<div className="dw-dashboard__kpi" onClick={() => navigate("/documents")} role="button">
|
| 87 |
-
<span className="dw-dashboard__kpi-value">
|
|
|
|
|
|
|
| 88 |
<span className="dw-dashboard__kpi-label">Documents</span>
|
| 89 |
</div>
|
| 90 |
<div className="dw-dashboard__kpi dw-dashboard__kpi--accent" onClick={() => navigate("/workflows?filter=RUNNING")} role="button">
|
| 91 |
-
<span className="dw-dashboard__kpi-value">
|
|
|
|
|
|
|
| 92 |
<span className="dw-dashboard__kpi-label">Running</span>
|
| 93 |
</div>
|
| 94 |
<div className="dw-dashboard__kpi dw-dashboard__kpi--warning" onClick={() => navigate("/workflows?filter=WAITING_FOR_REVIEW")} role="button">
|
| 95 |
-
<span className="dw-dashboard__kpi-value">
|
|
|
|
|
|
|
| 96 |
<span className="dw-dashboard__kpi-label">Needs Review</span>
|
| 97 |
</div>
|
| 98 |
<div className="dw-dashboard__kpi dw-dashboard__kpi--success" onClick={() => navigate("/workflows?filter=COMPLETED")} role="button">
|
| 99 |
-
<span className="dw-dashboard__kpi-value">
|
|
|
|
|
|
|
| 100 |
<span className="dw-dashboard__kpi-label">Completed</span>
|
| 101 |
</div>
|
| 102 |
<div className="dw-dashboard__kpi" onClick={() => navigate("/workflows?filter=WAITING_FOR_REVIEW")} role="button">
|
| 103 |
-
<span className="dw-dashboard__kpi-value">
|
|
|
|
|
|
|
| 104 |
<span className="dw-dashboard__kpi-label">Pending Proposals</span>
|
| 105 |
</div>
|
| 106 |
<div className="dw-dashboard__kpi" onClick={() => navigate("/knowledge")} role="button">
|
| 107 |
-
<span className="dw-dashboard__kpi-value">
|
|
|
|
|
|
|
| 108 |
<span className="dw-dashboard__kpi-label">Knowledge Items</span>
|
| 109 |
</div>
|
| 110 |
</div>
|
|
@@ -128,7 +175,12 @@ export default function Dashboard() {
|
|
| 128 |
}
|
| 129 |
/>
|
| 130 |
<CardBody>
|
| 131 |
-
{recentDocs.length === 0 ? (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
<p className="dw-text-muted">No documents yet.</p>
|
| 133 |
) : (
|
| 134 |
<div className="dw-dashboard__doc-list">
|
|
@@ -187,7 +239,12 @@ export default function Dashboard() {
|
|
| 187 |
action={<Button size="sm" variant="ghost" onClick={() => navigate("/activity")}>View all</Button>}
|
| 188 |
/>
|
| 189 |
<CardBody>
|
| 190 |
-
{activity.length === 0 ? (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
<p className="dw-text-muted">No activity yet.</p>
|
| 192 |
) : (
|
| 193 |
<div className="dw-dashboard__activity">
|
|
@@ -211,3 +268,4 @@ export default function Dashboard() {
|
|
| 211 |
</div>
|
| 212 |
);
|
| 213 |
}
|
|
|
|
|
|
| 32 |
const ACTIVE_STATUSES = new Set(["PENDING", "RUNNING"]);
|
| 33 |
const POLL_INTERVAL = 3000;
|
| 34 |
|
| 35 |
+
const getCachedDashboard = (workspaceId) => {
|
| 36 |
+
if (!workspaceId) return null;
|
| 37 |
+
try {
|
| 38 |
+
const raw = sessionStorage.getItem(`dw_dash_cache_${workspaceId}`);
|
| 39 |
+
return raw ? JSON.parse(raw) : null;
|
| 40 |
+
} catch {
|
| 41 |
+
return null;
|
| 42 |
+
}
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
export default function Dashboard() {
|
| 46 |
const { workspace, loading: wsLoading } = useWorkspace();
|
| 47 |
const navigate = useNavigate();
|
| 48 |
+
|
| 49 |
+
const cached = getCachedDashboard(workspace?.id);
|
| 50 |
+
const [stats, setStats] = useState(() => cached?.stats ?? null);
|
| 51 |
+
const [recentDocs, setRecentDocs] = useState(() => cached?.recentDocs ?? []);
|
| 52 |
+
const [activity, setActivity] = useState(() => cached?.activity ?? []);
|
| 53 |
+
const [loading, setLoading] = useState(() => !cached);
|
| 54 |
|
| 55 |
const load = useCallback(async () => {
|
| 56 |
if (!workspace) return;
|
|
|
|
| 57 |
try {
|
| 58 |
const [s, docs, a] = await Promise.all([
|
| 59 |
getDashboardStats(workspace.id),
|
| 60 |
listDocuments(workspace.id),
|
| 61 |
listActivity(workspace.id, 8),
|
| 62 |
]);
|
| 63 |
+
|
| 64 |
+
const newStats = (!s?.detail) ? s : null;
|
| 65 |
+
const newDocs = Array.isArray(docs) ? docs.slice(0, 5) : [];
|
| 66 |
+
const newActivity = Array.isArray(a?.events) ? a.events : (Array.isArray(a) ? a : []);
|
| 67 |
+
|
| 68 |
+
if (newStats) setStats(newStats);
|
| 69 |
+
setRecentDocs(newDocs);
|
| 70 |
+
setActivity(newActivity);
|
| 71 |
+
|
| 72 |
+
try {
|
| 73 |
+
sessionStorage.setItem(
|
| 74 |
+
`dw_dash_cache_${workspace.id}`,
|
| 75 |
+
JSON.stringify({ stats: newStats || stats, recentDocs: newDocs, activity: newActivity })
|
| 76 |
+
);
|
| 77 |
+
} catch {
|
| 78 |
+
// ignore quota errors
|
| 79 |
+
}
|
| 80 |
} catch {
|
| 81 |
// silently handle
|
| 82 |
} finally {
|
| 83 |
setLoading(false);
|
| 84 |
}
|
| 85 |
+
}, [workspace, stats]);
|
| 86 |
|
| 87 |
+
useEffect(() => {
|
| 88 |
+
if (workspace) {
|
| 89 |
+
const c = getCachedDashboard(workspace.id);
|
| 90 |
+
if (c) {
|
| 91 |
+
setStats(c.stats);
|
| 92 |
+
setRecentDocs(c.recentDocs);
|
| 93 |
+
setActivity(c.activity);
|
| 94 |
+
setLoading(false);
|
| 95 |
+
}
|
| 96 |
+
load();
|
| 97 |
+
}
|
| 98 |
+
}, [workspace?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 99 |
|
| 100 |
// Poll every 3 s while any of the recent docs is actively processing.
|
| 101 |
const hasActive = recentDocs.some((d) => ACTIVE_STATUSES.has(d.workflow_status));
|
| 102 |
usePolling(load, POLL_INTERVAL, hasActive);
|
| 103 |
|
| 104 |
+
if (wsLoading && !workspace) return <LoadingState label="Loading dashboard..." />;
|
| 105 |
if (!workspace) return <EmptyState title="No workspace" description="Create a workspace to get started." />;
|
| 106 |
|
| 107 |
return (
|
|
|
|
| 119 |
{/* KPI Cards */}
|
| 120 |
<div className="dw-dashboard__kpis">
|
| 121 |
<div className="dw-dashboard__kpi" onClick={() => navigate("/documents")} role="button">
|
| 122 |
+
<span className="dw-dashboard__kpi-value">
|
| 123 |
+
{stats ? (stats.total_documents ?? 0) : <span className="dw-dashboard__skeleton dw-dashboard__skeleton-val" />}
|
| 124 |
+
</span>
|
| 125 |
<span className="dw-dashboard__kpi-label">Documents</span>
|
| 126 |
</div>
|
| 127 |
<div className="dw-dashboard__kpi dw-dashboard__kpi--accent" onClick={() => navigate("/workflows?filter=RUNNING")} role="button">
|
| 128 |
+
<span className="dw-dashboard__kpi-value">
|
| 129 |
+
{stats ? (stats.workflows_running ?? 0) : <span className="dw-dashboard__skeleton dw-dashboard__skeleton-val" />}
|
| 130 |
+
</span>
|
| 131 |
<span className="dw-dashboard__kpi-label">Running</span>
|
| 132 |
</div>
|
| 133 |
<div className="dw-dashboard__kpi dw-dashboard__kpi--warning" onClick={() => navigate("/workflows?filter=WAITING_FOR_REVIEW")} role="button">
|
| 134 |
+
<span className="dw-dashboard__kpi-value">
|
| 135 |
+
{stats ? (stats.workflows_waiting_for_review ?? 0) : <span className="dw-dashboard__skeleton dw-dashboard__skeleton-val" />}
|
| 136 |
+
</span>
|
| 137 |
<span className="dw-dashboard__kpi-label">Needs Review</span>
|
| 138 |
</div>
|
| 139 |
<div className="dw-dashboard__kpi dw-dashboard__kpi--success" onClick={() => navigate("/workflows?filter=COMPLETED")} role="button">
|
| 140 |
+
<span className="dw-dashboard__kpi-value">
|
| 141 |
+
{stats ? (stats.workflows_completed ?? 0) : <span className="dw-dashboard__skeleton dw-dashboard__skeleton-val" />}
|
| 142 |
+
</span>
|
| 143 |
<span className="dw-dashboard__kpi-label">Completed</span>
|
| 144 |
</div>
|
| 145 |
<div className="dw-dashboard__kpi" onClick={() => navigate("/workflows?filter=WAITING_FOR_REVIEW")} role="button">
|
| 146 |
+
<span className="dw-dashboard__kpi-value">
|
| 147 |
+
{stats ? (stats.pending_proposals ?? 0) : <span className="dw-dashboard__skeleton dw-dashboard__skeleton-val" />}
|
| 148 |
+
</span>
|
| 149 |
<span className="dw-dashboard__kpi-label">Pending Proposals</span>
|
| 150 |
</div>
|
| 151 |
<div className="dw-dashboard__kpi" onClick={() => navigate("/knowledge")} role="button">
|
| 152 |
+
<span className="dw-dashboard__kpi-value">
|
| 153 |
+
{stats ? (stats.knowledge_items ?? 0) : <span className="dw-dashboard__skeleton dw-dashboard__skeleton-val" />}
|
| 154 |
+
</span>
|
| 155 |
<span className="dw-dashboard__kpi-label">Knowledge Items</span>
|
| 156 |
</div>
|
| 157 |
</div>
|
|
|
|
| 175 |
}
|
| 176 |
/>
|
| 177 |
<CardBody>
|
| 178 |
+
{loading && recentDocs.length === 0 ? (
|
| 179 |
+
<div className="dw-dashboard__doc-list">
|
| 180 |
+
<div className="dw-dashboard__skeleton dw-dashboard__skeleton-row" />
|
| 181 |
+
<div className="dw-dashboard__skeleton dw-dashboard__skeleton-row" />
|
| 182 |
+
</div>
|
| 183 |
+
) : recentDocs.length === 0 ? (
|
| 184 |
<p className="dw-text-muted">No documents yet.</p>
|
| 185 |
) : (
|
| 186 |
<div className="dw-dashboard__doc-list">
|
|
|
|
| 239 |
action={<Button size="sm" variant="ghost" onClick={() => navigate("/activity")}>View all</Button>}
|
| 240 |
/>
|
| 241 |
<CardBody>
|
| 242 |
+
{loading && activity.length === 0 ? (
|
| 243 |
+
<div className="dw-dashboard__activity">
|
| 244 |
+
<div className="dw-dashboard__skeleton dw-dashboard__skeleton-row" />
|
| 245 |
+
<div className="dw-dashboard__skeleton dw-dashboard__skeleton-row" />
|
| 246 |
+
</div>
|
| 247 |
+
) : activity.length === 0 ? (
|
| 248 |
<p className="dw-text-muted">No activity yet.</p>
|
| 249 |
) : (
|
| 250 |
<div className="dw-dashboard__activity">
|
|
|
|
| 268 |
</div>
|
| 269 |
);
|
| 270 |
}
|
| 271 |
+
|
frontend/src/pages/auth/Login.jsx
CHANGED
|
@@ -46,10 +46,6 @@ export default function Login() {
|
|
| 46 |
}
|
| 47 |
|
| 48 |
login(result.access_token);
|
| 49 |
-
|
| 50 |
-
const me = await apiRequest("/auth/me");
|
| 51 |
-
if (me && !me.detail) setUser(me);
|
| 52 |
-
|
| 53 |
navigate(redirectTo, { replace: true });
|
| 54 |
} catch (err) {
|
| 55 |
setError("Couldn't reach the server. Please try again.");
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
login(result.access_token);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
navigate(redirectTo, { replace: true });
|
| 50 |
} catch (err) {
|
| 51 |
setError("Couldn't reach the server. Please try again.");
|