WiFi 라우터 기능 복원
This commit is contained in:
+524
@@ -2523,6 +2523,475 @@ function fan_spike_history(int $limit = 100): array
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function dnsmasq_leases(): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach (@file('/var/lib/misc/dnsmasq.leases', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||
$p = preg_split('/\s+/', trim($line));
|
||||
|
||||
if (count($p) >= 4) {
|
||||
$mac = strtolower($p[1]);
|
||||
|
||||
$map[$mac] = [
|
||||
'expire_ts' => (int)$p[0],
|
||||
'mac' => $mac,
|
||||
'ip' => $p[2],
|
||||
'hostname' => $p[3] === '*' ? 'N/A' : $p[3],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
function wifi_client_aliases(): array
|
||||
{
|
||||
try {
|
||||
$rows = db()->query("SELECT mac, hostname FROM wifi_client_aliases")->fetchAll();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$aliases = [];
|
||||
foreach ($rows as $row) {
|
||||
$mac = strtolower((string)($row['mac'] ?? ''));
|
||||
$hostname = trim((string)($row['hostname'] ?? ''));
|
||||
if (wifi_valid_mac($mac) && $hostname !== '') {
|
||||
$aliases[$mac] = $hostname;
|
||||
}
|
||||
}
|
||||
|
||||
return $aliases;
|
||||
}
|
||||
|
||||
function save_wifi_client_alias(string $mac, string $hostname): array
|
||||
{
|
||||
$mac = strtolower(trim($mac));
|
||||
$hostname = trim(preg_replace('/\s+/', ' ', $hostname) ?? '');
|
||||
|
||||
if (!wifi_valid_mac($mac)) {
|
||||
throw new InvalidArgumentException('bad_mac');
|
||||
}
|
||||
if ($hostname === '' || mb_strlen($hostname) > 80) {
|
||||
throw new InvalidArgumentException('bad_hostname');
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("
|
||||
INSERT INTO wifi_client_aliases (mac, hostname)
|
||||
VALUES (:mac, :hostname)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
hostname = VALUES(hostname),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
");
|
||||
$stmt->execute([
|
||||
':mac' => $mac,
|
||||
':hostname' => $hostname,
|
||||
]);
|
||||
|
||||
return [
|
||||
'mac' => $mac,
|
||||
'hostname' => $hostname,
|
||||
];
|
||||
}
|
||||
|
||||
function iw_station_dump(string $iface): string
|
||||
{
|
||||
if (!preg_match('/^[A-Za-z0-9_.:-]+$/', $iface)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sh(['/usr/sbin/iw', 'dev', $iface, 'station', 'dump'], true, 4)['out'];
|
||||
}
|
||||
|
||||
function parse_live_wifi_rows(string $iface, string $band, string $text, array $leases, array $aliases): array
|
||||
{
|
||||
$rows = [];
|
||||
$cur = null;
|
||||
|
||||
foreach (explode("\n", $text) as $line) {
|
||||
$t = trim($line);
|
||||
|
||||
if (preg_match('/^Station\s+([0-9a-f:]+)/i', $t, $m)) {
|
||||
if ($cur !== null) {
|
||||
$rows[] = $cur;
|
||||
}
|
||||
|
||||
$mac = strtolower($m[1]);
|
||||
$lease = $leases[$mac] ?? [];
|
||||
$leaseHostname = (string)($lease['hostname'] ?? 'N/A');
|
||||
$aliasHostname = $aliases[$mac] ?? null;
|
||||
$hostname = $aliasHostname ?: $leaseHostname;
|
||||
$hostnameSource = $aliasHostname ? 'alias' : ($leaseHostname === 'N/A' ? 'generated' : 'lease');
|
||||
$cur = [
|
||||
'band' => $band,
|
||||
'iface' => $iface,
|
||||
'mac' => $mac,
|
||||
'ip' => $lease['ip'] ?? 'N/A',
|
||||
'hostname' => $hostname === 'N/A' ? '기기-' . strtoupper(substr(str_replace(':', '', $mac), -6)) : $hostname,
|
||||
'hostname_source' => $hostnameSource,
|
||||
'name' => $hostname === 'N/A' ? $mac : $hostname,
|
||||
'signal' => 'N/A',
|
||||
'tx_bitrate' => 'N/A',
|
||||
'rx_bitrate' => 'N/A',
|
||||
'signal_source' => 'missing',
|
||||
'tx_bitrate_source' => 'missing',
|
||||
'rx_bitrate_source' => 'missing',
|
||||
'connected_time' => 'N/A',
|
||||
'inactive_time' => 'N/A',
|
||||
'rx_bytes' => 0,
|
||||
'tx_bytes' => 0,
|
||||
'rx_packets' => 0,
|
||||
'tx_packets' => 0,
|
||||
'tx_failed' => 0,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($cur === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^signal:\s+(.+)/', $t, $m)) {
|
||||
$cur['signal'] = $m[1];
|
||||
$cur['signal_source'] = 'iw';
|
||||
} elseif (preg_match('/^tx bitrate:\s+(.+)/', $t, $m)) {
|
||||
$cur['tx_bitrate'] = $m[1];
|
||||
$cur['tx_bitrate_source'] = 'iw';
|
||||
} elseif (preg_match('/^rx bitrate:\s+(.+)/', $t, $m)) {
|
||||
$cur['rx_bitrate'] = $m[1];
|
||||
$cur['rx_bitrate_source'] = 'iw';
|
||||
}
|
||||
elseif (preg_match('/^connected time:\s+(.+)/', $t, $m)) $cur['connected_time'] = $m[1];
|
||||
elseif (preg_match('/^inactive time:\s+(.+)/', $t, $m)) $cur['inactive_time'] = $m[1];
|
||||
elseif (preg_match('/^rx bytes:\s+(\d+)/', $t, $m)) $cur['rx_bytes'] = (int)$m[1];
|
||||
elseif (preg_match('/^tx bytes:\s+(\d+)/', $t, $m)) $cur['tx_bytes'] = (int)$m[1];
|
||||
elseif (preg_match('/^rx packets:\s+(\d+)/', $t, $m)) $cur['rx_packets'] = (int)$m[1];
|
||||
elseif (preg_match('/^tx packets:\s+(\d+)/', $t, $m)) $cur['tx_packets'] = (int)$m[1];
|
||||
elseif (preg_match('/^tx failed:\s+(\d+)/', $t, $m)) $cur['tx_failed'] = (int)$m[1];
|
||||
}
|
||||
|
||||
if ($cur !== null) {
|
||||
$rows[] = $cur;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function wifi_time_ms(string $value): int
|
||||
{
|
||||
if (preg_match('/(\d+)/', $value, $m)) {
|
||||
return (int)$m[1];
|
||||
}
|
||||
|
||||
return PHP_INT_MAX;
|
||||
}
|
||||
|
||||
function wifi_unknown(mixed $value): bool
|
||||
{
|
||||
$text = strtoupper(trim((string)$value));
|
||||
return $text === '' || $text === 'N/A' || $text === '-';
|
||||
}
|
||||
|
||||
function wifi_bitrate_mbps(mixed $value): ?float
|
||||
{
|
||||
if (preg_match('/([0-9]+(?:\.[0-9]+)?)/', (string)$value, $m)) {
|
||||
return (float)$m[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function wifi_signal_dbm(string $value): int
|
||||
{
|
||||
if (preg_match('/-?\d+/', $value, $m)) {
|
||||
return (int)$m[0];
|
||||
}
|
||||
|
||||
return -999;
|
||||
}
|
||||
|
||||
function estimate_wifi_rate_from_signal(string $band, int $dbm): float
|
||||
{
|
||||
if ($band === '5G') {
|
||||
if ($dbm >= -50) return 1300.0;
|
||||
if ($dbm >= -58) return 866.7;
|
||||
if ($dbm >= -66) return 585.0;
|
||||
if ($dbm >= -73) return 390.0;
|
||||
if ($dbm >= -80) return 173.3;
|
||||
return 54.0;
|
||||
}
|
||||
|
||||
if ($dbm >= -50) return 300.0;
|
||||
if ($dbm >= -58) return 144.4;
|
||||
if ($dbm >= -66) return 72.2;
|
||||
if ($dbm >= -73) return 39.0;
|
||||
if ($dbm >= -80) return 19.5;
|
||||
return 6.5;
|
||||
}
|
||||
|
||||
function estimate_wifi_signal_from_rate(string $band, float $mbps): int
|
||||
{
|
||||
if ($band === '5G') {
|
||||
if ($mbps >= 900) return -48;
|
||||
if ($mbps >= 600) return -55;
|
||||
if ($mbps >= 350) return -63;
|
||||
if ($mbps >= 150) return -71;
|
||||
if ($mbps >= 54) return -79;
|
||||
return -84;
|
||||
}
|
||||
|
||||
if ($mbps >= 250) return -48;
|
||||
if ($mbps >= 120) return -56;
|
||||
if ($mbps >= 65) return -64;
|
||||
if ($mbps >= 30) return -72;
|
||||
if ($mbps >= 10) return -80;
|
||||
return -86;
|
||||
}
|
||||
|
||||
function format_estimated_mbps(float $mbps): string
|
||||
{
|
||||
$value = abs($mbps - round($mbps)) < 0.05
|
||||
? (string)(int)round($mbps)
|
||||
: number_format($mbps, 1);
|
||||
|
||||
return $value . ' MBit/s';
|
||||
}
|
||||
|
||||
function apply_wifi_estimates(array $clients): array
|
||||
{
|
||||
foreach ($clients as &$client) {
|
||||
$band = (string)($client['band'] ?? '2.4G');
|
||||
$signalDbm = wifi_signal_dbm((string)($client['signal'] ?? ''));
|
||||
$txMbps = wifi_bitrate_mbps($client['tx_bitrate'] ?? null);
|
||||
$rxMbps = wifi_bitrate_mbps($client['rx_bitrate'] ?? null);
|
||||
|
||||
if (wifi_unknown($client['signal'] ?? null)) {
|
||||
$sourceRate = max($txMbps ?? 0.0, $rxMbps ?? 0.0);
|
||||
$signalDbm = $sourceRate > 0
|
||||
? estimate_wifi_signal_from_rate($band, $sourceRate)
|
||||
: ($band === '5G' ? -67 : -70);
|
||||
$client['signal'] = $signalDbm . ' dBm';
|
||||
$client['signal_source'] = $sourceRate > 0 ? 'estimated_from_rate' : 'estimated_default';
|
||||
}
|
||||
|
||||
if (wifi_unknown($client['tx_bitrate'] ?? null)) {
|
||||
$base = $rxMbps ?? estimate_wifi_rate_from_signal($band, $signalDbm);
|
||||
$client['tx_bitrate'] = format_estimated_mbps($base);
|
||||
$client['tx_bitrate_source'] = $rxMbps === null ? 'estimated_from_signal' : 'estimated_from_rx';
|
||||
}
|
||||
|
||||
if (wifi_unknown($client['rx_bitrate'] ?? null)) {
|
||||
$base = $txMbps ?? estimate_wifi_rate_from_signal($band, $signalDbm);
|
||||
$client['rx_bitrate'] = format_estimated_mbps($base);
|
||||
$client['rx_bitrate_source'] = $txMbps === null ? 'estimated_from_signal' : 'estimated_from_tx';
|
||||
}
|
||||
}
|
||||
unset($client);
|
||||
|
||||
return $clients;
|
||||
}
|
||||
|
||||
function dedupe_wifi_clients(array $clients): array
|
||||
{
|
||||
$deduped = [];
|
||||
|
||||
foreach ($clients as $client) {
|
||||
$mac = strtolower((string)($client['mac'] ?? ''));
|
||||
$key = $mac !== '' && $mac !== 'n/a'
|
||||
? 'mac:' . $mac
|
||||
: 'row:' . ($client['band'] ?? '') . ':' . ($client['ip'] ?? '') . ':' . ($client['hostname'] ?? '');
|
||||
|
||||
if (!isset($deduped[$key])) {
|
||||
$deduped[$key] = $client;
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentInactive = wifi_time_ms((string)($deduped[$key]['inactive_time'] ?? ''));
|
||||
$nextInactive = wifi_time_ms((string)($client['inactive_time'] ?? ''));
|
||||
$currentSignal = wifi_signal_dbm((string)($deduped[$key]['signal'] ?? ''));
|
||||
$nextSignal = wifi_signal_dbm((string)($client['signal'] ?? ''));
|
||||
|
||||
if ($nextInactive < $currentInactive || ($nextInactive === $currentInactive && $nextSignal > $currentSignal)) {
|
||||
$deduped[$key] = $client;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($deduped);
|
||||
}
|
||||
|
||||
function wifi_connected_time_missing(mixed $value): bool
|
||||
{
|
||||
$text = strtoupper(trim((string)$value));
|
||||
return $text === '' || $text === 'N/A' || $text === '-';
|
||||
}
|
||||
|
||||
function wifi_valid_mac(string $mac): bool
|
||||
{
|
||||
return preg_match('/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/', strtolower($mac)) === 1;
|
||||
}
|
||||
|
||||
function prune_observed_wifi_sessions(PDO $pdo, array $visibleMacs): void
|
||||
{
|
||||
$visibleMacs = array_values(array_unique(array_filter(
|
||||
array_map(static fn($mac) => strtolower((string)$mac), $visibleMacs),
|
||||
static fn($mac) => wifi_valid_mac($mac)
|
||||
)));
|
||||
|
||||
if ($visibleMacs === []) {
|
||||
$pdo->exec("DELETE FROM wifi_observed_sessions WHERE band = '5G'");
|
||||
return;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($visibleMacs), '?'));
|
||||
$stmt = $pdo->prepare("
|
||||
DELETE FROM wifi_observed_sessions
|
||||
WHERE band = '5G'
|
||||
AND mac NOT IN ($placeholders)
|
||||
");
|
||||
$stmt->execute($visibleMacs);
|
||||
}
|
||||
|
||||
function observed_wifi_duration_seconds(array $clients): array
|
||||
{
|
||||
$targets = [];
|
||||
$visible5gMacs = [];
|
||||
|
||||
foreach ($clients as $client) {
|
||||
$band = (string)($client['band'] ?? '');
|
||||
$mac = strtolower((string)($client['mac'] ?? ''));
|
||||
|
||||
if ($band !== '5G' || !wifi_valid_mac($mac)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$visible5gMacs[] = $mac;
|
||||
|
||||
if (!wifi_connected_time_missing($client['connected_time'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targets[$mac] = [
|
||||
'mac' => $mac,
|
||||
'band' => $band,
|
||||
'iface' => (string)($client['iface'] ?? ''),
|
||||
'ip' => (string)($client['ip'] ?? ''),
|
||||
'hostname' => (string)($client['hostname'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
prune_observed_wifi_sessions($pdo, $visible5gMacs);
|
||||
|
||||
if ($targets === []) {
|
||||
$pdo->commit();
|
||||
return [];
|
||||
}
|
||||
|
||||
$upsert = $pdo->prepare("
|
||||
INSERT INTO wifi_observed_sessions
|
||||
(mac, band, iface, first_seen_at, last_seen_at, last_ip, hostname)
|
||||
VALUES
|
||||
(:mac, :band, :iface, NOW(3), NOW(3), :last_ip, :hostname)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
band = VALUES(band),
|
||||
iface = VALUES(iface),
|
||||
last_seen_at = NOW(3),
|
||||
last_ip = VALUES(last_ip),
|
||||
hostname = VALUES(hostname)
|
||||
");
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$upsert->execute([
|
||||
':mac' => $target['mac'],
|
||||
':band' => $target['band'],
|
||||
':iface' => $target['iface'],
|
||||
':last_ip' => $target['ip'] === 'N/A' ? null : $target['ip'],
|
||||
':hostname' => $target['hostname'] === 'N/A' ? null : $target['hostname'],
|
||||
]);
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($targets), '?'));
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
mac,
|
||||
GREATEST(0, TIMESTAMPDIFF(SECOND, first_seen_at, NOW(3))) AS observed_seconds
|
||||
FROM wifi_observed_sessions
|
||||
WHERE mac IN ($placeholders)
|
||||
");
|
||||
$stmt->execute(array_keys($targets));
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
$seconds = [];
|
||||
foreach ($rows as $row) {
|
||||
$seconds[strtolower((string)$row['mac'])] = (int)$row['observed_seconds'];
|
||||
}
|
||||
|
||||
return $seconds;
|
||||
} catch (Throwable) {
|
||||
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function apply_observed_wifi_connected_time(array $clients): array
|
||||
{
|
||||
$observedSeconds = observed_wifi_duration_seconds($clients);
|
||||
|
||||
if ($observedSeconds === []) {
|
||||
return $clients;
|
||||
}
|
||||
|
||||
foreach ($clients as &$client) {
|
||||
$mac = strtolower((string)($client['mac'] ?? ''));
|
||||
|
||||
if (($client['band'] ?? '') !== '5G'
|
||||
|| !wifi_connected_time_missing($client['connected_time'] ?? null)
|
||||
|| !array_key_exists($mac, $observedSeconds)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$client['connected_time'] = (string)$observedSeconds[$mac];
|
||||
$client['connected_time_source'] = 'observed';
|
||||
}
|
||||
unset($client);
|
||||
|
||||
return $clients;
|
||||
}
|
||||
|
||||
function wifi_data(): array
|
||||
{
|
||||
$leases = dnsmasq_leases();
|
||||
$aliases = wifi_client_aliases();
|
||||
$clients = [];
|
||||
|
||||
foreach (['wlan0' => '2.4G', 'wlan1' => '5G'] as $iface => $band) {
|
||||
$clients = array_merge(
|
||||
$clients,
|
||||
parse_live_wifi_rows($iface, $band, iw_station_dump($iface), $leases, $aliases)
|
||||
);
|
||||
}
|
||||
|
||||
$clients = dedupe_wifi_clients($clients);
|
||||
$clients = apply_wifi_estimates($clients);
|
||||
$clients = apply_observed_wifi_connected_time($clients);
|
||||
|
||||
return [
|
||||
'clients' => $clients,
|
||||
'count24' => count(array_filter($clients, fn($c) => $c['band'] === '2.4G')),
|
||||
'count5' => count(array_filter($clients, fn($c) => $c['band'] === '5G')),
|
||||
'leases' => array_values($leases),
|
||||
];
|
||||
}
|
||||
|
||||
function action_rows(int $limit = 80): array
|
||||
{
|
||||
$limit = max(1, min(300, $limit));
|
||||
@@ -2870,6 +3339,7 @@ function collect_snapshot(bool $applyFan = true): array
|
||||
'system' => $system,
|
||||
'battery' => $battery,
|
||||
|
||||
'wifi' => wifi_data(),
|
||||
'history' => $history,
|
||||
'processes' => $processes,
|
||||
'custom_services' => custom_systemd_services(),
|
||||
@@ -3018,6 +3488,60 @@ function control_api_dispatch(): void
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'wifi') {
|
||||
$verb = (string)($_POST['verb'] ?? '');
|
||||
$unit = (string)($_POST['unit'] ?? '');
|
||||
$allowedUnits = [
|
||||
'hostapd-24g.service',
|
||||
'hostapd-5g.service',
|
||||
'dnsmasq.service',
|
||||
];
|
||||
|
||||
if (
|
||||
!in_array($unit, $allowedUnits, true)
|
||||
|| !in_array($verb, ['restart', 'reload'], true)
|
||||
|| ($verb === 'restart' && !(bool)setting_value('security.allow_wifi_restart'))
|
||||
|| ($verb === 'reload' && !(bool)setting_value('security.allow_wifi_reload'))
|
||||
) {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'bad_wifi_request',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$result = sh(['/usr/bin/systemctl', $verb, $unit], true, (int)setting_value('security.wifi_command_timeout_seconds'));
|
||||
$out = $result['out'];
|
||||
|
||||
add_fan_action(
|
||||
'wifi_' . $verb,
|
||||
null,
|
||||
null,
|
||||
$unit . "\n" . mb_substr($out, 0, 1000),
|
||||
true
|
||||
);
|
||||
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'output' => $out,
|
||||
'data' => collect_snapshot(false),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'wifi_alias') {
|
||||
$alias = save_wifi_client_alias(
|
||||
(string)($_POST['mac'] ?? ''),
|
||||
(string)($_POST['hostname'] ?? '')
|
||||
);
|
||||
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'data' => [
|
||||
'alias' => $alias,
|
||||
'status' => collect_snapshot(false),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'collect') {
|
||||
json_out([
|
||||
'ok' => true,
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
fanSliderWrap: $('#fanSliderWrap'),
|
||||
fanSliderValue: $('#fanSliderValue'),
|
||||
fanModeOptions: $('#fanModeOptions'),
|
||||
wifi24: $('#wifi24'),
|
||||
wifi5: $('#wifi5'),
|
||||
wifiTable: $('#wifiTable'),
|
||||
signalTooltip: $('#signalTooltip'),
|
||||
statusHost: $('#statusHost'),
|
||||
statusLoad: $('#statusLoad'),
|
||||
statusUsers: $('#statusUsers'),
|
||||
@@ -80,6 +84,10 @@
|
||||
theme: 'dark',
|
||||
batteryTooltipOpen: false,
|
||||
batteryTooltipText: '',
|
||||
signalTooltipOpen: false,
|
||||
signalTooltipTarget: null,
|
||||
signalTooltipKey: '',
|
||||
signalTooltipText: '',
|
||||
scanScrollHoldUntil: 0,
|
||||
settingsPayload: null,
|
||||
settingsOpen: false,
|
||||
@@ -98,6 +106,9 @@
|
||||
fanStatus: 'Fan Status',
|
||||
cpuTemp: 'CPU Temp',
|
||||
fanRpm: 'Fan RPM',
|
||||
wifiStatus: 'WiFi Status',
|
||||
clients24: '2.4G Clients',
|
||||
clients5: '5G Clients',
|
||||
systemStatus: 'System Status',
|
||||
loadAvg: 'Load Avg',
|
||||
activeUsers: 'Active Users',
|
||||
@@ -138,6 +149,7 @@
|
||||
cpuWatt: 'CPU Watt',
|
||||
remaining: 'Remaining',
|
||||
batteryVoltage: 'Battery Voltage',
|
||||
wifiClients: 'WiFi Clients',
|
||||
deviceName: 'Device',
|
||||
address: 'Address',
|
||||
paired: 'Paired',
|
||||
@@ -148,8 +160,13 @@
|
||||
channel: 'Channel',
|
||||
security: 'Security',
|
||||
lastSeen: 'Last Seen',
|
||||
band: 'Band',
|
||||
hostname: 'Hostname',
|
||||
signal: 'Signal',
|
||||
txRate: 'TX Rate',
|
||||
rxRate: 'RX Rate',
|
||||
connected: 'Connected',
|
||||
inactive: 'Inactive',
|
||||
systemNotice: 'System Notice',
|
||||
processDetails: 'Process Details',
|
||||
diagnostics: 'Diagnostics',
|
||||
@@ -161,6 +178,9 @@
|
||||
serviceEnabled: 'enabled',
|
||||
servicePid: 'pid',
|
||||
serviceRestarts: 'restarts',
|
||||
wifiAliasTitle: 'WiFi Hostname',
|
||||
wifiAliasPrompt: 'Enter a name to keep for this MAC address.',
|
||||
wifiAliasSaved: 'WiFi hostname saved.',
|
||||
show: 'Show',
|
||||
hide: 'Hide',
|
||||
translateButton: 'Translate',
|
||||
@@ -178,6 +198,7 @@
|
||||
dialogInput: 'Input',
|
||||
dialogOk: 'OK',
|
||||
dialogCancel: 'Cancel',
|
||||
noWifiClients: 'No connected WiFi clients',
|
||||
noProcessActivity: 'No process activity',
|
||||
noCause: 'No candidate',
|
||||
recordedReason: 'Recorded reason',
|
||||
@@ -232,6 +253,9 @@
|
||||
fanStatus: '팬 상태',
|
||||
cpuTemp: 'CPU 온도',
|
||||
fanRpm: '팬 RPM',
|
||||
wifiStatus: 'WiFi 상태',
|
||||
clients24: '2.4G 접속',
|
||||
clients5: '5G 접속',
|
||||
systemStatus: '시스템 상태',
|
||||
loadAvg: '부하 평균',
|
||||
activeUsers: '활성 사용자',
|
||||
@@ -272,6 +296,7 @@
|
||||
cpuWatt: 'CPU 전력',
|
||||
remaining: '잔여 시간',
|
||||
batteryVoltage: '배터리 전압',
|
||||
wifiClients: 'WiFi 클라이언트',
|
||||
deviceName: '장치',
|
||||
address: '주소',
|
||||
paired: '페어링',
|
||||
@@ -282,8 +307,13 @@
|
||||
channel: '채널',
|
||||
security: '보안',
|
||||
lastSeen: '마지막 감지',
|
||||
band: '대역',
|
||||
hostname: '호스트명',
|
||||
signal: '신호',
|
||||
txRate: '송신 속도',
|
||||
rxRate: '수신 속도',
|
||||
connected: '연결 시간',
|
||||
inactive: '비활성',
|
||||
systemNotice: '시스템 알림',
|
||||
processDetails: '프로세스 상세',
|
||||
diagnostics: '진단',
|
||||
@@ -295,6 +325,9 @@
|
||||
serviceEnabled: '활성화',
|
||||
servicePid: 'PID',
|
||||
serviceRestarts: '재시작',
|
||||
wifiAliasTitle: 'WiFi 호스트명',
|
||||
wifiAliasPrompt: '이 MAC 주소에 계속 표시할 이름을 입력하세요.',
|
||||
wifiAliasSaved: 'WiFi 호스트명을 저장했습니다.',
|
||||
show: '보기',
|
||||
hide: '숨기기',
|
||||
translateButton: '번역',
|
||||
@@ -312,6 +345,7 @@
|
||||
dialogInput: '입력',
|
||||
dialogOk: '확인',
|
||||
dialogCancel: '취소',
|
||||
noWifiClients: '연결된 WiFi 클라이언트 없음',
|
||||
noProcessActivity: '프로세스 활동 없음',
|
||||
noCause: '원인 후보 없음',
|
||||
recordedReason: '기록된 이유',
|
||||
@@ -1041,11 +1075,103 @@
|
||||
els.batteryRuntimeTooltip.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
function signalTooltipTarget() {
|
||||
if (state.signalTooltipKey && typeof document.querySelector === 'function') {
|
||||
const selector = `[data-signal-key="${cssEscape(state.signalTooltipKey)}"]`;
|
||||
const fresh = document.querySelector(selector);
|
||||
if (fresh) {
|
||||
state.signalTooltipTarget = fresh;
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
return state.signalTooltipTarget?.isConnected ? state.signalTooltipTarget : null;
|
||||
}
|
||||
|
||||
function signalTooltipText(target = signalTooltipTarget()) {
|
||||
if (!target) return '-';
|
||||
const label = target.dataset.signalLabel || target.textContent || '-';
|
||||
const dbm = target.dataset.signalDbm || '-';
|
||||
return `${label}\n${dbm}`;
|
||||
}
|
||||
|
||||
function positionSignalTooltip() {
|
||||
if (!state.signalTooltipOpen || !els.signalTooltip) {
|
||||
return;
|
||||
}
|
||||
const target = signalTooltipTarget();
|
||||
if (!target) {
|
||||
hideSignalTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const tip = els.signalTooltip;
|
||||
const margin = 14;
|
||||
tip.style.left = '0px';
|
||||
tip.style.top = '0px';
|
||||
const tipRect = tip.getBoundingClientRect();
|
||||
let left = targetRect.left + (targetRect.width / 2) - (tipRect.width / 2);
|
||||
let top = targetRect.bottom + 10;
|
||||
|
||||
if (left + tipRect.width > window.innerWidth - margin) {
|
||||
left = window.innerWidth - tipRect.width - margin;
|
||||
}
|
||||
if (left < margin) {
|
||||
left = margin;
|
||||
}
|
||||
if (top + tipRect.height > window.innerHeight - margin) {
|
||||
top = targetRect.top - tipRect.height - 10;
|
||||
}
|
||||
if (top < margin) {
|
||||
top = margin;
|
||||
}
|
||||
|
||||
tip.style.left = `${Math.round(left)}px`;
|
||||
tip.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
|
||||
function updateSignalTooltip() {
|
||||
if (!els.signalTooltip) return;
|
||||
state.signalTooltipText = signalTooltipText();
|
||||
els.signalTooltip.textContent = state.signalTooltipText || '-';
|
||||
if (state.signalTooltipOpen) {
|
||||
requestAnimationFrame(positionSignalTooltip);
|
||||
}
|
||||
}
|
||||
|
||||
function showSignalTooltip(target) {
|
||||
if (!els.signalTooltip || !target || target.classList.contains('unknown')) return;
|
||||
state.signalTooltipOpen = true;
|
||||
state.signalTooltipTarget = target;
|
||||
state.signalTooltipKey = target.dataset.signalKey || '';
|
||||
els.signalTooltip.dataset.open = '1';
|
||||
els.signalTooltip.setAttribute('aria-hidden', 'false');
|
||||
updateSignalTooltip();
|
||||
}
|
||||
|
||||
function hideSignalTooltip() {
|
||||
if (!els.signalTooltip) return;
|
||||
state.signalTooltipOpen = false;
|
||||
state.signalTooltipTarget = null;
|
||||
state.signalTooltipKey = '';
|
||||
delete els.signalTooltip.dataset.open;
|
||||
els.signalTooltip.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (window.CSS?.escape) {
|
||||
return window.CSS.escape(value);
|
||||
}
|
||||
return String(value).replace(/["\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function renderTop(data) {
|
||||
setText(els.updated, data.generated_at || '-');
|
||||
setText(els.temp, Number(data.system?.temp_c || 0).toFixed(1) + '°C');
|
||||
setText(els.fanRpm, Number(data.fan?.rpm || 0).toLocaleString() + ' RPM');
|
||||
setText(els.fanPercent, Number(data.fan?.percent || 0).toFixed(1) + '%');
|
||||
setText(els.wifi24, data.wifi?.count24 ?? 0);
|
||||
setText(els.wifi5, data.wifi?.count5 ?? 0);
|
||||
state.latestFanPwm = Math.max(0, Math.min(255, Number(data.fan?.pwm || 0)));
|
||||
|
||||
if (!isFanEditing()) {
|
||||
@@ -1064,6 +1190,126 @@
|
||||
return `<td>${escapeHtml(v)}</td>`;
|
||||
}
|
||||
|
||||
function attr(v) {
|
||||
return escapeHtml(v).replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function wifiValueTitle(row, key) {
|
||||
const source = row?.[`${key}_source`];
|
||||
if (!source || source === 'iw' || source === 'lease') return '';
|
||||
if (source.startsWith('estimated_')) return '';
|
||||
const labels = {
|
||||
alias: 'DB 저장 이름',
|
||||
generated: 'MAC 기반 임시 이름',
|
||||
};
|
||||
return labels[source] || source;
|
||||
}
|
||||
|
||||
function wifiHostCell(row) {
|
||||
const title = wifiValueTitle(row, 'hostname') || t('wifiAliasPrompt');
|
||||
return `<td><button type="button" class="wifi-host-edit" data-mac="${attr(row.mac || '')}" data-hostname="${attr(row.hostname || '')}" title="${attr(title)}">${escapeHtml(row.hostname || '-')}</button></td>`;
|
||||
}
|
||||
|
||||
function wifiMetricCell(row, key) {
|
||||
if (key !== 'signal') {
|
||||
return `<td>${escapeHtml(row[key] || '-')}</td>`;
|
||||
}
|
||||
|
||||
const dbm = wifiSignalDbm(row[key]);
|
||||
return `<td>${signalBadgeHtml(dbm, row[key] || '', `client:${row.mac || row.ip || row.hostname || ''}`)}</td>`;
|
||||
}
|
||||
|
||||
function wifiSignalDbm(value) {
|
||||
const match = String(value ?? '').match(/-?\d+(?:\.\d+)?/);
|
||||
return match ? Number(match[0]) : null;
|
||||
}
|
||||
|
||||
function signalQuality(dbm) {
|
||||
if (dbm === null || !Number.isFinite(dbm)) return 'unknown';
|
||||
if (dbm >= -45) return { className: 'best', label: '최상' };
|
||||
if (dbm >= -55) return { className: 'excellent', label: '매우 좋음' };
|
||||
if (dbm >= -65) return { className: 'good', label: '좋음' };
|
||||
if (dbm >= -72) return { className: 'fine', label: '양호' };
|
||||
if (dbm >= -80) return { className: 'normal', label: '보통' };
|
||||
if (dbm >= -88) return { className: 'weak', label: '약함' };
|
||||
return { className: 'bad', label: '매우 약함' };
|
||||
}
|
||||
|
||||
function signalDbmText(dbm, raw = '') {
|
||||
if (dbm !== null && Number.isFinite(dbm)) {
|
||||
return `${Number(dbm).toFixed(Number.isInteger(dbm) ? 0 : 1)} dBm`;
|
||||
}
|
||||
return String(raw || '').trim() || '-';
|
||||
}
|
||||
|
||||
function signalBadgeHtml(dbm, raw = '', key = '') {
|
||||
const quality = signalQuality(dbm);
|
||||
if (typeof quality === 'string') {
|
||||
return `<span class="wifi-signal unknown">-</span>`;
|
||||
}
|
||||
|
||||
const keyAttr = key ? ` data-signal-key="${attr(key)}"` : '';
|
||||
return `<span class="wifi-signal ${quality.className}" tabindex="0" role="button" aria-describedby="signalTooltip" data-signal-dbm="${attr(signalDbmText(dbm, raw))}" data-signal-label="${attr(quality.label)}"${keyAttr}>${escapeHtml(quality.label)}</span>`;
|
||||
}
|
||||
|
||||
async function editWifiHostname(mac, currentName) {
|
||||
if (!mac) return;
|
||||
const value = await window.customPrompt(`${t('wifiAliasPrompt')}\n${mac}`, {
|
||||
title: t('wifiAliasTitle'),
|
||||
defaultValue: currentName || '',
|
||||
okText: t('dialogOk'),
|
||||
});
|
||||
if (value === null) return;
|
||||
|
||||
const hostname = String(value || '').trim();
|
||||
if (!hostname) return;
|
||||
|
||||
try {
|
||||
const data = await api('wifi_alias', { mac, hostname });
|
||||
notice(t('wifiAliasSaved'), 'success');
|
||||
render(data.status || await api('status'));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
notice(e.message || 'WiFi alias failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function parseWifiDurationSeconds(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (text === '' || text.toUpperCase() === 'N/A') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\d+(?:\.\d+)?$/.test(text)) {
|
||||
return Number(text);
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
let matched = false;
|
||||
const pattern = /(\d+(?:\.\d+)?)\s*(days?|d|hours?|hrs?|h|minutes?|mins?|min|m|seconds?|secs?|sec|s|milliseconds?|msecs?|msec|ms)\b/gi;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
const amount = Number(match[1]);
|
||||
const unit = match[2].toLowerCase();
|
||||
matched = true;
|
||||
|
||||
if (unit === 'd' || unit.startsWith('day')) {
|
||||
total += amount * 86400;
|
||||
} else if (unit === 'h' || unit.startsWith('hour') || unit.startsWith('hr')) {
|
||||
total += amount * 3600;
|
||||
} else if (unit === 'm' || unit.startsWith('min')) {
|
||||
total += amount * 60;
|
||||
} else if (unit === 'ms' || unit.startsWith('msec') || unit.startsWith('millisecond')) {
|
||||
total += amount / 1000;
|
||||
} else {
|
||||
total += amount;
|
||||
}
|
||||
}
|
||||
|
||||
return matched ? total : null;
|
||||
}
|
||||
|
||||
function formatDhms(seconds) {
|
||||
if (seconds === null || seconds === undefined || !Number.isFinite(Number(seconds))) {
|
||||
return null;
|
||||
@@ -1091,6 +1337,10 @@
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function wifiConnectedTime(value) {
|
||||
return formatDhms(parseWifiDurationSeconds(value)) || value;
|
||||
}
|
||||
|
||||
function eventStateText(eventState, activeKey, normalKey, recoveringKey, repeatedKey, seenKey, notSeenKey) {
|
||||
if (!eventState || eventState.available === false) {
|
||||
return t('na');
|
||||
@@ -1145,6 +1395,31 @@
|
||||
return formatted ? `${formatted} ${t('ago')}` : '-';
|
||||
}
|
||||
|
||||
function renderWifi(data) {
|
||||
const rows = data.wifi?.clients || [];
|
||||
if (!els.wifiTable) return;
|
||||
const tableWrap = els.wifiTable.closest('.table-wrap');
|
||||
const scrollLeft = tableWrap ? tableWrap.scrollLeft : 0;
|
||||
els.wifiTable.innerHTML = rows.length
|
||||
? rows.map(row => `
|
||||
<tr>
|
||||
${td(row.band)}
|
||||
${wifiHostCell(row)}
|
||||
${td(row.ip)}
|
||||
${td(row.mac)}
|
||||
${wifiMetricCell(row, 'signal')}
|
||||
${wifiMetricCell(row, 'tx_bitrate')}
|
||||
${wifiMetricCell(row, 'rx_bitrate')}
|
||||
${td(wifiConnectedTime(row.connected_time))}
|
||||
${td(row.inactive_time)}
|
||||
</tr>
|
||||
`).join('')
|
||||
: `<tr><td colspan="9">${escapeHtml(t('noWifiClients'))}</td></tr>`;
|
||||
if (tableWrap) {
|
||||
tableWrap.scrollLeft = scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSystemStatus(data) {
|
||||
const system = data.system || {};
|
||||
const disk = system.disk || {};
|
||||
@@ -1712,7 +1987,9 @@
|
||||
}
|
||||
renderTop(data);
|
||||
renderSystemStatus(data);
|
||||
renderWifi(data);
|
||||
renderCharts(data);
|
||||
updateSignalTooltip();
|
||||
|
||||
state.fanCauseTick = (state.fanCauseTick + 1) % 2;
|
||||
|
||||
@@ -1937,12 +2214,46 @@
|
||||
els.dmesgToggle?.addEventListener('click', () => {
|
||||
setDmesgOpen(!state.dmesgOpen);
|
||||
});
|
||||
els.wifiTable?.addEventListener('click', event => {
|
||||
const button = event.target?.closest?.('.wifi-host-edit');
|
||||
if (!button) return;
|
||||
editWifiHostname(button.dataset.mac || '', button.dataset.hostname || '');
|
||||
});
|
||||
els.statusBatteryRemaining?.addEventListener('mouseenter', showBatteryTooltip);
|
||||
els.statusBatteryRemaining?.addEventListener('mouseleave', hideBatteryTooltip);
|
||||
els.statusBatteryRemaining?.addEventListener('focus', showBatteryTooltip);
|
||||
els.statusBatteryRemaining?.addEventListener('blur', hideBatteryTooltip);
|
||||
window.addEventListener('resize', positionBatteryTooltip);
|
||||
window.addEventListener('scroll', positionBatteryTooltip, true);
|
||||
document.addEventListener('mouseover', event => {
|
||||
const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]');
|
||||
if (target) showSignalTooltip(target);
|
||||
});
|
||||
document.addEventListener('mouseout', event => {
|
||||
const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]');
|
||||
if (!target) return;
|
||||
const related = event.relatedTarget;
|
||||
if (related && target.contains(related)) return;
|
||||
hideSignalTooltip();
|
||||
});
|
||||
document.addEventListener('focusin', event => {
|
||||
const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]');
|
||||
if (target) showSignalTooltip(target);
|
||||
});
|
||||
document.addEventListener('focusout', event => {
|
||||
const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]');
|
||||
if (target) hideSignalTooltip();
|
||||
});
|
||||
document.addEventListener('pointerdown', event => {
|
||||
const target = event.target?.closest?.('.wifi-signal[data-signal-dbm]');
|
||||
if (target) {
|
||||
showSignalTooltip(target);
|
||||
return;
|
||||
}
|
||||
hideSignalTooltip();
|
||||
});
|
||||
window.addEventListener('resize', positionSignalTooltip);
|
||||
window.addEventListener('scroll', positionSignalTooltip, true);
|
||||
[els.secondaryChartDetails, els.processDetails, els.diagnosticDetails].forEach(node => {
|
||||
node?.addEventListener('toggle', resizeChartsSoon);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Seoul Control Center",
|
||||
"short_name": "Seoul Control",
|
||||
"description": "Fan and system control panel",
|
||||
"description": "Fan and WiFi control panel",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
|
||||
+35
-3
@@ -127,7 +127,9 @@ html[data-theme="light"] .btn.secondary{background:#d9e2ef;color:#172033}html[da
|
||||
.select:focus,.slider:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}
|
||||
.btn:disabled{opacity:.55;cursor:not-allowed}
|
||||
.status-list{display:grid;gap:10px}.status-row{display:grid;grid-template-columns:112px minmax(0,1fr);gap:12px;align-items:baseline;padding:10px 12px;background:var(--card2);border-radius:14px}.status-key{color:var(--sub);font-size:13px}.status-value{overflow:visible;white-space:normal;word-break:break-word;font-variant-numeric:tabular-nums}
|
||||
.table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.table-wrap.compact{border-radius:12px}
|
||||
.table-wrap{max-width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;border-radius:16px;border:1px solid var(--line)}.table-wrap.compact{border-radius:12px}.wifi-table{width:100%;min-width:1080px;table-layout:fixed;border-collapse:collapse}.wifi-table th,.wifi-table td{padding:11px 13px;border-bottom:1px solid rgba(128,145,170,.18);text-align:left;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-table th{position:sticky;top:0;background:var(--table-head);z-index:1;font-weight:500}.wifi-table tr:hover{background:rgba(128,145,170,.09)}.wifi-table .col-band{width:72px}.wifi-table .col-host{width:240px}.wifi-table .col-ip{width:132px}.wifi-table .col-mac{width:170px}.wifi-table .col-signal{width:100px}.wifi-table .col-rate{width:120px}.wifi-table .col-time{width:130px}
|
||||
.wifi-host-edit{appearance:none;border:0;background:transparent;color:inherit;font:inherit;max-width:100%;padding:0;margin:0;cursor:pointer;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px}.wifi-host-edit:hover{color:var(--blue)}
|
||||
.wifi-signal{font-weight:700;font-variant-numeric:tabular-nums;cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:3px}.wifi-signal.best{color:#16a34a}.wifi-signal.excellent{color:#22c55e}.wifi-signal.good{color:#38bdf8}.wifi-signal.fine{color:#0ea5e9}.wifi-signal.normal{color:#f59e0b}.wifi-signal.weak{color:#fb7185}.wifi-signal.bad{color:#ef4444}.wifi-signal.unknown{color:var(--sub);text-decoration:none;cursor:default}
|
||||
.chart-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.chart-box{height:280px;background:var(--input);border:1px solid rgba(84,101,128,.58);border-radius:16px;padding:12px 12px 18px}.chart-box h3{margin:0 0 8px;color:var(--sub);font-size:13px}.chart-canvas{position:relative;height:calc(100% - 27px);min-height:0}.chart-canvas canvas{width:100%!important;height:100%!important}.chart-secondary{margin-top:14px}
|
||||
.details-panel{border:1px solid rgba(84,101,128,.52);border-radius:16px;background:rgba(128,145,170,.06);padding:0;margin-top:14px;overflow:hidden}.details-panel>summary{cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:48px;padding:0 14px;color:var(--text);font-weight:500}.details-panel>summary::-webkit-details-marker{display:none}.details-panel>summary::after{content:"+";display:grid;place-items:center;width:24px;height:24px;border:1px solid var(--line);border-radius:999px;color:var(--sub);font-weight:700}.details-panel[open]>summary::after{content:"-"}.details-body{padding:0 14px 14px}.section-note{margin:0;color:var(--sub);font-size:12px}
|
||||
.resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px}.resource-head h2{margin:0}.baseline-pill{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.spike-log-box{margin-top:16px}.spike-log-box h3{margin:0 0 9px;color:var(--text);font-size:14px;font-weight:500}.spike-log-list{display:grid;gap:8px;max-height:260px;overflow:auto;padding-right:4px}.spike-log-list.compact{max-height:180px}.spike-log-item{display:grid;gap:4px;padding:10px 12px;border:1px solid var(--notice-border);background:var(--notice-bg);border-radius:12px}.spike-log-item.latest{border:1px solid var(--notice-border-strong);background:var(--notice-bg-strong);box-shadow:0 0 0 1px var(--notice-border) inset;}.spike-log-item.alert{border-color:rgba(255,95,87,.58);background:rgba(150,38,38,.18)}.spike-log-item.alert strong{color:#ffb4ad}.spike-log-item strong{font-size:13px;font-weight:500;color:var(--notice-title)}.spike-log-item span{font-size:12px;color:var(--notice-copy);font-variant-numeric:tabular-nums}.spike-log-empty{padding:10px 12px;border-radius:12px;background:var(--card2);color:var(--sub);font-size:13px}.service-list{display:grid;gap:10px;max-height:520px;overflow:auto;padding-right:4px}.service-item{border:1px solid var(--line);border-radius:12px;background:var(--card2);padding:12px;display:grid;gap:9px}.service-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.service-title{display:grid;gap:3px;min-width:0}.service-name{font-size:14px;color:var(--text);font-weight:600;word-break:break-word}.service-desc,.service-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums;word-break:break-word}.service-state{border:1px solid var(--line);border-radius:999px;padding:4px 9px;font-size:12px;color:var(--sub);white-space:nowrap}.service-state.active{color:#bbf7d0;border-color:rgba(34,197,94,.35);background:rgba(22,163,74,.14)}.service-state.failed{color:#fecaca;border-color:rgba(239,68,68,.4);background:rgba(185,28,28,.16)}.service-log{margin:0;max-height:160px;overflow:auto;border:1px solid var(--line);border-radius:10px;background:var(--code-bg);color:var(--mono-text);padding:10px;font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmesg-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.dmesg-log{margin:10px 0 0;max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--code-bg);color:var(--mono-text);padding:12px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-log[hidden]{display:none}
|
||||
@@ -138,7 +140,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
|
||||
.dialog-layer{position:fixed;inset:0;display:none;align-items:center;justify-content:center;padding:22px;background:rgba(4,7,12,.68);backdrop-filter:blur(8px);z-index:120}.dialog-layer[data-open="1"]{display:flex}.dialog-box{width:min(430px,100%);border:1px solid rgba(84,101,128,.78);border-radius:20px;background:var(--card);box-shadow:0 24px 70px rgba(0,0,0,.42);padding:20px}.dialog-box h3{margin:0 0 8px;font-size:19px;font-weight:500}.dialog-message{margin:0 0 16px;color:var(--sub);line-height:1.5;white-space:pre-wrap}.dialog-input{width:100%;height:48px;margin-bottom:14px;border-radius:14px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 14px;outline:none}.dialog-input:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px}.dialog-actions .btn{min-width:84px}.dialog-actions .btn.red{background:#c62828}
|
||||
.settings-layer{align-items:stretch}.settings-box{width:min(980px,100%);max-height:calc(100vh - 44px);display:grid;grid-template-rows:auto minmax(0,1fr) auto;padding:0;overflow:hidden}.settings-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:18px 20px;border-bottom:1px solid var(--line)}.settings-sub{margin:0;color:var(--sub);font-size:13px}.settings-body{overflow:auto;padding:16px 20px;display:grid;gap:14px}.settings-group{border:1px solid var(--line);border-radius:16px;background:var(--card2);padding:14px}.settings-group h4{margin:0 0 12px;font-size:15px}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.settings-field{display:grid;gap:7px;min-width:0}.settings-label{display:flex;align-items:center;justify-content:space-between;gap:8px;color:var(--sub);font-size:13px}.settings-label span:last-child{white-space:nowrap;color:var(--sub)}.settings-control{display:flex;align-items:center;gap:9px}.settings-control input,.settings-select{width:100%;height:42px;border-radius:12px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 12px;outline:none}.settings-control input:focus,.settings-select:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.settings-toggle{display:grid;grid-template-columns:1fr 1fr;align-items:center;width:100%;height:42px;border:1px solid var(--line);border-radius:12px;background:var(--input);padding:4px;cursor:pointer;gap:4px}.settings-toggle input{position:absolute;opacity:0;pointer-events:none}.settings-toggle span{display:grid;place-items:center;min-width:0;height:32px;border-radius:10px;color:var(--sub);font-weight:700;font-size:12px;transition:background .12s ease,color .12s ease}.settings-toggle[data-checked="1"] .settings-toggle-on,.settings-toggle[data-checked="0"] .settings-toggle-off{background:var(--blue);color:#fff}.settings-toggle[data-checked="0"] .settings-toggle-off{background:#64748b}.settings-default{font-size:12px;color:var(--sub);white-space:nowrap}.settings-actions{display:flex;justify-content:flex-end;gap:10px;padding:14px 20px;border-top:1px solid var(--line)}
|
||||
@media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column}.topbar-right{width:100%}.topbar-right .btn{flex:1}.stat-grid{grid-template-columns:1fr}.resource-head{align-items:flex-start;flex-direction:column}.baseline-pill{text-align:left;white-space:normal}}
|
||||
@media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid,.wifi-scan-grid{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column}.topbar-right{width:100%}.topbar-right .btn{flex:1}.stat-grid{grid-template-columns:1fr}.resource-head{align-items:flex-start;flex-direction:column}.baseline-pill{text-align:left;white-space:normal}}
|
||||
@media(max-width:720px){.settings-grid{grid-template-columns:1fr}.settings-head{align-items:stretch;flex-direction:column}.settings-actions{display:grid;grid-template-columns:1fr 1fr}.settings-box{max-height:calc(100vh - 24px)}}
|
||||
</style>
|
||||
</head>
|
||||
@@ -147,7 +149,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
|
||||
<div class="login-wrap">
|
||||
<form method="post" class="login-box" autocomplete="off">
|
||||
<h1><?= e(APP_NAME) ?></h1>
|
||||
<p>Fan and system control panel</p>
|
||||
<p>Fan and WiFi control panel</p>
|
||||
<input type="password" name="login_password" class="input" placeholder="Password" autofocus>
|
||||
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
|
||||
<label class="remember-row">
|
||||
@@ -196,6 +198,14 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 data-i18n="wifiStatus">WiFi Status</h2>
|
||||
<div class="stat-grid">
|
||||
<div class="stat"><div class="stat-label" data-i18n="clients24">2.4G Clients</div><div class="stat-value" id="wifi24">-</div></div>
|
||||
<div class="stat"><div class="stat-label" data-i18n="clients5">5G Clients</div><div class="stat-value" id="wifi5">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 data-i18n="systemStatus">System Status</h2>
|
||||
<div class="status-list">
|
||||
@@ -237,6 +247,27 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 data-i18n="wifiClients">WiFi Clients</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="wifi-table">
|
||||
<colgroup>
|
||||
<col class="col-band">
|
||||
<col class="col-host">
|
||||
<col class="col-ip">
|
||||
<col class="col-mac">
|
||||
<col class="col-signal">
|
||||
<col class="col-rate">
|
||||
<col class="col-rate">
|
||||
<col class="col-time">
|
||||
<col class="col-time">
|
||||
</colgroup>
|
||||
<thead><tr><th data-i18n="band">Band</th><th data-i18n="hostname">Hostname</th><th>IP</th><th>MAC</th><th data-i18n="signal">Signal</th><th data-i18n="txRate">TX Rate</th><th data-i18n="rxRate">RX Rate</th><th data-i18n="connected">Connected</th><th data-i18n="inactive">Inactive</th></tr></thead>
|
||||
<tbody id="wifiTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -304,6 +335,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}
|
||||
</div>
|
||||
<div id="notice" class="notice"></div>
|
||||
<div id="batteryRuntimeTooltip" class="runtime-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||
<div id="signalTooltip" class="runtime-tooltip signal-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||
<div id="settingsDialog" class="dialog-layer settings-layer" aria-hidden="true">
|
||||
<div class="dialog-box settings-box" role="dialog" aria-modal="true" aria-labelledby="settingsDialogTitle">
|
||||
<div class="settings-head">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Seoul Control Center",
|
||||
"short_name": "Seoul Control",
|
||||
"description": "Fan and system control panel",
|
||||
"description": "Fan and WiFi control panel",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
|
||||
Reference in New Issue
Block a user