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
11 changes: 11 additions & 0 deletions backend/airweave/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,22 @@ async def _authenticate_auth0_user(
db: AsyncSession, auth0_user: Auth0User
) -> Tuple[Optional[schemas.User], AuthMethod, dict]:
"""Authenticate Auth0 user."""
from datetime import datetime

try:
user = await crud.user.get_by_email(db, email=auth0_user.email)
except NotFoundException:
logger.error(f"User {auth0_user.email} not found in database")
return None, AuthMethod.AUTH0, {}

# Update last active timestamp using CRUD module
user = await crud.user.update(
db=db,
db_obj=user,
obj_in={"last_active_at": datetime.utcnow()},
current_user=user, # User updating their own record
)

user_context = schemas.User.model_validate(user)
return user_context, AuthMethod.AUTH0, {"auth0_id": auth0_user.id}

Expand Down
40 changes: 39 additions & 1 deletion backend/airweave/api/v1/endpoints/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,12 @@ async def list_all_organizations(
"""
_require_admin(ctx)

# Import for usage/billing period joins
# Import for joins
from datetime import datetime

from airweave.models.billing_period import BillingPeriod
from airweave.models.usage import Usage
from airweave.models.user import User
from airweave.schemas.billing_period import BillingPeriodStatus

# Build the base query with billing join
Expand Down Expand Up @@ -126,6 +127,25 @@ async def list_all_organizations(
),
).outerjoin(Usage, Usage.billing_period_id == BillingPeriod.id)

# For last_active_at sorting, join with User through UserOrganization
if sort_by == "last_active_at":
# Subquery to get max last_active_at per organization
from sqlalchemy import select as sa_select

max_active_subq = (
sa_select(
UserOrganization.organization_id,
func.max(User.last_active_at).label("max_last_active"),
)
.join(User, UserOrganization.user_id == User.id)
.group_by(UserOrganization.organization_id)
.subquery()
)

query = query.outerjoin(
max_active_subq, Organization.id == max_active_subq.c.organization_id
)

# Apply search filter
if search:
query = query.where(Organization.name.ilike(f"%{search}%"))
Expand All @@ -143,6 +163,8 @@ async def list_all_organizations(
sort_column = Usage.source_connections
elif sort_by == "query_count":
sort_column = Usage.queries
elif sort_by == "last_active_at":
sort_column = max_active_subq.c.max_last_active
elif sort_by == "is_member":
# This will be handled client-side, use created_at as default
sort_column = Organization.created_at
Expand Down Expand Up @@ -198,6 +220,21 @@ async def list_all_organizations(
uo.organization_id: uo.role for uo in admin_membership_result.scalars().all()
}

# Fetch last active timestamp for each organization (most recent user activity)
from airweave.models.user import User

last_active_query = (
select(
UserOrganization.organization_id,
func.max(User.last_active_at).label("last_active"),
)
.join(User, UserOrganization.user_id == User.id)
.where(UserOrganization.organization_id.in_(org_ids))
.group_by(UserOrganization.organization_id)
)
last_active_result = await db.execute(last_active_query)
last_active_map = {row.organization_id: row.last_active for row in last_active_result}

# Fetch current usage for all organizations using CRUD layer
usage_map = await crud.usage.get_current_usage_for_orgs(db, organization_ids=org_ids)

Expand Down Expand Up @@ -227,6 +264,7 @@ async def list_all_organizations(
source_connection_count=usage_record.source_connections if usage_record else 0,
entity_count=usage_record.entities if usage_record else 0,
query_count=usage_record.queries if usage_record else 0,
last_active_at=last_active_map.get(org.id),
is_member=admin_role is not None,
member_role=admin_role,
enabled_features=enabled_features,
Expand Down
4 changes: 3 additions & 1 deletion backend/airweave/models/user.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""User model."""

from datetime import datetime
from typing import TYPE_CHECKING, List

from sqlalchemy import UUID, Boolean, String
from sqlalchemy import UUID, Boolean, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column, relationship

from airweave.models._base import Base
Expand All @@ -23,6 +24,7 @@ class User(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
last_active_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)

# Many-to-many relationship with organizations
user_organizations: Mapped[List["UserOrganization"]] = relationship(
Expand Down
3 changes: 3 additions & 0 deletions backend/airweave/schemas/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class OrganizationMetrics(BaseModel):
)
entity_count: int = Field(0, description="Total number of entities (from Usage.entities)")
query_count: int = Field(0, description="Total number of queries (from Usage.queries)")
last_active_at: Optional[datetime] = Field(
None, description="Last active timestamp of any user in this organization"
)

# Admin membership info
is_member: bool = Field(False, description="Whether the current admin user is already a member")
Expand Down
2 changes: 2 additions & 0 deletions backend/airweave/schemas/user.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""User schema module."""

from datetime import datetime
from typing import Optional
from uuid import UUID

Expand Down Expand Up @@ -67,6 +68,7 @@ class UserInDBBase(UserBase):
primary_organization_id: Optional[UUID] = None
user_organizations: list[UserOrganization] = Field(default_factory=list)
is_admin: bool = False
last_active_at: Optional[datetime] = None

@field_validator("user_organizations", mode="before")
@classmethod
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""add_last_active_at_to_user

Revision ID: e4ebd5ee78b5
Revises: e3a7e8db826c
Create Date: 2025-10-21 14:51:55.260463

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'e4ebd5ee78b5'
down_revision = 'e3a7e8db826c'
branch_labels = None
depends_on = None


def upgrade():
# Add last_active_at column to user table
op.add_column('user', sa.Column('last_active_at', sa.DateTime(), nullable=True))


def downgrade():
# Remove last_active_at column from user table
op.drop_column('user', 'last_active_at')
51 changes: 33 additions & 18 deletions frontend/src/pages/AdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,13 @@ interface OrganizationMetrics {
source_connection_count: number;
entity_count: number;
query_count: number;
last_active_at?: string;
is_member: boolean;
member_role?: string;
enabled_features?: string[];
}

type SortField = 'name' | 'created_at' | 'billing_plan' | 'user_count' | 'source_connection_count' | 'entity_count' | 'query_count' | 'is_member';
type SortField = 'name' | 'created_at' | 'billing_plan' | 'user_count' | 'source_connection_count' | 'entity_count' | 'query_count' | 'last_active_at' | 'is_member';
type SortOrder = 'asc' | 'desc';
type MembershipFilter = 'all' | 'member' | 'non-member';

Expand Down Expand Up @@ -169,7 +170,7 @@ export function AdminDashboard() {
filtered = filtered.filter(org => !org.is_member);
}

// Apply client-side sorting if sorting by membership
// Apply client-side sorting if sorting by membership (not handled by backend)
if (sortField === 'is_member') {
filtered = [...filtered].sort((a, b) => {
const aValue = a.is_member ? 1 : 0;
Expand Down Expand Up @@ -471,10 +472,10 @@ export function AdminDashboard() {
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle>All Organizations</CardTitle>
<CardDescription>
View and manage all organizations on the platform
</CardDescription>
<CardTitle>All Organizations</CardTitle>
<CardDescription>
View and manage all organizations on the platform
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Select value={membershipFilter} onValueChange={(value: MembershipFilter) => setMembershipFilter(value)}>
Expand Down Expand Up @@ -505,13 +506,13 @@ export function AdminDashboard() {
) : filteredOrganizations.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
{searchTerm ? 'No organizations match your search' :
membershipFilter !== 'all' ? `No ${membershipFilter === 'member' ? 'member' : 'non-member'} organizations found` :
'No organizations found'}
membershipFilter !== 'all' ? `No ${membershipFilter === 'member' ? 'member' : 'non-member'} organizations found` :
'No organizations found'}
</div>
) : (
<div className="border-t">
<Table>
<TableHeader>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[220px]">
<Button
Expand Down Expand Up @@ -590,6 +591,17 @@ export function AdminDashboard() {
<ArrowUpDown className="ml-2 h-3 w-3" />
</Button>
</TableHead>
<TableHead className="w-[130px]">
<Button
variant="ghost"
size="sm"
className="h-8 -ml-3"
onClick={() => handleSort('last_active_at')}
>
Last Active
<ArrowUpDown className="ml-2 h-3 w-3" />
</Button>
</TableHead>
<TableHead className="w-[130px]">
<Button
variant="ghost"
Expand All @@ -602,7 +614,7 @@ export function AdminDashboard() {
</Button>
</TableHead>
<TableHead className="text-right w-[200px]">Actions</TableHead>
</TableRow>
</TableRow>
</TableHeader>
<TableBody>
{filteredOrganizations.map((org) => (
Expand Down Expand Up @@ -645,10 +657,13 @@ export function AdminDashboard() {
</TableCell>
<TableCell className="text-right py-2 font-mono text-sm">
{formatNumber(org.query_count)}
</TableCell>
</TableCell>
<TableCell className="py-2 text-xs text-muted-foreground">
{formatDate(org.created_at)}
</TableCell>
{org.last_active_at ? formatDate(org.last_active_at) : '—'}
</TableCell>
<TableCell className="py-2 text-xs text-muted-foreground">
{formatDate(org.created_at)}
</TableCell>
<TableCell className="text-right py-2">
<div className="flex justify-end gap-1.5">
{org.is_member ? (
Expand Down Expand Up @@ -699,10 +714,10 @@ export function AdminDashboard() {
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
Expand Down
Loading