Compare commits
No commits in common. "f485dc52aa3c854ba0cabda29bc8a3fba76d443d" and "626344cc1625193734f92e9b527e7570fe96b34f" have entirely different histories.
f485dc52aa
...
626344cc16
150
app/cm_api.py
150
app/cm_api.py
@ -1,7 +1,7 @@
|
||||
import os
|
||||
import threading
|
||||
from flask import Flask, jsonify, request
|
||||
from .db import DB, verify_tables_once
|
||||
from .db import DB
|
||||
|
||||
|
||||
def _debug_enabled() -> bool:
|
||||
@ -25,33 +25,28 @@ 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):
|
||||
"""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.
|
||||
"""
|
||||
"""Create a new database connection for use"""
|
||||
try:
|
||||
return DB()
|
||||
db = 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)
|
||||
@ -91,41 +86,18 @@ class CM_API:
|
||||
try:
|
||||
if username:
|
||||
query = "SELECT username, password, status, link FROM acc WHERE username = %s"
|
||||
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]
|
||||
query_params = [username]
|
||||
else:
|
||||
query = (
|
||||
"SELECT username, password, status, link FROM acc "
|
||||
f"ORDER BY username {direction} "
|
||||
"LIMIT %s OFFSET %s"
|
||||
)
|
||||
params = [limit, offset]
|
||||
query = "SELECT username, password, status, link FROM acc"
|
||||
query_params = []
|
||||
|
||||
return jsonify(db.query(query, params))
|
||||
results = db.query(query, query_params)
|
||||
return jsonify(results)
|
||||
|
||||
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()
|
||||
@ -135,41 +107,18 @@ class CM_API:
|
||||
try:
|
||||
if username:
|
||||
query = "SELECT f_username, f_password, t_username, t_password FROM user WHERE f_username = %s"
|
||||
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]
|
||||
query_params = [username]
|
||||
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]
|
||||
query = "SELECT f_username, f_password, t_username, t_password, last_update_time FROM user"
|
||||
query_params = []
|
||||
|
||||
return jsonify(db.query(query, params))
|
||||
results = db.query(query, query_params)
|
||||
return jsonify(results)
|
||||
|
||||
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()
|
||||
@ -198,6 +147,8 @@ 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()
|
||||
@ -226,6 +177,8 @@ 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()
|
||||
@ -250,6 +203,8 @@ 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()
|
||||
@ -274,6 +229,8 @@ 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()
|
||||
@ -301,6 +258,8 @@ 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()
|
||||
@ -328,26 +287,30 @@ 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()
|
||||
try:
|
||||
verify_tables_once()
|
||||
except Exception as e:
|
||||
print(f"Cannot start server: {e}")
|
||||
# Test database connection before starting server
|
||||
test_db = self._get_database_connection()
|
||||
if test_db is None:
|
||||
print("Cannot start server: Database not available")
|
||||
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"""
|
||||
try:
|
||||
verify_tables_once()
|
||||
except Exception as e:
|
||||
print(f"Cannot start server: {e}")
|
||||
# Test database connection before starting server
|
||||
test_db = self._get_database_connection()
|
||||
if test_db is None:
|
||||
print("Cannot start server: Database not available")
|
||||
return None
|
||||
self._close_database_connection(test_db)
|
||||
|
||||
def run_app():
|
||||
print(f'CM Bot DB API Listening at Port : {port}')
|
||||
@ -361,11 +324,10 @@ class CM_API:
|
||||
def create_app():
|
||||
"""WSGI factory used by gunicorn (`app.cm_api:create_app()`).
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
return CM_API().app
|
||||
|
||||
|
||||
190
app/db.py
190
app/db.py
@ -1,9 +1,8 @@
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import mysql.connector
|
||||
from mysql.connector import Error, pooling
|
||||
from mysql.connector import Error
|
||||
|
||||
|
||||
def _get_required_env(name: str) -> str:
|
||||
@ -13,135 +12,112 @@ def _get_required_env(name: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
# 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,
|
||||
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'))
|
||||
}
|
||||
return pooling.MySQLConnectionPool(**config)
|
||||
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_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):
|
||||
def get_connection(self):
|
||||
"""Get MySQL database connection."""
|
||||
for attempt in range(1, self.connect_retries + 1):
|
||||
try:
|
||||
_pool = _build_pool()
|
||||
return _pool
|
||||
connection = mysql.connector.connect(**self.config)
|
||||
return connection
|
||||
except Error as e:
|
||||
last_err = e
|
||||
if attempt < retries:
|
||||
print(f"Error connecting to MySQL: {e}")
|
||||
if attempt < self.connect_retries:
|
||||
print(
|
||||
f"MySQL pool init failed ({e}); "
|
||||
f"retry {attempt}/{retries} in {delay}s..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
raise RuntimeError(
|
||||
f"Failed to build MySQL pool after {retries} attempts: {last_err}"
|
||||
f"Retrying MySQL connection ({attempt}/{self.connect_retries}) "
|
||||
f"in {self.connect_retry_delay} seconds..."
|
||||
)
|
||||
time.sleep(self.connect_retry_delay)
|
||||
return None
|
||||
|
||||
|
||||
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()
|
||||
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
|
||||
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()
|
||||
finally:
|
||||
conn.close()
|
||||
if connection.is_connected():
|
||||
connection.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()
|
||||
def query(self, query, params=None):
|
||||
"""Execute a query and return results."""
|
||||
connection = self.get_connection()
|
||||
if connection is None:
|
||||
return []
|
||||
cursor = None
|
||||
try:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
try:
|
||||
cursor.execute(sql, params or ())
|
||||
return cursor.fetchall()
|
||||
finally:
|
||||
cursor.close()
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
if params:
|
||||
cursor.execute(query, params)
|
||||
else:
|
||||
cursor.execute(query)
|
||||
|
||||
results = cursor.fetchall()
|
||||
return results
|
||||
|
||||
except Error as e:
|
||||
print(f"Error executing query: {e}")
|
||||
return []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
conn = _get_pool().get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(sql, params or ())
|
||||
conn.commit()
|
||||
return True
|
||||
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
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
|
||||
if params:
|
||||
cursor.execute(query, params)
|
||||
else:
|
||||
cursor.execute(query)
|
||||
|
||||
connection.commit()
|
||||
return True
|
||||
|
||||
except Error as e:
|
||||
print(f"Error executing query: {e}")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
if cursor is not None:
|
||||
cursor.close()
|
||||
if connection.is_connected():
|
||||
connection.close()
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
# === 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
|
||||
@ -1,32 +0,0 @@
|
||||
# === 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,14 +16,9 @@ Arguments:
|
||||
Environment:
|
||||
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).
|
||||
|
||||
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
|
||||
Make sure you are authenticated first:
|
||||
docker login gitea.04080616.xyz
|
||||
EOF
|
||||
}
|
||||
|
||||
@ -32,57 +27,27 @@ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 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
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "Docker daemon is not reachable. Please start Docker and retry." >&2
|
||||
exit 1
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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}
|
||||
|
||||
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
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
@ -106,7 +71,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}" \
|
||||
|
||||
@ -1,27 +1,14 @@
|
||||
"use server";
|
||||
|
||||
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";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { fetchApi } from "@/lib/api";
|
||||
import type { AccUpdate, 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) {
|
||||
@ -32,7 +19,6 @@ 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) {
|
||||
@ -43,7 +29,6 @@ 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) {
|
||||
@ -54,7 +39,6 @@ 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) {
|
||||
@ -65,7 +49,6 @@ 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) {
|
||||
@ -76,34 +59,9 @@ 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 });
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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 = {
|
||||
@ -26,6 +27,7 @@ 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>
|
||||
);
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
import TableSkeleton from "@/components/table-skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <TableSkeleton eyebrow="Table" title="Accounts" />;
|
||||
}
|
||||
@ -1,15 +1,9 @@
|
||||
import { getAccountsPage } from "@/lib/api";
|
||||
import { getAccounts } 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 page = await getAccountsPage({ prefix: PREFIX_PATTERN, dir: "desc" });
|
||||
return (
|
||||
<AccountsTable
|
||||
initial={page.rows}
|
||||
initialHasMore={page.hasMore}
|
||||
prefixPattern={PREFIX_PATTERN}
|
||||
/>
|
||||
);
|
||||
const accounts = await getAccounts();
|
||||
return <AccountsTable initial={accounts} prefixPattern={PREFIX_PATTERN} />;
|
||||
}
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
import TableSkeleton from "@/components/table-skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <TableSkeleton eyebrow="Table" title="Users" />;
|
||||
}
|
||||
@ -1,19 +1,9 @@
|
||||
import { getUsersPage } from "@/lib/api";
|
||||
import { getUsers } 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 page = await getUsersPage({
|
||||
prefix: PREFIX_PATTERN,
|
||||
sort: "last_update_time",
|
||||
dir: "desc",
|
||||
});
|
||||
return (
|
||||
<UsersTable
|
||||
initial={page.rows}
|
||||
initialHasMore={page.hasMore}
|
||||
prefixPattern={PREFIX_PATTERN}
|
||||
/>
|
||||
);
|
||||
const users = await getUsers();
|
||||
return <UsersTable initial={users} prefixPattern={PREFIX_PATTERN} />;
|
||||
}
|
||||
|
||||
@ -1,26 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useOptimistic, useRef, useState, useTransition } from "react";
|
||||
import { useMemo, useOptimistic, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Acc } from "@/lib/types";
|
||||
import {
|
||||
deleteAccount,
|
||||
loadMoreAccounts,
|
||||
refreshAccounts,
|
||||
updateAccount,
|
||||
} from "@/app/actions";
|
||||
import { deleteAccount, 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[];
|
||||
initialHasMore: boolean;
|
||||
prefixPattern: string;
|
||||
};
|
||||
type Props = { initial: Acc[]; 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" },
|
||||
@ -58,110 +62,51 @@ function DeleteButton({
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountsTable({
|
||||
initial,
|
||||
initialHasMore,
|
||||
prefixPattern,
|
||||
}: Props) {
|
||||
export default function AccountsTable({ initial, prefixPattern }: Props) {
|
||||
const router = useRouter();
|
||||
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>(
|
||||
rows,
|
||||
initial,
|
||||
(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 = rows.find((r) => r.username === username);
|
||||
const row = initial.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);
|
||||
if (result.ok) {
|
||||
setRows((prev) => prev.map((r) => (r.username === username ? next : r)));
|
||||
resolve({ ok: true });
|
||||
} else {
|
||||
resolve({ ok: false, error: result.error });
|
||||
}
|
||||
resolve(result.ok ? { ok: true } : { ok: false, error: result.error });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
function refresh() {
|
||||
setRefreshing(true);
|
||||
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,
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
setTimeout(() => setRefreshing(false), 400);
|
||||
});
|
||||
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;
|
||||
@ -171,7 +116,6 @@ export default function AccountsTable({
|
||||
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 {
|
||||
@ -179,13 +123,11 @@ export default function AccountsTable({
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
if (initial.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<PageHead
|
||||
count={0}
|
||||
loaded={0}
|
||||
hasMore={false}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
onAdd={() => setCreateOpen(true)}
|
||||
@ -198,12 +140,6 @@ export default function AccountsTable({
|
||||
<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>
|
||||
@ -214,8 +150,6 @@ export default function AccountsTable({
|
||||
<div>
|
||||
<PageHead
|
||||
count={optimistic.length}
|
||||
loaded={optimistic.length}
|
||||
hasMore={hasMore}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
onAdd={() => setCreateOpen(true)}
|
||||
@ -229,7 +163,7 @@ export default function AccountsTable({
|
||||
<th className="w-[18%] px-5 py-3 text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeSort(sortDir === "asc" ? "desc" : "asc")}
|
||||
onClick={() => setSortDir((d) => (d === "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
|
||||
@ -243,7 +177,7 @@ export default function AccountsTable({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{optimistic.map((row) => {
|
||||
{sorted.map((row) => {
|
||||
const k = (f: string) => `${row.username}::${f}`;
|
||||
return (
|
||||
<tr key={row.username} className="transition-colors hover:bg-zinc-50/60">
|
||||
@ -299,7 +233,7 @@ export default function AccountsTable({
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="mt-6 space-y-3 sm:hidden">
|
||||
{optimistic.map((row) => {
|
||||
{sorted.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">
|
||||
@ -355,28 +289,12 @@ export default function AccountsTable({
|
||||
})}
|
||||
</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={async (name) => {
|
||||
setToast({ type: "success", message: `Account ${name} created` });
|
||||
await refresh();
|
||||
}}
|
||||
onSuccess={(name) =>
|
||||
setToast({ type: "success", message: `Account ${name} created` })
|
||||
}
|
||||
prefixPattern={prefixPattern}
|
||||
/>
|
||||
|
||||
@ -436,22 +354,15 @@ 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>
|
||||
@ -462,7 +373,6 @@ 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>
|
||||
|
||||
24
web/components/auto-refresh.tsx
Normal file
24
web/components/auto-refresh.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
"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;
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
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,23 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useOptimistic, useRef, useState, useTransition } from "react";
|
||||
import { useMemo, useOptimistic, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { User } from "@/lib/types";
|
||||
import {
|
||||
deleteUser,
|
||||
loadMoreUsers,
|
||||
refreshUsers,
|
||||
updateUser,
|
||||
} from "@/app/actions";
|
||||
import { deleteUser, 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[];
|
||||
initialHasMore: boolean;
|
||||
prefixPattern: string;
|
||||
};
|
||||
type Props = { initial: User[]; prefixPattern: string };
|
||||
type SortDir = "asc" | "desc";
|
||||
type SortKey = "f_username" | "last_update_time";
|
||||
type OptimisticPatch = {
|
||||
@ -26,6 +18,29 @@ 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);
|
||||
@ -59,35 +74,32 @@ function DeleteButton({
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsersTable({
|
||||
initial,
|
||||
initialHasMore,
|
||||
prefixPattern,
|
||||
}: Props) {
|
||||
export default function UsersTable({ initial, prefixPattern }: Props) {
|
||||
const router = useRouter();
|
||||
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>(
|
||||
rows,
|
||||
initial,
|
||||
(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"],
|
||||
@ -96,7 +108,7 @@ export default function UsersTable({
|
||||
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
|
||||
startTransition(async () => {
|
||||
applyOptimistic({ f_username, field, value });
|
||||
const row = rows.find((r) => r.f_username === f_username);
|
||||
const row = initial.find((r) => r.f_username === f_username);
|
||||
if (!row) return resolve({ ok: false, error: "row not found" });
|
||||
const next = {
|
||||
f_username: row.f_username,
|
||||
@ -106,86 +118,27 @@ export default function UsersTable({
|
||||
[field]: value,
|
||||
};
|
||||
const result = await updateUser(next);
|
||||
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 });
|
||||
}
|
||||
resolve(result.ok ? { ok: true } : { ok: false, error: result.error });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
function refresh() {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const page = await refreshUsers({
|
||||
prefix: prefixPattern,
|
||||
sort: sortKey,
|
||||
dir: sortDir,
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
setTimeout(() => setRefreshing(false), 400);
|
||||
});
|
||||
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);
|
||||
function toggleSort(k: SortKey) {
|
||||
if (sortKey === k) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else {
|
||||
setSortKey(k);
|
||||
setSortDir("desc");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@ -194,7 +147,6 @@ export default function UsersTable({
|
||||
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 {
|
||||
@ -207,7 +159,7 @@ export default function UsersTable({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeSort(k)}
|
||||
onClick={() => toggleSort(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"
|
||||
}`}
|
||||
@ -218,12 +170,11 @@ export default function UsersTable({
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
if (initial.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<PageHead
|
||||
count={0}
|
||||
hasMore={false}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
onAdd={() => setCreateOpen(true)}
|
||||
@ -236,11 +187,11 @@ export default function UsersTable({
|
||||
<CreateUserDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSuccess={async (name) => {
|
||||
setToast({ type: "success", message: `User ${name} created` });
|
||||
await refresh();
|
||||
}}
|
||||
onSuccess={(name) =>
|
||||
setToast({ type: "success", message: `User ${name} created` })
|
||||
}
|
||||
/>
|
||||
|
||||
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
||||
</div>
|
||||
);
|
||||
@ -250,7 +201,6 @@ export default function UsersTable({
|
||||
<div>
|
||||
<PageHead
|
||||
count={optimistic.length}
|
||||
hasMore={hasMore}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
onAdd={() => setCreateOpen(true)}
|
||||
@ -279,7 +229,7 @@ export default function UsersTable({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{optimistic.map((row) => {
|
||||
{sorted.map((row) => {
|
||||
const k = (f: string) => `${row.f_username}::${f}`;
|
||||
return (
|
||||
<tr key={row.f_username} className="transition-colors hover:bg-zinc-50/60">
|
||||
@ -336,7 +286,7 @@ export default function UsersTable({
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3 sm:hidden">
|
||||
{optimistic.map((row) => {
|
||||
{sorted.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">
|
||||
@ -394,25 +344,12 @@ export default function UsersTable({
|
||||
})}
|
||||
</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={async (name) => {
|
||||
setToast({ type: "success", message: `User ${name} created` });
|
||||
await refresh();
|
||||
}}
|
||||
onSuccess={(name) =>
|
||||
setToast({ type: "success", message: `User ${name} created` })
|
||||
}
|
||||
/>
|
||||
|
||||
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
||||
@ -460,28 +397,22 @@ 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}
|
||||
{showHasMore && <span className="text-zinc-300">+</span>}
|
||||
</span>
|
||||
<span className="ml-2 align-middle text-base font-medium text-zinc-400">{count}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -2,53 +2,20 @@ 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 & { next?: FetchInit["next"] } = {
|
||||
const init: RequestInit = {
|
||||
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}`);
|
||||
@ -56,39 +23,12 @@ export async function fetchApi(path: string, options: FetchInit = {}): Promise<u
|
||||
return res.json();
|
||||
}
|
||||
|
||||
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 getAccounts(): Promise<Acc[]> {
|
||||
const data = await fetchApi("/acc/");
|
||||
return data as Acc[];
|
||||
}
|
||||
|
||||
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 };
|
||||
export async function getUsers(): Promise<User[]> {
|
||||
const data = await fetchApi("/user/");
|
||||
return data as User[];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user