Compare commits

..

No commits in common. "f485dc52aa3c854ba0cabda29bc8a3fba76d443d" and "626344cc1625193734f92e9b527e7570fe96b34f" have entirely different histories.

16 changed files with 316 additions and 788 deletions

View File

@ -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, verify_tables_once from .db import DB
def _debug_enabled() -> bool: def _debug_enabled() -> bool:
@ -15,7 +15,7 @@ def _debug_enabled() -> bool:
class CM_API: class CM_API:
def __init__(self): def __init__(self):
self.app = Flask(__name__) self.app = Flask(__name__)
# No CORS middleware: api-server is internal-only (no host port # No CORS middleware: api-server is internal-only (no host port
@ -25,33 +25,28 @@ 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):
"""Return a DB handle backed by the shared connection pool. """Create a new database connection for use"""
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:
return DB() db = 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)
@ -87,89 +82,43 @@ class CM_API:
is_available, db, error_response = self._check_database_available() is_available, db, error_response = self._check_database_available()
if not is_available: if not is_available:
return error_response return error_response
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"
results = db.query(query, [username]) query_params = [username]
return jsonify(results)
# Listing path — pagination + prefix-priority sort.
try:
limit = max(1, min(int(request.args.get('limit', 200)), 1000))
offset = max(0, int(request.args.get('offset', 0)))
except (TypeError, ValueError):
return jsonify({"error": "limit and offset must be integers"}), 400
prefix = (request.args.get('prefix') or '').strip()
# Whitelist direction so it's safe to interpolate into the
# ORDER BY clause (parameterised binding doesn't apply to
# column names or sort directions).
direction = 'ASC' if request.args.get('dir', 'desc').lower() == 'asc' else 'DESC'
if prefix:
query = (
"SELECT username, password, status, link FROM acc "
"ORDER BY (CASE WHEN username LIKE %s THEN 0 ELSE 1 END), "
f"username {direction} "
"LIMIT %s OFFSET %s"
)
params = [f"{prefix}%", limit, offset]
else: else:
query = ( query = "SELECT username, password, status, link FROM acc"
"SELECT username, password, status, link FROM acc " query_params = []
f"ORDER BY username {direction} "
"LIMIT %s OFFSET %s" results = db.query(query, query_params)
) return jsonify(results)
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()
if not is_available: if not is_available:
return error_response return error_response
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"
results = db.query(query, [username]) query_params = [username]
return jsonify(results)
try:
limit = max(1, min(int(request.args.get('limit', 200)), 1000))
offset = max(0, int(request.args.get('offset', 0)))
except (TypeError, ValueError):
return jsonify({"error": "limit and offset must be integers"}), 400
prefix = (request.args.get('prefix') or '').strip()
sort_arg = request.args.get('sort', 'last_update_time')
sort_col = 'f_username' if sort_arg == 'f_username' else 'last_update_time'
direction = 'ASC' if request.args.get('dir', 'desc').lower() == 'asc' else 'DESC'
if prefix:
query = (
"SELECT f_username, f_password, t_username, t_password, last_update_time "
"FROM user "
"ORDER BY (CASE WHEN f_username LIKE %s THEN 0 ELSE 1 END), "
f"{sort_col} {direction} "
"LIMIT %s OFFSET %s"
)
params = [f"{prefix}%", limit, offset]
else: else:
query = ( query = "SELECT f_username, f_password, t_username, t_password, last_update_time FROM user"
"SELECT f_username, f_password, t_username, t_password, last_update_time " query_params = []
"FROM user "
f"ORDER BY {sort_col} {direction} " results = db.query(query, query_params)
"LIMIT %s OFFSET %s" return jsonify(results)
)
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()
@ -198,6 +147,8 @@ 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()
@ -226,6 +177,8 @@ 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()
@ -250,6 +203,8 @@ 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()
@ -274,6 +229,8 @@ 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()
@ -301,6 +258,8 @@ 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()
@ -328,31 +287,35 @@ 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()
try: # Test database connection before starting server
verify_tables_once() test_db = self._get_database_connection()
except Exception as e: if test_db is None:
print(f"Cannot start server: {e}") print("Cannot start server: Database not available")
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"""
try: # Test database connection before starting server
verify_tables_once() test_db = self._get_database_connection()
except Exception as e: if test_db is None:
print(f"Cannot start server: {e}") print("Cannot start server: Database not available")
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}')
self.app.run(host='0.0.0.0', port=port, debug=debug, use_reloader=False) self.app.run(host='0.0.0.0', port=port, debug=debug, use_reloader=False)
thread = threading.Thread(target=run_app, daemon=True) thread = threading.Thread(target=run_app, daemon=True)
thread.start() thread.start()
return thread return thread
@ -361,11 +324,10 @@ 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. Schema verification runs lazily on the Returns the Flask app object so gunicorn can serve it. The
first request (see CM_API._verify_schema_once) so the factory itself surrounding CM_API class still owns route registration and DB
never touches MySQL keeps gunicorn's preload phase unaffected by a connection management this just hands gunicorn the underlying
momentarily-unavailable DB and lets unit tests construct the app Flask instance.
without DB env wiring.
""" """
return CM_API().app return CM_API().app

200
app/db.py
View File

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

View File

@ -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

View File

@ -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

View File

@ -14,16 +14,11 @@ Arguments:
tag Optional tag to publish (default: latest). Override with DOCKER_IMAGE_TAG. tag Optional tag to publish (default: latest). Override with DOCKER_IMAGE_TAG.
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).
Authentication: Make sure you are authenticated first:
The script invokes docker via sudo by default (matching scripts/dev.sh). docker login gitea.04080616.xyz
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
} }
@ -32,57 +27,27 @@ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
exit 0 exit 0
fi fi
# Match scripts/dev.sh: prefix docker calls with sudo unless the user opts if ! docker info >/dev/null 2>&1; then
# out via NO_SUDO=1 (typically because they're in the docker group). echo "Docker daemon is not reachable. Please start Docker and retry." >&2
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
# (Earlier versions checked `docker system info` for the registry — but if ! docker system info --format '{{json .IndexServerAddress}}' | grep -q "gitea.04080616.xyz" 2>/dev/null; then
# IndexServerAddress always points at Docker Hub regardless of which cat <<'EOF' >&2
# registries you've logged into, so the check was a guaranteed false Reminder: run 'docker login gitea.04080616.xyz' before publishing so pushes succeed.
# positive. If push fails with 401, run: EOF
# ${SUDO:+sudo }docker login gitea.04080616.xyz fi
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
RUNNER="$([[ -n "${SUDO}" ]] && echo "root via sudo" || echo "current user")" cat <<'EOF' >&2
cat <<EOF >&2 Docker Buildx is required for producing registry-compatible images.
Docker Buildx isn't reachable as the user this script runs docker as Install/enable buildx and rerun, for example:
(${RUNNER}). docker buildx create --use --name cm-bot-builder
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
@ -106,7 +71,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}" \

View File

@ -1,27 +1,14 @@
"use server"; "use server";
import { revalidatePath, revalidateTag } from "next/cache"; import { revalidatePath } from "next/cache";
import { import { fetchApi } from "@/lib/api";
ACCOUNTS_TAG, import type { AccUpdate, UserUpdate } from "@/lib/types";
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) {
@ -32,7 +19,6 @@ 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) {
@ -43,7 +29,6 @@ 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) {
@ -54,7 +39,6 @@ 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) {
@ -65,7 +49,6 @@ 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) {
@ -76,34 +59,9 @@ 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 });
}

View File

@ -1,6 +1,7 @@
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 = {
@ -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"> <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>
); );

View File

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

View File

@ -1,15 +1,9 @@
import { getAccountsPage } from "@/lib/api"; import { getAccounts } 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 page = await getAccountsPage({ prefix: PREFIX_PATTERN, dir: "desc" }); const accounts = await getAccounts();
return ( return <AccountsTable initial={accounts} prefixPattern={PREFIX_PATTERN} />;
<AccountsTable
initial={page.rows}
initialHasMore={page.hasMore}
prefixPattern={PREFIX_PATTERN}
/>
);
} }

View File

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

View File

@ -1,19 +1,9 @@
import { getUsersPage } from "@/lib/api"; import { getUsers } 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 page = await getUsersPage({ const users = await getUsers();
prefix: PREFIX_PATTERN, return <UsersTable initial={users} prefixPattern={PREFIX_PATTERN} />;
sort: "last_update_time",
dir: "desc",
});
return (
<UsersTable
initial={page.rows}
initialHasMore={page.hasMore}
prefixPattern={PREFIX_PATTERN}
/>
);
} }

View File

@ -1,26 +1,30 @@
"use client"; "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 type { Acc } from "@/lib/types";
import { import { deleteAccount, updateAccount } from "@/app/actions";
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 = { type Props = { initial: Acc[]; prefixPattern: string };
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" },
@ -58,111 +62,52 @@ function DeleteButton({
); );
} }
export default function AccountsTable({ export default function AccountsTable({ initial, prefixPattern }: Props) {
initial, const router = useRouter();
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>(
rows, initial,
(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 = rows.find((r) => r.username === username); const row = initial.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);
if (result.ok) { resolve(result.ok ? { ok: true } : { ok: false, error: result.error });
setRows((prev) => prev.map((r) => (r.username === username ? next : r)));
resolve({ ok: true });
} else {
resolve({ ok: false, error: result.error });
}
}); });
}); });
} }
async function refresh() { function refresh() {
setRefreshing(true); setRefreshing(true);
try { startTransition(() => {
const page = await refreshAccounts({ prefix: prefixPattern, dir: sortDir }); router.refresh();
setRows(page.rows); setTimeout(() => setRefreshing(false), 400);
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;
setDeleting(true); setDeleting(true);
@ -171,7 +116,6 @@ export default function AccountsTable({
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 {
@ -179,13 +123,11 @@ export default function AccountsTable({
} }
} }
if (rows.length === 0) { if (initial.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)}
@ -198,12 +140,6 @@ export default function AccountsTable({
<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>
@ -214,8 +150,6 @@ export default function AccountsTable({
<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)}
@ -229,7 +163,7 @@ export default function AccountsTable({
<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={() => 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" 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
@ -243,7 +177,7 @@ export default function AccountsTable({
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100"> <tbody className="divide-y divide-zinc-100">
{optimistic.map((row) => { {sorted.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">
@ -299,7 +233,7 @@ export default function AccountsTable({
{/* Mobile cards */} {/* Mobile cards */}
<div className="mt-6 space-y-3 sm:hidden"> <div className="mt-6 space-y-3 sm:hidden">
{optimistic.map((row) => { {sorted.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">
@ -355,28 +289,12 @@ export default function AccountsTable({
})} })}
</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={async (name) => { onSuccess={(name) =>
setToast({ type: "success", message: `Account ${name} created` }); setToast({ type: "success", message: `Account ${name} created` })
await refresh(); }
}}
prefixPattern={prefixPattern} prefixPattern={prefixPattern}
/> />
@ -436,22 +354,15 @@ 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>
@ -462,7 +373,6 @@ 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>

View 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;
}

View File

@ -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>
);
}

View File

@ -1,23 +1,15 @@
"use client"; "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 type { User } from "@/lib/types";
import { import { deleteUser, updateUser } from "@/app/actions";
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 = { type Props = { initial: User[]; prefixPattern: string };
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 = {
@ -26,6 +18,29 @@ 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);
@ -59,35 +74,32 @@ function DeleteButton({
); );
} }
export default function UsersTable({ export default function UsersTable({ initial, prefixPattern }: Props) {
initial, const router = useRouter();
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>(
rows, initial,
(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"],
@ -96,7 +108,7 @@ export default function UsersTable({
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 = 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" }); if (!row) return resolve({ ok: false, error: "row not found" });
const next = { const next = {
f_username: row.f_username, f_username: row.f_username,
@ -106,86 +118,27 @@ export default function UsersTable({
[field]: value, [field]: value,
}; };
const result = await updateUser(next); const result = await updateUser(next);
if (result.ok) { resolve(result.ok ? { ok: true } : { ok: false, error: result.error });
setRows((prev) =>
prev.map((r) =>
r.f_username === f_username ? { ...r, [field]: value } : r,
),
);
resolve({ ok: true });
} else {
resolve({ ok: false, error: result.error });
}
}); });
}); });
} }
async function refresh() { function refresh() {
setRefreshing(true); setRefreshing(true);
try { startTransition(() => {
const page = await refreshUsers({ router.refresh();
prefix: prefixPattern, setTimeout(() => setRefreshing(false), 400);
sort: sortKey, });
dir: sortDir,
});
setRows(page.rows);
setHasMore(page.hasMore);
} finally {
setRefreshing(false);
}
} }
async function changeSort(nextKey: SortKey) { function toggleSort(k: SortKey) {
let nextDir: SortDir; if (sortKey === k) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
if (nextKey === sortKey) { else {
nextDir = sortDir === "asc" ? "desc" : "asc"; setSortKey(k);
} else { setSortDir("desc");
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);
@ -194,7 +147,6 @@ export default function UsersTable({
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 {
@ -207,7 +159,7 @@ export default function UsersTable({
return ( return (
<button <button
type="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 ${ 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"
}`} }`}
@ -218,12 +170,11 @@ export default function UsersTable({
); );
} }
if (rows.length === 0) { if (initial.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)}
@ -234,14 +185,14 @@ export default function UsersTable({
</p> </p>
</div> </div>
<CreateUserDialog <CreateUserDialog
open={createOpen} open={createOpen}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
onSuccess={async (name) => { onSuccess={(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>
); );
} }
@ -250,7 +201,6 @@ export default function UsersTable({
<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)}
@ -279,7 +229,7 @@ export default function UsersTable({
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-100"> <tbody className="divide-y divide-zinc-100">
{optimistic.map((row) => { {sorted.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">
@ -336,7 +286,7 @@ export default function UsersTable({
</div> </div>
<div className="mt-6 space-y-3 sm:hidden"> <div className="mt-6 space-y-3 sm:hidden">
{optimistic.map((row) => { {sorted.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">
@ -394,25 +344,12 @@ export default function UsersTable({
})} })}
</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={async (name) => { onSuccess={(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)} />
@ -460,28 +397,22 @@ 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"> <span className="ml-2 align-middle text-base font-medium text-zinc-400">{count}</span>
{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">

View File

@ -2,53 +2,20 @@ 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 & { next?: FetchInit["next"] } = { const init: RequestInit = {
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}`);
@ -56,39 +23,12 @@ export async function fetchApi(path: string, options: FetchInit = {}): Promise<u
return res.json(); return res.json();
} }
function buildAccountsUrl(opts: AccountsPageOpts): string { export async function getAccounts(): Promise<Acc[]> {
const { offset = 0, prefix = "", dir = "desc" } = opts; const data = await fetchApi("/acc/");
const params = new URLSearchParams({ return data as Acc[];
limit: String(PAGE_SIZE),
offset: String(offset),
dir,
});
if (prefix) params.set("prefix", prefix);
return `/acc/?${params.toString()}`;
} }
function buildUsersUrl(opts: UsersPageOpts): string { export async function getUsers(): Promise<User[]> {
const { offset = 0, prefix = "", sort = "last_update_time", dir = "desc" } = opts; const data = await fetchApi("/user/");
const params = new URLSearchParams({ return data as User[];
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 };
} }