release: Mark VIII 1.0
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 43 B |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,484 @@
|
||||
:root {
|
||||
--bg: #fafafa; --surface: #fff; --surface-alt: #f5f5f5;
|
||||
--toolbar: #424242; --rail: #f3f3f3; --rail-hover: #a9a9a9;
|
||||
--rail-fg: #212121; --rail-fg-active: #1976d2;
|
||||
--text: #212121; --muted: #686868; --border: #e0e0e0;
|
||||
--primary: #1976d2; --primary-dark: #105694; --danger: #d32f2f;
|
||||
--ok: #7cb342; --warn: #f9a825; --terminal-bg: #000;
|
||||
--shadow: 0 2px 2px rgba(0,0,0,.24), 0 0 2px rgba(0,0,0,.12);
|
||||
--ink: #1976d2;
|
||||
}
|
||||
html.dark {
|
||||
--bg: #303030; --surface: #424242; --surface-alt: #3a3a3a;
|
||||
--toolbar: #424242; --rail: #3a3a3a; --rail-hover: #545454;
|
||||
--rail-fg: #e0e0e0; --rail-fg-active: #fff;
|
||||
--text: #fff; --muted: #bdbdbd; --border: #545454;
|
||||
--shadow: 0 2px 2px rgba(0,0,0,.5), 0 0 2px rgba(0,0,0,.3);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font: 14px/1.45 Roboto, "Segoe UI", Arial, sans-serif;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
|
||||
#app { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
#login-screen { flex: 1; display: flex; align-items: center; justify-content: center; background: #9c9c9c; }
|
||||
.login-card {
|
||||
background: #fff; border-radius: 2px; padding: 32px; width: 360px;
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,.4); text-align: center;
|
||||
}
|
||||
.login-logo { height: 148px; margin-bottom: 8px; }
|
||||
.login-title { margin: 0 0 20px; font-size: 26px; font-weight: 400; color: #212121; }
|
||||
.login-field {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid #ccc;
|
||||
border-radius: 2px; margin: 6px 0; font-size: 14px;
|
||||
}
|
||||
.login-error { color: #d32f2f; font-size: 13px; min-height: 18px; }
|
||||
.spinner {
|
||||
display: inline-block; width: 18px; height: 18px;
|
||||
border: 2px solid rgba(25,118,210,.25); border-top-color: var(--primary);
|
||||
border-radius: 50%; animation: spin .8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
#topbar {
|
||||
height: 64px; background: var(--toolbar); color: #fff;
|
||||
display: flex; align-items: center; padding: 0 16px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,.4); position: relative; z-index: 80;
|
||||
}
|
||||
.brand-logo { height: 32px; margin-right: 10px; }
|
||||
.brand-text { font-size: 20px; font-weight: 400; letter-spacing: .5px; }
|
||||
.toolbar-spacer { flex: 1; }
|
||||
#live-status {
|
||||
color: #e0e0e0; font-size: 12px; margin-right: 16px; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.toolbar-action { position: relative; }
|
||||
.toolbar-icon-btn {
|
||||
width: 44px; height: 44px; padding: 10px; border: 0; border-radius: 50%;
|
||||
background: transparent; color: #fff; cursor: pointer; display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.toolbar-icon-btn:hover, .toolbar-icon-btn:focus-visible { background: rgba(255,255,255,.12); outline: none; }
|
||||
.toolbar-icon-btn svg { width: 24px; height: 24px; display: block; }
|
||||
#terminal-btn.active, #pager-btn.active, .toolbar-icon-btn[aria-expanded="true"] {
|
||||
background: #1976d2; color: #fff;
|
||||
}
|
||||
.notification-badge {
|
||||
position: absolute; top: 2px; right: 1px; min-width: 18px; height: 18px;
|
||||
padding: 0 5px; border-radius: 9px; background: #f44336; color: #fff;
|
||||
border: 2px solid var(--toolbar); font-size: 10px; font-weight: 600;
|
||||
line-height: 14px; text-align: center; pointer-events: none;
|
||||
}
|
||||
.toolbar-menu {
|
||||
position: absolute; top: calc(100% + 8px); right: 0; width: 280px;
|
||||
background: var(--surface); color: var(--text); border-radius: 3px;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,.35); overflow: hidden;
|
||||
}
|
||||
.toolbar-menu:not(.hidden) { animation: menu-in .12s ease-out; }
|
||||
@keyframes menu-in { from { opacity: 0; transform: translateY(-4px); } }
|
||||
.toolbar-menu-header {
|
||||
min-height: 48px; padding: 0 16px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: space-between; font-weight: 500;
|
||||
}
|
||||
.menu-link {
|
||||
border: 0; background: transparent; color: var(--primary); padding: 8px;
|
||||
cursor: pointer; font: inherit; font-size: 12px;
|
||||
}
|
||||
.menu-link:disabled { color: var(--muted); cursor: default; opacity: .65; }
|
||||
.notifications-menu { width: min(360px, calc(100vw - 24px)); }
|
||||
.notifications-list { max-height: min(420px, calc(100vh - 96px)); overflow-y: auto; }
|
||||
.notification-empty { padding: 28px 16px; color: var(--muted); text-align: center; }
|
||||
.notification-item {
|
||||
display: grid; grid-template-columns: 10px 1fr auto; gap: 10px; align-items: start;
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.notification-item:last-child { border-bottom: 0; }
|
||||
.notification-kind { width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; background: var(--primary); }
|
||||
.notification-kind.error { background: var(--danger); }
|
||||
.notification-kind.success { background: var(--ok); }
|
||||
.notification-message { min-width: 0; overflow-wrap: anywhere; }
|
||||
.notification-time { color: var(--muted); font-size: 11px; white-space: nowrap; margin-top: 2px; }
|
||||
.toolbar-menu-item {
|
||||
width: 100%; min-height: 48px; padding: 8px 16px; border: 0; background: transparent;
|
||||
color: var(--text); display: flex; align-items: center; gap: 16px; text-align: left;
|
||||
cursor: pointer; font: inherit;
|
||||
}
|
||||
.toolbar-menu-item:hover, .toolbar-menu-item:focus-visible { background: var(--surface-alt); outline: none; }
|
||||
.menu-icon { width: 22px; height: 22px; color: var(--muted); flex: none; }
|
||||
.menu-icon svg { width: 22px; height: 22px; display: block; }
|
||||
.toolbar-menu-divider { height: 1px; background: var(--border); margin: 4px 0; }
|
||||
.internet-menu-item > span:nth-child(2) { flex: 1; }
|
||||
.internet-menu-item small { display: block; color: var(--muted); font-size: 11px; margin-top: 1px; }
|
||||
.connection-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: none; }
|
||||
.connection-dot.online { background: var(--ok); }
|
||||
.connection-dot.offline { background: var(--danger); }
|
||||
.connection-dot.checking { animation: connection-pulse 1s ease-in-out infinite alternate; }
|
||||
@keyframes connection-pulse { to { opacity: .35; } }
|
||||
.danger-item { color: var(--danger); }
|
||||
.danger-item .menu-icon { color: var(--danger); }
|
||||
@media (max-width: 620px) {
|
||||
#topbar { padding: 0 8px; }
|
||||
#live-status { display: none; }
|
||||
.toolbar-icon-btn { width: 40px; height: 40px; padding: 8px; }
|
||||
}
|
||||
#layout { flex: 1; display: flex; min-height: 0; }
|
||||
#rail {
|
||||
width: 60px; background: var(--rail); color: var(--rail-fg);
|
||||
display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; transition: width .2s;
|
||||
}
|
||||
#rail.open { width: 220px; }
|
||||
#rail .entry {
|
||||
height: 48px; display: flex; align-items: center; padding: 0 0 0 18px;
|
||||
overflow: hidden; color: var(--rail-fg); cursor: pointer; border-right: 3px solid transparent; font-size: 14px;
|
||||
}
|
||||
#rail .entry:hover { background: var(--rail-hover); color: var(--text); }
|
||||
#rail .entry.active {
|
||||
border-right: 3px solid #1976d2; background: transparent; color: var(--rail-fg-active);
|
||||
}
|
||||
.entry-icon {
|
||||
font-size: 24px; width: 24px; height: 24px; display: flex;
|
||||
align-items: center; justify-content: center; margin-right: 14px; flex: none;
|
||||
}
|
||||
.entry-icon svg { width: 24px; height: 24px; display: block; }
|
||||
#topbar .btn.ghost { color: #fff; border-color: #fff; }
|
||||
.entry-text { white-space: nowrap; opacity: 0; transition: opacity .2s; }
|
||||
#rail.open .entry-text { opacity: 1; }
|
||||
#rail .entry.divider {
|
||||
height: 0; padding: 0; margin: 8px 0; border-top: 1px solid rgba(0,0,0,.12); flex: none;
|
||||
}
|
||||
.rail-footer { margin-top: auto; }
|
||||
#content { flex: 1; overflow-y: auto; padding: 20px 30px 5px 5px; }
|
||||
|
||||
.tabbar {
|
||||
display: flex; border-bottom: 2px solid #e0e0e0;
|
||||
margin-bottom: 16px; position: relative;
|
||||
}
|
||||
.tabbar .tab {
|
||||
padding: 10px 16px; cursor: pointer; color: var(--muted); font-size: 14px;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -2px;
|
||||
}
|
||||
.tabbar .tab.active { color: var(--primary); border-bottom-color: var(--ink); }
|
||||
|
||||
.page-title { font-size: 24px; font-weight: 400; margin: 0 0 12px; }
|
||||
.cards {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-bottom: 16px;
|
||||
}
|
||||
.card { background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); padding: 14px; }
|
||||
.card-label { font-size: 12px; color: var(--muted); }
|
||||
.card-value { font-size: 28px; font-weight: 400; margin-top: 4px; }
|
||||
.section {
|
||||
background: var(--surface); border-radius: 2px; box-shadow: var(--shadow);
|
||||
padding: 16px; margin-bottom: 16px;
|
||||
}
|
||||
.section h2 { margin: 0 0 12px; font-size: 16px; font-weight: 500; }
|
||||
.row { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
|
||||
.row > div { flex: 1; min-width: 140px; }
|
||||
.tbl { width: 100%; border-collapse: collapse; }
|
||||
.tbl th, .tbl td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
.tbl th { color: var(--muted); font-size: 12px; font-weight: 500; }
|
||||
.tbl tr:hover td { background: var(--surface-alt); }
|
||||
.toggle { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
|
||||
.toggle input { width: auto; }
|
||||
.badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 11px; }
|
||||
.badge.on { background: #e8f5e9; color: #2e7d32; }
|
||||
.badge.off { background: #fff3e0; color: #e65100; }
|
||||
.badge.unknown { background: #eeeeee; color: #616161; }
|
||||
.btn {
|
||||
background: var(--primary); color: #fff; border: 0; border-radius: 2px;
|
||||
padding: 8px 14px; font-size: 14px; cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.3);
|
||||
}
|
||||
.btn:hover { background: var(--primary-dark); }
|
||||
.btn:disabled, .btn.busy { opacity: .5; cursor: default; pointer-events: none; }
|
||||
.btn.ghost {
|
||||
background: transparent; color: var(--primary); box-shadow: none;
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
.btn.danger { background: var(--danger); }
|
||||
input, select {
|
||||
background: var(--surface); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 2px; padding: 8px 10px; width: 100%;
|
||||
}
|
||||
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 4px; }
|
||||
code { background: var(--surface-alt); padding: 1px 5px; border-radius: 2px; }
|
||||
pre.logs {
|
||||
background: var(--terminal-bg); color: #c9d1d9; padding: 12px; overflow: auto;
|
||||
max-height: 420px; font-size: 12px; white-space: pre-wrap; border-radius: 2px;
|
||||
}
|
||||
.log-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
.log-toolbar h2 { margin: 0; }
|
||||
.log-search { width: min(260px, 35vw); }
|
||||
.empty { padding: 24px; text-align: center; color: var(--muted); }
|
||||
#terminal-panel {
|
||||
position: fixed; left: 0; right: 0; bottom: 0;
|
||||
border-top: 1px solid var(--border); background: #000; height: 280px;
|
||||
display: flex; flex-direction: column; z-index: 60;
|
||||
box-shadow: 0 -3px 10px rgba(0,0,0,.35);
|
||||
}
|
||||
#terminal-panel:not(.hidden) { animation: term-up .18s ease-out; }
|
||||
@keyframes term-up { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
#terminal-bar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background: var(--toolbar); padding: 4px 10px; font-size: 12px; color: #e0e0e0;
|
||||
}
|
||||
#toast-container {
|
||||
position: fixed; right: 16px; bottom: 16px; display: flex;
|
||||
flex-direction: column; gap: 8px; z-index: 100;
|
||||
}
|
||||
.toast {
|
||||
padding: 10px 14px; border-radius: 2px; background: var(--surface);
|
||||
box-shadow: var(--shadow); color: var(--text);
|
||||
}
|
||||
.toast.error { border-left: 4px solid var(--danger); }
|
||||
.toast.info { border-left: 4px solid var(--primary); }
|
||||
.toast.success { border-left: 4px solid var(--ok); }
|
||||
#terminal .xterm { height: 100%; }
|
||||
|
||||
#pager-panel {
|
||||
position: fixed; left: 0; right: 0; bottom: 0;
|
||||
border-top: 1px solid var(--border); background: #303030; height: 540px;
|
||||
display: flex; flex-direction: column; z-index: 60;
|
||||
box-shadow: 0 -3px 10px rgba(0,0,0,.35);
|
||||
}
|
||||
#pager-panel:not(.hidden) { animation: term-up .18s ease-out; }
|
||||
#pager-bar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background: var(--toolbar); padding: 4px 10px; font-size: 12px; color: #e0e0e0;
|
||||
}
|
||||
#pager-body { flex: 1; min-height: 0; overflow: hidden; padding: 10px; }
|
||||
#pager_ui {
|
||||
border-collapse: collapse; border-spacing: 0; margin: 0 auto;
|
||||
border-radius: 62px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.035), inset 0 0 34px rgb(24 24 24),
|
||||
inset 0 -22px 36px rgba(0,0,0,.42), 0 14px 38px rgba(0,0,0,.48),
|
||||
0 0 22px rgba(0,239,53,.09);
|
||||
}
|
||||
#pager_ui td { padding: 0; margin: 0; }
|
||||
#pager_ui img { display: block; }
|
||||
.pager-scale-wrapper { transform-origin: top center; }
|
||||
.pager-btn {
|
||||
cursor: pointer; user-select: none; -webkit-user-drag: none;
|
||||
transition: filter 0.08s ease;
|
||||
}
|
||||
.pager-btn.pressed { filter: brightness(0.6); }
|
||||
#pager_error {
|
||||
position: absolute; top: 0; left: 0; width: 480px; height: 222px;
|
||||
font-size: large; text-align: center; color: #fff;
|
||||
display: flex; align-items: center; justify-content: center; flex-direction: column;
|
||||
background: rgba(0,0,0,.8); z-index: 5;
|
||||
}
|
||||
#pager_error[hidden] { display: none; }
|
||||
|
||||
.title-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin: 8px 0 16px; }
|
||||
.title-card { background: var(--card, #fff); border: 1px solid var(--border, #e0e0e0); border-radius: 4px; padding: 12px 16px; box-shadow: 0 2px 4px rgba(0, 0, 0, .12); }
|
||||
.title-card h2 { margin: 0 0 8px; font-size: 15px; font-weight: 500; }
|
||||
.chart-box { height: 160px; }
|
||||
.card-stat { font-size: 28px; font-weight: 500; margin: 4px 0; }
|
||||
.switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; -webkit-user-select: none; user-select: none; }
|
||||
.switch input { display: none; }
|
||||
.switch .track { width: 40px; height: 20px; border-radius: 10px; background: #bdbdbd; position: relative; transition: background .2s; }
|
||||
.switch .track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: left .2s; }
|
||||
.switch input:checked + .track { background: #1976d2; }
|
||||
.switch input:checked + .track::after { left: 22px; }
|
||||
.switch input:indeterminate + .track { background: #9e9e9e; }
|
||||
.switch input:indeterminate + .track::after { left: 12px; }
|
||||
.sel { padding: 6px 8px; border: 1px solid var(--border, #e0e0e0); border-radius: 4px; background: var(--card, #fff); color: var(--text, #212121); }
|
||||
.pager { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.muted { color: var(--muted, #686868); }
|
||||
html.dark .title-card { background: #424242; border-color: #545454; }
|
||||
html.dark .sel { background: #424242; border-color: #545454; color: #fff; }
|
||||
html.dark .muted { color: #bdbdbd; }
|
||||
|
||||
/* ---- Recon (Mark VII parity) ---- */
|
||||
.recon-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin: 8px 0 16px; }
|
||||
.recon-title-card { flex: 1 1 220px; min-width: 220px; margin-bottom: 1em; }
|
||||
.recon-card { background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); height: 200px; padding: 12px 16px; display: flex; flex-direction: column; }
|
||||
.recon-title-card-title { font-size: 20px; margin-bottom: 15px; display: flex; align-items: center; color: var(--text); }
|
||||
.recon-card-title-link { color: inherit; text-decoration: none; }
|
||||
.recon-card-title-link:visited { color: inherit; }
|
||||
.recon-card-title-link:hover { text-decoration: underline; }
|
||||
.recon-title-card-content { display: flex; justify-content: center; align-items: center; height: 70%; }
|
||||
.recon-chart-box { width: 100%; height: 150px; position: relative; }
|
||||
.recon-chart-box canvas { width: 100%; height: 100%; }
|
||||
.recon-no-data { font-style: italic; color: #787878; display: flex; justify-content: center; padding: 12px; }
|
||||
.recon-hs-col { display: flex; flex-direction: column; justify-content: center; align-items: center; }
|
||||
.recon-hs-count { font-size: 32px; font-weight: 700; line-height: 1.1; }
|
||||
.recon-hs-label { color: grey; margin: 2px 0 10px; }
|
||||
.recon-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); margin: 0; cursor: pointer; }
|
||||
.recon-ps-row { display: flex; align-items: center; width: 100%; gap: 4px; }
|
||||
.recon-ps-row .sel { width: 100%; }
|
||||
.icon-btn { background: transparent; color: var(--muted); border: 0; border-radius: 50%; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
|
||||
.icon-btn:hover { background: var(--surface-alt); color: var(--text); }
|
||||
.icon-btn:disabled { opacity: .38; cursor: default; }
|
||||
.icon-btn svg { width: 22px; height: 22px; }
|
||||
.recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.recon-scan-bar .sel { width: auto; }
|
||||
.recon-table-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.recon-table-head h2 { margin: 0; }
|
||||
.recon-search { max-width: 180px; }
|
||||
.recon-paginator { display: flex; align-items: center; gap: 2px; font-size: 12px; }
|
||||
.recon-paginator .icon-btn { width: 30px; height: 30px; }
|
||||
.recon-paginator .icon-btn svg { width: 18px; height: 18px; }
|
||||
.recon-row-selected td { background: #eaeaea; }
|
||||
html.dark .recon-row-selected td { background: #565656; }
|
||||
.recon-settings-sidebar {
|
||||
position: fixed; top: 64px; right: 0; bottom: 0; width: 270px; z-index: 50;
|
||||
background: var(--surface); box-shadow: -2px 0 6px rgba(0,0,0,.24); padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.recon-settings-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.recon-settings-title { font-size: 20px; }
|
||||
.recon-settings-section { font-size: 14px; font-weight: 500; margin: 14px 0 4px; color: var(--muted); }
|
||||
.recon-settings-sidebar .toggle { font-size: 13px; }
|
||||
.recon-handshakes-card .recon-table-head h2 { font-size: 20px; margin-bottom: 15px; }
|
||||
|
||||
/* ---- Recon AP focus sidebar + table sort ---- */
|
||||
.recon-focus-sidebar {
|
||||
position: fixed; top: 64px; right: 0; bottom: 0; width: 270px; z-index: 50;
|
||||
background: var(--surface); box-shadow: -2px 0 6px rgba(0,0,0,.24); padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.recon-focus-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.recon-focus-header-text { font-size: 20px; line-height: 1.2; flex: 1; word-break: break-word; }
|
||||
.recon-focus-bssid { font-family: Consolas, Menlo, monospace; font-size: 13px; -webkit-user-select: text; user-select: text; }
|
||||
.recon-focus-subtext { color: grey; font-style: italic; font-size: 12px; margin-bottom: 8px; }
|
||||
.recon-focus-body { margin-top: 18px; }
|
||||
.recon-focus-body-title { font-size: 16px; margin-bottom: 8px; color: var(--text); }
|
||||
.recon-focus-action-button { width: 100%; margin-bottom: 5px; }
|
||||
.recon-focus-detail { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.recon-focus-detail-label { color: var(--muted); flex: none; }
|
||||
.recon-sort-arrow { color: var(--muted); font-size: 11px; }
|
||||
th.recon-sorted { color: var(--primary); }
|
||||
.recon-per { width: auto; }
|
||||
|
||||
/* ---- Mark VII handshakes table + settings dialog ---- */
|
||||
.hs-cell-center { text-align: center; }
|
||||
.hs-ok, .hs-bad, .hs-na { display: inline-flex; vertical-align: middle; }
|
||||
.hs-ok svg, .hs-bad svg, .hs-na svg { width: 18px; height: 18px; }
|
||||
.hs-ok { color: #7cb342; }
|
||||
.hs-bad { color: #d32f2f; }
|
||||
.hs-na { color: #9e9e9e; }
|
||||
.hs-actions { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.hs-warn { color: #d32f2f; }
|
||||
.hs-flash { font-size: 12px; margin-left: 8px; }
|
||||
.hs-flash-ok { color: #7cb342; }
|
||||
.hs-flash-error { color: #d32f2f; }
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 100; display: flex; align-items: center; justify-content: center; }
|
||||
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; min-width: 420px; max-width: 600px; box-shadow: 0 8px 24px rgba(0,0,0,.3); padding: 20px; }
|
||||
.modal-title { font-size: 20px; margin-bottom: 16px; }
|
||||
.modal-body { display: flex; flex-direction: column; gap: 12px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.hs-settings-row { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; }
|
||||
.hs-settings-label { color: var(--muted); }
|
||||
.hs-settings-value { font-family: Consolas, Menlo, monospace; word-break: break-all; }
|
||||
html.dark .modal { background: #303030; }
|
||||
|
||||
/* ---- PineAP overview segmented control ---- */
|
||||
.seg { display: inline-flex; margin-top: 8px; border: 1px solid var(--border, #e0e0e0); border-radius: 4px; overflow: hidden; }
|
||||
.seg-btn { background: transparent; border: none; padding: 5px 14px; font-size: 12px; cursor: pointer; color: var(--muted, #666); }
|
||||
.seg-btn + .seg-btn { border-left: 1px solid var(--border, #e0e0e0); }
|
||||
.seg-btn.active { background: var(--primary, #1976d2); color: #fff; }
|
||||
.seg-btn.busy { opacity: .5; pointer-events: none; }
|
||||
|
||||
/* ---- PineAP Mark VII layout ---- */
|
||||
.pineap-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 30px; margin: 8px 0 16px; }
|
||||
.pineap-title-card { flex: 1; min-width: 220px; background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); padding: 14px 16px; margin-bottom: 16px; }
|
||||
.pineap-card-title { font-size: 20px; display: flex; align-items: center; margin-bottom: 10px; }
|
||||
.pineap-card-title-flex { display: flex; align-items: center; font-size: 20px; margin-bottom: 15px; }
|
||||
.pineap-card-title-link { color: inherit; text-decoration: none; cursor: pointer; }
|
||||
.pineap-card-title-link:visited { color: inherit; }
|
||||
.pineap-card-title-link:hover { text-decoration: underline; }
|
||||
.pineap-card-title-content { display: flex; justify-content: center; align-items: center; font-size: 24px; }
|
||||
.pineap-card-button-group { width: 100%; height: 30px; display: flex; }
|
||||
.pineap-card-button-group .seg { flex: 1; height: 100%; margin-top: 0; }
|
||||
.pineap-card-button-group .seg-btn { flex: 1; }
|
||||
.pineap-mode-save { display: flex; justify-content: flex-end; margin-top: 10px; }
|
||||
.pineap-mode-features { margin: 6px 0 0; padding-left: 20px; }
|
||||
.pineap-card-settings, .pineap-card-pool, .pineap-card-handshakes, .pineap-card-inject { flex: 1; }
|
||||
.pineap-handshakes-none { display: flex; justify-content: center; font-style: italic; color: var(--muted); }
|
||||
|
||||
/* ---- Open AP: Mark VII PineAP settings card ---- */
|
||||
.pineap-card-subtitle { color: var(--muted); font-size: 13px; margin: -8px 0 10px; }
|
||||
.pineap-settings-section { font-size: 13px; font-weight: 500; color: var(--muted); margin: 14px 0 4px; }
|
||||
.pineap-card-settings .switch { margin: 6px 0; }
|
||||
.pineap-title-card > .switch { display: flex; margin: 8px 0; }
|
||||
|
||||
/* ---- PineAP filters ---- */
|
||||
.filter-mode-seg { margin: 0 0 8px; }
|
||||
.pineap-filter-description { color: var(--muted); font-size: 13px; margin: 2px 0 12px; }
|
||||
.pineap-filter-entry-row { align-items: flex-end; }
|
||||
.pineap-filter-value { flex: 1 1 280px; }
|
||||
.pineap-filter-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0 14px; }
|
||||
.pineap-filter-card .tbl tbody tr { cursor: pointer; }
|
||||
.pineap-filter-card .tbl tbody tr:hover { background: rgba(25, 118, 210, .08); }
|
||||
|
||||
/* ---- Open AP: Mk7 filter notice boxes ---- */
|
||||
.pineap-infobox { border-radius: 2px; padding: 10px 12px; margin-top: 10px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.pineap-infobox.error { background: #fdecea; color: #b71c1c; border: 1px solid #f5c6cb; }
|
||||
.pineap-infobox.info { background: #e3f2fd; color: #0d47a1; border: 1px solid #90caf9; }
|
||||
.pineap-infobox-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
html.dark .pineap-infobox.error { background: #4a2020; color: #ffb4a9; border-color: #6b2d2d; }
|
||||
html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-color: #1d3a54; }
|
||||
|
||||
/* ---- Payloads (Pager-native Mark VII layout) ---- */
|
||||
.payload-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||
.payload-heading h2 { margin-bottom: 4px; font-size: 20px; font-weight: 400; }
|
||||
.payload-heading p { margin: 0; max-width: 760px; }
|
||||
.payload-filters { display: grid; grid-template-columns: minmax(220px, 2fr) minmax(170px, 1fr); gap: 10px; max-width: 720px; }
|
||||
.payload-result-count { margin: 9px 0; font-size: 12px; }
|
||||
.payload-list { display: grid; gap: 10px; }
|
||||
.payload-card { display: flex; align-items: center; gap: 16px; padding: 14px 2px; border-top: 1px solid var(--border); }
|
||||
.payload-card-main { flex: 1; min-width: 0; }
|
||||
.payload-card h3, .payload-run h3, .payload-dev-block h3 { margin: 0; font-size: 16px; font-weight: 500; }
|
||||
.payload-author { margin: 2px 0 7px; font-size: 12px; }
|
||||
.payload-description { margin: 0 0 10px; max-width: 850px; }
|
||||
.payload-meta { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.payload-tag { padding: 2px 8px; border-radius: 10px; background: var(--surface-alt); color: var(--muted); font-size: 11px; text-transform: capitalize; }
|
||||
.payload-actions { display: flex; align-items: center; justify-content: flex-end; gap: 7px; flex-wrap: wrap; }
|
||||
.payload-actions .btn { white-space: nowrap; }
|
||||
.payload-empty { padding: 30px 16px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; font-style: italic; }
|
||||
.payload-runs { display: grid; gap: 12px; }
|
||||
.payload-run { border: 1px solid var(--border); border-radius: 3px; padding: 13px; }
|
||||
.payload-run-head { display: flex; align-items: center; gap: 12px; }
|
||||
.payload-run-head > div:first-child { flex: 1; }
|
||||
.payload-run-head p { margin: 3px 0 0; font-size: 12px; }
|
||||
.payload-run-state { border-radius: 10px; padding: 3px 9px; font-size: 11px; }
|
||||
.payload-run-state.running { color: #2e7d32; background: #e8f5e9; }
|
||||
.payload-run-state.finished { color: var(--muted); background: var(--surface-alt); }
|
||||
.payload-output { max-height: 240px; overflow: auto; margin: 12px 0 0; padding: 10px; border-radius: 2px; background: #111; color: #d8d8d8; font: 12px/1.45 monospace; white-space: pre-wrap; }
|
||||
.payload-dev-grid { display: grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap: 14px; margin: 18px 0; }
|
||||
.payload-dev-block { padding: 15px; border: 1px solid var(--border); background: var(--surface-alt); }
|
||||
.payload-dev-block p { margin-bottom: 0; }
|
||||
.payload-code { margin: 10px 0 0; padding: 12px; overflow: auto; background: #111; color: #d8d8d8; font-size: 12px; }
|
||||
|
||||
/* ---- Settings (Mark VII structure, Pager capabilities) ---- */
|
||||
.settings-content { margin-top: 14px; }
|
||||
.settings-card { margin-bottom: 16px; }
|
||||
.settings-card h2 { font-size: 20px; font-weight: 400; margin-bottom: 10px; }
|
||||
.settings-subtitle { margin: -4px 0 14px; font-size: 13px; line-height: 1.45; }
|
||||
.settings-form-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 4px 16px; align-items: end;
|
||||
}
|
||||
.settings-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
|
||||
.settings-table-wrap { overflow-x: auto; width: 100%; }
|
||||
.settings-table-wrap .tbl { min-width: 680px; }
|
||||
.settings-kv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px 20px; }
|
||||
.settings-kv { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); padding: 7px 0; }
|
||||
.settings-kv > span { color: var(--muted); }
|
||||
.settings-chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.settings-chip { padding: 5px 9px; }
|
||||
.settings-diagnostics { max-height: 520px; white-space: pre-wrap; word-break: break-word; }
|
||||
.settings-card > .switch { display: flex; margin: 10px 0; color: var(--text); font-size: 13px; }
|
||||
.settings-card a { color: var(--primary); }
|
||||
@media (max-width: 700px) {
|
||||
.tabbar { overflow-x: auto; flex-wrap: nowrap; }
|
||||
.tabbar .tab { flex: 0 0 auto; }
|
||||
.settings-form-grid { grid-template-columns: 1fr; }
|
||||
.payload-heading, .payload-card, .payload-run-head { align-items: stretch; flex-direction: column; }
|
||||
.payload-filters, .payload-dev-grid { grid-template-columns: 1fr; }
|
||||
.payload-actions { justify-content: flex-start; }
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>WiFi Pineapple</title>
|
||||
<link rel="icon" type="image/png" href="assets/logo.png">
|
||||
<link rel="stylesheet" href="css/app.css?v=20260811-9">
|
||||
<link rel="stylesheet" href="js/xterm.css">
|
||||
</head>
|
||||
<body>
|
||||
<section id="login-screen">
|
||||
<form id="login-form" class="login-card">
|
||||
<img class="login-logo" src="assets/logo.png" alt="WiFi Pineapple">
|
||||
<h2 class="login-title">WiFi Pineapple</h2>
|
||||
<input id="login-password" class="login-field" type="password" placeholder="Password" autocomplete="current-password" required>
|
||||
<p id="login-error" class="login-error"></p>
|
||||
<button id="login-button" class="btn" type="submit">Login</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div id="app" class="hidden">
|
||||
<header id="topbar">
|
||||
<img id="brand-logo" class="brand-logo" src="assets/logo.png" alt="WiFi Pineapple">
|
||||
<span id="brand-text" class="brand-text">Mark VIII</span>
|
||||
<span class="toolbar-spacer"></span>
|
||||
<span id="live-status"></span>
|
||||
<div class="toolbar-action">
|
||||
<button id="notifications-btn" class="toolbar-icon-btn" type="button" title="Notifications"
|
||||
aria-label="Notifications" aria-haspopup="menu" aria-controls="notifications-menu" aria-expanded="false"></button>
|
||||
<span id="notification-badge" class="notification-badge hidden" aria-hidden="true"></span>
|
||||
<section id="notifications-menu" class="toolbar-menu notifications-menu hidden" role="menu" aria-label="Notifications">
|
||||
<header class="toolbar-menu-header">
|
||||
<span>Notifications</span>
|
||||
<button id="notifications-clear" class="menu-link" type="button">Clear all</button>
|
||||
</header>
|
||||
<div id="notifications-list" class="notifications-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
<button id="terminal-btn" class="toolbar-icon-btn" type="button" title="Terminal" aria-label="Open Terminal"></button>
|
||||
<button id="pager-btn" class="toolbar-icon-btn" type="button" title="Virtual Pager" aria-label="Open Virtual Pager"></button>
|
||||
<div class="toolbar-action">
|
||||
<button id="overflow-btn" class="toolbar-icon-btn" type="button" title="More options"
|
||||
aria-label="More options" aria-haspopup="menu" aria-controls="overflow-menu" aria-expanded="false"></button>
|
||||
<section id="overflow-menu" class="toolbar-menu overflow-menu hidden" role="menu" aria-label="More options">
|
||||
<button class="toolbar-menu-item" type="button" data-menu-action="help" role="menuitem">
|
||||
<span class="menu-icon" data-icon="help"></span><span>Help</span>
|
||||
</button>
|
||||
<button class="toolbar-menu-item" type="button" data-menu-action="updates" role="menuitem">
|
||||
<span class="menu-icon" data-icon="update"></span><span>Check for Updates</span>
|
||||
</button>
|
||||
<button class="toolbar-menu-item internet-menu-item" type="button" data-menu-action="internet" role="menuitem">
|
||||
<span class="menu-icon" data-icon="wifi"></span>
|
||||
<span>Internet Connection<small id="internet-status">Checking…</small></span>
|
||||
<span id="internet-status-dot" class="connection-dot checking" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="toolbar-menu-divider" role="separator"></div>
|
||||
<button class="toolbar-menu-item" type="button" data-menu-action="logout" role="menuitem">
|
||||
<span class="menu-icon" data-icon="logout"></span><span>Logout</span>
|
||||
</button>
|
||||
<button class="toolbar-menu-item danger-item" type="button" data-menu-action="reboot" role="menuitem">
|
||||
<span class="menu-icon" data-icon="reboot"></span><span>Reboot</span>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</header>
|
||||
<div id="layout">
|
||||
<nav id="rail"></nav>
|
||||
<main id="content"></main>
|
||||
</div>
|
||||
<div id="terminal-panel" class="hidden">
|
||||
<div id="terminal-bar">
|
||||
<span>Terminal</span>
|
||||
<button id="terminal-close" class="btn ghost">×</button>
|
||||
</div>
|
||||
<div id="terminal"></div>
|
||||
</div>
|
||||
<div id="pager-panel" class="hidden">
|
||||
<div id="pager-bar">
|
||||
<span>Virtual Pager</span>
|
||||
<button id="pager-close" class="btn ghost">×</button>
|
||||
</div>
|
||||
<div id="pager-body">
|
||||
<div id="pager-scale-wrapper" class="pager-scale-wrapper">
|
||||
<table id="pager_ui" width="745" height="531" border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td colspan="13">
|
||||
<img src="assets/pager/virtual_pager_01.png" width="744" height="67" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="1" height="67" alt="">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" rowspan="3">
|
||||
<img src="assets/pager/virtual_pager_02.png" width="132" height="324" alt="">
|
||||
</td>
|
||||
<td colspan="9" style="position:relative;padding:0;margin:0;font-size:0;line-height:0;">
|
||||
<!-- IMPORTANT: this is the live screen surface -->
|
||||
<img id="pager" width="480" height="222" tabindex="0">
|
||||
<!-- hidden canvas used to convert RGBA framebuffer -> PNG -->
|
||||
<canvas id="pager_canvas" width="480" height="222" style="display:none;"></canvas>
|
||||
<div hidden id="pager_error">
|
||||
Lost connection to virtual pager
|
||||
<br>
|
||||
<button id="screen_retry">Reconnect</button>
|
||||
</div>
|
||||
</td>
|
||||
<td colspan="2" rowspan="2">
|
||||
<img src="assets/pager/virtual_pager_04.png" width="132" height="287" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="1" height="222" alt="">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="9">
|
||||
<img src="assets/pager/virtual_pager_05.png" width="480" height="65" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="1" height="65" alt="">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<img src="assets/pager/virtual_pager_06.png" width="228" height="37" alt="">
|
||||
</td>
|
||||
<td rowspan="5">
|
||||
<img class="pager-btn" src="assets/pager/LEFT.png" role="button" width="87" height="109" alt="">
|
||||
</td>
|
||||
<td rowspan="6">
|
||||
<img src="assets/pager/virtual_pager_08.png" width="7" height="176" alt="">
|
||||
</td>
|
||||
<td rowspan="2">
|
||||
<img class="pager-btn" src="assets/pager/UP.png" role="button" width="139" height="49" alt="">
|
||||
</td>
|
||||
<td rowspan="6">
|
||||
<img src="assets/pager/virtual_pager_10.png" width="9" height="176" alt="">
|
||||
</td>
|
||||
<td colspan="2" rowspan="5">
|
||||
<img class="pager-btn" src="assets/pager/RIGHT.png" role="button" width="86" height="109" alt="">
|
||||
</td>
|
||||
<td rowspan="6">
|
||||
<img src="assets/pager/virtual_pager_12.png" width="56" height="176" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="1" height="37" alt="">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="5">
|
||||
<img src="assets/pager/virtual_pager_13.png" width="73" height="139" alt="">
|
||||
</td>
|
||||
<td colspan="2" rowspan="3">
|
||||
<img class="pager-btn" src="assets/pager/B_Button.png" role="button" width="115" height="44" alt="">
|
||||
</td>
|
||||
<td rowspan="5">
|
||||
<img src="assets/pager/virtual_pager_15.png" width="22" height="139" alt="">
|
||||
</td>
|
||||
<td rowspan="3">
|
||||
<img class="pager-btn" src="assets/pager/A_Button.png" role="button" width="115" height="44" alt="">
|
||||
</td>
|
||||
<td rowspan="5">
|
||||
<img src="assets/pager/virtual_pager_17.png" width="35" height="139" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="1" height="12" alt="">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="assets/pager/virtual_pager_18.png" width="139" height="11" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="1" height="11" alt="">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2">
|
||||
<img class="pager-btn" src="assets/pager/DOWN.png" role="button" width="139" height="49" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="1" height="21" alt="">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" rowspan="2">
|
||||
<img src="assets/pager/virtual_pager_20.png" width="115" height="95" alt="">
|
||||
</td>
|
||||
<td rowspan="2">
|
||||
<img src="assets/pager/virtual_pager_21.png" width="115" height="95" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="1" height="28" alt="">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="assets/pager/virtual_pager_22.png" width="87" height="67" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/virtual_pager_23.png" width="139" height="67" alt="">
|
||||
</td>
|
||||
<td colspan="2">
|
||||
<img src="assets/pager/virtual_pager_24.png" width="86" height="67" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="1" height="67" alt="">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="73" height="1" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="59" height="1" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="56" height="1" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="22" height="1" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="115" height="1" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="35" height="1" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="87" height="1" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="7" height="1" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="139" height="1" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="9" height="1" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="10" height="1" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="76" height="1" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<img src="assets/pager/spacer.gif" width="56" height="1" alt="">
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/icons.js?v=20260811-6"></script>
|
||||
<script src="js/api.js?v=20260811-3"></script>
|
||||
<script src="js/chart.js"></script>
|
||||
<script src="js/xterm.min.js"></script>
|
||||
<script src="js/xterm-addon-fit.min.js"></script>
|
||||
<script src="js/terminal.js"></script>
|
||||
<script src="js/pager.js"></script>
|
||||
<script src="js/views.js?v=20260811-11"></script>
|
||||
<script src="js/app.js?v=20260811-9"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
const PagerAPI = (() => {
|
||||
let apiBase = '';
|
||||
let on401 = null;
|
||||
async function request(method, path, body) {
|
||||
const opts = { method, headers: {}, credentials: 'include' };
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(apiBase + path, opts);
|
||||
if (res.status === 401) {
|
||||
if (on401) on401();
|
||||
throw new Error('unauthorized');
|
||||
}
|
||||
const ct = res.headers.get('Content-Type') || '';
|
||||
let data;
|
||||
if (ct.indexOf('json') !== -1) {
|
||||
try { data = await res.json(); }
|
||||
catch (e) { data = null; }
|
||||
} else {
|
||||
data = await res.text();
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = data && data.error ? data.error : ('HTTP ' + res.status);
|
||||
const error = new Error(message);
|
||||
error.status = res.status;
|
||||
error.data = data;
|
||||
throw error;
|
||||
}
|
||||
return { status: res.status, data };
|
||||
}
|
||||
return {
|
||||
setBase: (b) => { apiBase = b; },
|
||||
on401: (fn) => { on401 = fn; },
|
||||
get: (p) => request('GET', p),
|
||||
post: (p, b) => request('POST', p, b === undefined ? {} : b),
|
||||
del: (p, b) => request('DELETE', p, b === undefined ? {} : b),
|
||||
login: async (username, password) => request('POST', '/api/login', { username, password })
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,488 @@
|
||||
'use strict';
|
||||
|
||||
const Theme = (() => {
|
||||
const KEY = 'pw_theme';
|
||||
function apply() {
|
||||
document.documentElement.classList.toggle('dark', localStorage.getItem(KEY) === 'dark');
|
||||
}
|
||||
function toggle() {
|
||||
localStorage.setItem(KEY, Theme.current() === 'dark' ? 'light' : 'dark');
|
||||
apply();
|
||||
}
|
||||
function current() { return document.documentElement.classList.contains('dark') ? 'dark' : 'light'; }
|
||||
return { apply, toggle, current, KEY };
|
||||
})();
|
||||
|
||||
const App = (() => {
|
||||
const cfg = window.PAGER_CONFIG || {};
|
||||
const API_BASE = cfg.apiBase || '';
|
||||
const WS_BASE = (cfg.wsBase || location.origin).replace(/^http/, 'ws');
|
||||
const TERMINAL_WS = cfg.terminalWs || ('ws://' + location.hostname + ':1471/api/terminal/openWs');
|
||||
const PAGER_SCREEN_WS = cfg.pagerScreenWs || (WS_BASE + '/api/pager/display/screen.ws');
|
||||
const PAGER_KEYS_WS = cfg.pagerKeysWs || (WS_BASE + '/api/pager/input/keys.ws');
|
||||
|
||||
const els = {};
|
||||
let currentView = null;
|
||||
let notifications = [];
|
||||
let unreadNotifications = 0;
|
||||
const NOTIFICATIONS_KEY = 'markviii_notifications';
|
||||
|
||||
const railItems = [
|
||||
{ key: 'dashboard', label: 'Dashboard', hash: '#/dashboard', icon: 'dashboard' },
|
||||
{ key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'wifi' },
|
||||
{ key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' },
|
||||
{ key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' },
|
||||
{ key: 'modules', label: 'Payloads', hash: '#/modules', icon: 'modules' },
|
||||
{ key: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' }
|
||||
];
|
||||
const railDividers = new Set(['logging']);
|
||||
|
||||
function keyOf(hash) {
|
||||
const seg = (hash || '#/dashboard').replace(/^#\//, '').split('/')[0];
|
||||
return seg || 'dashboard';
|
||||
}
|
||||
|
||||
function buildRail() {
|
||||
const rail = els.rail;
|
||||
rail.innerHTML = '';
|
||||
function railEntry(it) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'entry';
|
||||
a.href = it.hash;
|
||||
a.title = it.label;
|
||||
a.innerHTML = '<span class="entry-icon">' + PineappleIcons[it.icon] + '</span><span class="entry-text">' + it.label + '</span>';
|
||||
a.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; });
|
||||
return a;
|
||||
}
|
||||
railItems.filter((it) => it.key !== 'settings').forEach((it) => {
|
||||
if (railDividers.has(it.key)) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'entry divider';
|
||||
rail.appendChild(d);
|
||||
}
|
||||
rail.appendChild(railEntry(it));
|
||||
});
|
||||
const foot = document.createElement('div');
|
||||
foot.className = 'rail-footer';
|
||||
const settings = railItems.find((it) => it.key === 'settings');
|
||||
if (settings) foot.appendChild(railEntry(settings));
|
||||
const open = document.createElement('a');
|
||||
open.className = 'entry';
|
||||
open.title = 'Open Menu';
|
||||
open.innerHTML = '<span class="entry-icon">' + PineappleIcons.chevron_right + '</span><span class="entry-text">Open Menu</span>';
|
||||
open.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const openState = localStorage.getItem('pw_rail') === 'open';
|
||||
localStorage.setItem('pw_rail', openState ? 'closed' : 'open');
|
||||
els.rail.classList.toggle('open', !openState);
|
||||
});
|
||||
foot.appendChild(open);
|
||||
rail.appendChild(foot);
|
||||
rail.classList.toggle('open', localStorage.getItem('pw_rail') === 'open');
|
||||
}
|
||||
|
||||
function route() {
|
||||
closeToolbarMenus();
|
||||
const hash = location.hash || '#/dashboard';
|
||||
const name = routes[hash];
|
||||
if (currentView && currentView.destroy) currentView.destroy();
|
||||
els.content.innerHTML = '';
|
||||
if (!name || !views[name]) {
|
||||
const ph = document.createElement('div');
|
||||
ph.className = 'section empty';
|
||||
ph.textContent = 'View not available.';
|
||||
els.content.appendChild(ph);
|
||||
currentView = null;
|
||||
} else {
|
||||
currentView = views[name](els.content);
|
||||
}
|
||||
const key = keyOf(hash);
|
||||
Array.prototype.forEach.call(els.rail.querySelectorAll('.entry'), (a) => {
|
||||
const href = a.getAttribute('href');
|
||||
a.classList.toggle('active', !!href && (href === hash || keyOf(href) === key));
|
||||
});
|
||||
}
|
||||
|
||||
function loadNotifications() {
|
||||
try {
|
||||
const stored = JSON.parse(sessionStorage.getItem(NOTIFICATIONS_KEY) || '{}');
|
||||
notifications = Array.isArray(stored.items) ? stored.items.slice(0, 30) : [];
|
||||
unreadNotifications = Math.max(0, Number(stored.unread) || 0);
|
||||
} catch (e) {
|
||||
notifications = [];
|
||||
unreadNotifications = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function saveNotifications() {
|
||||
try {
|
||||
sessionStorage.setItem(NOTIFICATIONS_KEY, JSON.stringify({
|
||||
items: notifications.slice(0, 30), unread: unreadNotifications
|
||||
}));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function notificationTime(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const today = new Date();
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function renderNotifications() {
|
||||
if (!els.notificationsList) return;
|
||||
els.notificationsList.innerHTML = '';
|
||||
els.notificationBadge.classList.toggle('hidden', unreadNotifications === 0);
|
||||
els.notificationBadge.textContent = unreadNotifications > 99 ? '99+' : String(unreadNotifications);
|
||||
els.notificationsClear.disabled = notifications.length === 0;
|
||||
if (!notifications.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'notification-empty';
|
||||
empty.textContent = 'No Notifications';
|
||||
els.notificationsList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
notifications.forEach((notice) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'notification-item';
|
||||
const marker = document.createElement('span');
|
||||
marker.className = 'notification-kind ' + (notice.kind || 'info');
|
||||
const message = document.createElement('span');
|
||||
message.className = 'notification-message';
|
||||
message.textContent = notice.message;
|
||||
const time = document.createElement('time');
|
||||
time.className = 'notification-time';
|
||||
time.dateTime = new Date(notice.timestamp).toISOString();
|
||||
time.textContent = notificationTime(notice.timestamp);
|
||||
item.append(marker, message, time);
|
||||
els.notificationsList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function addNotification(message, kind) {
|
||||
const normalizedKind = ['error', 'success'].indexOf(kind) === -1 ? 'info' : kind;
|
||||
notifications.unshift({
|
||||
message: String(message).slice(0, 300), kind: normalizedKind, timestamp: Date.now()
|
||||
});
|
||||
notifications = notifications.slice(0, 30);
|
||||
unreadNotifications += 1;
|
||||
saveNotifications();
|
||||
renderNotifications();
|
||||
}
|
||||
|
||||
function toast(msg, kind, options) {
|
||||
if (!options || options.notify !== false) addNotification(msg, kind);
|
||||
const d = document.createElement('div');
|
||||
d.className = 'toast ' + (kind || 'info');
|
||||
d.textContent = msg;
|
||||
els.toasts.appendChild(d);
|
||||
setTimeout(() => d.remove(), 4000);
|
||||
}
|
||||
|
||||
function closeToolbarMenus() {
|
||||
if (!els.notificationsMenu) return;
|
||||
els.notificationsMenu.classList.add('hidden');
|
||||
els.overflowMenu.classList.add('hidden');
|
||||
els.notificationsButton.setAttribute('aria-expanded', 'false');
|
||||
els.overflowButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
function toggleToolbarMenu(name) {
|
||||
const menu = name === 'notifications' ? els.notificationsMenu : els.overflowMenu;
|
||||
const button = name === 'notifications' ? els.notificationsButton : els.overflowButton;
|
||||
const opening = menu.classList.contains('hidden');
|
||||
closeToolbarMenus();
|
||||
if (!opening) return;
|
||||
menu.classList.remove('hidden');
|
||||
button.setAttribute('aria-expanded', 'true');
|
||||
if (name === 'notifications') {
|
||||
unreadNotifications = 0;
|
||||
saveNotifications();
|
||||
renderNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
function setInternetStatus(state) {
|
||||
const labels = { checking: 'Checking\u2026', online: 'Online', offline: 'Offline' };
|
||||
els.internetStatus.textContent = labels[state];
|
||||
els.internetStatusDot.className = 'connection-dot ' + state;
|
||||
}
|
||||
|
||||
function checkInternet(announce) {
|
||||
setInternetStatus('checking');
|
||||
return PagerAPI.get('/api/settings/internet')
|
||||
.then((response) => {
|
||||
const online = !!response.data.online;
|
||||
setInternetStatus(online ? 'online' : 'offline');
|
||||
if (announce) toast(online ? 'Internet connection is online' : 'Internet connection is offline', online ? 'success' : 'error');
|
||||
})
|
||||
.catch(() => {
|
||||
setInternetStatus('offline');
|
||||
if (announce) toast('Unable to check the internet connection', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function handleMenuAction(action) {
|
||||
closeToolbarMenus();
|
||||
if (action === 'help') {
|
||||
location.hash = '#/settings/help';
|
||||
} else if (action === 'updates') {
|
||||
location.hash = '#/settings/advanced';
|
||||
toast('Update controls are available in Advanced settings');
|
||||
} else if (action === 'internet') {
|
||||
checkInternet(true);
|
||||
} else if (action === 'logout') {
|
||||
PagerAPI.post('/api/logout')
|
||||
.then(() => showLogin())
|
||||
.catch((error) => toast(error.message || 'Logout failed', 'error'));
|
||||
} else if (action === 'reboot') {
|
||||
if (!window.confirm('Reboot Mark VIII now?')) return;
|
||||
PagerAPI.post('/api/settings/reboot')
|
||||
.then(() => toast('Reboot requested. Mark VIII will disconnect shortly.'))
|
||||
.catch((error) => toast(error.message || 'Reboot failed', 'error'));
|
||||
}
|
||||
}
|
||||
|
||||
function showDock(name) {
|
||||
if (name === 'pager') {
|
||||
if (typeof Term !== 'undefined' && Term.isOpen()) Term.close();
|
||||
} else {
|
||||
if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close();
|
||||
}
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
els.login.classList.add('hidden');
|
||||
els.app.classList.remove('hidden');
|
||||
Theme.apply();
|
||||
Live.start();
|
||||
route();
|
||||
checkInternet(false);
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
closeToolbarMenus();
|
||||
if (typeof Live !== 'undefined') Live.stop();
|
||||
if (typeof Term !== 'undefined' && Term.isOpen()) Term.close();
|
||||
if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close();
|
||||
els.app.classList.add('hidden');
|
||||
els.login.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function init() {
|
||||
els.login = document.getElementById('login-screen');
|
||||
els.app = document.getElementById('app');
|
||||
els.rail = document.getElementById('rail');
|
||||
els.content = document.getElementById('content');
|
||||
els.toasts = document.getElementById('toast-container');
|
||||
els.notificationsButton = document.getElementById('notifications-btn');
|
||||
els.notificationsMenu = document.getElementById('notifications-menu');
|
||||
els.notificationsList = document.getElementById('notifications-list');
|
||||
els.notificationsClear = document.getElementById('notifications-clear');
|
||||
els.notificationBadge = document.getElementById('notification-badge');
|
||||
els.overflowButton = document.getElementById('overflow-btn');
|
||||
els.overflowMenu = document.getElementById('overflow-menu');
|
||||
els.internetStatus = document.getElementById('internet-status');
|
||||
els.internetStatusDot = document.getElementById('internet-status-dot');
|
||||
|
||||
els.notificationsButton.innerHTML = PineappleIcons.notifications;
|
||||
document.getElementById('terminal-btn').innerHTML = PineappleIcons.terminal;
|
||||
document.getElementById('pager-btn').innerHTML = PineappleIcons.pager;
|
||||
els.overflowButton.innerHTML = PineappleIcons.more_vert;
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-icon]'), (icon) => {
|
||||
icon.innerHTML = PineappleIcons[icon.getAttribute('data-icon')] || '';
|
||||
});
|
||||
loadNotifications();
|
||||
renderNotifications();
|
||||
|
||||
els.notificationsButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
toggleToolbarMenu('notifications');
|
||||
});
|
||||
els.overflowButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
toggleToolbarMenu('overflow');
|
||||
});
|
||||
els.notificationsMenu.addEventListener('click', (event) => event.stopPropagation());
|
||||
els.overflowMenu.addEventListener('click', (event) => event.stopPropagation());
|
||||
els.notificationsClear.addEventListener('click', () => {
|
||||
notifications = [];
|
||||
unreadNotifications = 0;
|
||||
saveNotifications();
|
||||
renderNotifications();
|
||||
});
|
||||
Array.prototype.forEach.call(els.overflowMenu.querySelectorAll('[data-menu-action]'), (item) => {
|
||||
item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action')));
|
||||
});
|
||||
document.addEventListener('click', closeToolbarMenus);
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('login-button');
|
||||
const pw = document.getElementById('login-password').value;
|
||||
document.getElementById('login-error').textContent = '';
|
||||
btn.disabled = true;
|
||||
PagerAPI.login('root', pw)
|
||||
.then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); })
|
||||
.catch((err) => {
|
||||
document.getElementById('login-error').textContent = (err && err.message && err.message !== 'unauthorized')
|
||||
? 'Login failed.' : 'Invalid credentials.';
|
||||
})
|
||||
.finally(() => { btn.disabled = false; });
|
||||
});
|
||||
|
||||
document.getElementById('terminal-btn').addEventListener('click', () => {
|
||||
if (typeof Term === 'undefined') { toast('Terminal not available yet'); return; }
|
||||
showDock('terminal');
|
||||
Term.toggle();
|
||||
});
|
||||
document.getElementById('terminal-close').addEventListener('click', () => {
|
||||
if (typeof Term === 'undefined') { toast('Terminal not available yet'); return; }
|
||||
showDock('terminal');
|
||||
Term.toggle();
|
||||
});
|
||||
document.getElementById('pager-btn').addEventListener('click', () => {
|
||||
if (typeof Pager === 'undefined') { toast('Virtual Pager not available yet'); return; }
|
||||
showDock('pager');
|
||||
Pager.toggle();
|
||||
});
|
||||
document.getElementById('pager-close').addEventListener('click', () => {
|
||||
if (typeof Pager === 'undefined') { toast('Virtual Pager not available yet'); return; }
|
||||
showDock('pager');
|
||||
Pager.toggle();
|
||||
});
|
||||
window.addEventListener('hashchange', route);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeToolbarMenus();
|
||||
return;
|
||||
}
|
||||
if (localStorage.getItem('pw_hotkeys') === 'false') return;
|
||||
const t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const map = { d: '#/dashboard', p: '#/pineap', r: '#/recon', l: '#/logging', m: '#/modules' };
|
||||
if (map[e.key.toLowerCase()]) { location.hash = map[e.key.toLowerCase()]; }
|
||||
else if (e.key === '`') {
|
||||
if (typeof Term === 'undefined') return;
|
||||
if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close();
|
||||
Term.toggle();
|
||||
}
|
||||
});
|
||||
|
||||
PagerAPI.setBase(API_BASE);
|
||||
PagerAPI.on401(() => showLogin());
|
||||
buildRail();
|
||||
Theme.apply();
|
||||
checkSession();
|
||||
}
|
||||
|
||||
function checkSession() {
|
||||
PagerAPI.get('/api/api_ping')
|
||||
.then(() => showApp())
|
||||
.catch(() => showLogin());
|
||||
}
|
||||
|
||||
const routes = {
|
||||
'#/dashboard': 'dashboard',
|
||||
'#/pineap': 'pineap',
|
||||
'#/pineap/open': 'pineap_open',
|
||||
'#/pineap/evilwpa': 'pineap_evilwpa',
|
||||
'#/pineap/impersonation': 'pineap_impersonation',
|
||||
'#/pineap/clients': 'pineap_clients',
|
||||
'#/pineap/filtering': 'pineap_filtering',
|
||||
'#/recon': 'recon',
|
||||
'#/recon/handshakes': 'recon_handshakes',
|
||||
'#/logging': 'logging',
|
||||
'#/logging/system': 'logging_system',
|
||||
'#/modules': 'modules',
|
||||
'#/modules/online': 'modules_online',
|
||||
'#/modules/running': 'modules_running',
|
||||
'#/modules/develop': 'modules_develop',
|
||||
'#/settings': 'settings',
|
||||
'#/settings/networking': 'settings_networking',
|
||||
'#/settings/wifi': 'settings_wifi',
|
||||
'#/settings/led': 'settings_led',
|
||||
'#/settings/advanced': 'settings_advanced',
|
||||
'#/settings/help': 'settings_help'
|
||||
};
|
||||
|
||||
return { init, route, toast, showLogin, wsUrl: (p) => WS_BASE + p, terminalWs: TERMINAL_WS,
|
||||
pagerScreenWs: PAGER_SCREEN_WS, pagerKeysWs: PAGER_KEYS_WS, apiBase: API_BASE,
|
||||
keyOf, railItems, go: (h) => { location.hash = h; },
|
||||
get key() { return keyOf(location.hash); } };
|
||||
})();
|
||||
|
||||
const Live = (() => {
|
||||
let ws = null;
|
||||
let ever = false;
|
||||
let poll = null;
|
||||
const subs = [];
|
||||
let timer = null;
|
||||
function stopPoll() {
|
||||
if (poll) { clearInterval(poll); poll = null; }
|
||||
}
|
||||
function start() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
||||
stopPoll();
|
||||
try { ws = new WebSocket(App.wsUrl('/api/ws')); }
|
||||
catch (e) { fallback(); return; }
|
||||
ws.onopen = () => { ever = true; };
|
||||
ws.onmessage = (ev) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
||||
subs.forEach((fn) => fn(msg));
|
||||
updateBar(msg);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
ws = null;
|
||||
clearTimeout(timer);
|
||||
if (ever) timer = setTimeout(start, 5000);
|
||||
else fallback();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||||
}
|
||||
function stop() {
|
||||
ever = false;
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
stopPoll();
|
||||
if (ws) {
|
||||
const active = ws;
|
||||
ws = null;
|
||||
active.onclose = null;
|
||||
try { active.close(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
function fallback() {
|
||||
stopPoll();
|
||||
const seconds = Math.max(2, Math.min(120, parseInt(localStorage.getItem('pw-poll') || '5', 10) || 5));
|
||||
poll = setInterval(async () => {
|
||||
try {
|
||||
const r = await PagerAPI.get('/api/status');
|
||||
const msg = { type: 'tick', status: r.data, clients: r.data.clients };
|
||||
subs.forEach((fn) => fn(msg));
|
||||
updateBar(msg);
|
||||
} catch (e) {}
|
||||
}, seconds * 1000);
|
||||
}
|
||||
function updateBar(msg) {
|
||||
const b = (msg.status || {}).battery || {};
|
||||
const n = (msg.clients || []).length;
|
||||
const el = document.getElementById('live-status');
|
||||
if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n;
|
||||
}
|
||||
function onTick(fn) {
|
||||
subs.push(fn);
|
||||
return () => {
|
||||
const i = subs.indexOf(fn);
|
||||
if (i !== -1) subs.splice(i, 1);
|
||||
};
|
||||
}
|
||||
return { start, stop, onTick };
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => App.init());
|
||||
@@ -0,0 +1,137 @@
|
||||
'use strict';
|
||||
|
||||
const MiniChart = (() => {
|
||||
function draw(canvas, series, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = 140 * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = 140;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const max = Math.max(o.max || 10, ...series.map((s) => Math.max(...s.points, 0)), 1);
|
||||
const pad = 8;
|
||||
ctx.strokeStyle = o.grid || '#e0e0e0';
|
||||
ctx.lineWidth = 1;
|
||||
for (let g = 0; g <= 4; g++) {
|
||||
const y = pad + (h - pad * 2) * g / 4;
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
||||
}
|
||||
series.forEach((s) => {
|
||||
const pts = s.points;
|
||||
if (!pts || pts.length < 2) return;
|
||||
ctx.strokeStyle = s.color || '#1976d2';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
pts.forEach((v, i) => {
|
||||
if (v == null) { started = false; return; }
|
||||
const x = pad + (w - pad * 2) * i / Math.max(pts.length - 1, 1);
|
||||
const y = h - pad - (h - pad * 2) * (v / max);
|
||||
if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
const last = pts[pts.length - 1];
|
||||
if (last != null) {
|
||||
const x = pad + (w - pad * 2) * (pts.length - 1) / Math.max(pts.length - 1, 1);
|
||||
const y = h - pad - (h - pad * 2) * (last / max);
|
||||
ctx.fillStyle = s.color || '#1976d2';
|
||||
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function doughnut(canvas, segments, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const legendH = o.legend ? 22 : 0;
|
||||
const H = (o.height || 160) + legendH;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = o.height || 160;
|
||||
ctx.clearRect(0, 0, w, H);
|
||||
const cx = w / 2, cy = h / 2;
|
||||
const r = Math.min(w, h) / 2 - 8;
|
||||
const hole = (o.hole == null ? 0.65 : o.hole) * r;
|
||||
const total = segments.reduce((s, x) => s + x.value, 0);
|
||||
if (!total) {
|
||||
ctx.strokeStyle = '#e0e0e0';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.arc(cx, cy, hole, 0, Math.PI * 2); ctx.stroke();
|
||||
return;
|
||||
}
|
||||
let a0 = -Math.PI / 2;
|
||||
segments.forEach((seg) => {
|
||||
const a1 = a0 + (seg.value / total) * Math.PI * 2;
|
||||
ctx.fillStyle = seg.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, a0, a1);
|
||||
ctx.arc(cx, cy, hole, a1, a0, true);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
a0 = a1;
|
||||
});
|
||||
ctx.strokeStyle = o.stroke || '#ffffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.arc(cx, cy, hole, 0, Math.PI * 2); ctx.stroke();
|
||||
if (o.legend) {
|
||||
ctx.font = '11px Roboto, "Segoe UI", Arial, sans-serif';
|
||||
const dots = segments.filter((s) => s.value > 0);
|
||||
let tw = 0;
|
||||
dots.forEach((s) => { tw += 16 + ctx.measureText(s.label).width + 8; });
|
||||
tw = Math.max(tw - 8, 0);
|
||||
let x = (w - tw) / 2;
|
||||
const ly = h + 13;
|
||||
dots.forEach((s) => {
|
||||
ctx.fillStyle = s.color;
|
||||
ctx.beginPath(); ctx.arc(x + 4, ly - 3, 4, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#686868';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(s.label, x + 12, ly);
|
||||
x += 16 + ctx.measureText(s.label).width + 8;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bar(canvas, items, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const H = o.height || 160;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = H;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!items || !items.length) return;
|
||||
const max = Math.max(1, ...items.map((i) => i.value));
|
||||
const padB = 16, padT = 8, padL = 6, padR = 6;
|
||||
const plotW = w - padL - padR, plotH = h - padT - padB;
|
||||
const bw = plotW / items.length;
|
||||
ctx.strokeStyle = o.grid || '#e0e0e0';
|
||||
ctx.lineWidth = 1;
|
||||
for (let g = 0; g <= 4; g++) {
|
||||
const y = padT + plotH * g / 4;
|
||||
ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(w - padR, y); ctx.stroke();
|
||||
}
|
||||
items.forEach((it, i) => {
|
||||
const bh = it.value / max * plotH;
|
||||
const x = padL + bw * i + bw * 0.15;
|
||||
const wd = bw * 0.7;
|
||||
const y = padT + plotH - bh;
|
||||
ctx.fillStyle = it.color;
|
||||
ctx.fillRect(x, y, wd, bh);
|
||||
ctx.fillStyle = '#686868';
|
||||
ctx.font = '10px Roboto, "Segoe UI", Arial, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(String(it.label), padL + bw * i + bw / 2, h - 4);
|
||||
});
|
||||
}
|
||||
|
||||
return { draw, doughnut, bar };
|
||||
})();
|
||||
@@ -0,0 +1,7 @@
|
||||
window.PAGER_CONFIG = {
|
||||
apiBase: '',
|
||||
wsBase: '',
|
||||
terminalWs: '',
|
||||
pagerScreenWs: '',
|
||||
pagerKeysWs: ''
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
|
||||
window.PineappleIcons = {
|
||||
dashboard: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,16A3,3 0 0,1 9,13C9,11.88 9.61,10.9 10.5,10.39L20.21,4.77L14.68,14.35C14.18,15.33 13.17,16 12,16M12,3C13.81,3 15.5,3.5 16.97,4.32L14.87,5.53C14,5.19 13,5 12,5A8,8 0 0,0 4,13C4,15.21 4.89,17.21 6.34,18.65H6.35C6.74,19.04 6.74,19.67 6.35,20.06C5.96,20.45 5.32,20.45 4.93,20.07V20.07C3.12,18.26 2,15.76 2,13A10,10 0 0,1 12,3M22,13C22,15.76 20.88,18.26 19.07,20.07V20.07C18.68,20.45 18.05,20.45 17.66,20.06C17.27,19.67 17.27,19.04 17.66,18.65V18.65C19.11,17.2 20,15.21 20,13C20,12 19.81,11 19.46,10.1L20.67,8C21.5,9.5 22,11.18 22,13Z"/></svg>',
|
||||
pineap: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,21L15.6,16.2C16.2,15.4 16.8,14.5 17.2,13.6C18.1,11.5 18,9 18,9C18,6.5 16.5,4.3 15,3.5C13.5,2.7 10.5,2.7 9,3.5C7.5,4.3 6,6.5 6,9C6,9 5.9,11.5 6.8,13.6C7.2,14.5 7.8,15.4 8.4,16.2L12,21M12,5.5C13.4,5.5 14.5,6.6 14.5,8C14.5,9.4 13.4,10.5 12,10.5C10.6,10.5 9.5,9.4 9.5,8C9.5,6.6 10.6,5.5 12,5.5M7.1,13.1C7.1,13.1 8.2,14 12,14C15.8,14 16.9,13.1 16.9,13.1L15.9,12.1C15.9,12.1 14.8,12.8 12,12.8C9.2,12.8 8.1,12.1 8.1,12.1L7.1,13.1M12,17C10,17 9,17.6 9,17.6L10.3,19.3C10.3,19.3 11.1,19 12,19C12.9,19 13.7,19.3 13.7,19.3L15,17.6C15,17.6 14,17 12,17Z"/></svg>',
|
||||
recon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11,6H13V13H11V6M9,20A1,1 0 0,1 8,21H5A1,1 0 0,1 4,20V15L6,6H10V13A1,1 0 0,1 9,14V20M10,5H7V3H10V5M15,20V14A1,1 0 0,1 14,13V6H18L20,15V20A1,1 0 0,1 19,21H16A1,1 0 0,1 15,20M14,5V3H17V5H14Z"/></svg>',
|
||||
logging: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,17H4V15H14V17M14,13H4V11H14V13M14,9H4V7H14V9M18,13V11H16V9H18V7H20V9H22V11H20V13H18M20,3H2A2,2 0 0,0 0,5V19A2,2 0 0,0 2,21H20A2,2 0 0,0 22,19V17H20V19H2V5H20V3Z"/></svg>',
|
||||
modules: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z"/></svg>',
|
||||
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 11.11V7A2 2 0 0 0 19 5H15V3A2 2 0 0 0 13 1H9A2 2 0 0 0 7 3V5H3A2 2 0 0 0 1 7V18A2 2 0 0 0 3 20H10.26A7 7 0 1 0 21 11.11M9 3H13V5H9M19 20A5 5 0 0 1 13 20A5 5 0 1 1 19 20M15 13H16.5V15.82L18.94 17.23L18.19 18.53L15 16.69V13"/></svg>',
|
||||
chevron: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z"/></svg>',
|
||||
terminal: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20,19V7H4V19H20M20,3A2,2 0 0,1 22,5V19A2,2 0 0,1 20,21H4A2,2 0 0,1 2,19V5C2,3.89 2.9,3 4,3H20M13,17V15H18V17H13M9.58,13L5.57,9H8.4L11.7,12.3C12.09,12.69 12.09,13.33 11.7,13.72L8.42,17H5.59L9.58,13Z"/></svg>',
|
||||
wifi: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1,9L3,11C8,6 16,6 21,11L23,9C17,3 7,3 1,9M5,13L7,15C10,12.5 14,12.5 17,15L19,13C15,9 9,9 5,13M9,17L12,21L15,17C13.34,15.67 10.66,15.67 9,17Z"/></svg>',
|
||||
extension: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z"/></svg>',
|
||||
receipt: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,17H4V15H14V17M14,13H4V11H14V13M14,9H4V7H14V9M18,13V11H16V9H18V7H20V9H22V11H20V13H18M20,3H2A2,2 0 0,0 0,5V19A2,2 0 0,0 2,21H20A2,2 0 0,0 22,19V17H20V19H2V5H20V3Z"/></svg>',
|
||||
refresh: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/></svg>',
|
||||
file_download: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,9H15V3H9V9H5L12,16L19,9M5,18V20H19V18H5Z"/></svg>',
|
||||
delete: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,19C6,20.1 6.9,21 8,21H16C17.1,21 18,20.1 18,19V7H6V19M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19V4Z"/></svg>',
|
||||
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94C19.18,12.64 19.2,12.33 19.2,12C19.2,11.68 19.18,11.36 19.13,11.06L21.16,9.48C21.34,9.34 21.39,9.07 21.28,8.87L19.36,5.55C19.24,5.33 18.99,5.26 18.77,5.33L16.38,6.29C15.88,5.91 15.35,5.59 14.76,5.35L14.4,2.81C14.36,2.57 14.16,2.4 13.92,2.4H10.08C9.84,2.4 9.65,2.57 9.61,2.81L9.25,5.35C8.66,5.59 8.12,5.91 7.63,6.29L5.24,5.33C5.02,5.26 4.77,5.33 4.65,5.55L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48L4.89,11.06C4.84,11.36 4.8,11.67 4.8,12C4.8,12.33 4.82,12.64 4.87,12.94L2.84,14.52C2.66,14.66 2.61,14.93 2.72,15.13L4.64,18.45C4.76,18.67 5.01,18.74 5.23,18.67L7.62,17.71C8.12,18.09 8.65,18.41 9.24,18.65L9.6,21.19C9.65,21.43 9.84,21.6 10.08,21.6H13.92C14.16,21.6 14.36,21.43 14.4,21.19L14.76,18.65C15.35,18.41 15.88,18.09 16.38,17.71L18.77,18.67C18.99,18.74 19.24,18.67 19.36,18.45L21.28,15.13C21.39,14.93 21.34,14.66 21.16,14.52L19.14,12.94M12,15.6C10.02,15.6 8.4,13.98 8.4,12C8.4,10.02 10.02,8.4 12,8.4C13.98,8.4 15.6,10.02 15.6,12C15.6,13.98 13.98,15.6 12,15.6Z"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C5.91,3 3,5.91 3,9.5C3,13.09 5.91,16 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.49L20.49,19L15.5,14M9.5,14C7.01,14 5,11.99 5,9.5C5,7.01 7.01,5 9.5,5C11.99,5 14,7.01 14,9.5C14,11.99 11.99,14 9.5,14Z"/></svg>',
|
||||
first_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.41,16.59L13.82,12L18.41,7.41L17,6L11,12L17,18L18.41,16.59M6,6H8V18H6V6Z"/></svg>',
|
||||
last_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.59,7.41L10.18,12L5.59,16.59L7,18L13,12L7,6L5.59,7.41M16,6H18V18H16V6Z"/></svg>',
|
||||
chevron_left: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z"/></svg>',
|
||||
check: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9,16.17L4.83,12L3.41,13.41L9,19L21,7L19.59,5.59L9,16.17Z"/></svg>',
|
||||
close: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/></svg>',
|
||||
question_mark: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.07,12.85C11.07,12.85 12.23,12.5 13.03,11.64C13.83,10.79 13.85,9.61 13.24,8.63C12.5,7.58 11.3,7.63 10.67,7.85C10.17,8.03 9.9,8.36 9.63,8.84L8.4,8.12C8.77,7.42 9.23,6.82 9.98,6.39C11.09,5.78 12.58,5.66 13.85,6.53C15.12,7.41 15.83,8.85 15.42,10.13C15.04,11.31 14.05,11.96 13.03,12.45C12.44,12.73 12,13.06 12,13.86V14H11.07V12.85M11,16H12.93V18H11V16Z"/></svg>',
|
||||
chevron_right: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z"/></svg>',
|
||||
notifications: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,22A2,2 0 0,0 14,20H10A2,2 0 0,0 12,22M18,16V11C18,7.93 16.36,5.36 13.5,4.68V4A1.5,1.5 0 0,0 10.5,4V4.68C7.63,5.36 6,7.92 6,11V16L4,18V19H20V18L18,16Z"/></svg>',
|
||||
pager: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17,1H7A2,2 0 0,0 5,3V21A2,2 0 0,0 7,23H17A2,2 0 0,0 19,21V3A2,2 0 0,0 17,1M17,19H7V5H17V19M9,7H15V9H9V7M9,11H15V13H9V11Z"/></svg>',
|
||||
more_vert: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,8A2,2 0 1,0 12,4A2,2 0 0,0 12,8M12,10A2,2 0 1,0 12,14A2,2 0 0,0 12,10M12,16A2,2 0 1,0 12,20A2,2 0 0,0 12,16Z"/></svg>',
|
||||
help: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A10,10 0 1,0 22,12A10,10 0 0,0 12,2M13,19H11V17H13V19M15.07,11.25L14.17,12.17C13.45,12.9 13,13.5 13,15H11V14.5C11,13.4 11.45,12.4 12.17,11.67L13.41,10.41C13.78,10.05 14,9.55 14,9A2,2 0 0,0 10,9H8A4,4 0 0,1 16,9C16,9.88 15.64,10.68 15.07,11.25Z"/></svg>',
|
||||
update: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2A6.85,6.85 0 0,0 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.18,16.4 18.36,18.2C14.85,21.7 9.15,21.7 5.64,18.2C2.14,14.72 2.14,9.05 5.64,5.57C9.15,2.08 14.85,2.08 18.36,5.57L21,2.88V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z"/></svg>',
|
||||
logout: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14.08,15.59L16.67,13H7V11H16.67L14.08,8.41L15.5,7L20.5,12L15.5,17L14.08,15.59M5,3H13A2,2 0 0,1 15,5V8H13V5H5V19H13V16H15V19A2,2 0 0,1 13,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3Z"/></svg>',
|
||||
reboot: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13,3H11V13H13V3M17.83,5.17L16.42,6.58A7,7 0 1,1 7.58,6.58L6.17,5.17A9,9 0 1,0 17.83,5.17Z"/></svg>'
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
'use strict';
|
||||
|
||||
const Pager = (() => {
|
||||
const SCREEN_WIDTH = 480;
|
||||
const SCREEN_HEIGHT = 222;
|
||||
const FB_STRIDE = SCREEN_WIDTH * 4;
|
||||
const PAGER_WIDTH = 745;
|
||||
|
||||
const KEY_MAP = {
|
||||
'LEFT.png': 'ArrowLeft',
|
||||
'UP.png': 'ArrowUp',
|
||||
'RIGHT.png': 'ArrowRight',
|
||||
'DOWN.png': 'ArrowDown',
|
||||
'A_Button.png': 'Enter',
|
||||
'B_Button.png': 'Escape'
|
||||
};
|
||||
|
||||
let panel = null;
|
||||
let bodyEl = null;
|
||||
let table = null;
|
||||
let pager = null;
|
||||
let canvas = null;
|
||||
let screenerr = null;
|
||||
let keyws = null;
|
||||
let screenws = null;
|
||||
|
||||
function ensure() {
|
||||
if (table) return;
|
||||
panel = document.getElementById('pager-panel');
|
||||
bodyEl = document.getElementById('pager-body');
|
||||
table = document.getElementById('pager_ui');
|
||||
pager = document.getElementById('pager');
|
||||
canvas = document.getElementById('pager_canvas');
|
||||
screenerr = document.getElementById('pager_error');
|
||||
table.querySelectorAll('img.pager-btn').forEach((img) => {
|
||||
const src = img.getAttribute('src').split('/').pop();
|
||||
const key = KEY_MAP[src];
|
||||
if (!key) return;
|
||||
img.addEventListener('click', () => press(img, key));
|
||||
});
|
||||
const retry = document.getElementById('screen_retry');
|
||||
if (retry) retry.addEventListener('click', () => connect());
|
||||
}
|
||||
|
||||
function press(el, key) {
|
||||
el.classList.add('pressed');
|
||||
setTimeout(() => el.classList.remove('pressed'), 80);
|
||||
sendKey(key);
|
||||
}
|
||||
|
||||
function sendKey(k) {
|
||||
if (keyws && keyws.readyState === WebSocket.OPEN) keyws.send(k);
|
||||
}
|
||||
|
||||
function renderRGBAFrame(bytes) {
|
||||
if (bytes.length < FB_STRIDE * SCREEN_HEIGHT) {
|
||||
console.warn('pager frame too small for 480x222', bytes.length);
|
||||
return;
|
||||
}
|
||||
const ctx = canvas.getContext('2d');
|
||||
const imageData = ctx.createImageData(SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
const dst = imageData.data;
|
||||
let di = 0;
|
||||
for (let y = 0; y < SCREEN_HEIGHT; y++) {
|
||||
const rowStart = y * FB_STRIDE;
|
||||
for (let x = 0; x < SCREEN_WIDTH; x++) {
|
||||
const si = rowStart + x * 4;
|
||||
dst[di] = bytes[si];
|
||||
dst[di + 1] = bytes[si + 1];
|
||||
dst[di + 2] = bytes[si + 2];
|
||||
dst[di + 3] = bytes[si + 3];
|
||||
di += 4;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
pager.src = canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
function connect() {
|
||||
disconnect();
|
||||
try {
|
||||
const sock = new WebSocket(App.pagerScreenWs);
|
||||
sock.binaryType = 'arraybuffer';
|
||||
screenws = sock;
|
||||
sock.onopen = () => { screenerr.hidden = true; };
|
||||
sock.onmessage = (ev) => {
|
||||
if (ev.data instanceof ArrayBuffer) renderRGBAFrame(new Uint8Array(ev.data));
|
||||
else if (ev.data && ev.data.arrayBuffer) ev.data.arrayBuffer().then((b) => renderRGBAFrame(new Uint8Array(b)));
|
||||
};
|
||||
sock.onerror = () => { screenerr.hidden = false; };
|
||||
sock.onclose = () => { if (screenws === sock) screenws = null; screenerr.hidden = false; };
|
||||
} catch (e) {
|
||||
screenerr.hidden = false;
|
||||
}
|
||||
try {
|
||||
const sock = new WebSocket(App.pagerKeysWs);
|
||||
keyws = sock;
|
||||
sock.onclose = () => { if (keyws === sock) keyws = null; };
|
||||
sock.onerror = () => { try { sock.close(); } catch (e2) {} };
|
||||
} catch (e) {
|
||||
keyws = null;
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (screenws) { try { screenws.close(); } catch (e) {} screenws = null; }
|
||||
if (keyws) { try { keyws.close(); } catch (e) {} keyws = null; }
|
||||
}
|
||||
|
||||
function applyScale() {
|
||||
if (!bodyEl || !table) return;
|
||||
const availableWidth = bodyEl.clientWidth - 20;
|
||||
const scale = Math.min(1, availableWidth / PAGER_WIDTH);
|
||||
table.style.zoom = String(scale);
|
||||
table.style.marginBottom = '';
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
ensure();
|
||||
if (panel.classList.contains('hidden')) {
|
||||
panel.classList.remove('hidden');
|
||||
document.getElementById('pager-btn').classList.add('active');
|
||||
applyScale();
|
||||
connect();
|
||||
try { pager.focus(); } catch (e) {}
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
document.getElementById('pager-btn').classList.remove('active');
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return !!panel && !panel.classList.contains('hidden');
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (isOpen()) toggle();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (table && panel && !panel.classList.contains('hidden')) applyScale();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!isOpen()) return;
|
||||
const t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT'
|
||||
|| t.tagName === 'BUTTON' || t.tagName === 'A' || t.isContentEditable)) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const keys = ['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'Enter', 'Escape'];
|
||||
if (keys.indexOf(e.key) === -1) return;
|
||||
if (e.key.indexOf('Arrow') === 0) e.preventDefault();
|
||||
if (!e.repeat) sendKey(e.key);
|
||||
});
|
||||
|
||||
return { toggle, close, isOpen };
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
|
||||
const Term = (() => {
|
||||
let term = null;
|
||||
let fitAddon = null;
|
||||
let ws = null;
|
||||
let panel = null;
|
||||
|
||||
function ensure() {
|
||||
if (term) return;
|
||||
panel = document.getElementById('terminal-panel');
|
||||
term = new Terminal({ cursorBlink: true, scrollback: 2000, cols: 80, rows: 24 });
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById('terminal'));
|
||||
try { fitAddon.fit(); } catch (e) {}
|
||||
term.onData((d) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(d); });
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
ensure();
|
||||
if (panel.classList.contains('hidden')) {
|
||||
panel.classList.remove('hidden');
|
||||
document.getElementById('terminal-btn').classList.add('active');
|
||||
try { fitAddon.fit(); } catch (e) {}
|
||||
connect();
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
document.getElementById('terminal-btn').classList.remove('active');
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (ws) return;
|
||||
if (term) term.reset();
|
||||
let sock;
|
||||
try {
|
||||
sock = new WebSocket(App.terminalWs);
|
||||
} catch (e) {
|
||||
term.writeln('\r\n[cannot reach daemon terminal: ' + e.message + ']');
|
||||
return;
|
||||
}
|
||||
ws = sock;
|
||||
sock.onmessage = (ev) => {
|
||||
if (typeof ev.data === 'string') term.write(ev.data);
|
||||
else ev.data.text().then((t) => term.write(t));
|
||||
};
|
||||
sock.onclose = () => { if (ws === sock) ws = null; if (term) term.writeln('\r\n[connection closed]'); };
|
||||
sock.onerror = () => { try { sock.close(); } catch (e) {} };
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (ws) { try { ws.close(); } catch (e) {} ws = null; }
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return !!panel && !panel.classList.contains('hidden');
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (isOpen()) toggle();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (fitAddon && panel && !panel.classList.contains('hidden')) {
|
||||
try { fitAddon.fit(); } catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
return { toggle, close, isOpen };
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||