diff --git a/backend/transcribee_backend/auth.py b/backend/transcribee_backend/auth.py index 0177405b..e878692c 100644 --- a/backend/transcribee_backend/auth.py +++ b/backend/transcribee_backend/auth.py @@ -141,7 +141,9 @@ def create_user(session: Session, username: str, password: str) -> User: if existing_user is not None: raise UserAlreadyExists() salt, hash = pw_hash(password) - user = User(username=username, password_hash=hash, password_salt=salt) + user = User( + username=username, password_hash=hash, password_salt=salt, last_seen=None + ) session.add(user) session.commit() return user diff --git a/backend/transcribee_backend/db/migrations/versions/ef78fa0844f4_add_user_last_seen.py b/backend/transcribee_backend/db/migrations/versions/ef78fa0844f4_add_user_last_seen.py new file mode 100644 index 00000000..0c517bf7 --- /dev/null +++ b/backend/transcribee_backend/db/migrations/versions/ef78fa0844f4_add_user_last_seen.py @@ -0,0 +1,33 @@ +"""add User.last_seen + +Revision ID: ef78fa0844f4 +Revises: 417eece003cb +Create Date: 2024-05-03 16:08:10.602419 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "ef78fa0844f4" +down_revision = "417eece003cb" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.add_column( + sa.Column("last_seen", sa.DateTime(timezone=True), nullable=True) + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.drop_column("last_seen") + + # ### end Alembic commands ### diff --git a/backend/transcribee_backend/metrics.py b/backend/transcribee_backend/metrics.py index 83213057..8857eec4 100644 --- a/backend/transcribee_backend/metrics.py +++ b/backend/transcribee_backend/metrics.py @@ -14,17 +14,17 @@ from transcribee_backend.helpers.time import now_tz_aware from transcribee_backend.models.document import Document from transcribee_backend.models.task import Task, TaskAttempt, TaskState -from transcribee_backend.models.user import User +from transcribee_backend.models.user import User, UserToken from transcribee_backend.models.worker import Worker -class Metric: +class GaugeMetric: @abstractmethod def refresh(self, session: Session): pass -class TasksInState(Metric): +class TasksInState(GaugeMetric): def __init__(self): self.collector = Gauge( "transcribee_tasks", "Number of tasks", ["state", "task_type"] @@ -45,7 +45,7 @@ def refresh(self, session: Session): ) -class Workers(Metric): +class Workers(GaugeMetric): def __init__(self): self.collector = Gauge("transcribee_workers", "Workers", ["group"]) @@ -69,25 +69,67 @@ def refresh(self, session: Session): self.collector.labels(group="alive").set(result) -class Users(Metric): +class Users(GaugeMetric): def __init__(self): - self.collector = Gauge("transcribee_users", "Registered users") + self.collector = Gauge( + "transcribee_users", "Users at the Transcribee Instance", ["group"] + ) def refresh(self, session: Session): (result,) = session.query(func.count(User.id)).one() - self.collector.set(result) + self.collector.labels(group="all").set(result) + now = now_tz_aware() + user_timeout_active = now - datetime.timedelta(hours=1) + (result,) = ( + session.query(func.count(User.id)) + .where( + col(User.last_seen) >= user_timeout_active, + ) + .one() + ) + self.collector.labels(group="active").set(result) -class Documents(Metric): + now = now_tz_aware() + user_timeout_active = now - datetime.timedelta(hours=1) + (result,) = ( + session.query(func.count(User.id)) + .where(col(User.last_seen).is_not(None)) + .one() + ) + self.collector.labels(group="ever_logged_in").set(result) + + (result,) = ( + session.query(func.count(func.distinct(UserToken.user_id))) + .where( + col(UserToken.valid_until) >= now, + ) + .one() + ) + self.collector.labels(group="with_token").set(result) + + +class Documents(GaugeMetric): def __init__(self): - self.collector = Gauge("transcribe_documents", "Documents") + self.collector = Gauge("transcribe_documents", "Documents", ["group"]) def refresh(self, session: Session): (result,) = session.query(func.count(Document.id)).one() - self.collector.set(result) + self.collector.labels(group="all").set(result) + + now = now_tz_aware() + document_timeout_active = now - datetime.timedelta(hours=1) + (result,) = ( + session.query(func.count(func.distinct(Document.id))) + .where( + col(Document.changed_at) >= document_timeout_active, + ) + .one() + ) + self.collector.labels(group="active").set(result) -class Queue(Metric): +class Queue(GaugeMetric): def __init__(self): self.collector = Gauge( "transcribee_queue_seconds", "Queue length in seconds", ["task_type"] @@ -116,19 +158,25 @@ def refresh(self, session: Session): self.collector.labels(task_type=task_type.value).set(count) -METRIC_CLASSES: List[type[Metric]] = [TasksInState, Workers, Users, Documents, Queue] -METRICS: List[Metric] = [] +GAUGE_METRIC_CLASSES: List[type[GaugeMetric]] = [ + TasksInState, + Workers, + Users, + Documents, + Queue, +] +GAUGE_METRICS: List[GaugeMetric] = [] def refresh_metrics(): with SessionContextManager(path="repeating_task:refresh_metrics") as session: - for metric in METRICS: + for metric in GAUGE_METRICS: metric.refresh(session) def init_metrics(): - for klass in METRIC_CLASSES: - METRICS.append(klass()) + for klass in GAUGE_METRIC_CLASSES: + GAUGE_METRICS.append(klass()) security = HTTPBasic() diff --git a/backend/transcribee_backend/models/user.py b/backend/transcribee_backend/models/user.py index 2060cee3..02c9d8fb 100644 --- a/backend/transcribee_backend/models/user.py +++ b/backend/transcribee_backend/models/user.py @@ -1,5 +1,6 @@ import datetime import uuid +from typing import Optional from pydantic import BaseModel, ConstrainedStr from sqlmodel import Column, DateTime, Field, Relationship, SQLModel @@ -18,6 +19,9 @@ class User(UserBase, table=True): ) password_hash: bytes password_salt: bytes + last_seen: Optional[datetime.datetime] = Field( + sa_column=Column(DateTime(timezone=True), nullable=True) + ) class CreateUser(UserBase): diff --git a/backend/transcribee_backend/routers/user.py b/backend/transcribee_backend/routers/user.py index a2518ada..4be96ce5 100644 --- a/backend/transcribee_backend/routers/user.py +++ b/backend/transcribee_backend/routers/user.py @@ -62,7 +62,12 @@ def logout( @user_router.get("/me/") def read_user( token: UserToken = Depends(get_user_token), + session: Session = Depends(get_session), ) -> UserBase: + token.user.last_seen = now_tz_aware() + session.add(token.user) + session.commit() + return UserBase(username=token.user.username)