console: namespace = sub (убрать sless-SHA256) и удалить несуществующий /ensure
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
Функционал: вход, список устройств, credentials, эмулятор MQTT, заглушка телеметрии.
|
Функционал: вход, список устройств, credentials, эмулятор MQTT, заглушка телеметрии.
|
||||||
Встраивается в iot-service через go:embed, раздаётся по GET /console. -->
|
Встраивается в iot-service через go:embed, раздаётся по GET /console. -->
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -23,219 +24,598 @@
|
|||||||
#6b8eaa — text secondary
|
#6b8eaa — text secondary
|
||||||
#2d5070 — text muted / disabled */
|
#2d5070 — text muted / disabled */
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
background: #001120; color: #e2ecf6; min-height: 100vh;
|
background: #001120;
|
||||||
|
color: #e2ecf6;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— Navbar ——— */
|
/* ——— Navbar ——— */
|
||||||
.navbar {
|
.navbar {
|
||||||
background: #001C34;
|
background: #001C34;
|
||||||
border-bottom: 1px solid #0b2d50;
|
border-bottom: 1px solid #0b2d50;
|
||||||
padding: 0 28px; height: 58px;
|
padding: 0 28px;
|
||||||
display: flex; align-items: center; gap: 14px;
|
height: 58px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Логотип Nubes — SVG с инвертированием для белого цвета на тёмном фоне */
|
/* Логотип Nubes — SVG с инвертированием для белого цвета на тёмном фоне */
|
||||||
.navbar-logo {
|
.navbar-logo {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex;
|
||||||
cursor: pointer; user-select: none; text-decoration: none;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-logo img {
|
.navbar-logo img {
|
||||||
height: 18px;
|
height: 18px;
|
||||||
/* SVG залит #001C34, инвертируем до белого */
|
/* SVG залит #001C34, инвертируем до белого */
|
||||||
filter: brightness(0) invert(1);
|
filter: brightness(0) invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-logo-sep {
|
.navbar-logo-sep {
|
||||||
width: 1px; height: 18px; background: #1a4a73; margin: 0 4px;
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: #1a4a73;
|
||||||
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-logo-product {
|
.navbar-logo-product {
|
||||||
font-size: 13px; font-weight: 600; color: #7aafd4;
|
font-size: 13px;
|
||||||
letter-spacing: 0.04em; text-transform: uppercase;
|
font-weight: 600;
|
||||||
|
color: #7aafd4;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-spacer {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
.navbar-spacer { flex: 1; }
|
|
||||||
|
|
||||||
/* ——— Layout ——— */
|
/* ——— Layout ——— */
|
||||||
.container { max-width: 940px; margin: 0 auto; padding: 32px 24px; }
|
.container {
|
||||||
|
max-width: 940px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ——— Card ——— */
|
/* ——— Card ——— */
|
||||||
.card {
|
.card {
|
||||||
background: #001929;
|
background: #001929;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #0b2d50;
|
border: 1px solid #0b2d50;
|
||||||
padding: 24px; margin-bottom: 16px;
|
padding: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 15px; font-weight: 600;
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
color: #b8d4ec;
|
color: #b8d4ec;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— Form ——— */
|
/* ——— Form ——— */
|
||||||
.form-group { margin-bottom: 16px; }
|
.form-group {
|
||||||
label { display: block; font-size: 12px; font-weight: 500; color: #6b8eaa; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
|
margin-bottom: 16px;
|
||||||
input, textarea, select {
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b8eaa;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #001120;
|
background: #001120;
|
||||||
border: 1px solid #0b2d50;
|
border: 1px solid #0b2d50;
|
||||||
border-radius: 5px; padding: 10px 12px;
|
border-radius: 5px;
|
||||||
|
padding: 10px 12px;
|
||||||
color: #e2ecf6;
|
color: #e2ecf6;
|
||||||
font-size: 14px; font-family: inherit;
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
input:focus, textarea:focus { outline: none; border-color: #1a7fd4; box-shadow: 0 0 0 2px rgba(26,127,212,0.15); }
|
|
||||||
textarea { resize: vertical; font-family: 'Courier New', monospace; line-height: 1.5; }
|
input:focus,
|
||||||
input[type="number"] { width: auto; }
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1a7fd4;
|
||||||
|
box-shadow: 0 0 0 2px rgba(26, 127, 212, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* ——— Buttons ——— */
|
/* ——— Buttons ——— */
|
||||||
.btn {
|
.btn {
|
||||||
border: none; border-radius: 5px; padding: 10px 18px; font-size: 13px;
|
border: none;
|
||||||
font-weight: 600; cursor: pointer; transition: opacity 0.15s, background 0.15s;
|
border-radius: 5px;
|
||||||
display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
|
padding: 10px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, background 0.15s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
.btn:hover:not(:disabled) { opacity: 0.85; }
|
|
||||||
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
.btn:hover:not(:disabled) {
|
||||||
.btn-primary { background: #1a7fd4; color: #fff; }
|
opacity: 0.85;
|
||||||
.btn-danger { background: #c0392b; color: #fff; }
|
}
|
||||||
.btn-success { background: #1a9e5c; color: #fff; }
|
|
||||||
.btn-warning { background: #d48f0a; color: #fff; }
|
.btn:disabled {
|
||||||
.btn-ghost { background: transparent; color: #6b8eaa; border: 1px solid #0b2d50; }
|
opacity: 0.35;
|
||||||
.btn-ghost:hover:not(:disabled) { color: #b8d4ec; border-color: #1a4a73; opacity: 1; }
|
cursor: not-allowed;
|
||||||
.btn-sm { padding: 5px 11px; font-size: 12px; }
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #1a7fd4;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #c0392b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #1a9e5c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background: #d48f0a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #6b8eaa;
|
||||||
|
border: 1px solid #0b2d50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
color: #b8d4ec;
|
||||||
|
border-color: #1a4a73;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 5px 11px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ——— Table ——— */
|
/* ——— Table ——— */
|
||||||
table { width: 100%; border-collapse: collapse; }
|
table {
|
||||||
th {
|
width: 100%;
|
||||||
text-align: left; font-size: 11px; font-weight: 600; color: #2d5070;
|
border-collapse: collapse;
|
||||||
text-transform: uppercase; letter-spacing: 0.07em;
|
}
|
||||||
padding: 0 14px 10px; border-bottom: 1px solid #0b2d50;
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2d5070;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
padding: 0 14px 10px;
|
||||||
|
border-bottom: 1px solid #0b2d50;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 13px 14px;
|
||||||
|
border-bottom: 1px solid rgba(11, 45, 80, 0.6);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover td {
|
||||||
|
background: rgba(26, 127, 212, 0.04);
|
||||||
}
|
}
|
||||||
td { padding: 13px 14px; border-bottom: 1px solid rgba(11,45,80,0.6); vertical-align: middle; }
|
|
||||||
tbody tr:last-child td { border-bottom: none; }
|
|
||||||
tbody tr:hover td { background: rgba(26,127,212,0.04); }
|
|
||||||
|
|
||||||
/* ——— Badges ——— */
|
/* ——— Badges ——— */
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex; align-items: center; gap: 5px;
|
display: inline-flex;
|
||||||
font-size: 11px; font-weight: 600; border-radius: 4px;
|
align-items: center;
|
||||||
padding: 3px 9px; letter-spacing: 0.04em; text-transform: uppercase;
|
gap: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge::before {
|
||||||
|
content: '●';
|
||||||
|
font-size: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active {
|
||||||
|
background: rgba(26, 158, 92, 0.15);
|
||||||
|
color: #2ecc8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active::before {
|
||||||
|
color: #2ecc8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-disabled {
|
||||||
|
background: rgba(45, 80, 112, 0.3);
|
||||||
|
color: #2d5070;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-disabled::before {
|
||||||
|
color: #2d5070;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
background: rgba(192, 57, 43, 0.15);
|
||||||
|
color: #e55042;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error::before {
|
||||||
|
color: #e55042;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pending {
|
||||||
|
background: rgba(212, 143, 10, 0.15);
|
||||||
|
color: #d4a017;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pending::before {
|
||||||
|
color: #d4a017;
|
||||||
}
|
}
|
||||||
.badge::before { content: '●'; font-size: 6px; }
|
|
||||||
.badge-active { background: rgba(26,158,92,0.15); color: #2ecc8a; }
|
|
||||||
.badge-active::before { color: #2ecc8a; }
|
|
||||||
.badge-disabled { background: rgba(45,80,112,0.3); color: #2d5070; }
|
|
||||||
.badge-disabled::before { color: #2d5070; }
|
|
||||||
.badge-error { background: rgba(192,57,43,0.15); color: #e55042; }
|
|
||||||
.badge-error::before { color: #e55042; }
|
|
||||||
.badge-pending { background: rgba(212,143,10,0.15); color: #d4a017; }
|
|
||||||
.badge-pending::before { color: #d4a017; }
|
|
||||||
|
|
||||||
/* ——— Tabs ——— */
|
/* ——— Tabs ——— */
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex; border-bottom: 1px solid #0b2d50; margin-bottom: 24px;
|
display: flex;
|
||||||
|
border-bottom: 1px solid #0b2d50;
|
||||||
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
padding: 11px 20px; font-size: 13px; font-weight: 600;
|
padding: 11px 20px;
|
||||||
cursor: pointer; color: #2d5070;
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #2d5070;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
transition: color 0.15s, border-color 0.15s;
|
transition: color 0.15s, border-color 0.15s;
|
||||||
user-select: none; letter-spacing: 0.03em;
|
user-select: none;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: #7aafd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: #1a7fd4;
|
||||||
|
border-bottom-color: #1a7fd4;
|
||||||
}
|
}
|
||||||
.tab:hover { color: #7aafd4; }
|
|
||||||
.tab.active { color: #1a7fd4; border-bottom-color: #1a7fd4; }
|
|
||||||
|
|
||||||
/* ——— Credentials ——— */
|
/* ——— Credentials ——— */
|
||||||
.cred-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
.cred-row {
|
||||||
.cred-label { font-size: 13px; font-weight: 600; color: #7eb8e0; min-width: 110px; }
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cred-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #7eb8e0;
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
.cred-value {
|
.cred-value {
|
||||||
font-family: 'Courier New', monospace; font-size: 14px;
|
font-family: 'Courier New', monospace;
|
||||||
background: #0a1e30; padding: 9px 14px; border-radius: 6px;
|
font-size: 14px;
|
||||||
border: 1px solid #1a4a6e; flex: 1; color: #e2f0ff;
|
background: #0a1e30;
|
||||||
overflow-x: auto; word-break: break-all;
|
padding: 9px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #1a4a6e;
|
||||||
|
flex: 1;
|
||||||
|
color: #e2f0ff;
|
||||||
|
overflow-x: auto;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— Connection pill ——— */
|
/* ——— Connection pill ——— */
|
||||||
.conn-pill {
|
.conn-pill {
|
||||||
display: inline-flex; align-items: center; gap: 7px;
|
display: inline-flex;
|
||||||
font-size: 12px; font-weight: 600; padding: 4px 13px;
|
align-items: center;
|
||||||
border-radius: 4px; letter-spacing: 0.04em; text-transform: uppercase;
|
gap: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 13px;
|
||||||
|
border-radius: 4px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-pill::before {
|
||||||
|
content: '●';
|
||||||
|
font-size: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-pill.off {
|
||||||
|
background: rgba(45, 80, 112, 0.25);
|
||||||
|
color: #2d5070;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-pill.off::before {
|
||||||
|
color: #2d5070;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-pill.ing {
|
||||||
|
background: rgba(212, 143, 10, 0.15);
|
||||||
|
color: #d4a017;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-pill.ing::before {
|
||||||
|
color: #d4a017;
|
||||||
|
animation: blink 1s step-start infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-pill.on {
|
||||||
|
background: rgba(26, 158, 92, 0.15);
|
||||||
|
color: #2ecc8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-pill.on::before {
|
||||||
|
color: #2ecc8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.conn-pill::before { content: '●'; font-size: 6px; }
|
|
||||||
.conn-pill.off { background: rgba(45,80,112,0.25); color: #2d5070; }
|
|
||||||
.conn-pill.off::before { color: #2d5070; }
|
|
||||||
.conn-pill.ing { background: rgba(212,143,10,0.15); color: #d4a017; }
|
|
||||||
.conn-pill.ing::before { color: #d4a017; animation: blink 1s step-start infinite; }
|
|
||||||
.conn-pill.on { background: rgba(26,158,92,0.15); color: #2ecc8a; }
|
|
||||||
.conn-pill.on::before { color: #2ecc8a; }
|
|
||||||
@keyframes blink { 50% { opacity: 0; } }
|
|
||||||
|
|
||||||
/* ——— MQTT log ——— */
|
/* ——— MQTT log ——— */
|
||||||
.mqtt-log {
|
.mqtt-log {
|
||||||
background: #000d18; border: 1px solid #0b2d50; border-radius: 5px;
|
background: #000d18;
|
||||||
padding: 10px 12px; max-height: 180px; overflow-y: auto;
|
border: 1px solid #0b2d50;
|
||||||
font-family: 'Courier New', monospace; font-size: 12px;
|
border-radius: 5px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
.mqtt-log .ok { color: #2ecc8a; }
|
|
||||||
.mqtt-log .err { color: #e55042; }
|
.mqtt-log .ok {
|
||||||
.mqtt-log .info { color: #2d5070; }
|
color: #2ecc8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mqtt-log .err {
|
||||||
|
color: #e55042;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mqtt-log .info {
|
||||||
|
color: #2d5070;
|
||||||
|
}
|
||||||
|
|
||||||
/* ——— Modal ——— */
|
/* ——— Modal ——— */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(0,8,16,0.82);
|
position: fixed;
|
||||||
display: flex; align-items: center; justify-content: center; z-index: 200;
|
inset: 0;
|
||||||
|
background: rgba(0, 8, 16, 0.82);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: #001929; border: 1px solid #0b2d50; border-radius: 8px;
|
background: #001929;
|
||||||
padding: 28px; width: 100%; max-width: 460px;
|
border: 1px solid #0b2d50;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 28px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #b8d4ec;
|
||||||
}
|
}
|
||||||
.modal-title { font-size: 15px; font-weight: 700; margin-bottom: 20px; color: #b8d4ec; }
|
|
||||||
|
|
||||||
/* ——— Alerts ——— */
|
/* ——— Alerts ——— */
|
||||||
.alert { padding: 11px 15px; border-radius: 5px; font-size: 13px; margin-bottom: 14px; }
|
.alert {
|
||||||
.alert-error { background: rgba(192,57,43,0.1); color: #e55042; border: 1px solid rgba(192,57,43,0.25); }
|
padding: 11px 15px;
|
||||||
.alert-success { background: rgba(26,158,92,0.1); color: #2ecc8a; border: 1px solid rgba(26,158,92,0.25); }
|
border-radius: 5px;
|
||||||
.alert-info { background: rgba(26,127,212,0.1); color: #5aaae8; border: 1px solid rgba(26,127,212,0.25); }
|
font-size: 13px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: rgba(192, 57, 43, 0.1);
|
||||||
|
color: #e55042;
|
||||||
|
border: 1px solid rgba(192, 57, 43, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: rgba(26, 158, 92, 0.1);
|
||||||
|
color: #2ecc8a;
|
||||||
|
border: 1px solid rgba(26, 158, 92, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background: rgba(26, 127, 212, 0.1);
|
||||||
|
color: #5aaae8;
|
||||||
|
border: 1px solid rgba(26, 127, 212, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
/* ——— Misc ——— */
|
/* ——— Misc ——— */
|
||||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
.row {
|
||||||
.flex1 { flex: 1; }
|
display: flex;
|
||||||
.mt8 { margin-top: 8px; }
|
gap: 10px;
|
||||||
.mt16 { margin-top: 16px; }
|
align-items: center;
|
||||||
.mt24 { margin-top: 24px; }
|
flex-wrap: wrap;
|
||||||
.separator { border: none; border-top: 1px solid #0b2d50; margin: 20px 0; }
|
}
|
||||||
.hint { font-size: 14px; color: #a0bcd8; line-height: 1.7; }
|
|
||||||
.mono { font-family: 'Courier New', monospace; }
|
.flex1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt8 {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt16 {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt24 {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #0b2d50;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #a0bcd8;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
/* ——— Device list empty state ——— */
|
/* ——— Device list empty state ——— */
|
||||||
.coming-soon { text-align: center; padding: 56px 24px; color: #2d5070; }
|
.coming-soon {
|
||||||
.coming-soon .icon { font-size: 36px; margin-bottom: 12px; opacity: 0.5; }
|
text-align: center;
|
||||||
.coming-soon h3 { font-size: 17px; font-weight: 600; margin-bottom: 8px; color: #1a4a73; }
|
padding: 56px 24px;
|
||||||
|
color: #2d5070;
|
||||||
|
}
|
||||||
|
|
||||||
a { color: #1a7fd4; text-decoration: none; }
|
.coming-soon .icon {
|
||||||
a:hover { text-decoration: underline; }
|
font-size: 36px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon h3 {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #1a4a73;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #1a7fd4;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
/* ——— Help block ——— */
|
/* ——— Help block ——— */
|
||||||
.help-block {
|
.help-block {
|
||||||
border: 1px solid #0b2d50; border-radius: 8px;
|
border: 1px solid #0b2d50;
|
||||||
padding: 16px 20px; background: #001929;
|
border-radius: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #001929;
|
||||||
}
|
}
|
||||||
.help-block summary::-webkit-details-marker { display: none; }
|
|
||||||
.help-block[open] summary .help-arrow { transform: rotate(90deg); }
|
.help-block summary::-webkit-details-marker {
|
||||||
.help-arrow { display: inline-block; transition: transform 0.15s; margin-right: 6px; font-size: 10px; }
|
display: none;
|
||||||
.help-step { display: flex; gap: 14px; align-items: flex-start; font-size: 13px; color: #6b8eaa; line-height: 1.6; }
|
}
|
||||||
|
|
||||||
|
.help-block[open] summary .help-arrow {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-arrow {
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-step {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b8eaa;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.help-num {
|
.help-num {
|
||||||
flex-shrink: 0; width: 22px; height: 22px; border-radius: 4px;
|
flex-shrink: 0;
|
||||||
background: #001C34; border: 1px solid #1a4a73;
|
width: 22px;
|
||||||
color: #1a7fd4; font-weight: 700; font-size: 11px;
|
height: 22px;
|
||||||
display: flex; align-items: center; justify-content: center; margin-top: 2px;
|
border-radius: 4px;
|
||||||
|
background: #001C34;
|
||||||
|
border: 1px solid #1a4a73;
|
||||||
|
color: #1a7fd4;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-step strong {
|
||||||
|
color: #b8d4ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-step em {
|
||||||
|
color: #1a7fd4;
|
||||||
|
font-style: normal;
|
||||||
}
|
}
|
||||||
.help-step strong { color: #b8d4ec; }
|
|
||||||
.help-step em { color: #1a7fd4; font-style: normal; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
||||||
@@ -249,7 +629,7 @@
|
|||||||
const S = {
|
const S = {
|
||||||
// Сохраняемые настройки
|
// Сохраняемые настройки
|
||||||
token: localStorage.getItem('iot_token') || '',
|
token: localStorage.getItem('iot_token') || '',
|
||||||
// ns вычисляется из токена автоматически (SHA256(sub) → "sless-{hex}"), не вводится вручную
|
// ns = sub из токена (middleware сверяет namespace == sub), не вводится вручную
|
||||||
ns: localStorage.getItem('iot_ns') || '',
|
ns: localStorage.getItem('iot_ns') || '',
|
||||||
// apiBase — same-origin с API (консоль и /v1 раздаёт один iot-service);
|
// apiBase — same-origin с API (консоль и /v1 раздаёт один iot-service);
|
||||||
// mqttBase — отдельный хост EMQX (как в landing.html).
|
// mqttBase — отдельный хост EMQX (как в landing.html).
|
||||||
@@ -309,48 +689,29 @@ function displayNameFromToken(token) {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// namespaceFromToken — вычисляет namespace из токена.
|
// namespaceFromToken — возвращает namespace (= sub из токена).
|
||||||
// Алгоритм ИДЕНТИЧЕН Terraform провайдеру (client.NamespaceFromSub):
|
// Middleware сверяет namespace == sub (tenant-граница), поэтому namespace
|
||||||
// SHA256(sub) → первые 8 байт → "sless-{16 hex символов}"
|
// должен быть РОВНО sub, а не производной строкой: прежний «sless-{SHA256(sub)}»
|
||||||
|
// расходился с middleware и давал бы 403 (находка 2026-10-11).
|
||||||
//
|
//
|
||||||
// Поддерживает два режима (соответствует authTestMode на сервере):
|
// Два режима (соответствует authTestMode на сервере):
|
||||||
// - Режим JWT (прод): токен вида xxx.yyy.zzz → извлекаем sub из payload → SHA256(sub)
|
// - JWT (прод): sub из payload.
|
||||||
// - Тестовый режим: любая строка без пробелов → используем её саму как sub → SHA256(token)
|
// - Тестовый: строка без пробелов используется как sub.
|
||||||
//
|
function namespaceFromToken(token) {
|
||||||
// Чтобы вернуться только к JWT: убрать ветку isPlainToken ниже.
|
|
||||||
async function namespaceFromToken(token) {
|
|
||||||
let sub;
|
|
||||||
|
|
||||||
const parts = token.split('.');
|
const parts = token.split('.');
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
// ── JWT путь (боевой режим) ──────────────────────────────────────────────
|
// JWT: base64url → base64
|
||||||
// base64url → base64 (заменяем символы, добавляем padding)
|
|
||||||
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||||
while (payload.length % 4) payload += '=';
|
while (payload.length % 4) payload += '=';
|
||||||
|
|
||||||
let claims;
|
let claims;
|
||||||
try { claims = JSON.parse(atob(payload)); }
|
try { claims = JSON.parse(atob(payload)); }
|
||||||
catch (e) { throw new Error('Не удалось декодировать токен: ' + e.message); }
|
catch (e) { throw new Error('Не удалось декодировать токен: ' + e.message); }
|
||||||
|
|
||||||
if (!claims.sub) throw new Error('В токене нет поля sub — токен не поддерживается');
|
if (!claims.sub) throw new Error('В токене нет поля sub — токен не поддерживается');
|
||||||
sub = claims.sub;
|
return claims.sub;
|
||||||
} else {
|
|
||||||
// ── Тестовый режим: plain token ─────────────────────────────────────────
|
|
||||||
// Строка без пробелов используется напрямую как идентификатор (sub).
|
|
||||||
// Сервер (authTestMode=true) тоже принимает такой токен без JWT-проверки.
|
|
||||||
// ВАЖНО: каждая уникальная строка → отдельный изолированный namespace.
|
|
||||||
if (/\s/.test(token)) throw new Error('Токен не должен содержать пробелы');
|
|
||||||
sub = token;
|
|
||||||
}
|
}
|
||||||
|
// Тестовый режим: plain token = sub.
|
||||||
// SHA256(sub) через Web Crypto API (встроен во все современные браузеры)
|
if (/\s/.test(token)) throw new Error('Токен не должен содержать пробелы');
|
||||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(sub));
|
return token;
|
||||||
|
|
||||||
// Берём первые 8 байт → 16 hex символов — идентично Go: hash[:8]
|
|
||||||
const hex = Array.from(new Uint8Array(buf).slice(0, 8))
|
|
||||||
.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
|
|
||||||
return 'sless-' + hex;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================
|
// =============================================================
|
||||||
@@ -363,7 +724,7 @@ function sanitizeApiError(msg) {
|
|||||||
if (!msg) return 'Внутренняя ошибка. Попробуйте ещё раз.';
|
if (!msg) return 'Внутренняя ошибка. Попробуйте ещё раз.';
|
||||||
// Скрываем k8s-сообщения с namespace/not found
|
// Скрываем k8s-сообщения с namespace/not found
|
||||||
if (/namespaces?.*not found/i.test(msg)) return 'Ошибка инициализации. Выйдите и войдите снова.';
|
if (/namespaces?.*not found/i.test(msg)) return 'Ошибка инициализации. Выйдите и войдите снова.';
|
||||||
if (/not found/i.test(msg) && /sless-/i.test(msg)) return 'Ресурс не найден.';
|
if (/not found/i.test(msg)) return 'Ресурс не найден.';
|
||||||
if (/already exists/i.test(msg)) return 'Устройство с таким именем уже существует.';
|
if (/already exists/i.test(msg)) return 'Устройство с таким именем уже существует.';
|
||||||
if (/device.*id.*already/i.test(msg) || /device_id.*exists/i.test(msg)) return 'Device ID уже занят.';
|
if (/device.*id.*already/i.test(msg) || /device_id.*exists/i.test(msg)) return 'Device ID уже занят.';
|
||||||
if (/cannot decode jwt/i.test(msg) || /not a jwt/i.test(msg)) return 'Неверный токен.';
|
if (/cannot decode jwt/i.test(msg) || /not a jwt/i.test(msg)) return 'Неверный токен.';
|
||||||
@@ -626,7 +987,7 @@ async function doLogin() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Проверка...';
|
btn.textContent = 'Проверка...';
|
||||||
|
|
||||||
// Вычисляем namespace из токена — SHA256(sub) → "sless-{hex}"
|
// ns = sub из токена
|
||||||
let ns;
|
let ns;
|
||||||
try {
|
try {
|
||||||
ns = await namespaceFromToken(tok);
|
ns = await namespaceFromToken(tok);
|
||||||
@@ -644,10 +1005,8 @@ async function doLogin() {
|
|||||||
S.displayName = displayNameFromToken(tok);
|
S.displayName = displayNameFromToken(tok);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Создаём namespace если не существует — идемпотентный вызов.
|
// Отдельного «ensure» в новом API нет (tenant-БД создаётся лениво в
|
||||||
// Terraform-провайдер делает это в Configure(), консоль — при первом логине.
|
// consumer). Проверяем токен списком устройств.
|
||||||
// Без этого шага создание устройства упадёт с "namespace not found".
|
|
||||||
await apiCall('POST', `/v1/namespaces/${S.ns}/ensure`, {});
|
|
||||||
await apiListDevices(); // проверяем что токен валиден и API доступен
|
await apiListDevices(); // проверяем что токен валиден и API доступен
|
||||||
saveSettings();
|
saveSettings();
|
||||||
nav('devices');
|
nav('devices');
|
||||||
@@ -778,9 +1137,6 @@ async function doCreate() {
|
|||||||
btn.disabled = true; btn.textContent = 'Создание...';
|
btn.disabled = true; btn.textContent = 'Создание...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// EnsureNamespace — идемпотентный вызов.
|
|
||||||
// Страховка на случай если сессия была сохранена до автосоздания namespace при логине.
|
|
||||||
await apiCall('POST', `/v1/namespaces/${S.ns}/ensure`, {});
|
|
||||||
await apiCreateDevice(name, did);
|
await apiCreateDevice(name, did);
|
||||||
closeModal();
|
closeModal();
|
||||||
await renderDevices();
|
await renderDevices();
|
||||||
@@ -1329,4 +1685,5 @@ function copyVal(text, btn) {
|
|||||||
render();
|
render();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user