Compare commits
9 Commits
626344cc16
...
f485dc52aa
| Author | SHA1 | Date | |
|---|---|---|---|
| f485dc52aa | |||
| 6bb85222d1 | |||
| 549e9b5939 | |||
| 9a4072129a | |||
| 324c88e652 | |||
| a42fdf54b0 | |||
| 2871e04693 | |||
| 98a0a433a1 | |||
| 66737596b8 |
154
app/cm_api.py
154
app/cm_api.py
@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
from flask import Flask, jsonify, request
|
from flask import Flask, jsonify, request
|
||||||
from .db import DB
|
from .db import DB, verify_tables_once
|
||||||
|
|
||||||
|
|
||||||
def _debug_enabled() -> bool:
|
def _debug_enabled() -> bool:
|
||||||
@ -25,28 +25,33 @@ class CM_API:
|
|||||||
# default that becomes an attack surface if a host port is ever
|
# default that becomes an attack surface if a host port is ever
|
||||||
# accidentally re-exposed.
|
# accidentally re-exposed.
|
||||||
self._register_routes()
|
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):
|
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:
|
try:
|
||||||
db = DB()
|
return DB()
|
||||||
return db
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Database connection failed: {e}")
|
print(f"Database connection failed: {e}")
|
||||||
return None
|
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):
|
def _register_routes(self):
|
||||||
# Account routes
|
# Account routes
|
||||||
self.app.route('/acc/<username>', methods=['GET'])(self.get_account)
|
self.app.route('/acc/<username>', methods=['GET'])(self.get_account)
|
||||||
@ -86,18 +91,41 @@ class CM_API:
|
|||||||
try:
|
try:
|
||||||
if username:
|
if username:
|
||||||
query = "SELECT username, password, status, link FROM acc WHERE username = %s"
|
query = "SELECT username, password, status, link FROM acc WHERE username = %s"
|
||||||
query_params = [username]
|
results = db.query(query, [username])
|
||||||
else:
|
|
||||||
query = "SELECT username, password, status, link FROM acc"
|
|
||||||
query_params = []
|
|
||||||
|
|
||||||
results = db.query(query, query_params)
|
|
||||||
return jsonify(results)
|
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 "
|
||||||
|
f"ORDER BY username {direction} "
|
||||||
|
"LIMIT %s OFFSET %s"
|
||||||
|
)
|
||||||
|
params = [limit, offset]
|
||||||
|
|
||||||
|
return jsonify(db.query(query, params))
|
||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
return self._handle_error(error, "Not Found"), 404
|
return self._handle_error(error, "Not Found"), 404
|
||||||
finally:
|
|
||||||
self._close_database_connection(db)
|
|
||||||
|
|
||||||
def get_user(self, username=None):
|
def get_user(self, username=None):
|
||||||
is_available, db, error_response = self._check_database_available()
|
is_available, db, error_response = self._check_database_available()
|
||||||
@ -107,18 +135,41 @@ class CM_API:
|
|||||||
try:
|
try:
|
||||||
if username:
|
if username:
|
||||||
query = "SELECT f_username, f_password, t_username, t_password FROM user WHERE f_username = %s"
|
query = "SELECT f_username, f_password, t_username, t_password FROM user WHERE f_username = %s"
|
||||||
query_params = [username]
|
results = db.query(query, [username])
|
||||||
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)
|
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 "
|
||||||
|
f"ORDER BY {sort_col} {direction} "
|
||||||
|
"LIMIT %s OFFSET %s"
|
||||||
|
)
|
||||||
|
params = [limit, offset]
|
||||||
|
|
||||||
|
return jsonify(db.query(query, params))
|
||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
return self._handle_error(error, "Not Found"), 404
|
return self._handle_error(error, "Not Found"), 404
|
||||||
finally:
|
|
||||||
self._close_database_connection(db)
|
|
||||||
|
|
||||||
def update_acc_data(self):
|
def update_acc_data(self):
|
||||||
is_available, db, error_response = self._check_database_available()
|
is_available, db, error_response = self._check_database_available()
|
||||||
@ -147,8 +198,6 @@ class CM_API:
|
|||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
return self._handle_error(error, "Error updating data"), 500
|
return self._handle_error(error, "Error updating data"), 500
|
||||||
finally:
|
|
||||||
self._close_database_connection(db)
|
|
||||||
|
|
||||||
def update_user_data(self):
|
def update_user_data(self):
|
||||||
is_available, db, error_response = self._check_database_available()
|
is_available, db, error_response = self._check_database_available()
|
||||||
@ -177,8 +226,6 @@ class CM_API:
|
|||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
return self._handle_error(error, "Error updating data")
|
return self._handle_error(error, "Error updating data")
|
||||||
finally:
|
|
||||||
self._close_database_connection(db)
|
|
||||||
|
|
||||||
def delete_acc_data(self):
|
def delete_acc_data(self):
|
||||||
is_available, db, error_response = self._check_database_available()
|
is_available, db, error_response = self._check_database_available()
|
||||||
@ -203,8 +250,6 @@ class CM_API:
|
|||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
return self._handle_error(error, "Error deleting account"), 500
|
return self._handle_error(error, "Error deleting account"), 500
|
||||||
finally:
|
|
||||||
self._close_database_connection(db)
|
|
||||||
|
|
||||||
def delete_user_data(self):
|
def delete_user_data(self):
|
||||||
is_available, db, error_response = self._check_database_available()
|
is_available, db, error_response = self._check_database_available()
|
||||||
@ -229,8 +274,6 @@ class CM_API:
|
|||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
return self._handle_error(error, "Error deleting user"), 500
|
return self._handle_error(error, "Error deleting user"), 500
|
||||||
finally:
|
|
||||||
self._close_database_connection(db)
|
|
||||||
|
|
||||||
def create_acc_data(self):
|
def create_acc_data(self):
|
||||||
is_available, db, error_response = self._check_database_available()
|
is_available, db, error_response = self._check_database_available()
|
||||||
@ -258,8 +301,6 @@ class CM_API:
|
|||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
return self._handle_error(error, "Error creating account"), 500
|
return self._handle_error(error, "Error creating account"), 500
|
||||||
finally:
|
|
||||||
self._close_database_connection(db)
|
|
||||||
|
|
||||||
def create_user_data(self):
|
def create_user_data(self):
|
||||||
is_available, db, error_response = self._check_database_available()
|
is_available, db, error_response = self._check_database_available()
|
||||||
@ -287,30 +328,26 @@ class CM_API:
|
|||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
return self._handle_error(error, "Error creating user"), 500
|
return self._handle_error(error, "Error creating user"), 500
|
||||||
finally:
|
|
||||||
self._close_database_connection(db)
|
|
||||||
|
|
||||||
def run(self, port=3000, debug=None):
|
def run(self, port=3000, debug=None):
|
||||||
if debug is None:
|
if debug is None:
|
||||||
debug = _debug_enabled()
|
debug = _debug_enabled()
|
||||||
# Test database connection before starting server
|
try:
|
||||||
test_db = self._get_database_connection()
|
verify_tables_once()
|
||||||
if test_db is None:
|
except Exception as e:
|
||||||
print("Cannot start server: Database not available")
|
print(f"Cannot start server: {e}")
|
||||||
exit(1)
|
exit(1)
|
||||||
self._close_database_connection(test_db)
|
|
||||||
|
|
||||||
print(f'CM Bot DB API Listening at Port : {port}')
|
print(f'CM Bot DB API Listening at Port : {port}')
|
||||||
self.app.run(host='0.0.0.0', port=port, debug=debug)
|
self.app.run(host='0.0.0.0', port=port, debug=debug)
|
||||||
|
|
||||||
def run_in_thread(self, port=3000, debug=False):
|
def run_in_thread(self, port=3000, debug=False):
|
||||||
"""Run the Flask app in a separate thread"""
|
"""Run the Flask app in a separate thread"""
|
||||||
# Test database connection before starting server
|
try:
|
||||||
test_db = self._get_database_connection()
|
verify_tables_once()
|
||||||
if test_db is None:
|
except Exception as e:
|
||||||
print("Cannot start server: Database not available")
|
print(f"Cannot start server: {e}")
|
||||||
return None
|
return None
|
||||||
self._close_database_connection(test_db)
|
|
||||||
|
|
||||||
def run_app():
|
def run_app():
|
||||||
print(f'CM Bot DB API Listening at Port : {port}')
|
print(f'CM Bot DB API Listening at Port : {port}')
|
||||||
@ -324,10 +361,11 @@ class CM_API:
|
|||||||
def create_app():
|
def create_app():
|
||||||
"""WSGI factory used by gunicorn (`app.cm_api:create_app()`).
|
"""WSGI factory used by gunicorn (`app.cm_api:create_app()`).
|
||||||
|
|
||||||
Returns the Flask app object so gunicorn can serve it. The
|
Returns the Flask app object. Schema verification runs lazily on the
|
||||||
surrounding CM_API class still owns route registration and DB
|
first request (see CM_API._verify_schema_once) so the factory itself
|
||||||
connection management — this just hands gunicorn the underlying
|
never touches MySQL — keeps gunicorn's preload phase unaffected by a
|
||||||
Flask instance.
|
momentarily-unavailable DB and lets unit tests construct the app
|
||||||
|
without DB env wiring.
|
||||||
"""
|
"""
|
||||||
return CM_API().app
|
return CM_API().app
|
||||||
|
|
||||||
|
|||||||
186
app/db.py
186
app/db.py
@ -1,8 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import mysql.connector
|
import mysql.connector
|
||||||
from mysql.connector import Error
|
from mysql.connector import Error, pooling
|
||||||
|
|
||||||
|
|
||||||
def _get_required_env(name: str) -> str:
|
def _get_required_env(name: str) -> str:
|
||||||
@ -12,112 +13,135 @@ def _get_required_env(name: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class DB:
|
# Process-wide MySQL connection pool. Gunicorn forks workers; each worker
|
||||||
def __init__(self):
|
# gets its own pool (the global is rebuilt per process at first use).
|
||||||
self.config = {
|
_pool: "pooling.MySQLConnectionPool | None" = None
|
||||||
'host': _get_required_env('DB_HOST'),
|
_pool_lock = threading.Lock()
|
||||||
'user': _get_required_env('DB_USER'),
|
|
||||||
'password': _get_required_env('DB_PASSWORD'),
|
|
||||||
'database': _get_required_env('DB_NAME'),
|
def _build_pool() -> "pooling.MySQLConnectionPool":
|
||||||
'port': int(_get_required_env('DB_PORT')),
|
# pool_size default of 24 covers transfer-bot at full tilt:
|
||||||
'connection_timeout': int(_get_required_env('DB_CONNECTION_TIMEOUT'))
|
# 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,
|
||||||
}
|
}
|
||||||
self.connect_retries = max(1, int(_get_required_env('DB_CONNECT_RETRIES')))
|
return pooling.MySQLConnectionPool(**config)
|
||||||
self.connect_retry_delay = float(_get_required_env('DB_CONNECT_RETRY_DELAY'))
|
|
||||||
self.init_database()
|
|
||||||
|
|
||||||
def get_connection(self):
|
|
||||||
"""Get MySQL database connection."""
|
def _get_pool() -> "pooling.MySQLConnectionPool":
|
||||||
for attempt in range(1, self.connect_retries + 1):
|
"""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:
|
try:
|
||||||
connection = mysql.connector.connect(**self.config)
|
_pool = _build_pool()
|
||||||
return connection
|
return _pool
|
||||||
except Error as e:
|
except Error as e:
|
||||||
print(f"Error connecting to MySQL: {e}")
|
last_err = e
|
||||||
if attempt < self.connect_retries:
|
if attempt < retries:
|
||||||
print(
|
print(
|
||||||
f"Retrying MySQL connection ({attempt}/{self.connect_retries}) "
|
f"MySQL pool init failed ({e}); "
|
||||||
f"in {self.connect_retry_delay} seconds..."
|
f"retry {attempt}/{retries} in {delay}s..."
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to build MySQL pool after {retries} attempts: {last_err}"
|
||||||
)
|
)
|
||||||
time.sleep(self.connect_retry_delay)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def init_database(self):
|
|
||||||
"""Initialize the database connection."""
|
def verify_tables_once() -> None:
|
||||||
connection = self.get_connection()
|
"""Run once at app startup to confirm schema is present.
|
||||||
if connection is None:
|
|
||||||
raise Exception("Failed to connect to database")
|
Previously the DB() constructor ran two SHOW TABLES LIKE queries on
|
||||||
cursor = None
|
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:
|
try:
|
||||||
cursor = connection.cursor()
|
|
||||||
# Test connection by checking if required tables exist
|
|
||||||
cursor.execute("SHOW TABLES LIKE 'acc'")
|
cursor.execute("SHOW TABLES LIKE 'acc'")
|
||||||
if not cursor.fetchone():
|
if not cursor.fetchone():
|
||||||
raise Exception("Table 'acc' does not exist")
|
raise Exception("Table 'acc' does not exist")
|
||||||
|
|
||||||
cursor.execute("SHOW TABLES LIKE 'user'")
|
cursor.execute("SHOW TABLES LIKE 'user'")
|
||||||
if not cursor.fetchone():
|
if not cursor.fetchone():
|
||||||
raise Exception("Table 'user' does not exist")
|
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:
|
finally:
|
||||||
if cursor is not None:
|
|
||||||
cursor.close()
|
cursor.close()
|
||||||
if connection.is_connected():
|
finally:
|
||||||
connection.close()
|
conn.close()
|
||||||
|
|
||||||
def query(self, query, params=None):
|
|
||||||
"""Execute a query and return results."""
|
class DB:
|
||||||
connection = self.get_connection()
|
"""Thin handle backed by the process-wide MySQL pool.
|
||||||
if connection is None:
|
|
||||||
return []
|
Constructing DB() is now ~free — it just touches the (cached) pool.
|
||||||
cursor = None
|
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:
|
try:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
try:
|
||||||
if params:
|
cursor.execute(sql, params or ())
|
||||||
cursor.execute(query, params)
|
return cursor.fetchall()
|
||||||
else:
|
finally:
|
||||||
cursor.execute(query)
|
cursor.close()
|
||||||
|
|
||||||
results = cursor.fetchall()
|
|
||||||
return results
|
|
||||||
|
|
||||||
except Error as e:
|
except Error as e:
|
||||||
print(f"Error executing query: {e}")
|
print(f"Error executing query: {e}")
|
||||||
return []
|
return []
|
||||||
finally:
|
finally:
|
||||||
if cursor is not None:
|
conn.close()
|
||||||
cursor.close()
|
|
||||||
if connection.is_connected():
|
|
||||||
connection.close()
|
|
||||||
|
|
||||||
def execute(self, query, params=None):
|
def execute(self, sql, params=None):
|
||||||
"""Execute a query that modifies data (INSERT, UPDATE, DELETE) and return success status."""
|
conn = _get_pool().get_connection()
|
||||||
connection = self.get_connection()
|
|
||||||
if connection is None:
|
|
||||||
return False
|
|
||||||
cursor = None
|
|
||||||
try:
|
try:
|
||||||
cursor = connection.cursor()
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
if params:
|
cursor.execute(sql, params or ())
|
||||||
cursor.execute(query, params)
|
conn.commit()
|
||||||
else:
|
|
||||||
cursor.execute(query)
|
|
||||||
|
|
||||||
connection.commit()
|
|
||||||
return True
|
return True
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
except Error as e:
|
except Error as e:
|
||||||
print(f"Error executing query: {e}")
|
print(f"Error executing query: {e}")
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
if cursor is not None:
|
conn.close()
|
||||||
cursor.close()
|
|
||||||
if connection.is_connected():
|
|
||||||
connection.close()
|
|
||||||
|
|||||||
32
envs/rex/rex.env
Executable file
32
envs/rex/rex.env
Executable 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
32
envs/siong/siong.env
Executable 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
|
||||||
@ -16,9 +16,14 @@ Arguments:
|
|||||||
Environment:
|
Environment:
|
||||||
DOCKER_IMAGE_TAG Alternative way to set the tag (overrides CLI argument).
|
DOCKER_IMAGE_TAG Alternative way to set the tag (overrides CLI argument).
|
||||||
BUILD_ARGS Extra arguments passed to each docker build command.
|
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:
|
Authentication:
|
||||||
docker login gitea.04080616.xyz
|
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
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,27 +32,57 @@ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! docker info >/dev/null 2>&1; then
|
# Match scripts/dev.sh: prefix docker calls with sudo unless the user opts
|
||||||
echo "Docker daemon is not reachable. Please start Docker and retry." >&2
|
# 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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! docker system info --format '{{json .IndexServerAddress}}' | grep -q "gitea.04080616.xyz" 2>/dev/null; then
|
# (Earlier versions checked `docker system info` for the registry — but
|
||||||
cat <<'EOF' >&2
|
# IndexServerAddress always points at Docker Hub regardless of which
|
||||||
Reminder: run 'docker login gitea.04080616.xyz' before publishing so pushes succeed.
|
# registries you've logged into, so the check was a guaranteed false
|
||||||
EOF
|
# positive. If push fails with 401, run:
|
||||||
fi
|
# ${SUDO:+sudo }docker login gitea.04080616.xyz
|
||||||
|
|
||||||
IMAGE_TAG="${1:-${DOCKER_IMAGE_TAG:-latest}}"
|
IMAGE_TAG="${1:-${DOCKER_IMAGE_TAG:-latest}}"
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
PLATFORMS="${CM_IMAGE_PLATFORMS:-linux/amd64}"
|
PLATFORMS="${CM_IMAGE_PLATFORMS:-linux/amd64}"
|
||||||
|
|
||||||
if ! docker buildx version >/dev/null 2>&1; then
|
if ! "${DOCKER[@]}" buildx version >/dev/null 2>&1; then
|
||||||
cat <<'EOF' >&2
|
RUNNER="$([[ -n "${SUDO}" ]] && echo "root via sudo" || echo "current user")"
|
||||||
Docker Buildx is required for producing registry-compatible images.
|
cat <<EOF >&2
|
||||||
Install/enable buildx and rerun, for example:
|
Docker Buildx isn't reachable as the user this script runs docker as
|
||||||
docker buildx create --use --name cm-bot-builder
|
(${RUNNER}).
|
||||||
docker buildx inspect --bootstrap
|
|
||||||
|
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
|
EOF
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@ -71,7 +106,7 @@ for ENTRY in "${SERVICES[@]}"; do
|
|||||||
IMAGE_NAME="${REGISTRY_PREFIX}/cm-${SERVICE}:${IMAGE_TAG}"
|
IMAGE_NAME="${REGISTRY_PREFIX}/cm-${SERVICE}:${IMAGE_TAG}"
|
||||||
|
|
||||||
echo "==> Building and pushing ${IMAGE_NAME} (${DOCKERFILE})"
|
echo "==> Building and pushing ${IMAGE_NAME} (${DOCKERFILE})"
|
||||||
docker buildx build ${BUILD_ARGS:-} \
|
"${DOCKER[@]}" buildx build ${BUILD_ARGS:-} \
|
||||||
--platform "${PLATFORMS}" \
|
--platform "${PLATFORMS}" \
|
||||||
-f "${ROOT_DIR}/${DOCKERFILE}" \
|
-f "${ROOT_DIR}/${DOCKERFILE}" \
|
||||||
-t "${IMAGE_NAME}" \
|
-t "${IMAGE_NAME}" \
|
||||||
|
|||||||
@ -1,14 +1,27 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath, revalidateTag } from "next/cache";
|
||||||
import { fetchApi } from "@/lib/api";
|
import {
|
||||||
import type { AccUpdate, UserUpdate } from "@/lib/types";
|
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 };
|
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> {
|
export async function updateAccount(data: AccUpdate): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
await fetchApi("/update-acc-data", { method: "POST", body: data });
|
await fetchApi("/update-acc-data", { method: "POST", body: data });
|
||||||
|
revalidateTag(ACCOUNTS_TAG);
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -19,6 +32,7 @@ export async function updateAccount(data: AccUpdate): Promise<ActionResult> {
|
|||||||
export async function updateUser(data: UserUpdate): Promise<ActionResult> {
|
export async function updateUser(data: UserUpdate): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
await fetchApi("/update-user-data", { method: "POST", body: data });
|
await fetchApi("/update-user-data", { method: "POST", body: data });
|
||||||
|
revalidateTag(USERS_TAG);
|
||||||
revalidatePath("/users");
|
revalidatePath("/users");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -29,6 +43,7 @@ export async function updateUser(data: UserUpdate): Promise<ActionResult> {
|
|||||||
export async function createAccount(data: AccUpdate): Promise<ActionResult> {
|
export async function createAccount(data: AccUpdate): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
await fetchApi("/create-acc-data", { method: "POST", body: data });
|
await fetchApi("/create-acc-data", { method: "POST", body: data });
|
||||||
|
revalidateTag(ACCOUNTS_TAG);
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -39,6 +54,7 @@ export async function createAccount(data: AccUpdate): Promise<ActionResult> {
|
|||||||
export async function createUser(data: UserUpdate): Promise<ActionResult> {
|
export async function createUser(data: UserUpdate): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
await fetchApi("/create-user-data", { method: "POST", body: data });
|
await fetchApi("/create-user-data", { method: "POST", body: data });
|
||||||
|
revalidateTag(USERS_TAG);
|
||||||
revalidatePath("/users");
|
revalidatePath("/users");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -49,6 +65,7 @@ export async function createUser(data: UserUpdate): Promise<ActionResult> {
|
|||||||
export async function deleteAccount(username: string): Promise<ActionResult> {
|
export async function deleteAccount(username: string): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
await fetchApi("/delete-acc-data", { method: "POST", body: { username } });
|
await fetchApi("/delete-acc-data", { method: "POST", body: { username } });
|
||||||
|
revalidateTag(ACCOUNTS_TAG);
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -59,9 +76,34 @@ export async function deleteAccount(username: string): Promise<ActionResult> {
|
|||||||
export async function deleteUser(f_username: string): Promise<ActionResult> {
|
export async function deleteUser(f_username: string): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
await fetchApi("/delete-user-data", { method: "POST", body: { f_username } });
|
await fetchApi("/delete-user-data", { method: "POST", body: { f_username } });
|
||||||
|
revalidateTag(USERS_TAG);
|
||||||
revalidatePath("/users");
|
revalidatePath("/users");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, error: err instanceof Error ? err.message : String(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 });
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import Nav from "@/components/nav";
|
import Nav from "@/components/nav";
|
||||||
import AutoRefresh from "@/components/auto-refresh";
|
|
||||||
import { getSession } from "@/lib/auth";
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
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">
|
<main className="mx-auto max-w-6xl px-4 pb-16 pt-8 sm:px-6 sm:pt-12">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<AutoRefresh />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
5
web/app/loading.tsx
Normal file
5
web/app/loading.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import TableSkeleton from "@/components/table-skeleton";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <TableSkeleton eyebrow="Table" title="Accounts" />;
|
||||||
|
}
|
||||||
@ -1,9 +1,15 @@
|
|||||||
import { getAccounts } from "@/lib/api";
|
import { getAccountsPage } from "@/lib/api";
|
||||||
import AccountsTable from "@/components/accounts-table";
|
import AccountsTable from "@/components/accounts-table";
|
||||||
|
|
||||||
const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";
|
const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";
|
||||||
|
|
||||||
export default async function AccountsPage() {
|
export default async function AccountsPage() {
|
||||||
const accounts = await getAccounts();
|
const page = await getAccountsPage({ prefix: PREFIX_PATTERN, dir: "desc" });
|
||||||
return <AccountsTable initial={accounts} prefixPattern={PREFIX_PATTERN} />;
|
return (
|
||||||
|
<AccountsTable
|
||||||
|
initial={page.rows}
|
||||||
|
initialHasMore={page.hasMore}
|
||||||
|
prefixPattern={PREFIX_PATTERN}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
5
web/app/users/loading.tsx
Normal file
5
web/app/users/loading.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import TableSkeleton from "@/components/table-skeleton";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <TableSkeleton eyebrow="Table" title="Users" />;
|
||||||
|
}
|
||||||
@ -1,9 +1,19 @@
|
|||||||
import { getUsers } from "@/lib/api";
|
import { getUsersPage } from "@/lib/api";
|
||||||
import UsersTable from "@/components/users-table";
|
import UsersTable from "@/components/users-table";
|
||||||
|
|
||||||
const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";
|
const PREFIX_PATTERN = process.env.NEXT_PUBLIC_CM_PREFIX_PATTERN ?? "13c";
|
||||||
|
|
||||||
export default async function UsersPage() {
|
export default async function UsersPage() {
|
||||||
const users = await getUsers();
|
const page = await getUsersPage({
|
||||||
return <UsersTable initial={users} prefixPattern={PREFIX_PATTERN} />;
|
prefix: PREFIX_PATTERN,
|
||||||
|
sort: "last_update_time",
|
||||||
|
dir: "desc",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<UsersTable
|
||||||
|
initial={page.rows}
|
||||||
|
initialHasMore={page.hasMore}
|
||||||
|
prefixPattern={PREFIX_PATTERN}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useOptimistic, useState, useTransition } from "react";
|
import { useEffect, useOptimistic, useRef, useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import type { Acc } from "@/lib/types";
|
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 EditableCell from "./editable-cell";
|
||||||
import ConfirmDialog from "./confirm-dialog";
|
import ConfirmDialog from "./confirm-dialog";
|
||||||
import CreateAccountDialog from "./create-account-dialog";
|
import CreateAccountDialog from "./create-account-dialog";
|
||||||
import Toast, { type ToastMessage } from "./toast";
|
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 SortDir = "asc" | "desc";
|
||||||
type OptimisticPatch = { username: string; field: keyof Acc; value: string };
|
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 }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const map: Record<string, { bg: string; fg: string; label: string }> = {
|
const map: Record<string, { bg: string; fg: string; label: string }> = {
|
||||||
"": { bg: "bg-zinc-100", fg: "text-zinc-600", label: "available" },
|
"": { bg: "bg-zinc-100", fg: "text-zinc-600", label: "available" },
|
||||||
@ -62,51 +58,110 @@ function DeleteButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AccountsTable({ initial, prefixPattern }: Props) {
|
export default function AccountsTable({
|
||||||
const router = useRouter();
|
initial,
|
||||||
|
initialHasMore,
|
||||||
|
prefixPattern,
|
||||||
|
}: Props) {
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [toast, setToast] = useState<ToastMessage | null>(null);
|
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>(
|
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
|
||||||
initial,
|
rows,
|
||||||
(state, patch) =>
|
(state, patch) =>
|
||||||
state.map((row) =>
|
state.map((row) =>
|
||||||
row.username === patch.username ? { ...row, [patch.field]: patch.value } : 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) {
|
function saveCell(username: string, field: keyof Acc, value: string) {
|
||||||
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
|
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
applyOptimistic({ username, field, value });
|
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" });
|
if (!row) return resolve({ ok: false, error: "row not found" });
|
||||||
const next: Acc = { ...row, [field]: value };
|
const next: Acc = { ...row, [field]: value };
|
||||||
const result = await updateAccount(next);
|
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);
|
setRefreshing(true);
|
||||||
startTransition(() => {
|
try {
|
||||||
router.refresh();
|
const page = await refreshAccounts({ prefix: prefixPattern, dir: sortDir });
|
||||||
setTimeout(() => setRefreshing(false), 400);
|
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() {
|
async function confirmDelete() {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
@ -116,6 +171,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
|||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
const deleted = deleteTarget;
|
const deleted = deleteTarget;
|
||||||
|
setRows((prev) => prev.filter((r) => r.username !== deleted));
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
setToast({ type: "success", message: `Account ${deleted} deleted` });
|
setToast({ type: "success", message: `Account ${deleted} deleted` });
|
||||||
} else {
|
} else {
|
||||||
@ -123,11 +179,13 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initial.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHead
|
<PageHead
|
||||||
count={0}
|
count={0}
|
||||||
|
loaded={0}
|
||||||
|
hasMore={false}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
onAdd={() => setCreateOpen(true)}
|
onAdd={() => setCreateOpen(true)}
|
||||||
@ -140,6 +198,12 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
|||||||
<CreateAccountDialog
|
<CreateAccountDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
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}
|
prefixPattern={prefixPattern}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -150,6 +214,8 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<PageHead
|
<PageHead
|
||||||
count={optimistic.length}
|
count={optimistic.length}
|
||||||
|
loaded={optimistic.length}
|
||||||
|
hasMore={hasMore}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
onAdd={() => setCreateOpen(true)}
|
onAdd={() => setCreateOpen(true)}
|
||||||
@ -163,7 +229,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
|||||||
<th className="w-[18%] px-5 py-3 text-left">
|
<th className="w-[18%] px-5 py-3 text-left">
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-zinc-500 transition-colors hover:text-zinc-900"
|
||||||
>
|
>
|
||||||
Username
|
Username
|
||||||
@ -177,7 +243,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-100">
|
<tbody className="divide-y divide-zinc-100">
|
||||||
{sorted.map((row) => {
|
{optimistic.map((row) => {
|
||||||
const k = (f: string) => `${row.username}::${f}`;
|
const k = (f: string) => `${row.username}::${f}`;
|
||||||
return (
|
return (
|
||||||
<tr key={row.username} className="transition-colors hover:bg-zinc-50/60">
|
<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 */}
|
{/* Mobile cards */}
|
||||||
<div className="mt-6 space-y-3 sm:hidden">
|
<div className="mt-6 space-y-3 sm:hidden">
|
||||||
{sorted.map((row) => {
|
{optimistic.map((row) => {
|
||||||
const k = (f: string) => `${row.username}::${f}`;
|
const k = (f: string) => `${row.username}::${f}`;
|
||||||
return (
|
return (
|
||||||
<div key={row.username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
|
<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>
|
</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
|
<CreateAccountDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
onSuccess={(name) =>
|
onSuccess={async (name) => {
|
||||||
setToast({ type: "success", message: `Account ${name} created` })
|
setToast({ type: "success", message: `Account ${name} created` });
|
||||||
}
|
await refresh();
|
||||||
|
}}
|
||||||
prefixPattern={prefixPattern}
|
prefixPattern={prefixPattern}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -354,15 +436,22 @@ function CardRow({ label, children }: { label: string; children: React.ReactNode
|
|||||||
|
|
||||||
function PageHead({
|
function PageHead({
|
||||||
count,
|
count,
|
||||||
|
loaded,
|
||||||
|
hasMore,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
refreshing,
|
refreshing,
|
||||||
onAdd,
|
onAdd,
|
||||||
}: {
|
}: {
|
||||||
count: number;
|
count: number;
|
||||||
|
loaded: number;
|
||||||
|
hasMore: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
onAdd: () => void;
|
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 (
|
return (
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@ -373,6 +462,7 @@ function PageHead({
|
|||||||
Accounts
|
Accounts
|
||||||
<span className="ml-2 align-middle text-base font-medium text-zinc-400">
|
<span className="ml-2 align-middle text-base font-medium text-zinc-400">
|
||||||
{count}
|
{count}
|
||||||
|
{showHasMore && <span className="text-zinc-300">+</span>}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
50
web/components/table-skeleton.tsx
Normal file
50
web/components/table-skeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useOptimistic, useState, useTransition } from "react";
|
import { useEffect, useOptimistic, useRef, useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import type { User } from "@/lib/types";
|
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 EditableCell from "./editable-cell";
|
||||||
import ConfirmDialog from "./confirm-dialog";
|
import ConfirmDialog from "./confirm-dialog";
|
||||||
import CreateUserDialog from "./create-user-dialog";
|
import CreateUserDialog from "./create-user-dialog";
|
||||||
import Toast, { type ToastMessage } from "./toast";
|
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 SortDir = "asc" | "desc";
|
||||||
type SortKey = "f_username" | "last_update_time";
|
type SortKey = "f_username" | "last_update_time";
|
||||||
type OptimisticPatch = {
|
type OptimisticPatch = {
|
||||||
@ -18,29 +26,6 @@ type OptimisticPatch = {
|
|||||||
value: string;
|
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) {
|
function formatTime(t: string | null) {
|
||||||
if (!t) return <em className="not-italic text-zinc-400">—</em>;
|
if (!t) return <em className="not-italic text-zinc-400">—</em>;
|
||||||
const d = new Date(t);
|
const d = new Date(t);
|
||||||
@ -74,32 +59,35 @@ function DeleteButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UsersTable({ initial, prefixPattern }: Props) {
|
export default function UsersTable({
|
||||||
const router = useRouter();
|
initial,
|
||||||
|
initialHasMore,
|
||||||
|
prefixPattern,
|
||||||
|
}: Props) {
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("last_update_time");
|
const [sortKey, setSortKey] = useState<SortKey>("last_update_time");
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [toast, setToast] = useState<ToastMessage | null>(null);
|
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>(
|
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
|
||||||
initial,
|
rows,
|
||||||
(state, patch) =>
|
(state, patch) =>
|
||||||
state.map((row) =>
|
state.map((row) =>
|
||||||
row.f_username === patch.f_username ? { ...row, [patch.field]: patch.value } : 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(
|
function saveCell(
|
||||||
f_username: string,
|
f_username: string,
|
||||||
field: OptimisticPatch["field"],
|
field: OptimisticPatch["field"],
|
||||||
@ -108,7 +96,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
|
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
applyOptimistic({ f_username, field, value });
|
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" });
|
if (!row) return resolve({ ok: false, error: "row not found" });
|
||||||
const next = {
|
const next = {
|
||||||
f_username: row.f_username,
|
f_username: row.f_username,
|
||||||
@ -118,27 +106,86 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
[field]: value,
|
[field]: value,
|
||||||
};
|
};
|
||||||
const result = await updateUser(next);
|
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);
|
setRefreshing(true);
|
||||||
startTransition(() => {
|
try {
|
||||||
router.refresh();
|
const page = await refreshUsers({
|
||||||
setTimeout(() => setRefreshing(false), 400);
|
prefix: prefixPattern,
|
||||||
|
sort: sortKey,
|
||||||
|
dir: sortDir,
|
||||||
});
|
});
|
||||||
|
setRows(page.rows);
|
||||||
|
setHasMore(page.hasMore);
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSort(k: SortKey) {
|
async function changeSort(nextKey: SortKey) {
|
||||||
if (sortKey === k) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
let nextDir: SortDir;
|
||||||
else {
|
if (nextKey === sortKey) {
|
||||||
setSortKey(k);
|
nextDir = sortDir === "asc" ? "desc" : "asc";
|
||||||
setSortDir("desc");
|
} 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() {
|
async function confirmDelete() {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
@ -147,6 +194,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
const deleted = deleteTarget;
|
const deleted = deleteTarget;
|
||||||
|
setRows((prev) => prev.filter((r) => r.f_username !== deleted));
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
setToast({ type: "success", message: `User ${deleted} deleted` });
|
setToast({ type: "success", message: `User ${deleted} deleted` });
|
||||||
} else {
|
} else {
|
||||||
@ -159,7 +207,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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 ${
|
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"
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHead
|
<PageHead
|
||||||
count={0}
|
count={0}
|
||||||
|
hasMore={false}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
onAdd={() => setCreateOpen(true)}
|
onAdd={() => setCreateOpen(true)}
|
||||||
@ -187,11 +236,11 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
<CreateUserDialog
|
<CreateUserDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
onSuccess={(name) =>
|
onSuccess={async (name) => {
|
||||||
setToast({ type: "success", message: `User ${name} created` })
|
setToast({ type: "success", message: `User ${name} created` });
|
||||||
}
|
await refresh();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -201,6 +250,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<PageHead
|
<PageHead
|
||||||
count={optimistic.length}
|
count={optimistic.length}
|
||||||
|
hasMore={hasMore}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
onAdd={() => setCreateOpen(true)}
|
onAdd={() => setCreateOpen(true)}
|
||||||
@ -229,7 +279,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-100">
|
<tbody className="divide-y divide-zinc-100">
|
||||||
{sorted.map((row) => {
|
{optimistic.map((row) => {
|
||||||
const k = (f: string) => `${row.f_username}::${f}`;
|
const k = (f: string) => `${row.f_username}::${f}`;
|
||||||
return (
|
return (
|
||||||
<tr key={row.f_username} className="transition-colors hover:bg-zinc-50/60">
|
<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>
|
||||||
|
|
||||||
<div className="mt-6 space-y-3 sm:hidden">
|
<div className="mt-6 space-y-3 sm:hidden">
|
||||||
{sorted.map((row) => {
|
{optimistic.map((row) => {
|
||||||
const k = (f: string) => `${row.f_username}::${f}`;
|
const k = (f: string) => `${row.f_username}::${f}`;
|
||||||
return (
|
return (
|
||||||
<div key={row.f_username} className="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
|
<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>
|
||||||
|
|
||||||
|
<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
|
<CreateUserDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
onSuccess={(name) =>
|
onSuccess={async (name) => {
|
||||||
setToast({ type: "success", message: `User ${name} created` })
|
setToast({ type: "success", message: `User ${name} created` });
|
||||||
}
|
await refresh();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
||||||
@ -397,22 +460,28 @@ function MobileRow({ label, children }: { label: string; children: React.ReactNo
|
|||||||
|
|
||||||
function PageHead({
|
function PageHead({
|
||||||
count,
|
count,
|
||||||
|
hasMore,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
refreshing,
|
refreshing,
|
||||||
onAdd,
|
onAdd,
|
||||||
}: {
|
}: {
|
||||||
count: number;
|
count: number;
|
||||||
|
hasMore: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const showHasMore = hasMore && count > 0;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">Table</p>
|
<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">
|
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
|
||||||
Users
|
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>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@ -2,20 +2,53 @@ import type { Acc, User } from "./types";
|
|||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL ?? "http://api-server:3000";
|
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 = {
|
type FetchInit = {
|
||||||
method?: "GET" | "POST";
|
method?: "GET" | "POST";
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
cache?: RequestCache;
|
cache?: RequestCache;
|
||||||
|
next?: { revalidate?: number; tags?: string[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchApi(path: string, options: FetchInit = {}): Promise<unknown> {
|
export async function fetchApi(path: string, options: FetchInit = {}): Promise<unknown> {
|
||||||
const url = `${API_BASE_URL}${path}`;
|
const url = `${API_BASE_URL}${path}`;
|
||||||
const init: RequestInit = {
|
const init: RequestInit & { next?: FetchInit["next"] } = {
|
||||||
method: options.method ?? "GET",
|
method: options.method ?? "GET",
|
||||||
cache: options.cache ?? "no-store",
|
|
||||||
headers: options.body ? { "content-type": "application/json" } : undefined,
|
headers: options.body ? { "content-type": "application/json" } : undefined,
|
||||||
body: options.body ? JSON.stringify(options.body) : 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);
|
const res = await fetch(url, init);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`API ${options.method ?? "GET"} ${path} failed: ${res.status}`);
|
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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAccounts(): Promise<Acc[]> {
|
function buildAccountsUrl(opts: AccountsPageOpts): string {
|
||||||
const data = await fetchApi("/acc/");
|
const { offset = 0, prefix = "", dir = "desc" } = opts;
|
||||||
return data as Acc[];
|
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[]> {
|
function buildUsersUrl(opts: UsersPageOpts): string {
|
||||||
const data = await fetchApi("/user/");
|
const { offset = 0, prefix = "", sort = "last_update_time", dir = "desc" } = opts;
|
||||||
return data as User[];
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user