feat: add connected users monitoring and update student modal

This commit is contained in:
ssbg04 committed 2026-07-27 18:52:38 +08:00
1 parent 34189702d1
commit 750cfba699
8 files changed
+625 -97

No files matched your search

+2
View File
@@ -26,6 +26,7 @@ const requirementRoutes = require('./src/routes/requirements');
const ocrRoutes = require('./src/routes/ocr.routes.js');
const notificationRoutes = require('./src/routes/notifications');
const backupRoutes = require('./src/routes/backup');
const serverRoutes = require('./src/routes/server.routes');
const app = express();
const PORT = process.env.PORT || 18484;
@@ -53,6 +54,7 @@ app.use('/api/requirements', requirementRoutes);
app.use('/api/ocr', ocrRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/backup', backupRoutes);
app.use('/api/server', serverRoutes);
app.get(['/', '/api'], (req, res) => {
res.set('X-TIS-RMS', 'true');
@@ -0,0 +1,124 @@
const os = require('os');
// ─────────────────────────────────────────────────────────────────────────────
// In-memory connected-users store
// Key: username (string)
// Value: { username, role, platform, ip, loginTime, lastSeen }
// ─────────────────────────────────────────────────────────────────────────────
const connectedUsers = new Map();
const SERVER_START_TIME = Date.now();
// Evict users whose lastSeen is older than 3 minutes (3 × heartbeat interval)
const EVICT_MS = 3 * 60 * 1000;
function evictStale() {
const now = Date.now();
for (const [key, user] of connectedUsers) {
if (now - new Date(user.lastSeen).getTime() > EVICT_MS) {
connectedUsers.delete(key);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// POST /api/server/heartbeat
// Body: { username, role, platform, ip }
// Upserts the user into the in-memory store.
// ─────────────────────────────────────────────────────────────────────────────
exports.heartbeat = (req, res) => {
evictStale();
const { username, role, platform, ip } = req.body;
if (!username) {
return res.status(400).json({ message: 'username is required.' });
}
const now = new Date().toISOString();
const existing = connectedUsers.get(username);
connectedUsers.set(username, {
username,
role: role || existing?.role || 'unknown',
platform: platform || existing?.platform || 'unknown',
ip: ip || existing?.ip || null,
loginTime: existing?.loginTime || now,
lastSeen: now,
});
res.json({ ok: true });
};
// ─────────────────────────────────────────────────────────────────────────────
// POST /api/server/logout
// Body: { username }
// Removes the user from the in-memory store.
// ─────────────────────────────────────────────────────────────────────────────
exports.logout = (req, res) => {
const { username } = req.body;
if (username) connectedUsers.delete(username);
res.json({ ok: true });
};
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/server/connected-users
// Returns the current list of connected users (stale entries evicted first).
// ─────────────────────────────────────────────────────────────────────────────
exports.getConnectedUsers = (req, res) => {
evictStale();
res.json(Array.from(connectedUsers.values()));
};
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/server/status
// Returns CPU %, memory usage, uptime, and connected user count.
// ─────────────────────────────────────────────────────────────────────────────
/** Computes an instantaneous CPU usage percentage by sampling os.cpus() twice. */
function getCpuPercent() {
return new Promise((resolve) => {
const cpusBefore = os.cpus();
setTimeout(() => {
const cpusAfter = os.cpus();
let totalIdle = 0;
let totalTick = 0;
for (let i = 0; i < cpusBefore.length; i++) {
const before = cpusBefore[i].times;
const after = cpusAfter[i].times;
const idleDiff = after.idle - before.idle;
const totalDiff = Object.values(after).reduce((a, v) => a + v, 0)
- Object.values(before).reduce((a, v) => a + v, 0);
totalIdle += idleDiff;
totalTick += totalDiff;
}
const percent = totalTick === 0
? 0
: parseFloat(((1 - totalIdle / totalTick) * 100).toFixed(1));
resolve(percent);
}, 200); // 200 ms sample window
});
}
exports.getServerStatus = async (req, res) => {
evictStale();
try {
const cpuPercent = await getCpuPercent();
const memTotalMB = Math.round(os.totalmem() / 1024 / 1024);
const memUsedMB = Math.round((os.totalmem() - os.freemem()) / 1024 / 1024);
const uptimeSec = Math.round((Date.now() - SERVER_START_TIME) / 1000);
res.json({
uptime: uptimeSec,
cpuPercent,
memUsedMB,
memTotalMB,
connectedUserCount: connectedUsers.size,
});
} catch (err) {
res.status(500).json({ message: 'Failed to get server status.', error: err.message });
}
};
+23
View File
@@ -0,0 +1,23 @@
const express = require('express');
const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const {
heartbeat,
logout,
getConnectedUsers,
getServerStatus,
} = require('../controllers/connectedUsersController');
// POST /api/server/heartbeat — upsert caller into connected-users list
router.post('/heartbeat', authenticateToken, heartbeat);
// POST /api/server/logout — remove caller from connected-users list
router.post('/logout', authenticateToken, logout);
// GET /api/server/connected-users — list all currently connected users
router.get('/connected-users', authenticateToken, getConnectedUsers);
// GET /api/server/status — cpu%, mem, uptime, user count
router.get('/status', authenticateToken, getServerStatus);
module.exports = router;
@@ -0,0 +1,39 @@
class ConnectedUserModel {
final String username;
final String role;
final String platform;
final String? ip;
final String loginTime;
final String lastSeen;
ConnectedUserModel({
required this.username,
required this.role,
required this.platform,
this.ip,
required this.loginTime,
required this.lastSeen,
});
factory ConnectedUserModel.fromJson(Map<String, dynamic> json) {
return ConnectedUserModel(
username: json['username'] as String? ?? 'unknown',
role: json['role'] as String? ?? 'unknown',
platform: json['platform'] as String? ?? 'unknown',
ip: json['ip'] as String?,
loginTime: json['loginTime'] as String? ?? '',
lastSeen: json['lastSeen'] as String? ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'username': username,
'role': role,
'platform': platform,
'ip': ip,
'loginTime': loginTime,
'lastSeen': lastSeen,
};
}
}
@@ -0,0 +1,67 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../core/network/api_constants.dart';
import '../entities/connected_user_model.dart';
class ConnectedUsersRepository {
final Dio _dio = ApiConstants.createDio();
final FlutterSecureStorage _storage = const FlutterSecureStorage();
Future<Options> _getAuthOptions() async {
final token = await _storage.read(key: 'jwt_token');
return Options(headers: {'Authorization': 'Bearer $token'});
}
Future<void> sendHeartbeat({
required String username,
required String role,
required String platform,
String? ip,
}) async {
try {
final options = await _getAuthOptions();
await _dio.post(
'/server/heartbeat',
options: options,
data: {
'username': username,
'role': role,
'platform': platform,
'ip': ip,
},
);
} catch (_) {
// Ignore heartbeat network errors silently so UI is undisturbed
}
}
Future<void> sendLogout({required String username}) async {
try {
final options = await _getAuthOptions();
await _dio.post(
'/server/logout',
options: options,
data: {'username': username},
);
} catch (_) {
// Ignore logout network errors silently
}
}
Future<List<ConnectedUserModel>> getConnectedUsers() async {
try {
final options = await _getAuthOptions();
final response = await _dio.get(
'/server/connected-users',
options: options,
);
return (response.data as List)
.map((u) => ConnectedUserModel.fromJson(u))
.toList();
} on DioException catch (e) {
throw Exception(
e.response?.data['message'] ?? 'Failed to fetch connected users.',
);
}
}
}
@@ -12,6 +12,7 @@ import 'dashboard_provider.dart';
import 'setup_provider.dart';
import 'reports_provider.dart';
import 'ocr_provider.dart';
import 'connected_users_provider.dart';
final authRepositoryProvider = Provider<AuthRepository>(
(ref) => AuthRepository(),
@@ -41,6 +42,7 @@ class AuthNotifier extends AsyncNotifier<UserModel?> {
rememberMe: rememberMe,
);
state = AsyncData(user);
ref.read(heartbeatServiceProvider).start(user);
return true;
} catch (e, st) {
state = AsyncError(e, st);
@@ -55,6 +57,7 @@ class AuthNotifier extends AsyncNotifier<UserModel?> {
final user = await repository.tryAutoLogin();
if (user != null) {
state = AsyncData(user);
ref.read(heartbeatServiceProvider).start(user);
}
return user;
} catch (_) {
@@ -63,6 +66,7 @@ class AuthNotifier extends AsyncNotifier<UserModel?> {
}
Future<void> logout() async {
ref.read(heartbeatServiceProvider).stop();
final repository = ref.read(authRepositoryProvider);
await repository.logout();
state = const AsyncData(null);
@@ -0,0 +1,66 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/repositories/connected_users_repository.dart';
import '../../domain/entities/connected_user_model.dart';
import '../../domain/entities/user_model.dart';
final connectedUsersRepositoryProvider = Provider<ConnectedUsersRepository>(
(ref) => ConnectedUsersRepository(),
);
class HeartbeatService {
final ConnectedUsersRepository _repository;
Timer? _timer;
UserModel? _currentUser;
HeartbeatService(this._repository);
String get _platformName {
if (Platform.isWindows) return 'windows';
if (Platform.isAndroid) return 'android';
return 'other';
}
void start(UserModel user) {
stop(); // stop any existing timer
_currentUser = user;
_sendHeartbeat();
_timer = Timer.periodic(const Duration(minutes: 1), (_) {
_sendHeartbeat();
});
}
void _sendHeartbeat() {
final user = _currentUser;
if (user == null) return;
_repository.sendHeartbeat(
username: user.username,
role: user.role,
platform: _platformName,
);
}
void stop() {
_timer?.cancel();
_timer = null;
final user = _currentUser;
if (user != null) {
_repository.sendLogout(username: user.username);
_currentUser = null;
}
}
}
final heartbeatServiceProvider = Provider<HeartbeatService>((ref) {
final repository = ref.read(connectedUsersRepositoryProvider);
final service = HeartbeatService(repository);
ref.onDispose(() => service.stop());
return service;
});
final connectedUsersProvider =
FutureProvider.autoDispose<List<ConnectedUserModel>>((ref) async {
final repository = ref.read(connectedUsersRepositoryProvider);
return await repository.getConnectedUsers();
});
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_holo_date_picker/flutter_holo_date_picker.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../core/constants/app_sizes.dart';
@@ -1712,6 +1711,37 @@ class _ExtensionNameField extends StatelessWidget {
);
}
}
// ================================================================
// DOB MASKED INPUT FORMATTER
// Accepts digits only. Auto-inserts dashes: MM-DD-YYYY
// ================================================================
class _DobInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
// Strip everything that's not a digit
final digits = newValue.text.replaceAll(RegExp(r'[^\d]'), '');
// Limit to 8 raw digits (MMDDYYYY)
final d = digits.length > 8 ? digits.substring(0, 8) : digits;
final buf = StringBuffer();
for (int i = 0; i < d.length; i++) {
buf.write(d[i]);
if (i == 1 || i == 3) buf.write('-'); // after MM and DD
}
final result = buf.toString();
return TextEditingValue(
text: result,
selection: TextSelection.collapsed(offset: result.length),
);
}
}
// ================================================================
// DOB PICKER — single MM-DD-YYYY typed field with suggestion overlay
// ================================================================
class _DobPicker extends StatefulWidget {
final DateTime? initialDate;
final ValueChanged<DateTime?> onChanged;
@@ -1724,34 +1754,131 @@ class _DobPicker extends StatefulWidget {
}
class _DobPickerState extends State<_DobPicker> {
int? _month;
int? _day;
late final TextEditingController _yearController;
late final TextEditingController _ctrl;
late final FocusNode _focusNode;
OverlayEntry? _overlayEntry;
final LayerLink _layerLink = LayerLink();
static const List<String> _months = [
static const _monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
'July', 'August', 'September', 'October', 'November', 'December',
];
static String _fmt(DateTime d) =>
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}-'
'${d.year}';
@override
void initState() {
super.initState();
_yearController = TextEditingController();
if (widget.initialDate != null) {
_month = widget.initialDate!.month;
_day = widget.initialDate!.day;
_yearController.text = widget.initialDate!.year.toString();
_ctrl = TextEditingController(
text: widget.initialDate != null ? _fmt(widget.initialDate!) : '',
);
_focusNode = FocusNode();
_ctrl.addListener(_onTextChanged);
_focusNode.addListener(_onFocusChanged);
}
@override
void didUpdateWidget(covariant _DobPicker oldWidget) {
super.didUpdateWidget(oldWidget);
// External update (e.g. OCR fill): sync the text controller
if (widget.initialDate != oldWidget.initialDate &&
widget.initialDate != null) {
final formatted = _fmt(widget.initialDate!);
if (_ctrl.text != formatted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _ctrl.text = formatted;
});
}
}
if (widget.initialDate == null && oldWidget.initialDate != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _ctrl.clear();
});
}
}
@override
void dispose() {
_yearController.dispose();
_removeOverlay();
_ctrl.removeListener(_onTextChanged);
_focusNode.removeListener(_onFocusChanged);
_ctrl.dispose();
_focusNode.dispose();
super.dispose();
}
int _getDaysInMonth(int? month, int? year) {
if (month == null) return 31;
// ── Overlay management ──────────────────────────────────────────
void _onFocusChanged() {
if (_focusNode.hasFocus) {
_showOverlay();
} else {
_removeOverlay();
}
}
void _onTextChanged() {
_parseAndNotify();
if (_focusNode.hasFocus) {
_overlayEntry?.markNeedsBuild();
}
}
void _showOverlay() {
_removeOverlay();
_overlayEntry = OverlayEntry(builder: (_) => _buildOverlayWidget());
Overlay.of(context).insert(_overlayEntry!);
}
void _removeOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
}
// ── Suggestion computation ──────────────────────────────────────
/// Returns typed digits stripped of dashes.
String get _rawDigits => _ctrl.text.replaceAll('-', '');
List<String> _buildSuggestions() {
final raw = _rawDigits;
final len = raw.length;
// Phase 1: typing MM (0–2 digits)
if (len <= 2) {
return List.generate(12, (i) {
final mm = (i + 1).toString().padLeft(2, '0');
return '$mm - ${_monthNames[i]}';
}).where((s) {
if (len == 0) return true;
return s.startsWith(raw);
}).toList();
}
// Phase 2: typing DD (3–4 digits)
if (len <= 4) {
final mm = int.tryParse(raw.substring(0, 2));
if (mm == null || mm < 1 || mm > 12) return [];
final maxDay = _maxDayForMonth(mm, null);
final ddTyped = raw.length >= 3 ? raw.substring(2) : '';
return List.generate(maxDay, (i) {
final dd = (i + 1).toString().padLeft(2, '0');
return dd;
}).where((dd) => ddTyped.isEmpty || dd.startsWith(ddTyped)).toList();
}
// Phase 3: typing YYYY (5–8 digits)
final now = DateTime.now().year;
final yyyyTyped = raw.length >= 5 ? raw.substring(4) : '';
return List.generate(now - 1985, (i) {
return (now - 3 - i).toString();
}).where((y) => yyyyTyped.isEmpty || y.startsWith(yyyyTyped)).take(8).toList();
}
int _maxDayForMonth(int month, int? year) {
if (month == 2) {
if (year != null && (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0))) {
return 29;
@@ -1762,97 +1889,173 @@ class _DobPickerState extends State<_DobPicker> {
return 31;
}
void _update() {
int? year = int.tryParse(_yearController.text);
int maxDays = _getDaysInMonth(_month, year);
if (_day != null && _day! > maxDays) {
_day = maxDays;
}
if (_month != null && _day != null && year != null && _yearController.text.length == 4) {
widget.onChanged(DateTime(year, _month!, _day!));
// ── Suggestion tap handler ──────────────────────────────────────
void _onSuggestionTap(String suggestion) {
final raw = _rawDigits;
final len = raw.length;
String newRaw;
if (len <= 2) {
// suggestion = "01 - January" → extract "01"
newRaw = suggestion.split(' ').first;
} else if (len <= 4) {
// suggestion is just "DD"
final mm = raw.substring(0, 2);
newRaw = mm + suggestion;
} else {
widget.onChanged(null);
// suggestion is a 4-digit year string
final mmdd = raw.substring(0, 4);
newRaw = mmdd + suggestion;
}
// Rebuild formatted string MM-DD-YYYY from newRaw
final buf = StringBuffer();
for (int i = 0; i < newRaw.length && i < 8; i++) {
buf.write(newRaw[i]);
if (i == 1 || i == 3) buf.write('-');
}
_ctrl.text = buf.toString();
_parseAndNotify();
// Move to next phase or close if complete
if (newRaw.length >= 8) {
_focusNode.unfocus();
} else {
_overlayEntry?.markNeedsBuild();
}
setState(() {});
}
// ── Parse & notify parent ───────────────────────────────────────
void _parseAndNotify() {
final raw = _rawDigits;
if (raw.length == 8) {
final mm = int.tryParse(raw.substring(0, 2));
final dd = int.tryParse(raw.substring(2, 4));
final yyyy = int.tryParse(raw.substring(4, 8));
final now = DateTime.now().year;
if (mm != null && dd != null && yyyy != null &&
mm >= 1 && mm <= 12 &&
dd >= 1 && dd <= _maxDayForMonth(mm, yyyy) &&
yyyy >= 1900 && yyyy <= now) {
widget.onChanged(DateTime(yyyy, mm, dd));
return;
}
}
widget.onChanged(null);
}
// ── Overlay widget builder ──────────────────────────────────────
Widget _buildOverlayWidget() {
final suggestions = _buildSuggestions();
if (suggestions.isEmpty) return const SizedBox.shrink();
final raw = _rawDigits;
String label;
if (raw.length <= 2) {
label = 'Select month';
} else if (raw.length <= 4) {
label = 'Select day';
} else {
label = 'Select year';
}
return Positioned(
width: 260,
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: const Offset(0, 52),
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(AppSizes.radiusMedium),
color: Colors.white,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Text(
label,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
letterSpacing: 0.5,
),
),
),
const Divider(height: 1),
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: suggestions.length,
itemBuilder: (ctx, i) {
final s = suggestions[i];
return InkWell(
onTap: () => _onSuggestionTap(s),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 9,
),
child: Text(
s,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
);
},
),
),
],
),
),
),
),
);
}
// ── Build ───────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
int? year = int.tryParse(_yearController.text);
int maxDays = _getDaysInMonth(_month, year);
return InputDecorator(
decoration: const InputDecoration(
labelText: 'DATE OF BIRTH (Optional)',
prefixIcon: Icon(Icons.calendar_today, color: AppColors.textSecondary),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
),
child: Row(
children: [
Expanded(
flex: 4,
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
isExpanded: true,
hint: const Text('Month', style: TextStyle(fontSize: 14)),
value: _month,
items: List.generate(12, (index) {
return DropdownMenuItem(
value: index + 1,
child: Text(_months[index], style: const TextStyle(fontSize: 14)),
);
}),
onChanged: (val) {
_month = val;
_update();
},
),
),
return CompositedTransformTarget(
link: _layerLink,
child: TextFormField(
controller: _ctrl,
focusNode: _focusNode,
keyboardType: TextInputType.number,
inputFormatters: [_DobInputFormatter()],
style: const TextStyle(fontSize: 14, letterSpacing: 0.5),
decoration: InputDecoration(
labelText: 'DATE OF BIRTH (Optional)',
hintText: 'MM-DD-YYYY',
prefixIcon: const Icon(
Icons.cake_outlined,
color: AppColors.textSecondary,
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
isExpanded: true,
hint: const Text('Day', style: TextStyle(fontSize: 14)),
value: _day,
items: List.generate(maxDays, (index) {
return DropdownMenuItem(
value: index + 1,
child: Text('${index + 1}', style: const TextStyle(fontSize: 14)),
);
}),
onChanged: (val) {
_day = val;
_update();
},
),
),
),
const SizedBox(width: 8),
Expanded(
flex: 3,
child: TextField(
controller: _yearController,
keyboardType: TextInputType.number,
maxLength: 4,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(
hintText: 'Year',
counterText: '',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
onChanged: (_) => _update(),
),
),
],
suffixIcon: _ctrl.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
_ctrl.clear();
widget.onChanged(null);
_overlayEntry?.markNeedsBuild();
},
)
: null,
),
),
);
}
}