Compare commits

...

9 Commits

Author SHA1 Message Date
f485dc52aa update rex & siong production env 2026-05-03 11:32:26 +08:00
6bb85222d1 perf(web): server-side pagination + infinite-scroll for accounts/users
For 3k+ row deployments, returning the full table in one shot is the
bottleneck — the JSON payload alone is hundreds of KB and the client
mounts thousands of EditableCell instances on every visit. Pagination
with auto-fetch on scroll shrinks both the wire payload and the initial
render to a single page (200 rows).

Server (app/cm_api.py):
- /acc/ and /user/ accept ?limit, ?offset, ?prefix, ?dir, plus ?sort on
  /user/ (f_username | last_update_time). Defaults: limit=200 (capped at
  1000), offset=0, dir=desc.
- ORDER BY done in SQL with prefix-priority: rows whose username starts
  with the configured CM_PREFIX_PATTERN come first, then asc/desc by the
  sort column. The 'dir' value is whitelisted to ASC|DESC before string
  interpolation; everything else goes through parameterised binding.
- Schema verification (verify_tables_once) deferred to first request via
  a Flask before_request hook — keeps create_app() free of MySQL touches
  so unit tests + gunicorn preload still work without a live DB.

Web client:
- web/lib/api.ts: getAccountsPage / getUsersPage return { rows, hasMore }.
  hasMore = (rows.length === PAGE_SIZE), so the client knows when to
  stop fetching. Each page is its own Next.js cache entry (the URL is
  the cache key) — caching from the previous commit still applies.
- web/app/actions.ts: loadMoreAccounts / loadMoreUsers Server Actions
  for next-page requests; refreshAccounts / refreshUsers force-evict the
  cache via revalidateTag before refetching page 1.
- web/app/page.tsx + users/page.tsx: only fetch the first page now.
- web/components/{accounts,users}-table.tsx: rewrote state model. Rows
  accumulate as the user scrolls. An IntersectionObserver on a sentinel
  div near the bottom triggers loadMore when it enters the viewport
  (300px rootMargin so the next page starts loading before the user
  reaches the end). useOptimistic wraps the accumulated rows for in-
  flight edits; on success the row is committed locally so the change
  survives even though we no longer router.refresh.
- Sort toggle now refetches from page 1 with the new dir/sort param.
  Local sort over a partial set would be inconsistent.
- Mutations: delete filters from local state; create + refresh both
  reset to page 1 so the row appears in its sorted position.
- Header count shows '<loaded>+' when more pages exist so the operator
  knows what they're seeing isn't the full table.

Removed AutoRefresh:
- web/app/layout.tsx no longer mounts AutoRefresh.
- web/components/auto-refresh.tsx deleted.
- Reason: router.refresh every 30s would yank the user back to page 1
  every time, losing scroll position and accumulated rows. Manual
  Refresh button replaces it (now wired to refreshAccounts/refreshUsers
  which evict cache + refetch).

Tests: deferred verify_tables_once() means tests.test_bot_cli's
CreateAppFactoryTests pass without DB env vars again. All 38 existing
tests pass.
2026-05-03 11:29:34 +08:00
549e9b5939 perf(web): cache /acc/ and /user/ for 30s with tag invalidation
Eliminates the per-request DB hit when:
- A user opens a tab they've recently visited (within 30s)
- The 30s AutoRefresh fires while no mutations have happened
- Multiple browser tabs are open and switch between Accounts/Users
- A passing-by request races another request for the same data

How it works:
- web/lib/api.ts: fetchApi() now forwards the next.revalidate/tags
  options to Next.js's data cache. getAccounts/getUsers tag their
  responses ('accounts'/'users') with a 30-second freshness window.
- web/app/actions.ts: every mutation (update/create/delete X 2 tables)
  calls revalidateTag() so the next GET for that table bypasses the
  cache and re-reads from MySQL. Stale data never lingers after a write.

The cache lives in the cm-web Node process (per worker). For our
2-worker setup that's at most 2 cached copies; the next AutoRefresh
tick after the 30s window expires triggers exactly one DB read per
worker. If the operator manually clicks Refresh, that's a router.refresh
which also re-fetches.

Tradeoffs:
- External DB writes (e.g., the cm99.net monitor inserting a row) won't
  appear in the dashboard until the 30s window elapses or a mutation
  happens. The previous behavior had a 30s ceiling too (auto-refresh
  interval), so the perceived freshness is unchanged.
- Memory: each cached payload is a few KB to a few hundred KB. Trivial.

If you want stricter freshness later, drop CACHE_REVALIDATE_SECONDS in
web/lib/api.ts. If you want pagination on top of this, the cache key
becomes per-URL automatically, so /acc/?offset=200 caches separately
from /acc/?offset=0 — no further work needed.
2026-05-03 11:21:58 +08:00
9a4072129a perf(web): add route-level loading skeletons for instant tab switching
The 'switching is laggy with many accounts' report — root cause is that
both /page.tsx and /users/page.tsx are Server Components that block on
the API fetch before sending any HTML. During the wait, the previous
route stays frozen (no spinner, no feedback) — the user perceives a 'lag'
that grows with row count.

App Router's loading.tsx convention solves this: Next.js renders it
INSTANTLY on navigation, then streams in the real RSC tree once the data
fetch resolves. The skeleton matches the shape of the real shell + a few
placeholder rows so the swap is layout-stable.

Files:
- web/components/table-skeleton.tsx — shared skeleton (PageHead + N rows)
- web/app/loading.tsx — used for /
- web/app/users/loading.tsx — used for /users

If row counts keep growing past a few hundred and the table itself
becomes the bottleneck (vs the network fetch this addresses), the next
step is pagination: accept ?limit=&offset= on /acc/ and /user/ in
cm_api.py and add a 'Load more' button (or a virtual list) at the
table-component layer.
2026-05-03 11:14:13 +08:00
324c88e652 fix(db): pool_size default 24 to fit transfer-bot's 20 worker threads
The previous default of 8 was a regression risk: cm_transfer_credit.py
uses ThreadPoolExecutor with CM_TRANSFER_MAX_THREADS (default 20 in
prod compose), so up to 20 threads concurrently call self.db.query().
With pool_size=8, the 9th-20th threads would hit PoolError, which
gets caught by 'except Error' and silently returns []/False — making
transfers fail with no obvious cause.

Default bumped to 24 (covers the 20-thread default with 4 in reserve).
mysql.connector caps pool_size at 32; clamping with a clear log line
so a future operator who pushes CM_TRANSFER_MAX_THREADS too high gets
a readable message instead of a library traceback.

Operator note: if you raise CM_TRANSFER_MAX_THREADS, also raise
DB_POOL_SIZE to at least the same value (max 32). At 32 threads with
4 services × 32 = 128 conns total, still well under MySQL's default
max_connections=151.
2026-05-03 10:56:17 +08:00
a42fdf54b0 perf(api): pool MySQL connections + drop per-request schema check
Two wins, one root cause: every API request was opening TWO fresh MySQL
connections plus four wasted round-trips before the real query.

Old per-request shape (GET /acc/):
  1. DB() constructor → open conn, SHOW TABLES LIKE 'acc',
     SHOW TABLES LIKE 'user', close
  2. db.query() → open conn, run SELECT, close

That's ~4 round-trips for ~10 ms of useful work. With the dashboard's
30 s auto-refresh and two open tabs (accounts + users), the api-server
churned through ~10 fresh MySQL connections every minute even when
nothing changed.

Changes:
- app/db.py: introduce a process-wide MySQLConnectionPool (size 8 by
  default, override with DB_POOL_SIZE). DB() now just touches the cached
  pool — no schema check, no fresh handshake. query()/execute() rent a
  connection from the pool and return it via conn.close().
- app/db.py: extract the schema check into verify_tables_once() — runs
  once at WSGI boot inside create_app() so a misconfigured DB still
  fails fast at startup.
- app/cm_api.py: _close_database_connection() removed; the finally
  blocks that wrapped every route are gone too. Pool reclamation lives
  inside DB now.
- app/cm_api.py: create_app() and run() invoke verify_tables_once()
  once at startup instead of CM_API.__init__ doing nothing useful.

Net: ~4× round-trip reduction per request, no MySQL handshake on the
hot path. With two gunicorn workers × pool_size 8 = 16 max in-flight
connections, well under MySQL's default max_connections=151.

(The user asked about 'batching the queries' — but the queries already
return the full row set in one shot. The bottleneck was connection
churn, not query shape. If row count grows past the comfortable single-
fetch range later, swap to LIMIT/OFFSET pagination at the API + table
component layer.)
2026-05-03 10:54:11 +08:00
2871e04693 fix(scripts): typo in publish.sh buildx error ('sudosudo')
${SUDO:+...}${SUDO:-...} is not the right ternary — ${SUDO:+x} expands
to 'x' when SUDO is non-empty AND ${SUDO:-y} expands to 'y' when SUDO
is empty, but they're not exclusive substitutions of the same variable
in this context, so 'sudo' (the value of $SUDO when set) leaked into
the output as 'sudosudo'. Replaced with an explicit if/else.
2026-05-03 10:44:16 +08:00
98a0a433a1 fix(scripts): publish.sh — drop bogus auth check, helpful buildx error
- The 'authenticate first' reminder was checking docker system info's
  IndexServerAddress for 'gitea.04080616.xyz', but that field always
  reports Docker Hub regardless of which registries you've logged into.
  The reminder fired even right after a successful 'docker login' to
  Gitea — pure noise. Reduced to a comment for the maintainer.

- The buildx error message now points at the actual root cause: buildx
  is usually installed at the per-user ~/.docker/cli-plugins path, which
  sudo doesn't see. Two fixes presented: docker group (no-sudo) or apt
  install docker-buildx-plugin (sudo).
2026-05-03 10:41:52 +08:00
66737596b8 fix(scripts): publish.sh routes docker through sudo by default
Mirrors the SUDO=/NO_SUDO=1 pattern from scripts/dev.sh so the script
works on hosts where the user isn't in the docker group (the default
on this dev box). Without this, 'docker info' fails immediately even
though 'docker login' (which needs no daemon socket) succeeds, and
publish.sh aborts before doing anything.

Reminder text updated to tell operators to 'sudo docker login' (or to
opt into rootless docker via NO_SUDO=1).
2026-05-03 10:39:59 +08:00
16 changed files with 790 additions and 318 deletions

View File

@ -1,7 +1,7 @@
import os
import threading
from flask import Flask, jsonify, request
from .db import DB
from .db import DB, verify_tables_once
def _debug_enabled() -> bool:
@ -15,7 +15,7 @@ def _debug_enabled() -> bool:
class CM_API:
def __init__(self):
self.app = Flask(__name__)
# No CORS middleware: api-server is internal-only (no host port
@ -25,28 +25,33 @@ class CM_API:
# default that becomes an attack surface if a host port is ever
# accidentally re-exposed.
self._register_routes()
# Schema verification is deferred to the first request so that
# constructing the WSGI app (e.g., in tests, or via gunicorn's
# preload phase before MySQL is reachable) doesn't require the
# DB to be up. The first request hits this hook, validates the
# schema, and flips the latch — subsequent requests skip it.
self._schema_verified = False
self.app.before_request(self._verify_schema_once)
def _verify_schema_once(self):
if self._schema_verified:
return
verify_tables_once()
self._schema_verified = True
def _get_database_connection(self):
"""Create a new database connection for use"""
"""Return a DB handle backed by the shared connection pool.
DB() is now a near-zero-cost handle (it just touches the cached
process-wide pool); each query()/execute() rents a connection
and returns it. There's nothing to clean up explicitly.
"""
try:
db = DB()
return db
return DB()
except Exception as e:
print(f"Database connection failed: {e}")
return None
def _close_database_connection(self, db):
"""Close database connection if it exists"""
if db is not None:
try:
# Assuming DB class has a close method or similar cleanup
if hasattr(db, 'close'):
db.close()
elif hasattr(db, 'connection') and hasattr(db.connection, 'close'):
db.connection.close()
except Exception as e:
print(f"Error closing database connection: {e}")
def _register_routes(self):
# Account routes
self.app.route('/acc/<username>', methods=['GET'])(self.get_account)
@ -82,43 +87,89 @@ class CM_API:
is_available, db, error_response = self._check_database_available()
if not is_available:
return error_response
try:
if username:
query = "SELECT username, password, status, link FROM acc WHERE username = %s"
query_params = [username]
results = db.query(query, [username])
return jsonify(results)
# Listing path — pagination + prefix-priority sort.
try:
limit = max(1, min(int(request.args.get('limit', 200)), 1000))
offset = max(0, int(request.args.get('offset', 0)))
except (TypeError, ValueError):
return jsonify({"error": "limit and offset must be integers"}), 400
prefix = (request.args.get('prefix') or '').strip()
# Whitelist direction so it's safe to interpolate into the
# ORDER BY clause (parameterised binding doesn't apply to
# column names or sort directions).
direction = 'ASC' if request.args.get('dir', 'desc').lower() == 'asc' else 'DESC'
if prefix:
query = (
"SELECT username, password, status, link FROM acc "
"ORDER BY (CASE WHEN username LIKE %s THEN 0 ELSE 1 END), "
f"username {direction} "
"LIMIT %s OFFSET %s"
)
params = [f"{prefix}%", limit, offset]
else:
query = "SELECT username, password, status, link FROM acc"
query_params = []
results = db.query(query, query_params)
return jsonify(results)
query = (
"SELECT username, password, status, link FROM acc "
f"ORDER BY username {direction} "
"LIMIT %s OFFSET %s"
)
params = [limit, offset]
return jsonify(db.query(query, params))
except Exception as error:
return self._handle_error(error, "Not Found"), 404
finally:
self._close_database_connection(db)
def get_user(self, username=None):
is_available, db, error_response = self._check_database_available()
if not is_available:
return error_response
try:
if username:
query = "SELECT f_username, f_password, t_username, t_password FROM user WHERE f_username = %s"
query_params = [username]
results = db.query(query, [username])
return jsonify(results)
try:
limit = max(1, min(int(request.args.get('limit', 200)), 1000))
offset = max(0, int(request.args.get('offset', 0)))
except (TypeError, ValueError):
return jsonify({"error": "limit and offset must be integers"}), 400
prefix = (request.args.get('prefix') or '').strip()
sort_arg = request.args.get('sort', 'last_update_time')
sort_col = 'f_username' if sort_arg == 'f_username' else 'last_update_time'
direction = 'ASC' if request.args.get('dir', 'desc').lower() == 'asc' else 'DESC'
if prefix:
query = (
"SELECT f_username, f_password, t_username, t_password, last_update_time "
"FROM user "
"ORDER BY (CASE WHEN f_username LIKE %s THEN 0 ELSE 1 END), "
f"{sort_col} {direction} "
"LIMIT %s OFFSET %s"
)
params = [f"{prefix}%", limit, offset]
else:
query = "SELECT f_username, f_password, t_username, t_password, last_update_time FROM user"
query_params = []
results = db.query(query, query_params)
return jsonify(results)
query = (
"SELECT f_username, f_password, t_username, t_password, last_update_time "
"FROM user "
f"ORDER BY {sort_col} {direction} "
"LIMIT %s OFFSET %s"
)
params = [limit, offset]
return jsonify(db.query(query, params))
except Exception as error:
return self._handle_error(error, "Not Found"), 404
finally:
self._close_database_connection(db)
def update_acc_data(self):
is_available, db, error_response = self._check_database_available()
@ -147,8 +198,6 @@ class CM_API:
except Exception as error:
return self._handle_error(error, "Error updating data"), 500
finally:
self._close_database_connection(db)
def update_user_data(self):
is_available, db, error_response = self._check_database_available()
@ -177,8 +226,6 @@ class CM_API:
except Exception as error:
return self._handle_error(error, "Error updating data")
finally:
self._close_database_connection(db)
def delete_acc_data(self):
is_available, db, error_response = self._check_database_available()
@ -203,8 +250,6 @@ class CM_API:
except Exception as error:
return self._handle_error(error, "Error deleting account"), 500
finally:
self._close_database_connection(db)
def delete_user_data(self):
is_available, db, error_response = self._check_database_available()
@ -229,8 +274,6 @@ class CM_API:
except Exception as error:
return self._handle_error(error, "Error deleting user"), 500
finally:
self._close_database_connection(db)
def create_acc_data(self):
is_available, db, error_response = self._check_database_available()
@ -258,8 +301,6 @@ class CM_API:
except Exception as error:
return self._handle_error(error, "Error creating account"), 500
finally:
self._close_database_connection(db)
def create_user_data(self):
is_available, db, error_response = self._check_database_available()
@ -287,35 +328,31 @@ class CM_API:
except Exception as error:
return self._handle_error(error, "Error creating user"), 500
finally:
self._close_database_connection(db)
def run(self, port=3000, debug=None):
if debug is None:
debug = _debug_enabled()
# Test database connection before starting server
test_db = self._get_database_connection()
if test_db is None:
print("Cannot start server: Database not available")
try:
verify_tables_once()
except Exception as e:
print(f"Cannot start server: {e}")
exit(1)
self._close_database_connection(test_db)
print(f'CM Bot DB API Listening at Port : {port}')
self.app.run(host='0.0.0.0', port=port, debug=debug)
def run_in_thread(self, port=3000, debug=False):
"""Run the Flask app in a separate thread"""
# Test database connection before starting server
test_db = self._get_database_connection()
if test_db is None:
print("Cannot start server: Database not available")
try:
verify_tables_once()
except Exception as e:
print(f"Cannot start server: {e}")
return None
self._close_database_connection(test_db)
def run_app():
print(f'CM Bot DB API Listening at Port : {port}')
self.app.run(host='0.0.0.0', port=port, debug=debug, use_reloader=False)
thread = threading.Thread(target=run_app, daemon=True)
thread.start()
return thread
@ -324,10 +361,11 @@ class CM_API:
def create_app():
"""WSGI factory used by gunicorn (`app.cm_api:create_app()`).
Returns the Flask app object so gunicorn can serve it. The
surrounding CM_API class still owns route registration and DB
connection management this just hands gunicorn the underlying
Flask instance.
Returns the Flask app object. Schema verification runs lazily on the
first request (see CM_API._verify_schema_once) so the factory itself
never touches MySQL keeps gunicorn's preload phase unaffected by a
momentarily-unavailable DB and lets unit tests construct the app
without DB env wiring.
"""
return CM_API().app

200
app/db.py
View File

@ -1,8 +1,9 @@
import os
import threading
import time
import mysql.connector
from mysql.connector import Error
from mysql.connector import Error, pooling
def _get_required_env(name: str) -> str:
@ -12,112 +13,135 @@ def _get_required_env(name: str) -> str:
return value
class DB:
def __init__(self):
self.config = {
'host': _get_required_env('DB_HOST'),
'user': _get_required_env('DB_USER'),
'password': _get_required_env('DB_PASSWORD'),
'database': _get_required_env('DB_NAME'),
'port': int(_get_required_env('DB_PORT')),
'connection_timeout': int(_get_required_env('DB_CONNECTION_TIMEOUT'))
}
self.connect_retries = max(1, int(_get_required_env('DB_CONNECT_RETRIES')))
self.connect_retry_delay = float(_get_required_env('DB_CONNECT_RETRY_DELAY'))
self.init_database()
def get_connection(self):
"""Get MySQL database connection."""
for attempt in range(1, self.connect_retries + 1):
# Process-wide MySQL connection pool. Gunicorn forks workers; each worker
# gets its own pool (the global is rebuilt per process at first use).
_pool: "pooling.MySQLConnectionPool | None" = None
_pool_lock = threading.Lock()
def _build_pool() -> "pooling.MySQLConnectionPool":
# pool_size default of 24 covers transfer-bot at full tilt:
# CM_TRANSFER_MAX_THREADS defaults to 20, each thread can hold one
# connection for the duration of a transfer step. A smaller pool
# would surface as PoolError (caught silently by query/execute) and
# transfers would fail without obvious cause. mysql.connector caps
# pool_size at 32; if you bump CM_TRANSFER_MAX_THREADS, set
# DB_POOL_SIZE to at least the same value, capped at 32.
pool_size = int(os.getenv("DB_POOL_SIZE", "24"))
if pool_size > 32:
# Hard cap from mysql.connector; clamp here so the misconfigured
# value gets a clean message instead of a cryptic library error.
print(f"DB_POOL_SIZE={pool_size} exceeds mysql.connector max (32); clamping to 32")
pool_size = 32
config = {
"host": _get_required_env("DB_HOST"),
"user": _get_required_env("DB_USER"),
"password": _get_required_env("DB_PASSWORD"),
"database": _get_required_env("DB_NAME"),
"port": int(_get_required_env("DB_PORT")),
"connection_timeout": int(_get_required_env("DB_CONNECTION_TIMEOUT")),
"pool_name": "cm_pool",
"pool_size": pool_size,
"pool_reset_session": True,
}
return pooling.MySQLConnectionPool(**config)
def _get_pool() -> "pooling.MySQLConnectionPool":
"""Lazily build the per-process pool with retry, then memoize."""
global _pool
if _pool is not None:
return _pool
with _pool_lock:
if _pool is not None:
return _pool
retries = max(1, int(_get_required_env("DB_CONNECT_RETRIES")))
delay = float(_get_required_env("DB_CONNECT_RETRY_DELAY"))
last_err: "Exception | None" = None
for attempt in range(1, retries + 1):
try:
connection = mysql.connector.connect(**self.config)
return connection
_pool = _build_pool()
return _pool
except Error as e:
print(f"Error connecting to MySQL: {e}")
if attempt < self.connect_retries:
last_err = e
if attempt < retries:
print(
f"Retrying MySQL connection ({attempt}/{self.connect_retries}) "
f"in {self.connect_retry_delay} seconds..."
f"MySQL pool init failed ({e}); "
f"retry {attempt}/{retries} in {delay}s..."
)
time.sleep(self.connect_retry_delay)
return None
def init_database(self):
"""Initialize the database connection."""
connection = self.get_connection()
if connection is None:
raise Exception("Failed to connect to database")
cursor = None
time.sleep(delay)
raise RuntimeError(
f"Failed to build MySQL pool after {retries} attempts: {last_err}"
)
def verify_tables_once() -> None:
"""Run once at app startup to confirm schema is present.
Previously the DB() constructor ran two SHOW TABLES LIKE queries on
every request wasted round-trips. Now the check happens once when
create_app() boots the WSGI app; subsequent requests just rent a
connection from the pool.
"""
conn = _get_pool().get_connection()
try:
cursor = conn.cursor()
try:
cursor = connection.cursor()
# Test connection by checking if required tables exist
cursor.execute("SHOW TABLES LIKE 'acc'")
if not cursor.fetchone():
raise Exception("Table 'acc' does not exist")
cursor.execute("SHOW TABLES LIKE 'user'")
if not cursor.fetchone():
raise Exception("Table 'user' does not exist")
# print("Database connection verified - required tables exist")
except Error as e:
print(f"Error verifying database: {e}")
raise Exception(f"Database verification failed: {e}")
finally:
if cursor is not None:
cursor.close()
if connection.is_connected():
connection.close()
def query(self, query, params=None):
"""Execute a query and return results."""
connection = self.get_connection()
if connection is None:
return []
cursor = None
cursor.close()
finally:
conn.close()
class DB:
"""Thin handle backed by the process-wide MySQL pool.
Constructing DB() is now ~free it just touches the (cached) pool.
Each query()/execute() rents a connection from the pool and returns
it on completion via conn.close() (which the pool intercepts and
releases the connection back instead of actually closing it).
Pool size caps in-flight queries per worker; tune with DB_POOL_SIZE
(default 8). Two gunicorn workers × pool_size 8 = 16 max
connections comfortably under MySQL's default max_connections.
"""
def __init__(self):
_get_pool()
def query(self, sql, params=None):
conn = _get_pool().get_connection()
try:
cursor = connection.cursor(dictionary=True)
if params:
cursor.execute(query, params)
else:
cursor.execute(query)
results = cursor.fetchall()
return results
cursor = conn.cursor(dictionary=True)
try:
cursor.execute(sql, params or ())
return cursor.fetchall()
finally:
cursor.close()
except Error as e:
print(f"Error executing query: {e}")
return []
finally:
if cursor is not None:
cursor.close()
if connection.is_connected():
connection.close()
def execute(self, query, params=None):
"""Execute a query that modifies data (INSERT, UPDATE, DELETE) and return success status."""
connection = self.get_connection()
if connection is None:
return False
cursor = None
conn.close()
def execute(self, sql, params=None):
conn = _get_pool().get_connection()
try:
cursor = connection.cursor()
if params:
cursor.execute(query, params)
else:
cursor.execute(query)
connection.commit()
return True
cursor = conn.cursor()
try:
cursor.execute(sql, params or ())
conn.commit()
return True
finally:
cursor.close()
except Error as e:
print(f"Error executing query: {e}")
return False
finally:
if cursor is not None:
cursor.close()
if connection.is_connected():
connection.close()
conn.close()

32
envs/rex/rex.env Executable file
View File

@ -0,0 +1,32 @@
# === Runtime ===
CM_DEBUG=false
# === Deployment Identity ===
CM_DEPLOY_NAME=rex-cm
CM_WEB_HOST_PORT=8001
CM_AUTH_SECRET=ec96973f5ad293d5fd44fa1053b78fcbb7564ab6b29a791d629aa04d975d5132
# === Docker Registry ===
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
DOCKER_IMAGE_TAG=latest
# === Telegram ===
TELEGRAM_BOT_TOKEN=5315819168:AAH31xwNgPdnk123x97XalmTW6fQV5EUCFU
TELEGRAM_ALERT_CHAT_ID=818380985
# === Database ===
DB_HOST=192.168.0.210
DB_USER=rex_cm
DB_PASSWORD=hengserver
DB_NAME=rex_cm
DB_PORT=3306
DB_CONNECTION_TIMEOUT=8
DB_CONNECT_RETRIES=5
DB_CONNECT_RETRY_DELAY=2
# === Bot Config ===
CM_PREFIX_PATTERN=13c
CM_AGENT_ID=cm13a3
CM_AGENT_PASSWORD=Sky533535
CM_SECURITY_PIN=Sky533535
CM_BOT_BASE_URL=https://cm99.net

32
envs/siong/siong.env Executable file
View File

@ -0,0 +1,32 @@
# === Runtime ===
CM_DEBUG=false
# === Deployment Identity ===
CM_DEPLOY_NAME=siong-cm
CM_WEB_HOST_PORT=8005
CM_AUTH_SECRET=d2d5856aae4fba84bcdcde7d385a6b2dc647aef6da84d9542f3336f1e131a71e
# === Docker Registry ===
CM_IMAGE_PREFIX=gitea.04080616.xyz/yiekheng
DOCKER_IMAGE_TAG=latest
# === Telegram ===
TELEGRAM_BOT_TOKEN=7028479329:AAH_UTPoYcaB0iZMXJjO7pKYxyub8ZSXn2E
TELEGRAM_ALERT_CHAT_ID=818380985
# === Database ===
DB_HOST=192.168.0.210
DB_USER=siong_cm
DB_PASSWORD=hengserver
DB_NAME=siong_cm
DB_PORT=3306
DB_CONNECTION_TIMEOUT=8
DB_CONNECT_RETRIES=5
DB_CONNECT_RETRY_DELAY=2
# === Bot Config ===
CM_PREFIX_PATTERN=13sa
CM_AGENT_ID=cm13a39
CM_AGENT_PASSWORD=Wenwen12345
CM_SECURITY_PIN=Wenwen12345
CM_BOT_BASE_URL=https://cm99.net

View File

@ -14,11 +14,16 @@ Arguments:
tag Optional tag to publish (default: latest). Override with DOCKER_IMAGE_TAG.
Environment:
DOCKER_IMAGE_TAG Alternative way to set the tag (overrides CLI argument).
BUILD_ARGS Extra arguments passed to each docker build command.
DOCKER_IMAGE_TAG Alternative way to set the tag (overrides CLI argument).
BUILD_ARGS Extra arguments passed to each docker build command.
CM_IMAGE_PLATFORMS Buildx platforms (default: linux/amd64).
NO_SUDO=1 Skip the 'sudo' prefix (use if your user is in the docker group).
Make sure you are authenticated first:
docker login gitea.04080616.xyz
Authentication:
The script invokes docker via sudo by default (matching scripts/dev.sh).
Authenticate as the same user that runs the build:
sudo docker login gitea.04080616.xyz # default (sudo path)
docker login gitea.04080616.xyz # only with NO_SUDO=1
EOF
}
@ -27,27 +32,57 @@ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
exit 0
fi
if ! docker info >/dev/null 2>&1; then
echo "Docker daemon is not reachable. Please start Docker and retry." >&2
# Match scripts/dev.sh: prefix docker calls with sudo unless the user opts
# out via NO_SUDO=1 (typically because they're in the docker group).
SUDO="sudo"
[[ "${NO_SUDO:-0}" == "1" ]] && SUDO=""
DOCKER=(${SUDO} docker)
if ! "${DOCKER[@]}" info >/dev/null 2>&1; then
cat <<EOF >&2
Docker daemon is not reachable as the current effective user.
If you usually run docker via sudo (matching scripts/dev.sh), make sure
your password is cached / interactive — try 'sudo -v' first, then rerun.
If you've added yourself to the docker group, set NO_SUDO=1:
NO_SUDO=1 bash scripts/publish.sh ${1:-latest}
EOF
exit 1
fi
if ! docker system info --format '{{json .IndexServerAddress}}' | grep -q "gitea.04080616.xyz" 2>/dev/null; then
cat <<'EOF' >&2
Reminder: run 'docker login gitea.04080616.xyz' before publishing so pushes succeed.
EOF
fi
# (Earlier versions checked `docker system info` for the registry — but
# IndexServerAddress always points at Docker Hub regardless of which
# registries you've logged into, so the check was a guaranteed false
# positive. If push fails with 401, run:
# ${SUDO:+sudo }docker login gitea.04080616.xyz
IMAGE_TAG="${1:-${DOCKER_IMAGE_TAG:-latest}}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PLATFORMS="${CM_IMAGE_PLATFORMS:-linux/amd64}"
if ! docker buildx version >/dev/null 2>&1; then
cat <<'EOF' >&2
Docker Buildx is required for producing registry-compatible images.
Install/enable buildx and rerun, for example:
docker buildx create --use --name cm-bot-builder
docker buildx inspect --bootstrap
if ! "${DOCKER[@]}" buildx version >/dev/null 2>&1; then
RUNNER="$([[ -n "${SUDO}" ]] && echo "root via sudo" || echo "current user")"
cat <<EOF >&2
Docker Buildx isn't reachable as the user this script runs docker as
(${RUNNER}).
Likely cause: buildx is installed at the per-user path
~/.docker/cli-plugins/docker-buildx, which sudo doesn't see.
Pick one fix:
1) Add yourself to the docker group (works for everything, no sudo):
sudo usermod -aG docker \$USER
newgrp docker
docker login gitea.04080616.xyz
NO_SUDO=1 bash scripts/publish.sh ${1:-latest}
2) Install the buildx plugin system-wide:
sudo apt install docker-buildx-plugin
sudo docker login gitea.04080616.xyz
bash scripts/publish.sh ${1:-latest}
EOF
exit 1
fi
@ -71,7 +106,7 @@ for ENTRY in "${SERVICES[@]}"; do
IMAGE_NAME="${REGISTRY_PREFIX}/cm-${SERVICE}:${IMAGE_TAG}"
echo "==> Building and pushing ${IMAGE_NAME} (${DOCKERFILE})"
docker buildx build ${BUILD_ARGS:-} \
"${DOCKER[@]}" buildx build ${BUILD_ARGS:-} \
--platform "${PLATFORMS}" \
-f "${ROOT_DIR}/${DOCKERFILE}" \
-t "${IMAGE_NAME}" \

View File

@ -1,14 +1,27 @@
"use server";
import { revalidatePath } from "next/cache";
import { fetchApi } from "@/lib/api";
import type { AccUpdate, UserUpdate } from "@/lib/types";
import { revalidatePath, revalidateTag } from "next/cache";
import {
ACCOUNTS_TAG,
USERS_TAG,
fetchApi,
getAccountsPage,
getUsersPage,
type AccountsPageOpts,
type Page,
type UsersPageOpts,
} from "@/lib/api";
import type { Acc, AccUpdate, User, UserUpdate } from "@/lib/types";
export type ActionResult = { ok: true } | { ok: false; error: string };
// Each mutation evicts the matching tag so the next GET bypasses the
// 30s data cache and re-reads from MySQL.
export async function updateAccount(data: AccUpdate): Promise<ActionResult> {
try {
await fetchApi("/update-acc-data", { method: "POST", body: data });
revalidateTag(ACCOUNTS_TAG);
revalidatePath("/");
return { ok: true };
} catch (err) {
@ -19,6 +32,7 @@ export async function updateAccount(data: AccUpdate): Promise<ActionResult> {
export async function updateUser(data: UserUpdate): Promise<ActionResult> {
try {
await fetchApi("/update-user-data", { method: "POST", body: data });
revalidateTag(USERS_TAG);
revalidatePath("/users");
return { ok: true };
} catch (err) {
@ -29,6 +43,7 @@ export async function updateUser(data: UserUpdate): Promise<ActionResult> {
export async function createAccount(data: AccUpdate): Promise<ActionResult> {
try {
await fetchApi("/create-acc-data", { method: "POST", body: data });
revalidateTag(ACCOUNTS_TAG);
revalidatePath("/");
return { ok: true };
} catch (err) {
@ -39,6 +54,7 @@ export async function createAccount(data: AccUpdate): Promise<ActionResult> {
export async function createUser(data: UserUpdate): Promise<ActionResult> {
try {
await fetchApi("/create-user-data", { method: "POST", body: data });
revalidateTag(USERS_TAG);
revalidatePath("/users");
return { ok: true };
} catch (err) {
@ -49,6 +65,7 @@ export async function createUser(data: UserUpdate): Promise<ActionResult> {
export async function deleteAccount(username: string): Promise<ActionResult> {
try {
await fetchApi("/delete-acc-data", { method: "POST", body: { username } });
revalidateTag(ACCOUNTS_TAG);
revalidatePath("/");
return { ok: true };
} catch (err) {
@ -59,9 +76,34 @@ export async function deleteAccount(username: string): Promise<ActionResult> {
export async function deleteUser(f_username: string): Promise<ActionResult> {
try {
await fetchApi("/delete-user-data", { method: "POST", body: { f_username } });
revalidateTag(USERS_TAG);
revalidatePath("/users");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
// ---- Pagination + force-refresh ----
export async function loadMoreAccounts(opts: AccountsPageOpts): Promise<Page<Acc>> {
return getAccountsPage(opts);
}
export async function loadMoreUsers(opts: UsersPageOpts): Promise<Page<User>> {
return getUsersPage(opts);
}
// Force-refresh evicts the cached tag before refetching the first page,
// so manual Refresh always returns DB-fresh data even if the cache is
// still warm.
export async function refreshAccounts(opts: AccountsPageOpts = {}): Promise<Page<Acc>> {
revalidateTag(ACCOUNTS_TAG);
return getAccountsPage({ ...opts, offset: 0 });
}
export async function refreshUsers(opts: UsersPageOpts = {}): Promise<Page<User>> {
revalidateTag(USERS_TAG);
return getUsersPage({ ...opts, offset: 0 });
}

View File

@ -1,7 +1,6 @@
import "./globals.css";
import type { Metadata, Viewport } from "next";
import Nav from "@/components/nav";
import AutoRefresh from "@/components/auto-refresh";
import { getSession } from "@/lib/auth";
export const metadata: Metadata = {
@ -27,7 +26,6 @@ export default async function RootLayout({
<main className="mx-auto max-w-6xl px-4 pb-16 pt-8 sm:px-6 sm:pt-12">
{children}
</main>
<AutoRefresh />
</body>
</html>
);

5
web/app/loading.tsx Normal file
View File

@ -0,0 +1,5 @@
import TableSkeleton from "@/components/table-skeleton";
export default function Loading() {
return <TableSkeleton eyebrow="Table" title="Accounts" />;
}

View File

@ -1,9 +1,15 @@
import { getAccounts } from "@/lib/api";
import { getAccountsPage } from "@/lib/api";
import AccountsTable from "@/components/accounts-table";
const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";
export default async function AccountsPage() {
const accounts = await getAccounts();
return <AccountsTable initial={accounts} prefixPattern={PREFIX_PATTERN} />;
const page = await getAccountsPage({ prefix: PREFIX_PATTERN, dir: "desc" });
return (
<AccountsTable
initial={page.rows}
initialHasMore={page.hasMore}
prefixPattern={PREFIX_PATTERN}
/>
);
}

View File

@ -0,0 +1,5 @@
import TableSkeleton from "@/components/table-skeleton";
export default function Loading() {
return <TableSkeleton eyebrow="Table" title="Users" />;
}

View File

@ -1,9 +1,19 @@
import { getUsers } from "@/lib/api";
import { getUsersPage } from "@/lib/api";
import UsersTable from "@/components/users-table";
const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";
export default async function UsersPage() {
const users = await getUsers();
return <UsersTable initial={users} prefixPattern={PREFIX_PATTERN} />;
const page = await getUsersPage({
prefix: PREFIX_PATTERN,
sort: "last_update_time",
dir: "desc",
});
return (
<UsersTable
initial={page.rows}
initialHasMore={page.hasMore}
prefixPattern={PREFIX_PATTERN}
/>
);
}

View File

@ -1,30 +1,26 @@
"use client";
import { useMemo, useOptimistic, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useOptimistic, useRef, useState, useTransition } from "react";
import type { Acc } from "@/lib/types";
import { deleteAccount, updateAccount } from "@/app/actions";
import {
deleteAccount,
loadMoreAccounts,
refreshAccounts,
updateAccount,
} from "@/app/actions";
import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog";
import CreateAccountDialog from "./create-account-dialog";
import Toast, { type ToastMessage } from "./toast";
type Props = { initial: Acc[]; prefixPattern: string };
type Props = {
initial: Acc[];
initialHasMore: boolean;
prefixPattern: string;
};
type SortDir = "asc" | "desc";
type OptimisticPatch = { username: string; field: keyof Acc; value: string };
function sortAccounts(rows: Acc[], dir: SortDir, prefix: string): Acc[] {
return [...rows].sort((a, b) => {
const ap = a.username.startsWith(prefix);
const bp = b.username.startsWith(prefix);
if (ap && !bp) return -1;
if (!ap && bp) return 1;
return dir === "asc"
? a.username.localeCompare(b.username)
: b.username.localeCompare(a.username);
});
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { bg: string; fg: string; label: string }> = {
"": { bg: "bg-zinc-100", fg: "text-zinc-600", label: "available" },
@ -62,52 +58,111 @@ function DeleteButton({
);
}
export default function AccountsTable({ initial, prefixPattern }: Props) {
const router = useRouter();
export default function AccountsTable({
initial,
initialHasMore,
prefixPattern,
}: Props) {
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [toast, setToast] = useState<ToastMessage | null>(null);
// Accumulated rows from initial server-side fetch + every loadMore.
const [rows, setRows] = useState<Acc[]>(initial);
const [hasMore, setHasMore] = useState<boolean>(initialHasMore);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
initial,
rows,
(state, patch) =>
state.map((row) =>
row.username === patch.username ? { ...row, [patch.field]: patch.value } : row,
),
);
const sorted = useMemo(
() => sortAccounts(optimistic, sortDir, prefixPattern),
[optimistic, sortDir, prefixPattern],
);
function saveCell(username: string, field: keyof Acc, value: string) {
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
startTransition(async () => {
applyOptimistic({ username, field, value });
const row = initial.find((r) => r.username === username);
const row = rows.find((r) => r.username === username);
if (!row) return resolve({ ok: false, error: "row not found" });
const next: Acc = { ...row, [field]: value };
const result = await updateAccount(next);
resolve(result.ok ? { ok: true } : { ok: false, error: result.error });
if (result.ok) {
setRows((prev) => prev.map((r) => (r.username === username ? next : r)));
resolve({ ok: true });
} else {
resolve({ ok: false, error: result.error });
}
});
});
}
function refresh() {
async function refresh() {
setRefreshing(true);
startTransition(() => {
router.refresh();
setTimeout(() => setRefreshing(false), 400);
});
try {
const page = await refreshAccounts({ prefix: prefixPattern, dir: sortDir });
setRows(page.rows);
setHasMore(page.hasMore);
} finally {
setRefreshing(false);
}
}
async function changeSort(next: SortDir) {
if (next === sortDir) return;
setSortDir(next);
setLoadingMore(true);
try {
const page = await loadMoreAccounts({
offset: 0,
prefix: prefixPattern,
dir: next,
});
setRows(page.rows);
setHasMore(page.hasMore);
} finally {
setLoadingMore(false);
}
}
// Infinite scroll: when sentinel enters viewport, fetch the next page.
// 300px rootMargin so the next page starts loading before the user
// hits the bottom — feels seamless when scrolling fast.
useEffect(() => {
if (!hasMore || loadingMore || refreshing) return;
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
setLoadingMore(true);
loadMoreAccounts({
offset: rows.length,
prefix: prefixPattern,
dir: sortDir,
})
.then((page) => {
setRows((prev) => [...prev, ...page.rows]);
setHasMore(page.hasMore);
})
.catch((err) => console.error("loadMoreAccounts failed:", err))
.finally(() => setLoadingMore(false));
},
{ rootMargin: "300px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, loadingMore, refreshing, rows.length, prefixPattern, sortDir]);
async function confirmDelete() {
if (!deleteTarget) return;
setDeleting(true);
@ -116,6 +171,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
setDeleting(false);
if (result.ok) {
const deleted = deleteTarget;
setRows((prev) => prev.filter((r) => r.username !== deleted));
setDeleteTarget(null);
setToast({ type: "success", message: `Account ${deleted} deleted` });
} else {
@ -123,11 +179,13 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
}
}
if (initial.length === 0) {
if (rows.length === 0) {
return (
<div>
<PageHead
count={0}
loaded={0}
hasMore={false}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
@ -140,6 +198,12 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
<CreateAccountDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={async (name) => {
setToast({ type: "success", message: `Account ${name} created` });
// Force-refresh from page 1 so the new row appears in its
// sorted position. (We don't know where it ranks otherwise.)
await refresh();
}}
prefixPattern={prefixPattern}
/>
</div>
@ -150,6 +214,8 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
<div>
<PageHead
count={optimistic.length}
loaded={optimistic.length}
hasMore={hasMore}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
@ -163,7 +229,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
<th className="w-[18%] px-5 py-3 text-left">
<button
type="button"
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
onClick={() => changeSort(sortDir === "asc" ? "desc" : "asc")}
className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-zinc-500 transition-colors hover:text-zinc-900"
>
Username
@ -177,7 +243,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{sorted.map((row) => {
{optimistic.map((row) => {
const k = (f: string) => `${row.username}::${f}`;
return (
<tr key={row.username} className="transition-colors hover:bg-zinc-50/60">
@ -233,7 +299,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
{/* Mobile cards */}
<div className="mt-6 space-y-3 sm:hidden">
{sorted.map((row) => {
{optimistic.map((row) => {
const k = (f: string) => `${row.username}::${f}`;
return (
<div key={row.username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
@ -289,12 +355,28 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
})}
</div>
{/* Sentinel for infinite scroll. Hidden visually unless we're at
the bottom; the IntersectionObserver triggers loadMore as it
comes into view. */}
<div ref={sentinelRef} className="h-10" aria-hidden="true" />
{loadingMore && (
<p className="mt-4 text-center text-[11px] font-medium uppercase tracking-wider text-zinc-400">
Loading more
</p>
)}
{!hasMore && rows.length > 0 && (
<p className="mt-6 text-center text-[11px] text-zinc-400">
End of list {rows.length} accounts loaded
</p>
)}
<CreateAccountDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={(name) =>
setToast({ type: "success", message: `Account ${name} created` })
}
onSuccess={async (name) => {
setToast({ type: "success", message: `Account ${name} created` });
await refresh();
}}
prefixPattern={prefixPattern}
/>
@ -354,15 +436,22 @@ function CardRow({ label, children }: { label: string; children: React.ReactNode
function PageHead({
count,
loaded,
hasMore,
onRefresh,
refreshing,
onAdd,
}: {
count: number;
loaded: number;
hasMore: boolean;
onRefresh: () => void;
refreshing: boolean;
onAdd: () => void;
}) {
// count == loaded for now; kept separate so a future "showing X of Y"
// header (when we surface a server-side total) drops in cleanly.
const showHasMore = hasMore && loaded > 0;
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
@ -373,6 +462,7 @@ function PageHead({
Accounts
<span className="ml-2 align-middle text-base font-medium text-zinc-400">
{count}
{showHasMore && <span className="text-zinc-300">+</span>}
</span>
</h1>
</div>

View File

@ -1,24 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
/**
* Mounts a setInterval that calls router.refresh() every `intervalMs`.
* router.refresh() re-runs the matching Server Component fetch and
* patches the rendered output in no full page reload, no flicker.
*
* Renders nothing.
*/
export default function AutoRefresh({
intervalMs = 30_000,
}: {
intervalMs?: number;
}) {
const router = useRouter();
useEffect(() => {
const id = setInterval(() => router.refresh(), intervalMs);
return () => clearInterval(id);
}, [router, intervalMs]);
return null;
}

View File

@ -0,0 +1,50 @@
type Props = { eyebrow: string; title: string; rows?: number };
/**
* Lightweight skeleton that mimics the AccountsTable / UsersTable shell.
* Rendered by app/loading.tsx and app/users/loading.tsx Next.js shows
* this immediately on tab navigation, then streams in the real Server
* Component once its data fetch resolves. Without it, the previous
* route's UI freezes until the fetch finishes (the "tab switch is
* laggy" symptom).
*
* The pulse animation comes from Tailwind's animate-pulse on each
* placeholder bar; no JS, no layout shift when the real content swaps in.
*/
export default function TableSkeleton({ eyebrow, title, rows = 8 }: Props) {
return (
<div className="space-y-8">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
{eyebrow}
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
{title}
<span className="ml-2 inline-block h-4 w-8 animate-pulse rounded bg-zinc-200 align-middle" />
</h1>
</div>
<div className="flex items-center gap-2">
<span className="h-8 w-20 animate-pulse rounded-full bg-zinc-200" />
<span className="h-8 w-16 animate-pulse rounded-full bg-zinc-200" />
</div>
</div>
<ul className="space-y-2">
{Array.from({ length: rows }).map((_, i) => (
<li
key={i}
className="rounded-xl bg-white px-4 py-3 ring-1 ring-zinc-200/60"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="h-4 w-24 animate-pulse rounded bg-zinc-200" />
<span className="h-4 w-16 animate-pulse rounded-full bg-zinc-100" />
</div>
<span className="h-6 w-6 animate-pulse rounded-md bg-zinc-100" />
</div>
</li>
))}
</ul>
</div>
);
}

View File

@ -1,15 +1,23 @@
"use client";
import { useMemo, useOptimistic, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useOptimistic, useRef, useState, useTransition } from "react";
import type { User } from "@/lib/types";
import { deleteUser, updateUser } from "@/app/actions";
import {
deleteUser,
loadMoreUsers,
refreshUsers,
updateUser,
} from "@/app/actions";
import EditableCell from "./editable-cell";
import ConfirmDialog from "./confirm-dialog";
import CreateUserDialog from "./create-user-dialog";
import Toast, { type ToastMessage } from "./toast";
type Props = { initial: User[]; prefixPattern: string };
type Props = {
initial: User[];
initialHasMore: boolean;
prefixPattern: string;
};
type SortDir = "asc" | "desc";
type SortKey = "f_username" | "last_update_time";
type OptimisticPatch = {
@ -18,29 +26,6 @@ type OptimisticPatch = {
value: string;
};
function timeOf(t: string | null) {
if (!t) return 0;
const ms = Date.parse(t);
return Number.isNaN(ms) ? 0 : ms;
}
function sortUsers(rows: User[], key: SortKey, dir: SortDir, prefix: string): User[] {
return [...rows].sort((a, b) => {
const ap = a.f_username.startsWith(prefix);
const bp = b.f_username.startsWith(prefix);
if (ap && !bp) return -1;
if (!ap && bp) return 1;
if (key === "f_username") {
return dir === "asc"
? a.f_username.localeCompare(b.f_username)
: b.f_username.localeCompare(a.f_username);
}
return dir === "asc"
? timeOf(a.last_update_time) - timeOf(b.last_update_time)
: timeOf(b.last_update_time) - timeOf(a.last_update_time);
});
}
function formatTime(t: string | null) {
if (!t) return <em className="not-italic text-zinc-400"></em>;
const d = new Date(t);
@ -74,32 +59,35 @@ function DeleteButton({
);
}
export default function UsersTable({ initial, prefixPattern }: Props) {
const router = useRouter();
export default function UsersTable({
initial,
initialHasMore,
prefixPattern,
}: Props) {
const [sortKey, setSortKey] = useState<SortKey>("last_update_time");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [editingKey, setEditingKey] = useState<string | null>(null);
const [, startTransition] = useTransition();
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [toast, setToast] = useState<ToastMessage | null>(null);
const [rows, setRows] = useState<User[]>(initial);
const [hasMore, setHasMore] = useState<boolean>(initialHasMore);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
initial,
rows,
(state, patch) =>
state.map((row) =>
row.f_username === patch.f_username ? { ...row, [patch.field]: patch.value } : row,
),
);
const sorted = useMemo(
() => sortUsers(optimistic, sortKey, sortDir, prefixPattern),
[optimistic, sortKey, sortDir, prefixPattern],
);
function saveCell(
f_username: string,
field: OptimisticPatch["field"],
@ -108,7 +96,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
startTransition(async () => {
applyOptimistic({ f_username, field, value });
const row = initial.find((r) => r.f_username === f_username);
const row = rows.find((r) => r.f_username === f_username);
if (!row) return resolve({ ok: false, error: "row not found" });
const next = {
f_username: row.f_username,
@ -118,27 +106,86 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
[field]: value,
};
const result = await updateUser(next);
resolve(result.ok ? { ok: true } : { ok: false, error: result.error });
if (result.ok) {
setRows((prev) =>
prev.map((r) =>
r.f_username === f_username ? { ...r, [field]: value } : r,
),
);
resolve({ ok: true });
} else {
resolve({ ok: false, error: result.error });
}
});
});
}
function refresh() {
async function refresh() {
setRefreshing(true);
startTransition(() => {
router.refresh();
setTimeout(() => setRefreshing(false), 400);
});
}
function toggleSort(k: SortKey) {
if (sortKey === k) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortKey(k);
setSortDir("desc");
try {
const page = await refreshUsers({
prefix: prefixPattern,
sort: sortKey,
dir: sortDir,
});
setRows(page.rows);
setHasMore(page.hasMore);
} finally {
setRefreshing(false);
}
}
async function changeSort(nextKey: SortKey) {
let nextDir: SortDir;
if (nextKey === sortKey) {
nextDir = sortDir === "asc" ? "desc" : "asc";
} else {
nextDir = "desc";
}
setSortKey(nextKey);
setSortDir(nextDir);
setLoadingMore(true);
try {
const page = await loadMoreUsers({
offset: 0,
prefix: prefixPattern,
sort: nextKey,
dir: nextDir,
});
setRows(page.rows);
setHasMore(page.hasMore);
} finally {
setLoadingMore(false);
}
}
useEffect(() => {
if (!hasMore || loadingMore || refreshing) return;
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
setLoadingMore(true);
loadMoreUsers({
offset: rows.length,
prefix: prefixPattern,
sort: sortKey,
dir: sortDir,
})
.then((page) => {
setRows((prev) => [...prev, ...page.rows]);
setHasMore(page.hasMore);
})
.catch((err) => console.error("loadMoreUsers failed:", err))
.finally(() => setLoadingMore(false));
},
{ rootMargin: "300px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, loadingMore, refreshing, rows.length, prefixPattern, sortKey, sortDir]);
async function confirmDelete() {
if (!deleteTarget) return;
setDeleting(true);
@ -147,6 +194,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
setDeleting(false);
if (result.ok) {
const deleted = deleteTarget;
setRows((prev) => prev.filter((r) => r.f_username !== deleted));
setDeleteTarget(null);
setToast({ type: "success", message: `User ${deleted} deleted` });
} else {
@ -159,7 +207,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
return (
<button
type="button"
onClick={() => toggleSort(k)}
onClick={() => changeSort(k)}
className={`inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider transition-colors ${
active ? "text-zinc-900" : "text-zinc-500 hover:text-zinc-900"
}`}
@ -170,11 +218,12 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
);
}
if (initial.length === 0) {
if (rows.length === 0) {
return (
<div>
<PageHead
count={0}
hasMore={false}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
@ -185,14 +234,14 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
</p>
</div>
<CreateUserDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={(name) =>
setToast({ type: "success", message: `User ${name} created` })
}
/>
<Toast toast={toast} onDismiss={() => setToast(null)} />
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={async (name) => {
setToast({ type: "success", message: `User ${name} created` });
await refresh();
}}
/>
<Toast toast={toast} onDismiss={() => setToast(null)} />
</div>
);
}
@ -201,6 +250,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
<div>
<PageHead
count={optimistic.length}
hasMore={hasMore}
onRefresh={refresh}
refreshing={refreshing}
onAdd={() => setCreateOpen(true)}
@ -229,7 +279,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{sorted.map((row) => {
{optimistic.map((row) => {
const k = (f: string) => `${row.f_username}::${f}`;
return (
<tr key={row.f_username} className="transition-colors hover:bg-zinc-50/60">
@ -286,7 +336,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
</div>
<div className="mt-6 space-y-3 sm:hidden">
{sorted.map((row) => {
{optimistic.map((row) => {
const k = (f: string) => `${row.f_username}::${f}`;
return (
<div key={row.f_username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
@ -344,12 +394,25 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
})}
</div>
<div ref={sentinelRef} className="h-10" aria-hidden="true" />
{loadingMore && (
<p className="mt-4 text-center text-[11px] font-medium uppercase tracking-wider text-zinc-400">
Loading more
</p>
)}
{!hasMore && rows.length > 0 && (
<p className="mt-6 text-center text-[11px] text-zinc-400">
End of list {rows.length} users loaded
</p>
)}
<CreateUserDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={(name) =>
setToast({ type: "success", message: `User ${name} created` })
}
onSuccess={async (name) => {
setToast({ type: "success", message: `User ${name} created` });
await refresh();
}}
/>
<Toast toast={toast} onDismiss={() => setToast(null)} />
@ -397,22 +460,28 @@ function MobileRow({ label, children }: { label: string; children: React.ReactNo
function PageHead({
count,
hasMore,
onRefresh,
refreshing,
onAdd,
}: {
count: number;
hasMore: boolean;
onRefresh: () => void;
refreshing: boolean;
onAdd: () => void;
}) {
const showHasMore = hasMore && count > 0;
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Table</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
Users
<span className="ml-2 align-middle text-base font-medium text-zinc-400">{count}</span>
<span className="ml-2 align-middle text-base font-medium text-zinc-400">
{count}
{showHasMore && <span className="text-zinc-300">+</span>}
</span>
</h1>
</div>
<div className="flex items-center gap-2">

View File

@ -2,20 +2,53 @@ import type { Acc, User } from "./types";
const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";
// Tab-switch responses come from the Next.js data cache for this many
// seconds before re-fetching. Mutations call revalidateTag() to evict
// the cached entry and force the next read to hit MySQL.
const CACHE_REVALIDATE_SECONDS = 30;
export const ACCOUNTS_TAG = "accounts";
export const USERS_TAG = "users";
// Page size used for both initial server-side fetch and infinite scroll
// on the client. 200 keeps each cached payload under ~50KB and renders
// in well under one frame even on phones.
export const PAGE_SIZE = 200;
export type Page<T> = { rows: T[]; hasMore: boolean };
export type AccountsPageOpts = {
offset?: number;
prefix?: string;
dir?: "asc" | "desc";
};
export type UsersPageOpts = {
offset?: number;
prefix?: string;
sort?: "f_username" | "last_update_time";
dir?: "asc" | "desc";
};
type FetchInit = {
method?: "GET" | "POST";
body?: unknown;
cache?: RequestCache;
next?: { revalidate?: number; tags?: string[] };
};
export async function fetchApi(path: string, options: FetchInit = {}): Promise<unknown> {
const url = `${API_BASE_URL}${path}`;
const init: RequestInit = {
const init: RequestInit & { next?: FetchInit["next"] } = {
method: options.method ?? "GET",
cache: options.cache ?? "no-store",
headers: options.body ? { "content-type": "application/json" } : undefined,
body: options.body ? JSON.stringify(options.body) : undefined,
};
if (options.next) {
init.next = options.next;
} else {
init.cache = options.cache ?? "no-store";
}
const res = await fetch(url, init);
if (!res.ok) {
throw new Error(`API ${options.method ?? "GET"} ${path} failed: ${res.status}`);
@ -23,12 +56,39 @@ export async function fetchApi(path: string, options: FetchInit = {}): Promise<u
return res.json();
}
export async function getAccounts(): Promise<Acc[]> {
const data = await fetchApi("/acc/");
return data as Acc[];
function buildAccountsUrl(opts: AccountsPageOpts): string {
const { offset = 0, prefix = "", dir = "desc" } = opts;
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(offset),
dir,
});
if (prefix) params.set("prefix", prefix);
return `/acc/?${params.toString()}`;
}
export async function getUsers(): Promise<User[]> {
const data = await fetchApi("/user/");
return data as User[];
function buildUsersUrl(opts: UsersPageOpts): string {
const { offset = 0, prefix = "", sort = "last_update_time", dir = "desc" } = opts;
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(offset),
sort,
dir,
});
if (prefix) params.set("prefix", prefix);
return `/user/?${params.toString()}`;
}
export async function getAccountsPage(opts: AccountsPageOpts = {}): Promise<Page<Acc>> {
const data = (await fetchApi(buildAccountsUrl(opts), {
next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [ACCOUNTS_TAG] },
})) as Acc[];
return { rows: data, hasMore: data.length === PAGE_SIZE };
}
export async function getUsersPage(opts: UsersPageOpts = {}): Promise<Page<User>> {
const data = (await fetchApi(buildUsersUrl(opts), {
next: { revalidate: CACHE_REVALIDATE_SECONDS, tags: [USERS_TAG] },
})) as User[];
return { rows: data, hasMore: data.length === PAGE_SIZE };
}