Skip to content

Commit 4b2f926

Browse files
authored
Merge pull request #995 from airweave-ai/feat/introduce_last_login
feat: Add "last active" tracking and sorting to admin dashboard
2 parents 6226fdd + 2fc852e commit 4b2f926

File tree

7 files changed

+117
-20
lines changed

7 files changed

+117
-20
lines changed

backend/airweave/api/deps.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,22 @@ async def _authenticate_auth0_user(
4242
db: AsyncSession, auth0_user: Auth0User
4343
) -> Tuple[Optional[schemas.User], AuthMethod, dict]:
4444
"""Authenticate Auth0 user."""
45+
from datetime import datetime
46+
4547
try:
4648
user = await crud.user.get_by_email(db, email=auth0_user.email)
4749
except NotFoundException:
4850
logger.error(f"User {auth0_user.email} not found in database")
4951
return None, AuthMethod.AUTH0, {}
52+
53+
# Update last active timestamp using CRUD module
54+
user = await crud.user.update(
55+
db=db,
56+
db_obj=user,
57+
obj_in={"last_active_at": datetime.utcnow()},
58+
current_user=user, # User updating their own record
59+
)
60+
5061
user_context = schemas.User.model_validate(user)
5162
return user_context, AuthMethod.AUTH0, {"auth0_id": auth0_user.id}
5263

backend/airweave/api/v1/endpoints/admin.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,12 @@ async def list_all_organizations(
9393
"""
9494
_require_admin(ctx)
9595

96-
# Import for usage/billing period joins
96+
# Import for joins
9797
from datetime import datetime
9898

9999
from airweave.models.billing_period import BillingPeriod
100100
from airweave.models.usage import Usage
101+
from airweave.models.user import User
101102
from airweave.schemas.billing_period import BillingPeriodStatus
102103

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

130+
# For last_active_at sorting, join with User through UserOrganization
131+
if sort_by == "last_active_at":
132+
# Subquery to get max last_active_at per organization
133+
from sqlalchemy import select as sa_select
134+
135+
max_active_subq = (
136+
sa_select(
137+
UserOrganization.organization_id,
138+
func.max(User.last_active_at).label("max_last_active"),
139+
)
140+
.join(User, UserOrganization.user_id == User.id)
141+
.group_by(UserOrganization.organization_id)
142+
.subquery()
143+
)
144+
145+
query = query.outerjoin(
146+
max_active_subq, Organization.id == max_active_subq.c.organization_id
147+
)
148+
129149
# Apply search filter
130150
if search:
131151
query = query.where(Organization.name.ilike(f"%{search}%"))
@@ -143,6 +163,8 @@ async def list_all_organizations(
143163
sort_column = Usage.source_connections
144164
elif sort_by == "query_count":
145165
sort_column = Usage.queries
166+
elif sort_by == "last_active_at":
167+
sort_column = max_active_subq.c.max_last_active
146168
elif sort_by == "is_member":
147169
# This will be handled client-side, use created_at as default
148170
sort_column = Organization.created_at
@@ -198,6 +220,21 @@ async def list_all_organizations(
198220
uo.organization_id: uo.role for uo in admin_membership_result.scalars().all()
199221
}
200222

223+
# Fetch last active timestamp for each organization (most recent user activity)
224+
from airweave.models.user import User
225+
226+
last_active_query = (
227+
select(
228+
UserOrganization.organization_id,
229+
func.max(User.last_active_at).label("last_active"),
230+
)
231+
.join(User, UserOrganization.user_id == User.id)
232+
.where(UserOrganization.organization_id.in_(org_ids))
233+
.group_by(UserOrganization.organization_id)
234+
)
235+
last_active_result = await db.execute(last_active_query)
236+
last_active_map = {row.organization_id: row.last_active for row in last_active_result}
237+
201238
# Fetch current usage for all organizations using CRUD layer
202239
usage_map = await crud.usage.get_current_usage_for_orgs(db, organization_ids=org_ids)
203240

@@ -227,6 +264,7 @@ async def list_all_organizations(
227264
source_connection_count=usage_record.source_connections if usage_record else 0,
228265
entity_count=usage_record.entities if usage_record else 0,
229266
query_count=usage_record.queries if usage_record else 0,
267+
last_active_at=last_active_map.get(org.id),
230268
is_member=admin_role is not None,
231269
member_role=admin_role,
232270
enabled_features=enabled_features,

backend/airweave/models/user.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""User model."""
22

3+
from datetime import datetime
34
from typing import TYPE_CHECKING, List
45

5-
from sqlalchemy import UUID, Boolean, String
6+
from sqlalchemy import UUID, Boolean, DateTime, String
67
from sqlalchemy.orm import Mapped, mapped_column, relationship
78

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

2729
# Many-to-many relationship with organizations
2830
user_organizations: Mapped[List["UserOrganization"]] = relationship(

backend/airweave/schemas/admin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ class OrganizationMetrics(BaseModel):
4040
)
4141
entity_count: int = Field(0, description="Total number of entities (from Usage.entities)")
4242
query_count: int = Field(0, description="Total number of queries (from Usage.queries)")
43+
last_active_at: Optional[datetime] = Field(
44+
None, description="Last active timestamp of any user in this organization"
45+
)
4346

4447
# Admin membership info
4548
is_member: bool = Field(False, description="Whether the current admin user is already a member")

backend/airweave/schemas/user.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""User schema module."""
22

3+
from datetime import datetime
34
from typing import Optional
45
from uuid import UUID
56

@@ -67,6 +68,7 @@ class UserInDBBase(UserBase):
6768
primary_organization_id: Optional[UUID] = None
6869
user_organizations: list[UserOrganization] = Field(default_factory=list)
6970
is_admin: bool = False
71+
last_active_at: Optional[datetime] = None
7072

7173
@field_validator("user_organizations", mode="before")
7274
@classmethod
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""add_last_active_at_to_user
2+
3+
Revision ID: e4ebd5ee78b5
4+
Revises: e3a7e8db826c
5+
Create Date: 2025-10-21 14:51:55.260463
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = 'e4ebd5ee78b5'
14+
down_revision = 'e3a7e8db826c'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# Add last_active_at column to user table
21+
op.add_column('user', sa.Column('last_active_at', sa.DateTime(), nullable=True))
22+
23+
24+
def downgrade():
25+
# Remove last_active_at column from user table
26+
op.drop_column('user', 'last_active_at')

frontend/src/pages/AdminDashboard.tsx

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,13 @@ interface OrganizationMetrics {
5353
source_connection_count: number;
5454
entity_count: number;
5555
query_count: number;
56+
last_active_at?: string;
5657
is_member: boolean;
5758
member_role?: string;
5859
enabled_features?: string[];
5960
}
6061

61-
type SortField = 'name' | 'created_at' | 'billing_plan' | 'user_count' | 'source_connection_count' | 'entity_count' | 'query_count' | 'is_member';
62+
type SortField = 'name' | 'created_at' | 'billing_plan' | 'user_count' | 'source_connection_count' | 'entity_count' | 'query_count' | 'last_active_at' | 'is_member';
6263
type SortOrder = 'asc' | 'desc';
6364
type MembershipFilter = 'all' | 'member' | 'non-member';
6465

@@ -169,7 +170,7 @@ export function AdminDashboard() {
169170
filtered = filtered.filter(org => !org.is_member);
170171
}
171172

172-
// Apply client-side sorting if sorting by membership
173+
// Apply client-side sorting if sorting by membership (not handled by backend)
173174
if (sortField === 'is_member') {
174175
filtered = [...filtered].sort((a, b) => {
175176
const aValue = a.is_member ? 1 : 0;
@@ -471,10 +472,10 @@ export function AdminDashboard() {
471472
<CardHeader className="pb-3">
472473
<div className="flex items-center justify-between">
473474
<div>
474-
<CardTitle>All Organizations</CardTitle>
475-
<CardDescription>
476-
View and manage all organizations on the platform
477-
</CardDescription>
475+
<CardTitle>All Organizations</CardTitle>
476+
<CardDescription>
477+
View and manage all organizations on the platform
478+
</CardDescription>
478479
</div>
479480
<div className="flex items-center gap-2">
480481
<Select value={membershipFilter} onValueChange={(value: MembershipFilter) => setMembershipFilter(value)}>
@@ -505,13 +506,13 @@ export function AdminDashboard() {
505506
) : filteredOrganizations.length === 0 ? (
506507
<div className="text-center py-12 text-muted-foreground">
507508
{searchTerm ? 'No organizations match your search' :
508-
membershipFilter !== 'all' ? `No ${membershipFilter === 'member' ? 'member' : 'non-member'} organizations found` :
509-
'No organizations found'}
509+
membershipFilter !== 'all' ? `No ${membershipFilter === 'member' ? 'member' : 'non-member'} organizations found` :
510+
'No organizations found'}
510511
</div>
511512
) : (
512513
<div className="border-t">
513-
<Table>
514-
<TableHeader>
514+
<Table>
515+
<TableHeader>
515516
<TableRow className="hover:bg-transparent">
516517
<TableHead className="w-[220px]">
517518
<Button
@@ -590,6 +591,17 @@ export function AdminDashboard() {
590591
<ArrowUpDown className="ml-2 h-3 w-3" />
591592
</Button>
592593
</TableHead>
594+
<TableHead className="w-[130px]">
595+
<Button
596+
variant="ghost"
597+
size="sm"
598+
className="h-8 -ml-3"
599+
onClick={() => handleSort('last_active_at')}
600+
>
601+
Last Active
602+
<ArrowUpDown className="ml-2 h-3 w-3" />
603+
</Button>
604+
</TableHead>
593605
<TableHead className="w-[130px]">
594606
<Button
595607
variant="ghost"
@@ -602,7 +614,7 @@ export function AdminDashboard() {
602614
</Button>
603615
</TableHead>
604616
<TableHead className="text-right w-[200px]">Actions</TableHead>
605-
</TableRow>
617+
</TableRow>
606618
</TableHeader>
607619
<TableBody>
608620
{filteredOrganizations.map((org) => (
@@ -645,10 +657,13 @@ export function AdminDashboard() {
645657
</TableCell>
646658
<TableCell className="text-right py-2 font-mono text-sm">
647659
{formatNumber(org.query_count)}
648-
</TableCell>
660+
</TableCell>
649661
<TableCell className="py-2 text-xs text-muted-foreground">
650-
{formatDate(org.created_at)}
651-
</TableCell>
662+
{org.last_active_at ? formatDate(org.last_active_at) : '—'}
663+
</TableCell>
664+
<TableCell className="py-2 text-xs text-muted-foreground">
665+
{formatDate(org.created_at)}
666+
</TableCell>
652667
<TableCell className="text-right py-2">
653668
<div className="flex justify-end gap-1.5">
654669
{org.is_member ? (
@@ -699,10 +714,10 @@ export function AdminDashboard() {
699714
</Button>
700715
</div>
701716
</TableCell>
702-
</TableRow>
703-
))}
704-
</TableBody>
705-
</Table>
717+
</TableRow>
718+
))}
719+
</TableBody>
720+
</Table>
706721
</div>
707722
)}
708723
</CardContent>

0 commit comments

Comments
 (0)