Initial control project import

This commit is contained in:
seo
2026-06-07 00:33:58 +09:00
commit e1ca2fc125
25 changed files with 8231 additions and 0 deletions
+1797
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -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]);
+31
View File
@@ -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,
]);
+38
View File
@@ -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),
]);
+1100
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+98
View File
@@ -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; }
}
+6
View File
@@ -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

+24
View File
@@ -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"
}
]
}
+70
View File
@@ -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

+296
View File
@@ -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>
+24
View File
@@ -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"
}
]
}
+332
View File
@@ -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
View File
@@ -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));
});