Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 121 additions & 2 deletions src/backend/db.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import os
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
from datetime import datetime
from dotenv import load_dotenv
from sqlalchemy import Column, String, JSON, DateTime, func, create_engine
from sqlalchemy import Column, String, JSON, DateTime, Integer, func, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
Expand Down Expand Up @@ -31,6 +32,20 @@
# Create base model
Base = declarative_base()

# Canvas backup configuration
BACKUP_INTERVAL_SECONDS = 300 # 5 minutes between backups
MAX_BACKUPS_PER_USER = 10 # Maximum number of backups to keep per user

# In-memory dictionaries to track user activity
user_last_backup_time = {} # Tracks when each user last had a backup
user_last_activity_time = {} # Tracks when each user was last active

# Memory management configuration
INACTIVITY_THRESHOLD_MINUTES = 30 # Remove users from memory after this many minutes of inactivity
MAX_USERS_BEFORE_CLEANUP = 1000 # Trigger cleanup when we have this many users in memory
CLEANUP_INTERVAL_SECONDS = 3600 # Run cleanup at least once per hour (1 hour)
last_cleanup_time = datetime.now() # Track when we last ran cleanup

class CanvasData(Base):
"""Model for canvas data table"""
__tablename__ = "canvas_data"
Expand All @@ -42,6 +57,18 @@ class CanvasData(Base):
def __repr__(self):
return f"<CanvasData(user_id='{self.user_id}')>"

class CanvasBackup(Base):
"""Model for canvas backups table"""
__tablename__ = "canvas_backups"

id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(String, nullable=False)
timestamp = Column(DateTime(timezone=True), server_default=func.now())
canvas_data = Column(JSON, nullable=False)

def __repr__(self):
return f"<CanvasBackup(id={self.id}, user_id='{self.user_id}')>"

async def get_db_session():
"""Get a database session"""
async with async_session() as session:
Expand All @@ -52,8 +79,47 @@ async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

async def cleanup_inactive_users(inactivity_threshold_minutes: int = INACTIVITY_THRESHOLD_MINUTES):
"""Remove users from memory tracking if they've been inactive for the specified time"""
current_time = datetime.now()
inactive_users = []

for user_id, last_activity in user_last_activity_time.items():
# Check if user has been inactive for longer than the threshold
if (current_time - last_activity).total_seconds() > (inactivity_threshold_minutes * 60):
inactive_users.append(user_id)

# Remove inactive users from both dictionaries
for user_id in inactive_users:
if user_id in user_last_backup_time:
del user_last_backup_time[user_id]
if user_id in user_last_activity_time:
del user_last_activity_time[user_id]

return len(inactive_users) # Return count of removed users for logging

async def check_if_cleanup_needed():
"""Check if we should run the cleanup function"""
global last_cleanup_time
current_time = datetime.now()
time_since_last_cleanup = (current_time - last_cleanup_time).total_seconds()

# Run cleanup if we have too many users or it's been too long
if (len(user_last_activity_time) > MAX_USERS_BEFORE_CLEANUP or
time_since_last_cleanup > CLEANUP_INTERVAL_SECONDS):
removed_count = await cleanup_inactive_users()
last_cleanup_time = current_time
print(f"[db.py] Cleanup completed: removed {removed_count} inactive users from memory")

async def store_canvas_data(user_id: str, data: Dict[str, Any]) -> bool:
try:
# Update user's last activity time
current_time = datetime.now()
user_last_activity_time[user_id] = current_time

# Check if cleanup is needed
await check_if_cleanup_needed()

async with async_session() as session:
# Check if record exists
stmt = select(CanvasData).where(CanvasData.user_id == user_id)
Expand All @@ -68,6 +134,39 @@ async def store_canvas_data(user_id: str, data: Dict[str, Any]) -> bool:
canvas_data = CanvasData(user_id=user_id, data=data)
session.add(canvas_data)

# Check if we should create a backup
should_backup = False
if user_id not in user_last_backup_time:
# First time this user is saving, create a backup
should_backup = True
else:
# Check if backup interval has passed since last backup
time_since_last_backup = (current_time - user_last_backup_time[user_id]).total_seconds()
if time_since_last_backup >= BACKUP_INTERVAL_SECONDS:
should_backup = True

if should_backup:
# Update the backup timestamp
user_last_backup_time[user_id] = current_time

# Count existing backups for this user
backup_count_stmt = select(func.count()).select_from(CanvasBackup).where(CanvasBackup.user_id == user_id)
backup_count_result = await session.execute(backup_count_stmt)
backup_count = backup_count_result.scalar()

# If user has reached the maximum number of backups, delete the oldest one
if backup_count >= MAX_BACKUPS_PER_USER:
oldest_backup_stmt = select(CanvasBackup).where(CanvasBackup.user_id == user_id).order_by(CanvasBackup.timestamp).limit(1)
oldest_backup_result = await session.execute(oldest_backup_stmt)
oldest_backup = oldest_backup_result.scalars().first()

if oldest_backup:
await session.delete(oldest_backup)

# Create new backup
new_backup = CanvasBackup(user_id=user_id, canvas_data=data)
session.add(new_backup)

await session.commit()
return True
except Exception as e:
Expand All @@ -76,6 +175,9 @@ async def store_canvas_data(user_id: str, data: Dict[str, Any]) -> bool:

async def get_canvas_data(user_id: str) -> Optional[Dict[str, Any]]:
try:
# Update user's last activity time
user_last_activity_time[user_id] = datetime.now()

async with async_session() as session:
stmt = select(CanvasData).where(CanvasData.user_id == user_id)
result = await session.execute(stmt)
Expand All @@ -87,3 +189,20 @@ async def get_canvas_data(user_id: str) -> Optional[Dict[str, Any]]:
except Exception as e:
print(f"Error retrieving canvas data: {e}")
return None

async def get_recent_canvases(user_id: str, limit: int = MAX_BACKUPS_PER_USER) -> List[Dict[str, Any]]:
"""Get the most recent canvas backups for a user"""
try:
# Update user's last activity time
user_last_activity_time[user_id] = datetime.now()

async with async_session() as session:
# Get the most recent backups, limited to MAX_BACKUPS_PER_USER
stmt = select(CanvasBackup).where(CanvasBackup.user_id == user_id).order_by(CanvasBackup.timestamp.desc()).limit(limit)
result = await session.execute(stmt)
backups = result.scalars().all()

return [{"id": backup.id, "timestamp": backup.timestamp, "data": backup.canvas_data} for backup in backups]
except Exception as e:
print(f"Error retrieving canvas backups: {e}")
return []
18 changes: 16 additions & 2 deletions src/backend/routers/canvas.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import json
import jwt
from typing import Dict, Any
from typing import Dict, Any, List
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.responses import JSONResponse

from dependencies import SessionData, require_auth
from db import store_canvas_data, get_canvas_data
from db import store_canvas_data, get_canvas_data, get_recent_canvases, MAX_BACKUPS_PER_USER
import posthog

canvas_router = APIRouter()
Expand Down Expand Up @@ -74,3 +74,17 @@ async def get_canvas(auth: SessionData = Depends(require_auth)):
if data is None:
return get_default_canvas_data()
return data

@canvas_router.get("/recent")
async def get_recent_canvas_backups(limit: int = MAX_BACKUPS_PER_USER, auth: SessionData = Depends(require_auth)):
"""Get the most recent canvas backups for the authenticated user"""
access_token = auth.token_data.get("access_token")
decoded = jwt.decode(access_token, options={"verify_signature": False})
user_id = decoded["sub"]

# Limit the number of backups to the maximum configured value
if limit > MAX_BACKUPS_PER_USER:
limit = MAX_BACKUPS_PER_USER

backups = await get_recent_canvases(user_id, limit)
return {"backups": backups}
17 changes: 1 addition & 16 deletions src/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ExcalidrawWrapper } from "./ExcalidrawWrapper";
import { debounce } from "./utils/debounce";
import { capture } from "./utils/posthog";
import posthog from "./utils/posthog";
import { normalizeCanvasData } from "./utils/canvasUtils";
import { useSaveCanvas } from "./api/hooks";
import type * as TExcalidraw from "@atyrode/excalidraw";
import type { NonDeletedExcalidrawElement } from "@atyrode/excalidraw/element/types";
Expand Down Expand Up @@ -40,22 +41,6 @@ export default function App({
useCustom(excalidrawAPI, customArgs);
useHandleLibrary({ excalidrawAPI });

function normalizeCanvasData(data: any) {
if (!data) return data;
const appState = { ...data.appState };
appState.width = undefined;
if ("width" in appState) {
delete appState.width;
}
if ("height" in appState) {
delete appState.height;
}
if (!(appState.collaborators instanceof Map)) {
appState.collaborators = new Map();
}
return { ...data, appState };
}

useEffect(() => {
if (excalidrawAPI && canvasData) {
excalidrawAPI.updateScene(normalizeCanvasData(canvasData));
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/src/CustomEmbeddableRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
StateIndicator,
ControlButton,
HtmlEditor,
Editor
Editor,
} from './pad';
import { ActionButton } from './pad/buttons';

Expand Down
23 changes: 22 additions & 1 deletion src/frontend/src/ExcalidrawWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { AppState } from '@atyrode/excalidraw/types';
import { MainMenuConfig } from './ui/MainMenu';
import { renderCustomEmbeddable } from './CustomEmbeddableRenderer';
import AuthDialog from './ui/AuthDialog';
import BackupsModal from './ui/BackupsDialog';

const defaultInitialData = {
elements: [],
Expand Down Expand Up @@ -44,13 +45,21 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
// Add state for modal animation
const [isExiting, setIsExiting] = useState(false);

// State for BackupsModal
const [showBackupsModal, setShowBackupsModal] = useState(false);

// Handle auth state changes
useEffect(() => {
if (isAuthenticated === true) {
setIsExiting(true);
}
}, [isAuthenticated]);

// Handler for closing the backups modal
const handleCloseBackupsModal = () => {
setShowBackupsModal(false);
};

const renderExcalidraw = (children: React.ReactNode) => {
const Excalidraw = Children.toArray(children).find(
(child: any) =>
Expand Down Expand Up @@ -81,12 +90,24 @@ export const ExcalidrawWrapper: React.FC<ExcalidrawWrapperProps> = ({
)),
},
<>
<MainMenuConfig MainMenu={MainMenu} excalidrawAPI={excalidrawAPI} />
<MainMenuConfig
MainMenu={MainMenu}
excalidrawAPI={excalidrawAPI}
showBackupsModal={showBackupsModal}
setShowBackupsModal={setShowBackupsModal}
/>
{!isAuthLoading && isAuthenticated === false && (
<AuthDialog
onClose={() => {}}
/>
)}

{showBackupsModal && (
<BackupsModal
excalidrawAPI={excalidrawAPI}
onClose={handleCloseBackupsModal}
/>
)}
</>
);
};
Expand Down
32 changes: 32 additions & 0 deletions src/frontend/src/api/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ export interface CanvasData {
files: any;
}

export interface CanvasBackup {
id: number;
timestamp: string;
data: CanvasData;
}

export interface CanvasBackupsResponse {
backups: CanvasBackup[];
}

// API functions
export const api = {
// Authentication
Expand Down Expand Up @@ -113,6 +123,16 @@ export const api = {
throw error;
}
},

// Canvas Backups
getCanvasBackups: async (limit: number = 10): Promise<CanvasBackupsResponse> => {
try {
const result = await fetchApi(`/api/canvas/recent?limit=${limit}`);
return result;
} catch (error) {
throw error;
}
},
};

// Query hooks
Expand Down Expand Up @@ -163,6 +183,14 @@ export function useDefaultCanvas(options?: UseQueryOptions<CanvasData>) {
});
}

export function useCanvasBackups(limit: number = 10, options?: UseQueryOptions<CanvasBackupsResponse>) {
return useQuery({
queryKey: ['canvasBackups', limit],
queryFn: () => api.getCanvasBackups(limit),
...options,
});
}

// Mutation hooks
export function useStartWorkspace(options?: UseMutationOptions) {
return useMutation({
Expand All @@ -189,6 +217,10 @@ export function useStopWorkspace(options?: UseMutationOptions) {
export function useSaveCanvas(options?: UseMutationOptions<any, Error, CanvasData>) {
return useMutation({
mutationFn: api.saveCanvas,
onSuccess: () => {
// Invalidate canvas backups query to trigger refetch
queryClient.invalidateQueries({ queryKey: ['canvasBackups'] });
},
...options,
});
}
Loading