cacode commited on
Commit
cf289c1
·
verified ·
1 Parent(s): 57892de

Upload 67 files

Browse files
.env.example CHANGED
@@ -8,4 +8,8 @@ SQL_PORT=21260
8
  SQL_DATABASE=CAM
9
  MYSQL_CA_FILE=ca.pem
10
  APP_TIMEZONE=Asia/Shanghai
11
- UPLOAD_ROOT=data/submissions
 
 
 
 
 
8
  SQL_DATABASE=CAM
9
  MYSQL_CA_FILE=ca.pem
10
  APP_TIMEZONE=Asia/Shanghai
11
+ DOCKER_ROOT=docker_data
12
+ DB_POOL_SIZE=12
13
+ DB_MAX_OVERFLOW=24
14
+ DB_POOL_TIMEOUT=30
15
+ DB_POOL_RECYCLE=1800
app/__pycache__/config.cpython-313.pyc CHANGED
Binary files a/app/__pycache__/config.cpython-313.pyc and b/app/__pycache__/config.cpython-313.pyc differ
 
app/__pycache__/database.cpython-313.pyc CHANGED
Binary files a/app/__pycache__/database.cpython-313.pyc and b/app/__pycache__/database.cpython-313.pyc differ
 
app/__pycache__/main.cpython-313.pyc CHANGED
Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ
 
app/config.py CHANGED
@@ -21,13 +21,16 @@ class Settings:
21
 
22
  mysql_user: str = os.getenv("SQL_USER", "avnadmin")
23
  mysql_password: str = os.getenv("SQL_PASSWORD", "")
24
- mysql_host: str = os.getenv(
25
- "SQL_HOST", "mysql-2bace9cd-cacode.i.aivencloud.com"
26
- )
27
  mysql_port: int = int(os.getenv("SQL_PORT", "21260"))
28
  mysql_db: str = os.getenv("SQL_DATABASE", "CAM")
29
  mysql_ca_file: Path = ROOT_DIR / os.getenv("MYSQL_CA_FILE", "ca.pem")
30
 
 
 
 
 
 
31
  docker_root: Path = ROOT_DIR / os.getenv("DOCKER_ROOT", "docker_data")
32
 
33
  @property
@@ -44,10 +47,7 @@ class Settings:
44
  return raw_url
45
  user = quote_plus(self.mysql_user)
46
  password = quote_plus(self.mysql_password)
47
- return (
48
- f"mysql+pymysql://{user}:{password}@{self.mysql_host}:"
49
- f"{self.mysql_port}/{self.mysql_db}"
50
- )
51
 
52
  @property
53
  def database_connect_args(self) -> dict:
@@ -57,6 +57,23 @@ class Settings:
57
  return {"ssl": {"ca": str(self.mysql_ca_file)}}
58
  return {}
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
- settings = Settings()
62
-
 
21
 
22
  mysql_user: str = os.getenv("SQL_USER", "avnadmin")
23
  mysql_password: str = os.getenv("SQL_PASSWORD", "")
24
+ mysql_host: str = os.getenv("SQL_HOST", "mysql-2bace9cd-cacode.i.aivencloud.com")
 
 
25
  mysql_port: int = int(os.getenv("SQL_PORT", "21260"))
26
  mysql_db: str = os.getenv("SQL_DATABASE", "CAM")
27
  mysql_ca_file: Path = ROOT_DIR / os.getenv("MYSQL_CA_FILE", "ca.pem")
28
 
29
+ db_pool_size: int = int(os.getenv("DB_POOL_SIZE", "12"))
30
+ db_max_overflow: int = int(os.getenv("DB_MAX_OVERFLOW", "24"))
31
+ db_pool_timeout: int = int(os.getenv("DB_POOL_TIMEOUT", "30"))
32
+ db_pool_recycle: int = int(os.getenv("DB_POOL_RECYCLE", "1800"))
33
+
34
  docker_root: Path = ROOT_DIR / os.getenv("DOCKER_ROOT", "docker_data")
35
 
36
  @property
 
47
  return raw_url
48
  user = quote_plus(self.mysql_user)
49
  password = quote_plus(self.mysql_password)
50
+ return f"mysql+pymysql://{user}:{password}@{self.mysql_host}:{self.mysql_port}/{self.mysql_db}"
 
 
 
51
 
52
  @property
53
  def database_connect_args(self) -> dict:
 
57
  return {"ssl": {"ca": str(self.mysql_ca_file)}}
58
  return {}
59
 
60
+ @property
61
+ def database_engine_kwargs(self) -> dict:
62
+ kwargs = {
63
+ "pool_pre_ping": True,
64
+ "connect_args": self.database_connect_args,
65
+ }
66
+ if not self.database_url.startswith("sqlite"):
67
+ kwargs.update(
68
+ {
69
+ "pool_size": self.db_pool_size,
70
+ "max_overflow": self.db_max_overflow,
71
+ "pool_timeout": self.db_pool_timeout,
72
+ "pool_recycle": self.db_pool_recycle,
73
+ "pool_use_lifo": True,
74
+ }
75
+ )
76
+ return kwargs
77
+
78
 
79
+ settings = Settings()
 
app/database.py CHANGED
@@ -12,11 +12,7 @@ class Base(DeclarativeBase):
12
  pass
13
 
14
 
15
- engine = create_engine(
16
- settings.database_url,
17
- pool_pre_ping=True,
18
- connect_args=settings.database_connect_args,
19
- )
20
  SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
21
 
22
 
 
12
  pass
13
 
14
 
15
+ engine = create_engine(settings.database_url, **settings.database_engine_kwargs)
 
 
 
 
16
  SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
17
 
18
 
app/main.py CHANGED
@@ -9,11 +9,9 @@ from starlette.middleware.sessions import SessionMiddleware
9
  from app.auth import get_current_admin, get_current_user
10
  from app.config import ROOT_DIR, settings
11
  from app.database import SessionLocal
12
- from app.models import Admin, User
13
  from app.routes import admin, auth, media, user
14
  from app.services.bootstrap import initialize_database, seed_super_admin
15
- from app.services.presence import should_write_presence
16
- from app.web import local_now, redirect, templates
17
 
18
 
19
  app = FastAPI(title=settings.app_name)
@@ -32,40 +30,10 @@ def on_startup() -> None:
32
 
33
 
34
  @app.middleware("http")
35
- async def track_presence(request: Request, call_next):
36
  response = await call_next(request)
37
  if request.url.path.startswith("/static"):
38
  response.headers["Cache-Control"] = "no-store"
39
- return response
40
- if request.url.path.startswith("/media"):
41
- return response
42
- if request.url.path.startswith("/api"):
43
- return response
44
-
45
- admin_id = request.session.get("admin_id")
46
- user_id = request.session.get("user_id")
47
- if not admin_id and not user_id:
48
- return response
49
-
50
- now = local_now()
51
- if not should_write_presence(request.session, now):
52
- return response
53
-
54
- db = SessionLocal()
55
- try:
56
- if admin_id:
57
- admin = db.get(Admin, admin_id)
58
- if admin:
59
- admin.last_seen_at = now
60
- db.add(admin)
61
- elif user_id:
62
- user = db.get(User, user_id)
63
- if user:
64
- user.last_seen_at = now
65
- db.add(user)
66
- db.commit()
67
- finally:
68
- db.close()
69
  return response
70
 
71
 
@@ -120,7 +88,4 @@ def format_timedelta(value):
120
 
121
 
122
  templates.env.filters["datetime_local"] = format_datetime
123
- templates.env.filters["duration_human"] = format_timedelta
124
-
125
-
126
-
 
9
  from app.auth import get_current_admin, get_current_user
10
  from app.config import ROOT_DIR, settings
11
  from app.database import SessionLocal
 
12
  from app.routes import admin, auth, media, user
13
  from app.services.bootstrap import initialize_database, seed_super_admin
14
+ from app.web import redirect, templates
 
15
 
16
 
17
  app = FastAPI(title=settings.app_name)
 
30
 
31
 
32
  @app.middleware("http")
33
+ async def apply_response_policies(request: Request, call_next):
34
  response = await call_next(request)
35
  if request.url.path.startswith("/static"):
36
  response.headers["Cache-Control"] = "no-store"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  return response
38
 
39
 
 
88
 
89
 
90
  templates.env.filters["datetime_local"] = format_datetime
91
+ templates.env.filters["duration_human"] = format_timedelta
 
 
 
app/routes/__pycache__/admin.cpython-313.pyc CHANGED
Binary files a/app/routes/__pycache__/admin.cpython-313.pyc and b/app/routes/__pycache__/admin.cpython-313.pyc differ
 
app/routes/__pycache__/auth.cpython-313.pyc CHANGED
Binary files a/app/routes/__pycache__/auth.cpython-313.pyc and b/app/routes/__pycache__/auth.cpython-313.pyc differ
 
app/routes/admin.py CHANGED
@@ -659,7 +659,6 @@ def admin_admins(request: Request, db: Session = Depends(get_db)):
659
  now = local_now()
660
  server_ts = unix_seconds(now)
661
  admins = db.query(Admin).order_by(Admin.created_at.desc()).all()
662
- users = db.query(User).options(joinedload(User.group)).order_by(User.full_name.asc()).all()
663
  admin_statuses = [
664
  {
665
  **serialize_presence_entry(item.display_name, display_admin_role(item.role), item.last_seen_at, now),
@@ -667,13 +666,6 @@ def admin_admins(request: Request, db: Session = Depends(get_db)):
667
  }
668
  for item in admins
669
  ]
670
- user_statuses = [
671
- {
672
- **serialize_presence_entry(item.full_name, "用户", item.last_seen_at, now),
673
- "group_name": item.group.name if item.group else "未分组",
674
- }
675
- for item in users
676
- ]
677
  return render(
678
  request,
679
  "admin_admins.html",
@@ -682,7 +674,6 @@ def admin_admins(request: Request, db: Session = Depends(get_db)):
682
  "admin": admin,
683
  "admins": admins,
684
  "admin_statuses": admin_statuses,
685
- "user_statuses": user_statuses,
686
  "presence_server_ts": server_ts,
687
  "online_window_seconds": ONLINE_WINDOW_SECONDS,
688
  },
@@ -698,7 +689,6 @@ def presence_overview(request: Request, db: Session = Depends(get_db)):
698
  now = local_now()
699
  server_ts = unix_seconds(now)
700
  admins = db.query(Admin).order_by(Admin.display_name.asc()).all()
701
- users = db.query(User).options(joinedload(User.group)).order_by(User.full_name.asc()).all()
702
  return JSONResponse(
703
  {
704
  "server_ts": server_ts,
@@ -710,13 +700,6 @@ def presence_overview(request: Request, db: Session = Depends(get_db)):
710
  }
711
  for item in admins
712
  ],
713
- "users": [
714
- {
715
- **serialize_presence_entry(item.full_name, "用户", item.last_seen_at, now),
716
- "group_name": item.group.name if item.group else "未分组",
717
- }
718
- for item in users
719
- ],
720
  },
721
  headers={"Cache-Control": "no-store"},
722
  )
@@ -1377,3 +1360,4 @@ async def delete_managed_image(request: Request, db: Session = Depends(get_db)):
1377
 
1378
 
1379
 
 
 
659
  now = local_now()
660
  server_ts = unix_seconds(now)
661
  admins = db.query(Admin).order_by(Admin.created_at.desc()).all()
 
662
  admin_statuses = [
663
  {
664
  **serialize_presence_entry(item.display_name, display_admin_role(item.role), item.last_seen_at, now),
 
666
  }
667
  for item in admins
668
  ]
 
 
 
 
 
 
 
669
  return render(
670
  request,
671
  "admin_admins.html",
 
674
  "admin": admin,
675
  "admins": admins,
676
  "admin_statuses": admin_statuses,
 
677
  "presence_server_ts": server_ts,
678
  "online_window_seconds": ONLINE_WINDOW_SECONDS,
679
  },
 
689
  now = local_now()
690
  server_ts = unix_seconds(now)
691
  admins = db.query(Admin).order_by(Admin.display_name.asc()).all()
 
692
  return JSONResponse(
693
  {
694
  "server_ts": server_ts,
 
700
  }
701
  for item in admins
702
  ],
 
 
 
 
 
 
 
703
  },
704
  headers={"Cache-Control": "no-store"},
705
  )
 
1360
 
1361
 
1362
 
1363
+
app/routes/auth.py CHANGED
@@ -107,7 +107,13 @@ def admin_login_submit(
107
  if not admin or not admin.is_active or not verify_password(password, admin.password_hash):
108
  add_flash(request, "error", "管理员账号或密码错误。")
109
  return redirect("/admin")
 
 
 
 
 
110
  sign_in_admin(request, admin)
 
111
  add_flash(request, "success", f"管理员 {admin.display_name} 已登录。")
112
  return redirect("/admin/dashboard")
113
 
@@ -168,26 +174,24 @@ def change_password(
168
  @router.api_route("/api/presence/ping", methods=["GET", "POST"])
169
  def presence_ping(request: Request, db: Session = Depends(get_db)):
170
  admin = get_current_admin(request, db)
171
- user = get_current_user(request, db)
172
- if not admin and not user:
173
- return JSONResponse({"ok": False}, status_code=401)
 
 
174
 
175
  now = local_now()
176
  wrote = False
177
- admin_was_online = bool(admin and is_online(admin.last_seen_at, now))
178
  if should_write_presence(request.session, now):
179
- if admin:
180
- admin.last_seen_at = now
181
- db.add(admin)
182
- elif user:
183
- user.last_seen_at = now
184
- db.add(user)
185
  db.commit()
186
  wrote = True
187
 
188
  # Only rebalance on an offline -> online transition. Fresh uploads are handled
189
  # at submit time, so this keeps the 5s heartbeat lightweight.
190
- if admin and not admin_was_online:
191
  rebalance_pending_reviews(db)
192
 
193
  return JSONResponse(
@@ -201,3 +205,4 @@ def presence_ping(request: Request, db: Session = Depends(get_db)):
201
 
202
 
203
 
 
 
107
  if not admin or not admin.is_active or not verify_password(password, admin.password_hash):
108
  add_flash(request, "error", "管理员账号或密码错误。")
109
  return redirect("/admin")
110
+
111
+ admin.last_seen_at = local_now()
112
+ db.add(admin)
113
+ db.commit()
114
+
115
  sign_in_admin(request, admin)
116
+ rebalance_pending_reviews(db)
117
  add_flash(request, "success", f"管理员 {admin.display_name} 已登录。")
118
  return redirect("/admin/dashboard")
119
 
 
174
  @router.api_route("/api/presence/ping", methods=["GET", "POST"])
175
  def presence_ping(request: Request, db: Session = Depends(get_db)):
176
  admin = get_current_admin(request, db)
177
+ if not admin:
178
+ return JSONResponse(
179
+ {"ok": False, "skipped": True},
180
+ headers={"Cache-Control": "no-store"},
181
+ )
182
 
183
  now = local_now()
184
  wrote = False
185
+ admin_was_online = bool(is_online(admin.last_seen_at, now))
186
  if should_write_presence(request.session, now):
187
+ admin.last_seen_at = now
188
+ db.add(admin)
 
 
 
 
189
  db.commit()
190
  wrote = True
191
 
192
  # Only rebalance on an offline -> online transition. Fresh uploads are handled
193
  # at submit time, so this keeps the 5s heartbeat lightweight.
194
+ if not admin_was_online:
195
  rebalance_pending_reviews(db)
196
 
197
  return JSONResponse(
 
205
 
206
 
207
 
208
+
app/templates/activity_detail.html CHANGED
@@ -320,6 +320,38 @@
320
  </tr>`).join('');
321
  };
322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  const refreshStatuses = async () => {
324
  try {
325
  const statusResponse = await fetch('/api/activities/{{ activity.id }}/status', {
@@ -403,9 +435,25 @@
403
  }
404
  };
405
 
406
- refreshStatuses();
407
- window.setInterval(refreshStatuses, 10000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  })();
409
  </script>
410
  {% endblock %}
411
 
 
 
320
  </tr>`).join('');
321
  };
322
 
323
+ const STATUS_POLL_INTERVAL_MS = 15000;
324
+ let statusTimerId = null;
325
+ let statusInFlight = false;
326
+
327
+ const canRefreshStatuses = () => !document.hidden && navigator.onLine;
328
+
329
+ const scheduleStatusRefresh = (delay = STATUS_POLL_INTERVAL_MS) => {
330
+ if (statusTimerId) {
331
+ window.clearTimeout(statusTimerId);
332
+ }
333
+ statusTimerId = window.setTimeout(runStatusRefresh, delay);
334
+ };
335
+
336
+ const runStatusRefresh = async () => {
337
+ if (!canRefreshStatuses()) {
338
+ statusTimerId = null;
339
+ return;
340
+ }
341
+ if (statusInFlight) {
342
+ scheduleStatusRefresh(1500);
343
+ return;
344
+ }
345
+
346
+ statusInFlight = true;
347
+ try {
348
+ await refreshStatuses();
349
+ } finally {
350
+ statusInFlight = false;
351
+ scheduleStatusRefresh(STATUS_POLL_INTERVAL_MS + Math.floor(Math.random() * 1200));
352
+ }
353
+ };
354
+
355
  const refreshStatuses = async () => {
356
  try {
357
  const statusResponse = await fetch('/api/activities/{{ activity.id }}/status', {
 
435
  }
436
  };
437
 
438
+ runStatusRefresh();
439
+ document.addEventListener('visibilitychange', () => {
440
+ if (document.hidden) {
441
+ if (statusTimerId) {
442
+ window.clearTimeout(statusTimerId);
443
+ statusTimerId = null;
444
+ }
445
+ return;
446
+ }
447
+ runStatusRefresh();
448
+ });
449
+ window.addEventListener('focus', () => {
450
+ if (!document.hidden) {
451
+ runStatusRefresh();
452
+ }
453
+ });
454
+ window.addEventListener('online', runStatusRefresh);
455
  })();
456
  </script>
457
  {% endblock %}
458
 
459
+
app/templates/admin_admins.html CHANGED
@@ -91,33 +91,22 @@
91
  </div>
92
  </article>
93
 
94
- <article class="glass-card table-panel">
95
  <div class="section-head">
96
  <div>
97
- <p class="eyebrow">Presence</p>
98
- <h3>所有用户在线状态</h3>
99
- <p class="mini-note">仅超级管理员可见,适合现场查看整体在线情况。</p>
100
  </div>
101
  </div>
102
- <div class="stack-list" id="user-presence-list">
103
- {% for item in user_statuses %}
104
- <div class="stack-item presence-item">
105
- <div>
106
- <strong>{{ item.name }}</strong>
107
- <p class="muted">{{ item.group_name }}</p>
108
- </div>
109
- <span
110
- class="status-badge {% if item.is_online %}status-approved{% else %}status-rejected{% endif %}"
111
- data-presence-badge
112
- data-last-seen-ts="{{ item.last_seen_ts or '' }}"
113
- title="最近心跳 {{ item.last_seen_at }}"
114
- >
115
- {{ '在线' if item.is_online else '离线' }} · {{ item.last_seen_at }}
116
- </span>
117
- </div>
118
- {% else %}
119
- <p class="muted">暂无用户在线状态。</p>
120
- {% endfor %}
121
  </div>
122
  </article>
123
  </section>
@@ -125,8 +114,7 @@
125
  <script>
126
  (() => {
127
  const adminList = document.getElementById('admin-presence-list');
128
- const userList = document.getElementById('user-presence-list');
129
- if (!adminList || !userList) return;
130
 
131
  let serverTs = Number('{{ presence_server_ts or 0 }}') || Math.floor(Date.now() / 1000);
132
  let syncClientTs = Date.now();
@@ -197,31 +185,6 @@
197
  }).join('');
198
  };
199
 
200
- const renderUserItems = (items) => {
201
- if (!items.length) {
202
- userList.innerHTML = '<p class="muted">暂无用户在线状态。</p>';
203
- return;
204
- }
205
- userList.innerHTML = items.map((item) => {
206
- const status = getStatusMeta(Number(item.last_seen_ts || 0));
207
- return `
208
- <div class="stack-item presence-item">
209
- <div>
210
- <strong>${escapeHtml(item.name)}</strong>
211
- <p class="muted">${escapeHtml(item.group_name)}</p>
212
- </div>
213
- <span
214
- class="status-badge ${status.online ? 'status-approved' : 'status-rejected'}"
215
- data-presence-badge
216
- data-last-seen-ts="${item.last_seen_ts || ''}"
217
- title="最近心跳 ${escapeHtml(item.last_seen_at || '-') }"
218
- >
219
- ${status.text}
220
- </span>
221
- </div>`;
222
- }).join('');
223
- };
224
-
225
  const refreshPresenceBadges = () => {
226
  if (document.hidden) return;
227
  document.querySelectorAll('[data-presence-badge]').forEach((badge) => {
@@ -248,7 +211,6 @@
248
  syncClientTs = Date.now();
249
  onlineWindowSeconds = Number(payload.online_window_seconds || 0) || onlineWindowSeconds;
250
  renderAdminItems(payload.admins || []);
251
- renderUserItems(payload.users || []);
252
  refreshPresenceBadges();
253
  } catch (error) {
254
  console.debug('presence refresh skipped', error);
@@ -269,7 +231,4 @@
269
  window.addEventListener('focus', refreshPresence);
270
  })();
271
  </script>
272
- {% endblock %}
273
-
274
-
275
-
 
91
  </div>
92
  </article>
93
 
94
+ <article class="glass-card form-panel">
95
  <div class="section-head">
96
  <div>
97
+ <p class="eyebrow">Notice</p>
98
+ <h3>在线检测说明</h3>
 
99
  </div>
100
  </div>
101
+ <div class="info-box">
102
+ <div class="stack-item stack-item-block">
103
+ <strong>普通用户在线心跳已关闭</strong>
104
+ <p class="muted">为降低数据库连接占用与轮询压力,系统不再维护普通用户的实时在线状态,只保留管理员在线检测与审核分发所需的心跳。</p>
105
+ </div>
106
+ <div class="stack-item stack-item-block">
107
+ <strong>当前策略</strong>
108
+ <p class="muted">管理员登录时会立即标记在线,之后仅由管理员端每 5 秒心跳续期;页面隐藏或离线时会自动暂停心跳,减少无效请求。</p>
109
+ </div>
 
 
 
 
 
 
 
 
 
 
110
  </div>
111
  </article>
112
  </section>
 
114
  <script>
115
  (() => {
116
  const adminList = document.getElementById('admin-presence-list');
117
+ if (!adminList) return;
 
118
 
119
  let serverTs = Number('{{ presence_server_ts or 0 }}') || Math.floor(Date.now() / 1000);
120
  let syncClientTs = Date.now();
 
185
  }).join('');
186
  };
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  const refreshPresenceBadges = () => {
189
  if (document.hidden) return;
190
  document.querySelectorAll('[data-presence-badge]').forEach((badge) => {
 
211
  syncClientTs = Date.now();
212
  onlineWindowSeconds = Number(payload.online_window_seconds || 0) || onlineWindowSeconds;
213
  renderAdminItems(payload.admins || []);
 
214
  refreshPresenceBadges();
215
  } catch (error) {
216
  console.debug('presence refresh skipped', error);
 
231
  window.addEventListener('focus', refreshPresence);
232
  })();
233
  </script>
234
+ {% endblock %}
 
 
 
app/templates/admin_reviews.html CHANGED
@@ -112,6 +112,9 @@
112
  const recentList = document.getElementById('recent-review-list');
113
  const onlineAdminPill = document.getElementById('online-admin-pill');
114
  const activityFilter = '{{ activity_filter }}';
 
 
 
115
  const selectedIds = new Set(
116
  Array.from(document.querySelectorAll('input[name="submission_ids"]:checked')).map((item) => item.value)
117
  );
@@ -196,6 +199,28 @@
196
  });
197
  };
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  const refreshFeed = async () => {
200
  try {
201
  const search = activityFilter ? `?activity_id=${activityFilter}` : '';
@@ -229,8 +254,20 @@
229
  };
230
 
231
  attachReviewForms();
232
- refreshFeed();
233
- window.setInterval(refreshFeed, 5000);
 
 
 
 
 
 
 
 
 
 
 
234
  })();
235
  </script>
236
  {% endblock %}
 
 
112
  const recentList = document.getElementById('recent-review-list');
113
  const onlineAdminPill = document.getElementById('online-admin-pill');
114
  const activityFilter = '{{ activity_filter }}';
115
+ const FEED_POLL_INTERVAL_MS = 5000;
116
+ let feedTimerId = null;
117
+ let feedInFlight = false;
118
  const selectedIds = new Set(
119
  Array.from(document.querySelectorAll('input[name="submission_ids"]:checked')).map((item) => item.value)
120
  );
 
199
  });
200
  };
201
 
202
+ const scheduleFeedRefresh = (delay = FEED_POLL_INTERVAL_MS) => {
203
+ if (feedTimerId) {
204
+ window.clearTimeout(feedTimerId);
205
+ }
206
+ feedTimerId = window.setTimeout(runFeedRefresh, delay);
207
+ };
208
+
209
+ const runFeedRefresh = async () => {
210
+ if (document.hidden || feedInFlight) {
211
+ scheduleFeedRefresh(document.hidden ? FEED_POLL_INTERVAL_MS : 1000);
212
+ return;
213
+ }
214
+
215
+ feedInFlight = true;
216
+ try {
217
+ await refreshFeed();
218
+ } finally {
219
+ feedInFlight = false;
220
+ scheduleFeedRefresh(FEED_POLL_INTERVAL_MS + Math.floor(Math.random() * 800));
221
+ }
222
+ };
223
+
224
  const refreshFeed = async () => {
225
  try {
226
  const search = activityFilter ? `?activity_id=${activityFilter}` : '';
 
254
  };
255
 
256
  attachReviewForms();
257
+ runFeedRefresh();
258
+ document.addEventListener('visibilitychange', () => {
259
+ if (document.hidden) {
260
+ if (feedTimerId) {
261
+ window.clearTimeout(feedTimerId);
262
+ feedTimerId = null;
263
+ }
264
+ return;
265
+ }
266
+ runFeedRefresh();
267
+ });
268
+ window.addEventListener('focus', runFeedRefresh);
269
+ window.addEventListener('online', runFeedRefresh);
270
  })();
271
  </script>
272
  {% endblock %}
273
+
app/templates/base.html CHANGED
@@ -46,7 +46,7 @@
46
  </main>
47
  </div>
48
 
49
- {% if user or admin %}
50
  <script>
51
  (() => {
52
  const PRESENCE_INTERVAL_MS = 5000;
@@ -124,3 +124,4 @@
124
 
125
 
126
 
 
 
46
  </main>
47
  </div>
48
 
49
+ {% if admin %}
50
  <script>
51
  (() => {
52
  const PRESENCE_INTERVAL_MS = 5000;
 
124
 
125
 
126
 
127
+