Initial control project import
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
vendor/
|
||||
composer.phar
|
||||
.agents/
|
||||
.codex/
|
||||
.env
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sql
|
||||
cache/
|
||||
tmp/
|
||||
secrets/
|
||||
secret/
|
||||
@@ -0,0 +1,40 @@
|
||||
# Control
|
||||
|
||||
PHP based control panel for fan control, system monitoring, WiFi control, WakeLock, and Web Push notifications.
|
||||
|
||||
## Main Features
|
||||
|
||||
- Authenticated dashboard for fan, sensor, notice, WiFi, WakeLock, and Push subscription status.
|
||||
- Background collection and policy application for fan state.
|
||||
- WebSocket status updates with HTTP fallback.
|
||||
- Web Push subscription registration, recovery, and notice delivery.
|
||||
|
||||
## Main APIs
|
||||
|
||||
- `public/api.php?action=status`
|
||||
- `public/api.php?action=collect`
|
||||
- `public/api.php?action=fan`
|
||||
- `public/api.php?action=wifi`
|
||||
- `public/api.php?action=push_devices`
|
||||
|
||||
## Structure
|
||||
|
||||
- `public/index.php`: management UI.
|
||||
- `public/api.php`: status and control API.
|
||||
- `config/config.php`: DB, auth, CSRF, push, and shell helpers.
|
||||
- `config/vapid.php`: VAPID bridge loaded from external secret config.
|
||||
- `apply_policy.php`: CLI/cron fan policy application.
|
||||
- `bin/control_ws.php`: WebSocket server.
|
||||
|
||||
## Secrets
|
||||
|
||||
Runtime secrets are loaded from `/home/seo/secret/control.php`. Do not commit that file.
|
||||
|
||||
Expected values include the app password, DB settings, and VAPID keys.
|
||||
|
||||
## Operations
|
||||
|
||||
- Keep sensor collection cadence aligned with DB growth.
|
||||
- Confirm fan sysfs paths after hardware or OS changes.
|
||||
- Keep VAPID keys and app password outside the repository.
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
|
||||
function local_fan_paths(): array
|
||||
{
|
||||
$candidates = glob('/sys/devices/platform/cooling_fan/hwmon/hwmon*/pwm1') ?: [];
|
||||
$candidates = array_merge(
|
||||
$candidates,
|
||||
glob('/sys/class/hwmon/hwmon*/pwm1') ?: []
|
||||
);
|
||||
|
||||
foreach ($candidates as $pwm) {
|
||||
$dir = dirname($pwm);
|
||||
|
||||
return [
|
||||
'base' => $dir,
|
||||
'pwm' => $pwm,
|
||||
'enable' => $dir . '/pwm1_enable',
|
||||
'rpm' => $dir . '/fan1_input',
|
||||
'name' => first_readable([$dir . '/name']) ?: 'cooling_fan',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'base' => '',
|
||||
'pwm' => '',
|
||||
'enable' => '',
|
||||
'rpm' => '',
|
||||
'name' => '',
|
||||
];
|
||||
}
|
||||
|
||||
function local_temp(): float
|
||||
{
|
||||
$raw = first_readable([
|
||||
'/sys/class/thermal/thermal_zone0/temp',
|
||||
'/sys/devices/virtual/thermal/thermal_zone0/temp',
|
||||
]);
|
||||
|
||||
if ($raw !== '' && is_numeric($raw)) {
|
||||
return round(((float)$raw) / 1000, 2);
|
||||
}
|
||||
|
||||
$vc = sh(['/usr/bin/vcgencmd', 'measure_temp'], false, 3)['out'];
|
||||
|
||||
if (preg_match('/([0-9.]+)/', $vc, $m)) {
|
||||
return round((float)$m[1], 2);
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
function local_target(float $temp): int
|
||||
{
|
||||
if ($temp >= 80) {
|
||||
return 255;
|
||||
}
|
||||
|
||||
if ($temp <= 50) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$ratio = ($temp - 50) / 30;
|
||||
|
||||
return max(0, min(255, (int)round($ratio * 255)));
|
||||
}
|
||||
|
||||
function local_ramped_pwm(int $current, int $desired, float $temp): int
|
||||
{
|
||||
if ($temp >= 80) {
|
||||
return $desired;
|
||||
}
|
||||
|
||||
if ($desired > $current) {
|
||||
return min($desired, $current + 2);
|
||||
}
|
||||
|
||||
if ($desired < $current) {
|
||||
return max($desired, $current - 2);
|
||||
}
|
||||
|
||||
return $desired;
|
||||
}
|
||||
|
||||
function local_mem(): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach (@file('/proc/meminfo', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
||||
if (preg_match('/^([^:]+):\s+(\d+)/', $line, $m)) {
|
||||
$rows[$m[1]] = (int)$m[2];
|
||||
}
|
||||
}
|
||||
|
||||
$total = (int)($rows['MemTotal'] ?? 0);
|
||||
$available = (int)($rows['MemAvailable'] ?? 0);
|
||||
$used = max(0, $total - $available);
|
||||
|
||||
return [
|
||||
'total_mb' => (int)round($total / 1024),
|
||||
'used_mb' => (int)round($used / 1024),
|
||||
'free_mb' => (int)round($available / 1024),
|
||||
];
|
||||
}
|
||||
|
||||
function local_disk(string $path = '/'): array
|
||||
{
|
||||
$total = @disk_total_space($path) ?: 0;
|
||||
$free = @disk_free_space($path) ?: 0;
|
||||
$used = max(0, $total - $free);
|
||||
|
||||
return [
|
||||
'total_kb' => (int)round($total / 1024),
|
||||
'used_kb' => (int)round($used / 1024),
|
||||
'free_kb' => (int)round($free / 1024),
|
||||
];
|
||||
}
|
||||
|
||||
function local_uptime_seconds(): int
|
||||
{
|
||||
$raw = trim((string)@file_get_contents('/proc/uptime'));
|
||||
|
||||
if ($raw === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)floor((float)explode(' ', $raw)[0]);
|
||||
}
|
||||
|
||||
function read_int_file(string $file): int
|
||||
{
|
||||
if ($file === '' || !is_readable($file)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$raw = trim((string)@file_get_contents($file));
|
||||
|
||||
return is_numeric($raw) ? (int)$raw : 0;
|
||||
}
|
||||
|
||||
function write_sys_value(string $path, int $value): bool
|
||||
{
|
||||
if ($path === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return @file_put_contents($path, $value . "\n", LOCK_EX) !== false;
|
||||
}
|
||||
|
||||
function dmesg_log(string $message): void
|
||||
{
|
||||
$line = '<6>fanpanel: ' . $message . "\n";
|
||||
@file_put_contents('/dev/kmsg', $line, FILE_APPEND);
|
||||
}
|
||||
|
||||
function dmesg_log_throttled(string $key, string $message, int $seconds = 30): void
|
||||
{
|
||||
$file = '/tmp/fanpanel_dmesg_' . preg_replace('/[^a-zA-Z0-9_.-]/', '_', $key) . '.last';
|
||||
$now = time();
|
||||
$last = is_file($file) ? (int)trim((string)@file_get_contents($file)) : 0;
|
||||
|
||||
if (($now - $last) < $seconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
@file_put_contents($file, (string)$now, LOCK_EX);
|
||||
dmesg_log($message);
|
||||
}
|
||||
|
||||
try {
|
||||
$state = get_control_state();
|
||||
|
||||
$mode = (string)($state['mode'] ?? 'auto');
|
||||
$manualPwm = max(0, min(255, (int)($state['manual_pwm'] ?? 120)));
|
||||
|
||||
if (!in_array($mode, ['auto', 'manual', 'off'], true)) {
|
||||
$mode = 'auto';
|
||||
}
|
||||
|
||||
$paths = local_fan_paths();
|
||||
$temp = local_temp();
|
||||
|
||||
$currentPwm = read_int_file($paths['pwm']);
|
||||
$desired = match ($mode) {
|
||||
'manual' => $manualPwm,
|
||||
'off' => 0,
|
||||
default => local_target($temp),
|
||||
};
|
||||
$target = $mode === 'auto'
|
||||
? local_ramped_pwm($currentPwm, $desired, $temp)
|
||||
: $desired;
|
||||
|
||||
$enableOk = write_sys_value($paths['enable'], $mode === 'off' ? 0 : 1);
|
||||
$pwmOk = write_sys_value($paths['pwm'], $target);
|
||||
|
||||
usleep(80000);
|
||||
|
||||
$actualPwm = read_int_file($paths['pwm']);
|
||||
|
||||
$rpm = read_int_file($paths['rpm']);
|
||||
|
||||
$load = sys_getloadavg() ?: [0, 0, 0];
|
||||
$mem = local_mem();
|
||||
$disk = local_disk('/');
|
||||
$cpuPower = cpu_power_status();
|
||||
$battery = battery_status();
|
||||
|
||||
$ok = $enableOk && $pwmOk;
|
||||
|
||||
add_sensor_log([
|
||||
'cpu_temp_c' => $temp,
|
||||
'fan_rpm' => $rpm,
|
||||
'fan_efficiency' => fan_efficiency($rpm, $temp, $cpuPower['watts'] ?? null),
|
||||
'rp1_temp_c' => rp1_temp_c(),
|
||||
'cpu_voltage' => $cpuPower['voltage'],
|
||||
'cpu_watts' => $cpuPower['watts'],
|
||||
'battery_voltage' => $battery['voltage'],
|
||||
'battery_percent' => $battery['percent'],
|
||||
|
||||
'pwm_value' => $actualPwm,
|
||||
'pwm_percent' => round($actualPwm / 255 * 100, 2),
|
||||
'pwm_mode' => $mode,
|
||||
|
||||
'cpu_load_1' => round((float)$load[0], 2),
|
||||
'cpu_load_5' => round((float)$load[1], 2),
|
||||
'cpu_load_15' => round((float)$load[2], 2),
|
||||
|
||||
'mem_total_mb' => $mem['total_mb'],
|
||||
'mem_used_mb' => $mem['used_mb'],
|
||||
'mem_free_mb' => $mem['free_mb'],
|
||||
|
||||
'disk_total_kb' => $disk['total_kb'],
|
||||
'disk_used_kb' => $disk['used_kb'],
|
||||
'disk_free_kb' => $disk['free_kb'],
|
||||
|
||||
'uptime_seconds' => local_uptime_seconds(),
|
||||
|
||||
'hostname' => gethostname() ?: null,
|
||||
]);
|
||||
|
||||
send_battery_low_push_if_needed($battery);
|
||||
|
||||
if (!$ok) {
|
||||
add_fan_action(
|
||||
'apply_policy_failed',
|
||||
$mode,
|
||||
$target,
|
||||
'enable=' . ($enableOk ? 'ok' : 'fail')
|
||||
. ', pwm=' . ($pwmOk ? 'ok' : 'fail')
|
||||
. ', path=' . ($paths['pwm'] ?: 'N/A'),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
exit($ok ? 0 : 1);
|
||||
} catch (Throwable $e) {
|
||||
try {
|
||||
add_fan_action(
|
||||
'apply_policy_exception',
|
||||
null,
|
||||
null,
|
||||
$e->getMessage(),
|
||||
false
|
||||
);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
fwrite(STDERR, $e->getMessage() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Ratchet\ConnectionInterface;
|
||||
use Ratchet\MessageComponentInterface;
|
||||
use Ratchet\Http\HttpServer;
|
||||
use Ratchet\Server\IoServer;
|
||||
use Ratchet\WebSocket\WsServer;
|
||||
use React\EventLoop\Loop;
|
||||
use React\Socket\SocketServer;
|
||||
|
||||
define('CONTROL_API_LIBRARY', true);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../public/api.php';
|
||||
|
||||
final class ControlWebSocket implements MessageComponentInterface
|
||||
{
|
||||
/** @var SplObjectStorage<ConnectionInterface, array{dmesg: bool, ip: string}> */
|
||||
private SplObjectStorage $clients;
|
||||
|
||||
private bool $statusBusy = false;
|
||||
private bool $dmesgBusy = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->clients = new SplObjectStorage();
|
||||
}
|
||||
|
||||
public function onOpen(ConnectionInterface $conn): void
|
||||
{
|
||||
$request = $conn->httpRequest ?? null;
|
||||
$cookies = $this->cookiesFromHeader($request?->getHeaderLine('Cookie') ?? '');
|
||||
$ip = $request?->getHeaderLine('X-Real-IP')
|
||||
?: $request?->getHeaderLine('X-Forwarded-For')
|
||||
?: '127.0.0.1';
|
||||
$ua = $request?->getHeaderLine('User-Agent') ?? '';
|
||||
|
||||
if (!$this->signedIn($cookies, $ip, $ua)) {
|
||||
$conn->send($this->json([
|
||||
'type' => 'error',
|
||||
'message' => 'login_required',
|
||||
]));
|
||||
$conn->close();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->clients[$conn] = [
|
||||
'dmesg' => false,
|
||||
'ip' => $ip,
|
||||
];
|
||||
$this->sendStatus($conn);
|
||||
}
|
||||
|
||||
public function onMessage(ConnectionInterface $from, $msg): void
|
||||
{
|
||||
$data = json_decode((string)$msg, true);
|
||||
if (!is_array($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state = $this->clients[$from] ?? [
|
||||
'dmesg' => false,
|
||||
'ip' => '127.0.0.1',
|
||||
];
|
||||
|
||||
if (($data['type'] ?? '') === 'dmesg') {
|
||||
$state['dmesg'] = !empty($data['open']);
|
||||
$this->clients[$from] = $state;
|
||||
if ($state['dmesg']) {
|
||||
$this->sendDmesg($from);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (($data['type'] ?? '') === 'status_refresh') {
|
||||
$this->sendStatus($from);
|
||||
}
|
||||
}
|
||||
|
||||
public function onClose(ConnectionInterface $conn): void
|
||||
{
|
||||
if ($this->clients->contains($conn)) {
|
||||
$this->clients->detach($conn);
|
||||
}
|
||||
}
|
||||
|
||||
public function onError(ConnectionInterface $conn, Exception $e): void
|
||||
{
|
||||
$conn->send($this->json([
|
||||
'type' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
]));
|
||||
$conn->close();
|
||||
}
|
||||
|
||||
public function broadcastStatus(): void
|
||||
{
|
||||
if ($this->clients->count() === 0 || $this->statusBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->statusBusy = true;
|
||||
try {
|
||||
$payload = $this->statusPayload();
|
||||
foreach ($this->clients as $client) {
|
||||
$client->send($payload);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->broadcastError($e);
|
||||
} finally {
|
||||
$this->statusBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function broadcastDmesg(): void
|
||||
{
|
||||
if ($this->clients->count() === 0 || $this->dmesgBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targets = [];
|
||||
foreach ($this->clients as $client) {
|
||||
$state = $this->clients[$client];
|
||||
if (!empty($state['dmesg'])) {
|
||||
$targets[] = $client;
|
||||
}
|
||||
}
|
||||
|
||||
if ($targets === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dmesgBusy = true;
|
||||
try {
|
||||
$payload = $this->json([
|
||||
'type' => 'dmesg',
|
||||
'data' => dmesg_log(),
|
||||
]);
|
||||
foreach ($targets as $client) {
|
||||
$client->send($payload);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->broadcastError($e);
|
||||
} finally {
|
||||
$this->dmesgBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private function sendStatus(ConnectionInterface $conn): void
|
||||
{
|
||||
try {
|
||||
$conn->send($this->statusPayload());
|
||||
} catch (Throwable $e) {
|
||||
$conn->send($this->json([
|
||||
'type' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
private function sendDmesg(ConnectionInterface $conn): void
|
||||
{
|
||||
try {
|
||||
$conn->send($this->json([
|
||||
'type' => 'dmesg',
|
||||
'data' => dmesg_log(),
|
||||
]));
|
||||
} catch (Throwable $e) {
|
||||
$conn->send($this->json([
|
||||
'type' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
private function statusPayload(): string
|
||||
{
|
||||
try {
|
||||
$data = collect_snapshot(false);
|
||||
} catch (PDOException $e) {
|
||||
if (!db_connection_lost($e)) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
db(true);
|
||||
$data = collect_snapshot(false);
|
||||
}
|
||||
|
||||
return $this->json([
|
||||
'type' => 'status',
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
private function broadcastError(Throwable $e): void
|
||||
{
|
||||
$payload = $this->json([
|
||||
'type' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
foreach ($this->clients as $client) {
|
||||
$client->send($payload);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function cookiesFromHeader(string $header): array
|
||||
{
|
||||
$cookies = [];
|
||||
foreach (explode(';', $header) as $chunk) {
|
||||
$parts = explode('=', trim($chunk), 2);
|
||||
if (count($parts) !== 2 || $parts[0] === '') {
|
||||
continue;
|
||||
}
|
||||
$cookies[$parts[0]] = urldecode($parts[1]);
|
||||
}
|
||||
|
||||
return $cookies;
|
||||
}
|
||||
|
||||
/** @param array<string, string> $cookies */
|
||||
private function signedIn(array $cookies, string $ip, string $ua): bool
|
||||
{
|
||||
if (
|
||||
empty($cookies[session_name()])
|
||||
&& empty($cookies[REMEMBER_COOKIE])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_write_close();
|
||||
}
|
||||
|
||||
$_COOKIE = $cookies;
|
||||
$_SERVER['REMOTE_ADDR'] = $ip;
|
||||
$_SERVER['HTTP_USER_AGENT'] = $ua;
|
||||
$_SERVER['HTTPS'] = 'on';
|
||||
|
||||
$sessionId = (string)($cookies[session_name()] ?? '');
|
||||
if ($sessionId !== '' && preg_match('/^[A-Za-z0-9,-]{16,128}$/', $sessionId)) {
|
||||
session_id($sessionId);
|
||||
}
|
||||
|
||||
session_start();
|
||||
$ok = signed_in();
|
||||
session_write_close();
|
||||
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $payload */
|
||||
private function json(array $payload): string
|
||||
{
|
||||
return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}';
|
||||
}
|
||||
}
|
||||
|
||||
$address = getenv('CONTROL_WS_ADDRESS') ?: '127.0.0.1:8088';
|
||||
$loop = Loop::get();
|
||||
$app = new ControlWebSocket();
|
||||
|
||||
$loop->addPeriodicTimer(1.0, static fn() => $app->broadcastStatus());
|
||||
$loop->addPeriodicTimer(1.0, static fn() => $app->broadcastDmesg());
|
||||
|
||||
$socket = new SocketServer($address, [], $loop);
|
||||
$server = new IoServer(
|
||||
new HttpServer(new WsServer($app)),
|
||||
$socket,
|
||||
$loop
|
||||
);
|
||||
|
||||
fwrite(STDOUT, 'Control WebSocket listening on ' . $address . PHP_EOL);
|
||||
$server->run();
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"require": {
|
||||
"minishlink/web-push": "^10.0",
|
||||
"cboden/ratchet": "^0.4",
|
||||
"react/event-loop": "^1.5"
|
||||
}
|
||||
}
|
||||
Generated
+2012
File diff suppressed because it is too large
Load Diff
+1616
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$controlSecretConfig = $controlSecretConfig ?? (is_file('/home/seo/secret/control.php') ? require '/home/seo/secret/control.php' : []);
|
||||
$controlVapidConfig = is_array($controlSecretConfig['vapid'] ?? null) ? $controlSecretConfig['vapid'] : [];
|
||||
|
||||
if (!defined('VAPID_PUBLIC_KEY')) {
|
||||
define('VAPID_PUBLIC_KEY', (string)($controlVapidConfig['public_key'] ?? ''));
|
||||
}
|
||||
|
||||
if (!defined('VAPID_PRIVATE_KEY')) {
|
||||
define('VAPID_PRIVATE_KEY', (string)($controlVapidConfig['private_key'] ?? ''));
|
||||
}
|
||||
+1797
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$raw = (string)file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
|
||||
$event = (string)($data['event'] ?? '');
|
||||
$allowedEvents = [
|
||||
'push_received',
|
||||
'notification_shown',
|
||||
'notification_show_failed',
|
||||
'notification_click',
|
||||
'notification_close',
|
||||
'client_log_failed',
|
||||
];
|
||||
|
||||
if (!in_array($event, $allowedEvents, true)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_event'], 422);
|
||||
}
|
||||
|
||||
$endpoint = (string)($data['endpoint'] ?? '');
|
||||
$meta = is_array($data['meta'] ?? null) ? $data['meta'] : [];
|
||||
$context = [
|
||||
'endpoint' => $endpoint,
|
||||
'message' => mb_substr((string)($data['push_id'] ?? ''), 0, 255),
|
||||
'push_id' => (string)($data['push_id'] ?? ''),
|
||||
'tag' => (string)($data['tag'] ?? ''),
|
||||
'visibility_state' => (string)($data['visibility_state'] ?? ''),
|
||||
'client_count' => isset($data['client_count']) ? (int)$data['client_count'] : null,
|
||||
'meta' => $meta,
|
||||
];
|
||||
|
||||
push_log_event($event, $context);
|
||||
|
||||
json_out(['ok' => true]);
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
|
||||
if (!signed_in()) {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'login_required',
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'method_not_allowed',
|
||||
], 405);
|
||||
}
|
||||
|
||||
require_csrf();
|
||||
|
||||
$subscription = push_subscription_from_json((string)file_get_contents('php://input'));
|
||||
save_push_subscription($subscription);
|
||||
|
||||
json_out([
|
||||
'ok' => true,
|
||||
]);
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
|
||||
if (!signed_in()) {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'login_required',
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'method_not_allowed',
|
||||
], 405);
|
||||
}
|
||||
|
||||
require_csrf();
|
||||
|
||||
$data = push_subscription_from_json((string)file_get_contents('php://input'));
|
||||
$payload = [
|
||||
'title' => (string)($data['title'] ?? 'Seoul Control Center'),
|
||||
'body' => (string)($data['body'] ?? 'Push notification test'),
|
||||
'url' => '/',
|
||||
'tag' => (string)($data['tag'] ?? 'control-test'),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'data' => send_push_payload($payload),
|
||||
]);
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,98 @@
|
||||
:root {
|
||||
--bg: #090d12;
|
||||
--panel: #111821;
|
||||
--tile: #0c1219;
|
||||
--line: #263341;
|
||||
--line2: #3a4a5d;
|
||||
--text: #eaf1f8;
|
||||
--muted: #91a0af;
|
||||
--dim: #6e7c8b;
|
||||
--blue: #54c7ec;
|
||||
--green: #69db9a;
|
||||
--yellow: #f0c860;
|
||||
--red: #ff7a70;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--bg); }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font-family: "Segoe UI", "Noto Sans KR", Arial, sans-serif; }
|
||||
button, input, select { font: inherit; }
|
||||
.login-page { min-height: 100vh; display: grid; place-items: center; }
|
||||
.login-card { width: min(420px, calc(100vw - 32px)); border: 1px solid var(--line); background: var(--panel); padding: 28px; }
|
||||
.login-card span, .brand span, .top-metrics span, .panel-head h2, .kv span, .fan-stats span, .path-box span, .card-list span, .triple h3 { color: var(--muted); font-size: 11px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.login-card h1 { margin: 8px 0 20px; font-size: 24px; }
|
||||
.login-card input, .login-card button { width: 100%; min-height: 42px; margin-top: 10px; border: 1px solid var(--line2); background: var(--tile); color: var(--text); padding: 9px 11px; }
|
||||
.login-card button { background: var(--blue); color: #061018; font-weight: 900; }
|
||||
.login-card p { color: var(--red); margin-bottom: 0; }
|
||||
.shell { width: 100%; min-width: 1260px; max-width: 1920px; margin: 0 auto; padding: 12px; }
|
||||
.topbar { position: sticky; top: 0; z-index: 20; display: grid; grid-template-columns: 250px minmax(0, 1fr) auto; gap: 10px; align-items: stretch; border: 1px solid var(--line); background: rgba(9, 13, 18, .96); padding: 10px; margin-bottom: 12px; }
|
||||
.brand { display: grid; align-content: center; min-width: 0; }
|
||||
.brand h1 { margin: 4px 0 0; font-size: 20px; line-height: 1.1; }
|
||||
.top-metrics { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
|
||||
.top-metrics div, .fan-stats div, .kv div, .path-box div, .card-list article { min-width: 0; border: 1px solid var(--line); background: var(--tile); padding: 9px; }
|
||||
b, strong, td, th { overflow-wrap: anywhere; }
|
||||
.top-metrics b, .fan-stats b, .kv b, .path-box b, .card-list b { display: block; margin-top: 4px; font-family: Consolas, Monaco, monospace; font-size: 13px; }
|
||||
.top-metrics b { font-size: 15px; }
|
||||
.logout { display: grid; place-items: center; border: 1px solid var(--line2); background: #172130; color: var(--text); padding: 0 12px; text-decoration: none; }
|
||||
.layout { display: grid; grid-template-columns: 330px minmax(0, 1fr) 410px; gap: 12px; align-items: start; }
|
||||
.left-col, .center-col, .right-col { display: grid; gap: 12px; min-width: 0; }
|
||||
.left-col, .right-col { position: sticky; top: 88px; max-height: calc(100vh - 100px); overflow: auto; scrollbar-width: thin; }
|
||||
.panel { min-width: 0; border: 1px solid var(--line); background: var(--panel); padding: 10px; }
|
||||
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 30px; margin-bottom: 10px; }
|
||||
.panel-head h2 { margin: 0; color: var(--text); font-size: 13px; }
|
||||
.panel-head h2::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 7px; background: var(--blue); }
|
||||
.panel-head em { color: var(--green); font-style: normal; font-size: 12px; font-weight: 900; }
|
||||
.fan-panel { background: #12202a; }
|
||||
.fan-gauge { display: grid; place-items: center; height: 132px; border: 1px solid var(--line2); background: radial-gradient(circle, #172d39 0%, #0c141c 72%); }
|
||||
.fan-gauge strong { font-family: Consolas, Monaco, monospace; font-size: 44px; line-height: 1; }
|
||||
.fan-gauge span { color: var(--muted); font-size: 12px; }
|
||||
.fan-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 8px; }
|
||||
.mode-row, .pwm-row { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.mode-row button, .pwm-row button, .service-cards button, td button { min-height: 28px; border: 1px solid var(--line2); background: #182332; color: var(--text); cursor: pointer; padding: 5px 8px; }
|
||||
body[data-fan-mode="auto"] #btnFanAuto, body[data-fan-mode="manual"] #btnFanManual, .pwm-row button { background: var(--blue); color: #061018; font-weight: 900; }
|
||||
.mode-row button { flex: 1 1 0; }
|
||||
.pwm-row input[type="range"] { flex: 1 1 auto; accent-color: var(--blue); }
|
||||
.pwm-row input[type="number"], select { width: 82px; border: 1px solid var(--line2); background: var(--tile); color: var(--text); padding: 5px 7px; }
|
||||
.path-box { display: grid; gap: 6px; margin-top: 10px; max-height: 140px; overflow: auto; }
|
||||
.kv, .card-list, .service-cards { display: grid; gap: 8px; }
|
||||
.service-cards { max-height: 430px; overflow: auto; }
|
||||
.service-cards article { border: 1px solid var(--line); background: var(--tile); padding: 9px; }
|
||||
.service-cards article.up { border-left: 3px solid var(--green); }
|
||||
.service-cards article.down { border-left: 3px solid var(--red); }
|
||||
.service-cards strong, .card-list strong { display: block; font-family: Consolas, Monaco, monospace; }
|
||||
.service-cards span, .service-cards small { display: block; color: var(--muted); margin-top: 4px; }
|
||||
.service-cards div { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||||
.chart-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
.chart-grid div { height: 220px; border: 1px solid var(--line); background: var(--tile); padding: 8px 8px 14px; }
|
||||
.chart-grid canvas { display: block; max-height: calc(100% - 24px); }
|
||||
.table-box, .event-box, .triple section { max-width: 100%; overflow: auto; border: 1px solid var(--line); background: var(--tile); }
|
||||
.table-box.big { height: 360px; }
|
||||
.table-box.huge { height: 430px; }
|
||||
.table-box.small { max-height: 220px; }
|
||||
.triple { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 8px; }
|
||||
.triple section { height: 170px; padding: 9px; }
|
||||
.triple h3 { margin: 0 0 8px; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 760px; }
|
||||
th, td { padding: 7px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; white-space: nowrap; font-size: 12px; }
|
||||
th { position: sticky; top: 0; z-index: 2; background: #1a2532; color: var(--text); }
|
||||
tr:nth-child(even) td { background: rgba(255,255,255,.025); }
|
||||
.mono { font-family: Consolas, Monaco, monospace; }
|
||||
.card-list article { position: relative; }
|
||||
.card-list em { position: absolute; right: 9px; top: 9px; font-style: normal; font-size: 11px; font-weight: 900; }
|
||||
.card-list em.up { color: var(--green); }
|
||||
.card-list em.down { color: var(--red); }
|
||||
.card-list div { border-top: 1px solid var(--line); margin-top: 8px; padding-top: 7px; }
|
||||
.event-box { max-height: 220px; margin-top: 8px; }
|
||||
.event { padding: 7px 8px; border-bottom: 1px solid var(--line); color: #dbe5ef; font-family: Consolas, Monaco, monospace; font-size: 12px; line-height: 1.35; }
|
||||
.event.bad { color: var(--red); }
|
||||
.empty { padding: 14px; color: var(--muted); font-size: 13px; }
|
||||
.bottom { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px; }
|
||||
.bottom .table-box { height: 300px; }
|
||||
.notice { position: fixed; right: 16px; bottom: 16px; z-index: 80; border: 1px solid var(--line2); background: #142030; color: var(--text); padding: 12px 14px; }
|
||||
.notice.error { border-color: var(--red); }
|
||||
.notice.hidden { display: none; }
|
||||
@media (max-width: 1500px) {
|
||||
.shell { min-width: 1180px; }
|
||||
.layout { grid-template-columns: 310px minmax(0, 1fr) 360px; }
|
||||
.top-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.chart-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="16" fill="#0f1115"/>
|
||||
<path d="M18 40c4-10 8-20 14-20s10 10 14 20" fill="none" stroke="#3b82f6" stroke-width="5" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="32" r="6" fill="#35c46b"/>
|
||||
<path d="M16 47h32" stroke="#edf1f7" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "Seoul Control Center",
|
||||
"short_name": "Seoul Control",
|
||||
"description": "Fan and WiFi control panel",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f1115",
|
||||
"theme_color": "#0f1115",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/assets/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const button = document.querySelector('#wakeLockBtn');
|
||||
if (!button) return;
|
||||
|
||||
let wakeLock = null;
|
||||
let wanted = false;
|
||||
|
||||
function supported() {
|
||||
return 'wakeLock' in navigator;
|
||||
}
|
||||
|
||||
function active() {
|
||||
return wakeLock !== null && wakeLock.released === false;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const isActive = active();
|
||||
button.disabled = !supported();
|
||||
button.classList.toggle('wake-active', isActive);
|
||||
button.textContent = 'WakeLock';
|
||||
button.title = supported() ? '화면 꺼짐 방지' : '현재 브라우저에서 WakeLock을 지원하지 않습니다.';
|
||||
}
|
||||
|
||||
async function requestWakeLock() {
|
||||
if (!supported()) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
wakeLock = await navigator.wakeLock.request('screen');
|
||||
wakeLock.addEventListener('release', () => {
|
||||
wakeLock = null;
|
||||
render();
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
async function releaseWakeLock() {
|
||||
wanted = false;
|
||||
if (wakeLock) {
|
||||
await wakeLock.release();
|
||||
}
|
||||
wakeLock = null;
|
||||
render();
|
||||
}
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const job = active() ? releaseWakeLock() : (async () => {
|
||||
wanted = true;
|
||||
await requestWakeLock();
|
||||
})();
|
||||
|
||||
job.catch(error => {
|
||||
wanted = false;
|
||||
wakeLock = null;
|
||||
alert(error.message || 'WakeLock failed');
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && wanted && !active()) {
|
||||
requestWakeLock().catch(() => render());
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
if (isset($_GET['logout'])) {
|
||||
clear_remember_token();
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
|
||||
}
|
||||
session_destroy();
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login_password'])) {
|
||||
if (hash_equals(APP_PASSWORD, (string)$_POST['login_password'])) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['control_login'] = true;
|
||||
if (!empty($_POST['remember_login'])) {
|
||||
issue_remember_token();
|
||||
} else {
|
||||
clear_remember_token();
|
||||
}
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
$error = '비밀번호가 올바르지 않습니다.';
|
||||
}
|
||||
|
||||
$loggedIn = signed_in();
|
||||
$csrf = csrf_token();
|
||||
|
||||
if ($loggedIn && array_key_exists('push', $_GET)) {
|
||||
$testPushBody = trim((string)$_GET['push'], " \t\n\r\0\x0B\"'");
|
||||
|
||||
if ($testPushBody !== '') {
|
||||
send_push_payload([
|
||||
'title' => '푸시 알림 테스트',
|
||||
'body' => mb_substr($testPushBody, 0, 500),
|
||||
'url' => '/',
|
||||
'tag' => 'control-test-push-' . time(),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<script src="https://chaegeon.com/log/bancheck.min.js?_=<?php echo time(); ?>"></script>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="<?= e($csrf) ?>">
|
||||
<meta name="vapid-public-key" content="<?= e(vapid_public_key()) ?>">
|
||||
<title><?= e(APP_NAME) ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Gowun+Dodum&display=swap" rel="stylesheet">
|
||||
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/assets/icon-192.png" type="image/png" sizes="192x192">
|
||||
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#0f1115">
|
||||
<?php if ($loggedIn): ?>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<?php endif; ?>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1115;--card:#171a21;--card2:#202633;--line:#2e3747;
|
||||
--text:#edf1f7;--sub:#a9b4c7;--blue:#3b82f6;--green:#35c46b;
|
||||
--red:#ff5f57;--yellow:#ffcc00;--shadow:0 12px 28px rgba(0,0,0,.20);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--text);font-family:"Gowun Dodum",system-ui,sans-serif}
|
||||
button,input,select{font:inherit}
|
||||
a{color:inherit;text-decoration:none}
|
||||
.login-wrap{min-height:100vh;display:grid;place-items:center;padding:24px}
|
||||
.login-box{width:min(420px,100%);background:var(--card);border:1px solid var(--line);border-radius:24px;padding:32px;box-shadow:var(--shadow)}
|
||||
.login-box h1{margin:0 0 10px;font-size:28px}.login-box p{margin:0 0 24px;color:var(--sub)}
|
||||
.input{width:100%;height:52px;border-radius:14px;border:1px solid var(--line);background:#11151d;color:#fff;padding:0 16px;outline:none}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;border:0;cursor:pointer;border-radius:14px;min-height:44px;padding:0 18px;background:var(--blue);color:#fff;font-weight:500;line-height:1.15;text-align:center;transition:filter .12s ease,transform .12s ease}
|
||||
.btn:hover{filter:brightness(1.05)}.btn:active{transform:translateY(1px)}
|
||||
.remember-row{display:flex;align-items:center;gap:9px;margin-top:14px;color:var(--sub);font-size:14px}.remember-row input{width:18px;height:18px;margin:0;accent-color:var(--blue)}
|
||||
.btn.secondary{background:#2b3342}.btn.secondary[data-active="1"],.btn.wake-active{background:#198754}.btn.red{background:#c62828}.btn.warn{background:#8a6d1b}.login-btn{width:100%;margin-top:16px}.error{margin-top:16px;color:#ff7b7b}
|
||||
.app{padding:22px}.topbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.topbar h1{margin:0;font-size:29px;font-weight:500;letter-spacing:.01em}.topbar p{margin:6px 0 0;color:var(--sub);font-size:14px}.topbar-right{display:flex;gap:10px;flex-wrap:wrap}.topbar-right .btn{min-width:88px}
|
||||
.layout{display:grid;grid-template-columns:440px minmax(0,1fr);gap:18px;align-items:start}
|
||||
.stack{display:grid;gap:18px}.card{background:var(--card);border:1px solid rgba(84,101,128,.62);border-radius:22px;padding:18px;box-shadow:var(--shadow);min-width:0}.card h2{margin:0 0 16px;font-size:18px;font-weight:500}
|
||||
.stat-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.stat{background:var(--card2);border-radius:16px;padding:14px}.stat.featured{grid-column:1/-1;min-height:112px;display:flex;flex-direction:column;justify-content:center}.stat.featured .stat-value{font-size:36px}.stat-label{color:var(--sub);font-size:13px;margin-bottom:8px}.stat-value{font-size:24px;font-weight:500;word-break:break-word}
|
||||
.row{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:10px;margin-bottom:12px}.small{color:var(--sub);font-size:13px}.select{width:130px;height:42px;border-radius:12px;border:1px solid var(--line);background:#11151d;color:#fff;padding:0 12px}.mode-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}.mode-options label{display:block;min-width:0}.mode-options input{position:absolute;opacity:0;pointer-events:none}.mode-options span{display:grid;place-items:center;min-height:42px;border:1px solid var(--line);border-radius:12px;background:#11151d;color:var(--sub);cursor:pointer;transition:background .12s ease,border-color .12s ease,color .12s ease}.mode-options input:checked+span{background:var(--blue);border-color:var(--blue);color:#fff}.mode-options input[value="off"]:checked+span,.mode-options input[value="disabled"]:checked+span{background:var(--red);border-color:var(--red)}.mode-options input:focus-visible+span{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}
|
||||
.control-row{align-items:center}.slider-value{width:62px;text-align:right;font-weight:500;font-variant-numeric:tabular-nums}
|
||||
.slider-wrap{display:flex;gap:12px;align-items:center}.slider-wrap[hidden]{display:none}.slider{width:100%;accent-color:var(--blue)}
|
||||
.select:focus,.slider:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}
|
||||
.btn:disabled{opacity:.55;cursor:not-allowed}
|
||||
.wifi-actions{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.wifi-actions .btn{min-height:48px;padding:6px 10px;flex-direction:column;gap:2px}.btn-line{display:block}
|
||||
.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)}.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(255,255,255,.06);text-align:left;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wifi-table th{position:sticky;top:0;background:#1d2330;z-index:1;font-weight:500}.wifi-table tr:hover{background:rgba(255,255,255,.03)}.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}
|
||||
.chart-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.chart-box{height:280px;background:#11151d;border:1px solid rgba(84,101,128,.58);border-radius:16px;padding:12px 12px 18px}.chart-box h3{margin:0 0 8px;color:#c6d3e6;font-size:13px}.chart-canvas{position:relative;height:calc(100% - 27px);min-height:0}.chart-canvas canvas{width:100%!important;height:100%!important}
|
||||
.resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px}.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:#c6d3e6;font-size:14px;font-weight:500}.spike-log-list{display:grid;gap:8px;max-height:260px;overflow:auto;padding-right:4px}.spike-log-item{display:grid;gap:4px;padding:10px 12px;border:1px solid rgba(255,204,0,.35);background:rgba(138,109,27,.16);border-radius:12px}.spike-log-item.latest{border:1px solid rgba(255,204,0,.58);background:rgba(138,109,27,.28);box-shadow:0 0 0 1px rgba(255,204,0,.14) inset;}.spike-log-item strong{font-size:13px;font-weight:500;color:#f3e3a3}.spike-log-item span{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.spike-log-empty{padding:10px 12px;border-radius:12px;background:var(--card2);color:var(--sub);font-size:13px}.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:#070a10;color:#d9e2f2;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}
|
||||
.resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.resource-box{min-width:0}.resource-box h3{margin:0 0 9px;color:#c6d3e6;font-size:14px;font-weight:500}.resource-table{width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;border:1px solid var(--line);border-radius:14px;overflow:hidden}.resource-table th,.resource-table td{padding:9px 10px;border-bottom:1px solid rgba(255,255,255,.06);background:#11151d;text-align:left;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.resource-table th{background:#1d2330;color:#c6d3e6;font-weight:500}.resource-table .pid{width:72px}.resource-table .metric{width:82px}.resource-table .service{width:150px}.resource-table .cmd{width:auto}.push-device-list{display:grid;gap:8px;margin-top:8px}.push-device-row{padding:10px 12px;border:1px solid rgba(84,101,128,.58);background:#11151d;border-radius:12px}.push-device-main{min-width:0;color:var(--sub);font-size:12px;line-height:1.45;display:grid;gap:2px;word-break:break-all}.push-device-main strong{color:#edf1f7;font-size:13px}
|
||||
.notice{position:fixed;right:20px;bottom:20px;min-width:220px;max-width:420px;padding:14px 16px;border-radius:14px;font-weight:500;background:#1d2330;border:1px solid var(--line);box-shadow:var(--shadow);z-index:99}.notice:empty{display:none}.notice[data-type=success]{border-color:rgba(53,196,107,.5)}.notice[data-type=error]{border-color:rgba(255,95,87,.5)}
|
||||
@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}.wifi-actions{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}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<?php if (!$loggedIn): ?>
|
||||
<div class="login-wrap">
|
||||
<form method="post" class="login-box" autocomplete="off">
|
||||
<h1><?= e(APP_NAME) ?></h1>
|
||||
<p>Fan and WiFi control panel</p>
|
||||
<input type="password" name="login_password" class="input" placeholder="Password" autofocus>
|
||||
<label class="remember-row">
|
||||
<input type="checkbox" name="remember_login" value="1" checked>
|
||||
<span>자동로그인 유지</span>
|
||||
</label>
|
||||
<button type="submit" class="btn login-btn">로그인</button>
|
||||
<?php if ($error !== ''): ?><div class="error"><?= e($error) ?></div><?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="app">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1><?= e(APP_NAME) ?></h1>
|
||||
<p>Updated: <span id="updatedAt">loading...</span></p>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button id="pushEnableBtn" class="btn secondary" type="button">Push</button>
|
||||
<button id="wakeLockBtn" class="btn secondary" type="button">WakeLock</button>
|
||||
<a href="/?logout=1" class="btn red">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="layout">
|
||||
<section class="stack">
|
||||
<div class="card">
|
||||
<h2>Fan Control</h2>
|
||||
<div class="stat-grid">
|
||||
<div class="stat featured"><div class="stat-label">CPU Temp</div><div class="stat-value" id="tempValue">-</div></div>
|
||||
<div class="stat"><div class="stat-label">Fan RPM</div><div class="stat-value" id="fanRpm">-</div></div>
|
||||
<div class="stat"><div class="stat-label">PWM %</div><div class="stat-value" id="fanPercent">-</div></div>
|
||||
</div>
|
||||
<div style="height:18px"></div>
|
||||
<div class="row control-row">
|
||||
<div class="small">Mode</div>
|
||||
<div class="mode-options" id="fanModeOptions" role="radiogroup" aria-label="Fan mode">
|
||||
<label><input type="radio" name="fanModeOption" value="auto" checked><span>auto</span></label>
|
||||
<label><input type="radio" name="fanModeOption" value="manual"><span>manual</span></label>
|
||||
<label><input type="radio" name="fanModeOption" value="off"><span>off</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slider-wrap" id="fanSliderWrap" hidden>
|
||||
<input type="range" min="0" max="255" value="120" class="slider" id="fanSlider">
|
||||
<div id="fanSliderValue" class="slider-value">120</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>WiFi Control</h2>
|
||||
<div class="stat-grid">
|
||||
<div class="stat"><div class="stat-label">2.4G Clients</div><div class="stat-value" id="wifi24">-</div></div>
|
||||
<div class="stat"><div class="stat-label">5G Clients</div><div class="stat-value" id="wifi5">-</div></div>
|
||||
</div>
|
||||
<div style="height:14px"></div>
|
||||
<div class="wifi-actions">
|
||||
<button class="btn secondary" data-wifi-action="restart" data-wifi-unit="hostapd-24g.service"><span class="btn-line">Restart</span><span class="btn-line">2.4G</span></button>
|
||||
<button class="btn secondary" data-wifi-action="restart" data-wifi-unit="hostapd-5g.service"><span class="btn-line">Restart</span><span class="btn-line">5G</span></button>
|
||||
<button class="btn warn" data-wifi-action="restart" data-wifi-unit="dnsmasq.service"><span class="btn-line">Restart</span><span class="btn-line">DHCP</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>System Status</h2>
|
||||
<div class="status-list">
|
||||
<div class="status-row"><div class="status-key">Host</div><div class="status-value" id="statusHost">-</div></div>
|
||||
<div class="status-row"><div class="status-key">Load Avg</div><div class="status-value" id="statusLoad">-</div></div>
|
||||
<div class="status-row"><div class="status-key">Active Users</div><div class="status-value" id="statusUsers">-</div></div>
|
||||
<div class="status-row"><div class="status-key">Disk /</div><div class="status-value" id="statusDisk">-</div></div>
|
||||
<div class="status-row"><div class="status-key">Memory</div><div class="status-value" id="statusMemory">-</div></div>
|
||||
<div class="status-row"><div class="status-key">Uptime</div><div class="status-value" id="statusUptime">-</div></div>
|
||||
<div class="status-row"><div class="status-key">Battery V</div><div class="status-value" id="statusBatteryVoltage">-</div></div>
|
||||
<div class="status-row"><div class="status-key">Battery SOC</div><div class="status-value" id="statusBatterySoc">-</div></div>
|
||||
<div class="status-row"><div class="status-key">Remaining</div><div class="status-value" id="statusBatteryRemaining">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="stack">
|
||||
<div class="card">
|
||||
<h2>Sensor History</h2>
|
||||
<div class="chart-grid">
|
||||
<div class="chart-box"><h3>Temperature</h3><div class="chart-canvas"><canvas id="tempChart"></canvas></div></div>
|
||||
<div class="chart-box"><h3>RP1 Temp</h3><div class="chart-canvas"><canvas id="rp1TempChart"></canvas></div></div>
|
||||
<div class="chart-box"><h3>Fan RPM</h3><div class="chart-canvas"><canvas id="fanRpmChart"></canvas></div></div>
|
||||
<div class="chart-box"><h3>Fan Efficiency</h3><div class="chart-canvas"><canvas id="fanEfficiencyChart"></canvas></div></div>
|
||||
<div class="chart-box"><h3>CPU Watt</h3><div class="chart-canvas"><canvas id="cpuWattChart"></canvas></div></div>
|
||||
<div class="chart-box"><h3>Battery SOC</h3><div class="chart-canvas"><canvas id="batterySocChart"></canvas></div></div>
|
||||
<div class="chart-box"><h3>Remaining</h3><div class="chart-canvas"><canvas id="remainingChart"></canvas></div></div>
|
||||
<div class="chart-box"><h3>Battery Voltage</h3><div class="chart-canvas"><canvas id="batteryVoltageChart"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>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>Band</th><th>Hostname</th><th>IP</th><th>MAC</th><th>Signal</th><th>TX Rate</th><th>RX Rate</th><th>Connected</th><th>Inactive</th></tr></thead>
|
||||
<tbody id="wifiTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section class="card resource-card">
|
||||
<div class="resource-head">
|
||||
<h2>System Notice</h2>
|
||||
<div id="noticeBaseline" class="baseline-pill">Baseline: -</div>
|
||||
</div>
|
||||
<div class="resource-grid">
|
||||
<div class="resource-box">
|
||||
<h3>CPU</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="resource-table">
|
||||
<colgroup><col class="pid"><col class="metric"><col class="service"><col class="cmd"></colgroup>
|
||||
<thead><tr><th>PID</th><th>CPU</th><th>Service</th><th>Command</th></tr></thead>
|
||||
<tbody id="processCpuTable"><tr><td colspan="4">loading...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-box">
|
||||
<h3>Memory</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="resource-table">
|
||||
<colgroup><col class="pid"><col class="metric"><col class="service"><col class="cmd"></colgroup>
|
||||
<thead><tr><th>PID</th><th>MEM</th><th>Service</th><th>Command</th></tr></thead>
|
||||
<tbody id="processMemoryTable"><tr><td colspan="4">loading...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spike-log-box">
|
||||
<h3>Notice History</h3>
|
||||
<div id="spikeLogList" class="spike-log-list">
|
||||
<div class="spike-log-empty">No system notice history.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spike-log-box">
|
||||
<h3>Push Devices</h3>
|
||||
<div id="pushStatus" class="spike-log-empty">Push status checking...</div>
|
||||
<div id="pushDeviceList" class="push-device-list">
|
||||
<div class="spike-log-empty">No push devices.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spike-log-box">
|
||||
<div class="dmesg-head">
|
||||
<h3>dmesg</h3>
|
||||
<button class="btn secondary" id="dmesgToggle" type="button">Show</button>
|
||||
</div>
|
||||
<div class="dmesg-meta" id="dmesgMeta">/tmp/dmesg.log</div>
|
||||
<pre class="dmesg-log" id="dmesgOutput" hidden></pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="notice" class="notice"></div>
|
||||
<script src="/assets/app.js?v=20260606_wifitime1"></script>
|
||||
<script src="/assets/wakelock.js?v=20260606_wakelock2"></script>
|
||||
<script src="/push_subscribe.js?v=20260606_noappend1"></script>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "Seoul Control Center",
|
||||
"short_name": "Seoul Control",
|
||||
"description": "Fan and WiFi control panel",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f1115",
|
||||
"theme_color": "#0f1115",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/assets/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const button = document.querySelector('#pushEnableBtn');
|
||||
const publicKey = document.querySelector('meta[name="vapid-public-key"]')?.content || '';
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const pushDeviceNameStorageKey = 'controlPushDeviceName';
|
||||
const pushDisabledStorageKey = 'controlPushDisabled';
|
||||
let pushAutoRepairRunning = false;
|
||||
|
||||
function setButton(text, disabled = false, active = false) {
|
||||
if (!button) return;
|
||||
button.textContent = text || 'Push';
|
||||
button.disabled = disabled;
|
||||
button.dataset.active = active ? '1' : '0';
|
||||
}
|
||||
|
||||
function publishPushStatus(detail) {
|
||||
window.dispatchEvent(new CustomEvent('pushstatus:update', {
|
||||
detail: Object.assign({
|
||||
supported: ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window),
|
||||
permission: ('Notification' in window) ? Notification.permission : 'unsupported',
|
||||
browser_subscription: false,
|
||||
server_subscription: false,
|
||||
server_checked: false,
|
||||
manual_disabled: localStorage.getItem(pushDisabledStorageKey) === '1',
|
||||
}, detail || {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function hangulCount(value) {
|
||||
return (String(value || '').match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []).length;
|
||||
}
|
||||
|
||||
function deviceNameFromUser() {
|
||||
const name = prompt('등록할까요? 등록하려면 디바이스 이름을 지정하세요 최소 2글자.', '');
|
||||
|
||||
if (name === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = String(name || '').trim();
|
||||
|
||||
if (hangulCount(trimmed) < 2) {
|
||||
throw new Error('기기 이름은 한글 2글자 이상이어야 합니다.');
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function rememberDeviceName(deviceName) {
|
||||
const trimmed = String(deviceName || '').trim();
|
||||
if (hangulCount(trimmed) >= 2) {
|
||||
localStorage.setItem(pushDeviceNameStorageKey, trimmed);
|
||||
localStorage.removeItem(pushDisabledStorageKey);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function savedDeviceName() {
|
||||
const stored = String(localStorage.getItem(pushDeviceNameStorageKey) || '').trim();
|
||||
return hangulCount(stored) >= 2 ? stored : '자동복구';
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(value) {
|
||||
const padding = '='.repeat((4 - value.length % 4) % 4);
|
||||
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
const output = new Uint8Array(raw.length);
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
output[i] = raw.charCodeAt(i);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async function postForm(action, body) {
|
||||
const fd = new URLSearchParams();
|
||||
Object.entries(body).forEach(([key, value]) => fd.append(key, String(value)));
|
||||
fd.append('action', action);
|
||||
fd.append('csrf', csrf);
|
||||
|
||||
const res = await fetch('/api.php', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: fd.toString(),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok || !json.ok) {
|
||||
throw new Error(json?.message || json?.error || 'push_request_failed');
|
||||
}
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function saveSubscription(subscription, deviceName) {
|
||||
const payload = subscription.toJSON();
|
||||
payload.device_name = rememberDeviceName(deviceName);
|
||||
|
||||
const res = await fetch('/api/save_subscription.php', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok || !json.ok) {
|
||||
throw new Error(json?.message || json?.error || 'subscription_save_failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function registration() {
|
||||
const reg = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/',
|
||||
updateViaCache: 'none',
|
||||
});
|
||||
await reg.update().catch(() => {});
|
||||
return reg;
|
||||
}
|
||||
|
||||
async function currentSubscription() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await navigator.serviceWorker.getRegistration('/');
|
||||
if (existing) {
|
||||
await existing.update().catch(() => {});
|
||||
}
|
||||
return existing ? existing.pushManager.getSubscription() : null;
|
||||
}
|
||||
|
||||
async function subscribePush(reg) {
|
||||
return reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
}
|
||||
|
||||
async function pushServerStatus(subscription) {
|
||||
const endpoint = subscription ? subscription.endpoint : '';
|
||||
const res = await fetch('/api.php?action=push_status&endpoint=' + encodeURIComponent(endpoint), {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok || !json.ok) {
|
||||
throw new Error(json?.message || json?.error || 'push_status_failed');
|
||||
}
|
||||
|
||||
return json.data || {};
|
||||
}
|
||||
|
||||
async function repairSubscriptionIfNeeded() {
|
||||
if (pushAutoRepairRunning) return;
|
||||
if (!publicKey) return;
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return;
|
||||
if (Notification.permission !== 'granted') return;
|
||||
if (localStorage.getItem(pushDisabledStorageKey) === '1') return;
|
||||
|
||||
pushAutoRepairRunning = true;
|
||||
|
||||
try {
|
||||
const reg = await registration();
|
||||
let subscription = await reg.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
subscription = await subscribePush(reg);
|
||||
await saveSubscription(subscription, savedDeviceName());
|
||||
await refreshButton();
|
||||
window.dispatchEvent(new CustomEvent('pushdevices:refresh'));
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await pushServerStatus(subscription);
|
||||
publishPushStatus({
|
||||
browser_subscription: true,
|
||||
server_subscription: status.subscribed === true,
|
||||
server_checked: true,
|
||||
});
|
||||
if (status.device_name) {
|
||||
rememberDeviceName(status.device_name);
|
||||
}
|
||||
|
||||
if (status.subscribed) {
|
||||
await refreshButton();
|
||||
return;
|
||||
}
|
||||
|
||||
await saveSubscription(subscription, savedDeviceName());
|
||||
publishPushStatus({
|
||||
browser_subscription: true,
|
||||
server_subscription: true,
|
||||
server_checked: true,
|
||||
});
|
||||
await refreshButton();
|
||||
window.dispatchEvent(new CustomEvent('pushdevices:refresh'));
|
||||
} catch (error) {
|
||||
console.warn('push auto repair failed', error);
|
||||
} finally {
|
||||
pushAutoRepairRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshButton() {
|
||||
if (!button) return;
|
||||
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
|
||||
setButton('Push', true, false);
|
||||
publishPushStatus({ supported: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = await currentSubscription();
|
||||
if (subscription) {
|
||||
setButton('Push', false, true);
|
||||
publishPushStatus({
|
||||
browser_subscription: true,
|
||||
});
|
||||
} else if (Notification.permission === 'denied') {
|
||||
setButton('Push', true, false);
|
||||
publishPushStatus({
|
||||
browser_subscription: false,
|
||||
});
|
||||
} else {
|
||||
setButton('Push', false, false);
|
||||
publishPushStatus({
|
||||
browser_subscription: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe() {
|
||||
if (!publicKey) {
|
||||
setButton('Push', true, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceName = deviceNameFromUser();
|
||||
if (deviceName === null) {
|
||||
await refreshButton();
|
||||
return;
|
||||
}
|
||||
|
||||
setButton('Push', true, false);
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
setButton('Push', false, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reg = await registration();
|
||||
let subscription = await reg.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
subscription = await subscribePush(reg);
|
||||
}
|
||||
|
||||
await saveSubscription(subscription, deviceName);
|
||||
publishPushStatus({
|
||||
browser_subscription: true,
|
||||
server_subscription: true,
|
||||
server_checked: true,
|
||||
manual_disabled: false,
|
||||
});
|
||||
await refreshButton();
|
||||
window.dispatchEvent(new CustomEvent('pushdevices:refresh'));
|
||||
}
|
||||
|
||||
async function unsubscribe() {
|
||||
if (!confirm('푸시 기기 삭제할까요?')) {
|
||||
await refreshButton();
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = await currentSubscription();
|
||||
if (!subscription) {
|
||||
await refreshButton();
|
||||
return;
|
||||
}
|
||||
|
||||
setButton('Push', true, true);
|
||||
localStorage.setItem(pushDisabledStorageKey, '1');
|
||||
await subscription.unsubscribe();
|
||||
await postForm('delete_push_endpoint', {
|
||||
endpoint: subscription.endpoint,
|
||||
});
|
||||
publishPushStatus({
|
||||
browser_subscription: false,
|
||||
server_subscription: false,
|
||||
server_checked: true,
|
||||
manual_disabled: true,
|
||||
});
|
||||
await refreshButton();
|
||||
window.dispatchEvent(new CustomEvent('pushdevices:refresh'));
|
||||
}
|
||||
|
||||
if (!button) return;
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const active = button.dataset.active === '1';
|
||||
const job = active ? unsubscribe() : subscribe();
|
||||
job.catch(error => {
|
||||
alert(error.message || 'Push failed');
|
||||
refreshButton().catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
refreshButton().catch(() => setButton('Push', false, false));
|
||||
repairSubscriptionIfNeeded();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
repairSubscriptionIfNeeded();
|
||||
}
|
||||
});
|
||||
setInterval(repairSubscriptionIfNeeded, 5 * 60 * 1000);
|
||||
})();
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
async function currentEndpoint() {
|
||||
const subscription = await self.registration.pushManager.getSubscription();
|
||||
return subscription ? subscription.endpoint : '';
|
||||
}
|
||||
|
||||
async function windowClientCount() {
|
||||
const allClients = await clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
return allClients.length;
|
||||
}
|
||||
|
||||
function logPushEvent(eventName, payload = {}, extra = {}) {
|
||||
return Promise.all([
|
||||
currentEndpoint().catch(() => ''),
|
||||
windowClientCount().catch(() => 0),
|
||||
]).then(([endpoint, clientCount]) => fetch('/api/push_event.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({
|
||||
event: eventName,
|
||||
endpoint,
|
||||
push_id: payload.push_id || '',
|
||||
tag: payload.tag || '',
|
||||
client_count: clientCount,
|
||||
meta: extra,
|
||||
}),
|
||||
})).catch(() => {});
|
||||
}
|
||||
|
||||
self.addEventListener('push', event => {
|
||||
let payload = {};
|
||||
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {};
|
||||
} catch (e) {
|
||||
payload = {
|
||||
body: event.data ? event.data.text() : '',
|
||||
};
|
||||
}
|
||||
|
||||
const title = payload.title || 'Seoul Control Center';
|
||||
const vibrate = Array.isArray(payload.vibrate)
|
||||
? payload.vibrate.map(value => Number(value)).filter(value => Number.isFinite(value) && value >= 0)
|
||||
: undefined;
|
||||
const options = {
|
||||
body: payload.body || 'System notice detected',
|
||||
icon: '/assets/icon-192.png',
|
||||
badge: '/assets/icon-192.png',
|
||||
tag: payload.tag || 'control-push',
|
||||
renotify: payload.renotify === true,
|
||||
requireInteraction: payload.require_interaction === true || payload.requireInteraction === true,
|
||||
silent: payload.silent === true,
|
||||
vibrate,
|
||||
data: {
|
||||
url: payload.url || '/',
|
||||
push_id: payload.push_id || '',
|
||||
tag: payload.tag || 'control-push',
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil((async () => {
|
||||
await logPushEvent('push_received', payload);
|
||||
try {
|
||||
await self.registration.showNotification(title, options);
|
||||
await logPushEvent('notification_shown', payload);
|
||||
} catch (error) {
|
||||
await logPushEvent('notification_show_failed', payload, {
|
||||
reason: error && error.message ? error.message : 'show_failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
|
||||
const url = event.notification.data?.url || '/';
|
||||
const payload = {
|
||||
push_id: event.notification.data?.push_id || '',
|
||||
tag: event.notification.data?.tag || event.notification.tag || '',
|
||||
};
|
||||
|
||||
event.waitUntil((async () => {
|
||||
await logPushEvent('notification_click', payload, {
|
||||
action: event.action || '',
|
||||
});
|
||||
|
||||
const allClients = await clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
|
||||
for (const client of allClients) {
|
||||
if ('focus' in client) {
|
||||
await client.focus();
|
||||
if ('navigate' in client) {
|
||||
return client.navigate(url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(url);
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclose', event => {
|
||||
const payload = {
|
||||
push_id: event.notification.data?.push_id || '',
|
||||
tag: event.notification.data?.tag || event.notification.tag || '',
|
||||
};
|
||||
|
||||
event.waitUntil(logPushEvent('notification_close', payload));
|
||||
});
|
||||
Reference in New Issue
Block a user