|
|
|
@@ -5,84 +5,139 @@
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
<title>Hammerhead</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root { --bg:#0a0a0a; --side:#0f0f10; --panel:#141415; --line:#262628; --text:#fafafa; --dim:#a1a1aa; --muted:#1c1c1e; --accent:#06b6d4; --accent-fg:#fff; --ok:#22c55e; --bad:#ef4444; --warn:#eab308; --shade:rgba(0,0,0,.65); --radius:10px; }
|
|
|
|
|
@media (prefers-color-scheme: light) { :root { --bg:#fafafa; --side:#fff; --panel:#fff; --line:#e4e4e7; --text:#18181b; --dim:#71717a; --muted:#f4f4f5; --accent:#0891b2; --shade:rgba(24,24,27,.5); } }
|
|
|
|
|
/* Design tokens: the only place raw colors, spacing and sizes are defined (same system as Lightwell). */
|
|
|
|
|
:root {
|
|
|
|
|
--accent:#0891b2; --accent-contrast:#fff;
|
|
|
|
|
--bg:#fafafa; --surface:#fff; --surface-2:#f4f4f5; --border:#e4e4e7; --border-strong:#d4d4d8;
|
|
|
|
|
--text:#09090b; --text-muted:#52525b; --text-faint:#71717a;
|
|
|
|
|
--accent-text:color-mix(in srgb, var(--accent) 85%, #000);
|
|
|
|
|
--accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface));
|
|
|
|
|
--accent-hover:color-mix(in srgb, var(--accent) 86%, #000);
|
|
|
|
|
--focus:color-mix(in srgb, var(--accent) 65%, #fff);
|
|
|
|
|
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3;
|
|
|
|
|
--overlay:rgb(15 18 22 / .5); --shadow:0 8px 24px rgb(15 18 22 / .14);
|
|
|
|
|
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
|
|
|
|
|
--radius:8px; --radius-lg:12px;
|
|
|
|
|
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
|
|
|
|
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
|
|
|
|
|
--text-xs:12px; --text-sm:13px; --text-base:14px; --text-lg:16px; --text-xl:20px; --text-2xl:26px;
|
|
|
|
|
--control-h:36px; --sidebar-w:248px; --content-max:1180px; --ease:140ms ease;
|
|
|
|
|
color-scheme:light;
|
|
|
|
|
}
|
|
|
|
|
@media (prefers-color-scheme: dark) { :root {
|
|
|
|
|
--accent:#22b8d4; --accent-contrast:#04222a;
|
|
|
|
|
--bg:#09090b; --surface:#0f0f11; --surface-2:#18181b; --border:#27272a; --border-strong:#3f3f46;
|
|
|
|
|
--text:#fafafa; --text-muted:#a1a1aa; --text-faint:#71717a;
|
|
|
|
|
--accent-text:color-mix(in srgb, var(--accent) 80%, #fff); --accent-soft:color-mix(in srgb, var(--accent) 18%, var(--surface)); --accent-hover:color-mix(in srgb, var(--accent) 85%, #fff);
|
|
|
|
|
--success:#86d3a0; --success-bg:#16301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18;
|
|
|
|
|
--overlay:rgb(0 0 0 / .65); --shadow:0 8px 24px rgb(0 0 0 / .5); color-scheme:dark;
|
|
|
|
|
} }
|
|
|
|
|
* { box-sizing:border-box; }
|
|
|
|
|
html, body { height:100%; }
|
|
|
|
|
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 Inter,system-ui,-apple-system,Segoe UI,sans-serif; -webkit-font-smoothing:antialiased; }
|
|
|
|
|
a { color:var(--accent); }
|
|
|
|
|
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/1.5 var(--font); -webkit-font-smoothing:antialiased; }
|
|
|
|
|
a { color:var(--accent-text); text-underline-offset:2px; }
|
|
|
|
|
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
|
|
|
|
|
h1, h2 { line-height:1.25; letter-spacing:-.01em; }
|
|
|
|
|
|
|
|
|
|
/* shell */
|
|
|
|
|
.app { display:flex; min-height:100vh; }
|
|
|
|
|
.side { width:248px; flex:none; background:var(--side); border-right:1px solid var(--line); display:flex; flex-direction:column; position:sticky; top:0; height:100vh; padding:14px 12px; gap:4px; overflow-y:auto; }
|
|
|
|
|
.brand { display:flex; align-items:center; gap:10px; font-weight:700; font-size:16px; padding:4px 8px 12px; }
|
|
|
|
|
.side { width:var(--sidebar-w); flex:none; background:var(--bg); border-right:1px solid var(--border); display:flex; flex-direction:column; position:sticky; top:0; height:100vh; padding:var(--s5) var(--s3); gap:2px; overflow-y:auto; }
|
|
|
|
|
.brand { display:flex; align-items:center; gap:var(--s2); font-weight:700; font-size:var(--text-lg); letter-spacing:-.02em; padding:0 var(--s3) var(--s4); }
|
|
|
|
|
.brand svg { color:var(--accent); }
|
|
|
|
|
.orgsel { margin:0 0 10px; }
|
|
|
|
|
.orgsel select { background:var(--muted); font-weight:600; }
|
|
|
|
|
.navsec { font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--dim); padding:14px 10px 4px; }
|
|
|
|
|
.navitem { display:flex; align-items:center; gap:10px; width:100%; background:none; border:0; color:var(--dim); padding:8px 10px; border-radius:8px; cursor:pointer; font:inherit; font-weight:500; text-align:left; }
|
|
|
|
|
.navitem:hover { background:var(--muted); color:var(--text); }
|
|
|
|
|
.navitem.on { background:color-mix(in srgb, var(--accent) 14%, transparent); color:var(--accent); }
|
|
|
|
|
.orgsel { margin:0 0 var(--s2); }
|
|
|
|
|
.navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-faint); padding:var(--s4) var(--s3) var(--s1); }
|
|
|
|
|
a.navitem { text-decoration:none; }
|
|
|
|
|
.navitem { display:flex; align-items:center; gap:var(--s3); width:100%; background:none; border:0; color:var(--text-muted); padding:var(--s2) var(--s3); min-height:34px; border-radius:var(--radius); cursor:pointer; font:inherit; font-weight:500; text-align:left; transition:background var(--ease), color var(--ease); }
|
|
|
|
|
.navitem:hover { background:var(--surface-2); color:var(--text); }
|
|
|
|
|
.navitem { border-radius:var(--radius); }
|
|
|
|
|
.navitem.on { background:var(--surface-2); color:var(--text); }
|
|
|
|
|
.navitem.on svg { color:var(--accent); }
|
|
|
|
|
.navitem svg { flex:none; }
|
|
|
|
|
.grow { flex:1; }
|
|
|
|
|
.userbox { border-top:1px solid var(--line); padding-top:10px; margin-top:8px; }
|
|
|
|
|
.userbox .who { padding:4px 10px 8px; font-size:12px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
|
|
|
.userbox { border-top:1px solid var(--border); padding-top:var(--s3); margin-top:var(--s2); }
|
|
|
|
|
.userbox .who { padding:var(--s1) var(--s3) var(--s2); font-size:var(--text-xs); color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
|
|
|
.content { flex:1; min-width:0; }
|
|
|
|
|
main { max-width:1180px; margin:0 auto; padding:28px 32px 80px; }
|
|
|
|
|
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:22px; flex-wrap:wrap; }
|
|
|
|
|
.page-h h1 { font-size:24px; font-weight:700; margin:0; letter-spacing:-.01em; }
|
|
|
|
|
.page-h p { margin:4px 0 0; color:var(--dim); }
|
|
|
|
|
.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); padding:18px; margin-bottom:18px; box-shadow:0 1px 2px rgba(0,0,0,.18); }
|
|
|
|
|
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s5) var(--s8); }
|
|
|
|
|
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--s4); margin-bottom:var(--s5); flex-wrap:wrap; }
|
|
|
|
|
.page-h h1 { font-size:var(--text-2xl); font-weight:600; margin:0; }
|
|
|
|
|
.page-h p { margin:var(--s1) 0 0; color:var(--text-muted); }
|
|
|
|
|
|
|
|
|
|
/* cards, layout */
|
|
|
|
|
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); margin-bottom:var(--s4); }
|
|
|
|
|
.card.flush { padding:0; overflow:hidden; }
|
|
|
|
|
.card > h2 { font-size:15px; font-weight:600; margin:0 0 12px; }
|
|
|
|
|
.row-h { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-bottom:12px; flex-wrap:wrap; }
|
|
|
|
|
.card > h2 { font-size:var(--text-lg); font-weight:600; margin:0 0 var(--s3); }
|
|
|
|
|
.row-h { display:flex; align-items:center; justify-content:flex-end; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
|
|
|
|
|
.row-h h2 { display:none; }
|
|
|
|
|
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:12px; align-items:end; }
|
|
|
|
|
.stack { display:flex; flex-direction:column; gap:12px; }
|
|
|
|
|
label { display:flex; flex-direction:column; gap:5px; font-size:12.5px; font-weight:500; color:var(--dim); }
|
|
|
|
|
label.chk { flex-direction:row; align-items:center; gap:8px; color:var(--text); font-size:14px; font-weight:400; }
|
|
|
|
|
input, select, textarea { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:8px; padding:8px 10px; font:inherit; min-width:0; width:100%; }
|
|
|
|
|
input[type=checkbox] { width:16px; height:16px; accent-color:var(--accent); }
|
|
|
|
|
input:focus, select:focus, textarea:focus { outline:2px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset:0; border-color:var(--accent); }
|
|
|
|
|
input:disabled { opacity:.6; }
|
|
|
|
|
button.btn { background:var(--accent); color:var(--accent-fg); border:0; border-radius:8px; padding:8px 16px; cursor:pointer; font:inherit; font-weight:600; }
|
|
|
|
|
button.btn:hover { filter:brightness(1.08); }
|
|
|
|
|
button.ghost { background:var(--panel); border:1px solid var(--line); color:var(--text); border-radius:8px; padding:5px 11px; cursor:pointer; font:inherit; font-weight:500; }
|
|
|
|
|
button.ghost:hover { background:var(--muted); }
|
|
|
|
|
button.danger:hover { border-color:var(--bad); color:var(--bad); background:transparent; }
|
|
|
|
|
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:var(--s4); align-items:end; }
|
|
|
|
|
.stack { display:flex; flex-direction:column; gap:var(--s3); }
|
|
|
|
|
|
|
|
|
|
/* forms */
|
|
|
|
|
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
|
|
|
|
|
label.chk { flex-direction:row; align-items:center; gap:var(--s2); font-size:var(--text-base); font-weight:400; }
|
|
|
|
|
input, select, textarea { background:var(--surface); color:var(--text); border:1px solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; min-width:0; width:100%; }
|
|
|
|
|
textarea { min-height:96px; resize:vertical; }
|
|
|
|
|
input[type=checkbox] { width:16px; height:16px; min-height:0; padding:0; accent-color:var(--accent); }
|
|
|
|
|
input:focus, select:focus, textarea:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
|
|
|
|
|
input:disabled, select:disabled { opacity:.6; }
|
|
|
|
|
.sub { color:var(--text-muted); font-size:var(--text-sm); font-weight:400; }
|
|
|
|
|
|
|
|
|
|
/* buttons: one height, one shape; ghost is the quiet variant */
|
|
|
|
|
button.btn, button.ghost { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; white-space:nowrap; transition:background var(--ease), border-color var(--ease); }
|
|
|
|
|
button.btn { background:var(--accent); color:var(--accent-contrast); border:1px solid var(--accent); }
|
|
|
|
|
button.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
|
|
|
|
|
button.ghost { background:var(--surface); border:1px solid var(--border-strong); color:var(--text); }
|
|
|
|
|
button.ghost:hover { background:var(--surface-2); }
|
|
|
|
|
button.danger:hover { border-color:var(--danger); color:var(--danger); background:var(--danger-bg); }
|
|
|
|
|
td button.ghost, .actions button.ghost, .tgt button.ghost { min-height:30px; padding:0 var(--s3); font-size:var(--text-sm); }
|
|
|
|
|
button:disabled { opacity:.55; cursor:not-allowed; }
|
|
|
|
|
|
|
|
|
|
/* toolbar above a table */
|
|
|
|
|
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); }
|
|
|
|
|
.toolbar input[type=search] { max-width:320px; }
|
|
|
|
|
.page-h .btn { white-space:nowrap; }
|
|
|
|
|
|
|
|
|
|
/* tables */
|
|
|
|
|
table { width:100%; border-collapse:collapse; }
|
|
|
|
|
th { text-align:left; font-size:12px; color:var(--dim); font-weight:600; padding:10px 14px; border-bottom:1px solid var(--line); white-space:nowrap; background:color-mix(in srgb, var(--muted) 55%, transparent); }
|
|
|
|
|
td { padding:12px 14px; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
|
|
|
tbody tr:hover td { background:color-mix(in srgb, var(--muted) 45%, transparent); }
|
|
|
|
|
th { text-align:left; font-size:var(--text-sm); color:var(--text-muted); font-weight:600; padding:var(--s3) var(--s4); border-bottom:1px solid var(--border); white-space:nowrap; background:var(--surface); }
|
|
|
|
|
td { padding:var(--s3) var(--s4); border-bottom:1px solid var(--border); vertical-align:top; }
|
|
|
|
|
tbody tr:hover td { background:var(--surface-2); }
|
|
|
|
|
tr:last-child td { border-bottom:0; }
|
|
|
|
|
.card > table, .card > .scroll > table { margin:-4px 0; }
|
|
|
|
|
.tag { display:inline-flex; align-items:center; padding:1px 8px; border-radius:6px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.03em; background:var(--muted); border:1px solid var(--line); color:var(--dim); }
|
|
|
|
|
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--dim); margin-right:7px; flex:none; }
|
|
|
|
|
.dot.ok { background:var(--ok); box-shadow:0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent); } .dot.bad { background:var(--bad); } .dot.warn { background:var(--warn); }
|
|
|
|
|
code, .mono { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:12.5px; }
|
|
|
|
|
pre { background:var(--bg); border:1px solid var(--line); border-radius:8px; padding:12px; overflow:auto; margin:8px 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
|
|
|
|
|
.dim { color:var(--dim); } .err { color:var(--bad); } .small { font-size:12px; }
|
|
|
|
|
.empty { color:var(--dim); padding:28px 12px; text-align:center; }
|
|
|
|
|
.card > table, .card > .scroll > table { margin:calc(var(--s1) * -1) 0; }
|
|
|
|
|
.scroll { overflow-x:auto; }
|
|
|
|
|
.actions { display:flex; gap:6px; flex-wrap:wrap; }
|
|
|
|
|
.center { max-width:400px; margin:11vh auto; padding:0 16px; }
|
|
|
|
|
.center .brand { justify-content:center; font-size:20px; padding-bottom:18px; }
|
|
|
|
|
.banner { background:color-mix(in srgb, var(--warn) 12%, transparent); border:1px solid color-mix(in srgb, var(--warn) 60%, transparent); border-radius:var(--radius); padding:12px 16px; margin-bottom:18px; }
|
|
|
|
|
.shade { position:fixed; inset:0; background:var(--shade); backdrop-filter:blur(2px); display:flex; align-items:flex-start; justify-content:center; padding:4vh 12px; overflow:auto; z-index:10; }
|
|
|
|
|
.modal { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:22px; width:100%; max-width:780px; box-shadow:0 20px 60px rgba(0,0,0,.45); }
|
|
|
|
|
.modal h2 { margin:0 0 16px; font-size:18px; }
|
|
|
|
|
fieldset { border:1px solid var(--line); border-radius:10px; padding:14px; margin:0; }
|
|
|
|
|
legend { font-size:12px; font-weight:600; color:var(--dim); padding:0 6px; }
|
|
|
|
|
.tgt { display:grid; grid-template-columns:1.2fr 1.4fr .7fr auto auto; gap:8px; align-items:end; }
|
|
|
|
|
.pillrow { display:flex; flex-wrap:wrap; gap:6px 16px; }
|
|
|
|
|
.sub { color:var(--dim); font-size:12px; font-weight:400; }
|
|
|
|
|
.tabs2 { display:flex; gap:4px; margin-bottom:16px; background:var(--muted); padding:4px; border-radius:10px; width:fit-content; max-width:100%; flex-wrap:wrap; }
|
|
|
|
|
.tabs2 button { background:none; border:0; color:var(--dim); border-radius:7px; padding:5px 14px; cursor:pointer; font:inherit; font-weight:500; }
|
|
|
|
|
.tabs2 button.on { background:var(--panel); color:var(--text); box-shadow:0 1px 2px rgba(0,0,0,.25); }
|
|
|
|
|
#toast { position:fixed; bottom:20px; right:20px; background:var(--panel); border:1px solid var(--line); padding:11px 16px; border-radius:10px; z-index:30; display:none; max-width:min(420px,90vw); box-shadow:0 10px 30px rgba(0,0,0,.4); }
|
|
|
|
|
#toast.bad { border-color:var(--bad); color:var(--bad); }
|
|
|
|
|
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
|
|
|
|
|
|
|
|
|
|
/* badges and status */
|
|
|
|
|
.tag { display:inline-flex; align-items:center; gap:var(--s1); padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--surface-2); color:var(--text-muted); white-space:nowrap; }
|
|
|
|
|
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--text-faint); margin-right:var(--s2); flex:none; }
|
|
|
|
|
.dot.ok { background:var(--success); box-shadow:0 0 0 3px var(--success-bg); } .dot.bad { background:var(--danger); } .dot.warn { background:var(--warning); }
|
|
|
|
|
code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
|
|
|
|
|
pre { background:var(--surface-2); border:1px solid var(--border); border-radius:var(--radius); padding:var(--s3); overflow:auto; margin:var(--s2) 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
|
|
|
|
|
.dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); }
|
|
|
|
|
.empty { color:var(--text-muted); padding:var(--s7) var(--s5); text-align:center; border:1px dashed var(--border-strong); border-radius:var(--radius-lg); margin:var(--s3); }
|
|
|
|
|
.center { max-width:400px; margin:11vh auto; padding:0 var(--s4); }
|
|
|
|
|
.center .brand { justify-content:center; font-size:var(--text-xl); padding-bottom:var(--s5); }
|
|
|
|
|
.banner { background:var(--warning-bg); border:1px solid var(--warning); color:var(--warning); border-radius:var(--radius); padding:var(--s3) var(--s4); margin-bottom:var(--s4); }
|
|
|
|
|
|
|
|
|
|
/* dialogs, tabs, toast */
|
|
|
|
|
.shade { position:fixed; inset:0; background:var(--overlay); display:flex; align-items:flex-start; justify-content:center; padding:4vh var(--s3); overflow:auto; z-index:10; }
|
|
|
|
|
.modal { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:780px; box-shadow:var(--shadow); }
|
|
|
|
|
.modal h2 { margin:0 0 var(--s4); font-size:var(--text-lg); }
|
|
|
|
|
fieldset { border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s4); margin:0; }
|
|
|
|
|
legend { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); padding:0 var(--s2); }
|
|
|
|
|
.tgt { display:grid; grid-template-columns:1.2fr 1.4fr .7fr auto auto; gap:var(--s2); align-items:end; }
|
|
|
|
|
.pillrow { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s4); }
|
|
|
|
|
.tabs2 { display:inline-flex; margin-bottom:var(--s4); border:1px solid var(--border-strong); border-radius:var(--radius); overflow:hidden; max-width:100%; flex-wrap:wrap; background:var(--surface); }
|
|
|
|
|
.tabs2 button { background:var(--surface); border:0; color:var(--text-muted); padding:0 var(--s3); min-height:var(--control-h); cursor:pointer; font:inherit; font-weight:500; }
|
|
|
|
|
.tabs2 button + button { border-left:1px solid var(--border-strong); }
|
|
|
|
|
.tabs2 button:hover { background:var(--surface-2); }
|
|
|
|
|
.tabs2 button.on { background:var(--accent-soft); color:var(--accent-text); }
|
|
|
|
|
#toast { position:fixed; bottom:var(--s4); right:var(--s4); background:var(--text); color:var(--bg); padding:var(--s3) var(--s4); border-radius:var(--radius); z-index:30; display:none; max-width:min(420px,90vw); box-shadow:var(--shadow); font-size:var(--text-sm); }
|
|
|
|
|
#toast.bad { background:var(--danger); color:var(--accent-contrast); }
|
|
|
|
|
@media (prefers-reduced-motion: reduce) { * { transition:none !important; } }
|
|
|
|
|
@media (max-width:860px) {
|
|
|
|
|
.app { flex-direction:column; } .side { width:100%; height:auto; position:static; flex-direction:row; flex-wrap:wrap; align-items:center; }
|
|
|
|
|
.app { flex-direction:column; } .side { width:100%; height:auto; position:static; flex-direction:row; flex-wrap:wrap; align-items:center; border-right:0; border-bottom:1px solid var(--border); }
|
|
|
|
|
.navsec, .userbox .who { display:none; } .userbox { border:0; margin:0; padding:0; display:flex; } .orgsel { margin:0; }
|
|
|
|
|
main { padding:18px 14px 60px; } .tgt { grid-template-columns:1fr 1fr; }
|
|
|
|
|
main { padding:var(--s5) var(--s4) var(--s7); } .tgt { grid-template-columns:1fr 1fr; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
</style>
|
|
|
|
@@ -130,6 +185,10 @@ const byId = (list, id) => (list || []).find(x => x.id === id);
|
|
|
|
|
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null);
|
|
|
|
|
const chk = (label, checked, onchange) => { const i = h('input', {type:'checkbox', checked: !!checked}); if (onchange) i.onchange = () => onchange(i.checked); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); };
|
|
|
|
|
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
|
|
|
|
|
function dialogForm(title, intro, fields, label, submit) {
|
|
|
|
|
const close = modal(title, h('div', {class:'stack'}, intro ? h('p', {class:'dim'}, intro) : null, ...fields,
|
|
|
|
|
h('div', {class:'actions'}, h('button', {class:'btn', onclick: act(async () => { await submit(); close(); })}, label), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))));
|
|
|
|
|
}
|
|
|
|
|
const cmdBox = t => h('pre', {class:'mono'}, t);
|
|
|
|
|
function modal(title, body, opts = {}) {
|
|
|
|
|
const shade = h('div', {class:'shade'});
|
|
|
|
@@ -182,6 +241,8 @@ async function boot() {
|
|
|
|
|
if (!S.me) return $app.replaceChildren(loginView());
|
|
|
|
|
if (!S.me.orgs.find(o => o.id === S.org)) S.org = (S.me.orgs[0] || {}).id || null;
|
|
|
|
|
if (S.org) localStorage.setItem('org', S.org);
|
|
|
|
|
const fromPath = tabFromPath(location.pathname);
|
|
|
|
|
if (fromPath) selectTab(fromPath);
|
|
|
|
|
await render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -248,8 +309,17 @@ const PAGES = {
|
|
|
|
|
// server pages are top-level sidebar entries that map onto serverViews
|
|
|
|
|
const SRV = {edges:'edges', srvorgs:'orgs', users:'users', idps:'idps', settings:'settings', srvalerts:'alerts', srvaudit:'audit'};
|
|
|
|
|
|
|
|
|
|
// every page has its own address, so links, reloads and the back button work
|
|
|
|
|
const TAB_PATH = {mine:'/apps', ports:'/resources', sites:'/sites', domains:'/domains', egress:'/mail-egress', alerts:'/alerts', access:'/access', logs:'/logs', account:'/account',
|
|
|
|
|
edges:'/server/edges', srvorgs:'/server/organizations', users:'/server/users', idps:'/server/sso', settings:'/server/settings', srvalerts:'/server/alerts', srvaudit:'/server/audit'};
|
|
|
|
|
const tabFromPath = p => Object.keys(TAB_PATH).find(k => TAB_PATH[k] === p.replace(/\/+$/, '')) || null;
|
|
|
|
|
function selectTab(key) { S.tab = key; if (SRV[key]) serverTab = SRV[key]; }
|
|
|
|
|
window.addEventListener('popstate', () => { const k = tabFromPath(location.pathname); if (S.me && k) { selectTab(k); render(); } });
|
|
|
|
|
|
|
|
|
|
async function render() {
|
|
|
|
|
const nav = (key, label, ic) => h('button', {class:'navitem' + (S.tab === key ? ' on' : ''), onclick:() => { S.tab = key; if (SRV[key]) serverTab = SRV[key]; render(); }}, icon(ic), label);
|
|
|
|
|
const nav = (key, label, ic) => h('a', {class:'navitem' + (S.tab === key ? ' on' : ''), href:TAB_PATH[key], onclick:e => {
|
|
|
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button) return;
|
|
|
|
|
e.preventDefault(); selectTab(key); history.pushState(null, '', TAB_PATH[key]); render(); }}, icon(ic), label);
|
|
|
|
|
const side = [h('div', {}, brand())];
|
|
|
|
|
if (S.me.orgs.length) side.push(h('div', {class:'orgsel'}, h('select', {onchange: act(async e => { S.org = e.target.value; localStorage.setItem('org', S.org); await render(); })},
|
|
|
|
|
S.me.orgs.map(o => h('option', {value:o.id, selected:o.id === S.org}, o.name)))));
|
|
|
|
@@ -268,6 +338,7 @@ async function render() {
|
|
|
|
|
}
|
|
|
|
|
all.push('account');
|
|
|
|
|
if (!all.includes(S.tab)) S.tab = all[0];
|
|
|
|
|
if (location.pathname !== TAB_PATH[S.tab]) history.replaceState(null, '', TAB_PATH[S.tab]);
|
|
|
|
|
side.push(h('div', {class:'grow'}), h('div', {class:'userbox'}, h('div', {class:'who'}, S.me.email), nav('account', 'Account', 'account'),
|
|
|
|
|
h('button', {class:'navitem', onclick: act(async () => { await api('POST', '/api/logout'); S.me = null; boot(); })}, icon('out'), 'Sign out')));
|
|
|
|
|
$app.replaceChildren(h('div', {class:'app'}, h('aside', {class:'side'}, side), h('div', {class:'content'}, h('main', {id:'view'}))));
|
|
|
|
@@ -276,7 +347,8 @@ async function render() {
|
|
|
|
|
|
|
|
|
|
function pageHeader(tab) {
|
|
|
|
|
const [t, d] = PAGES[tab] || ['', ''];
|
|
|
|
|
return h('div', {class:'page-h'}, h('div', {}, h('h1', {}, t), d ? h('p', {}, d) : null));
|
|
|
|
|
const a = S.headerAction;
|
|
|
|
|
return h('div', {class:'page-h'}, h('div', {}, h('h1', {}, t), d ? h('p', {}, d) : null), a ? h('button', {class:'btn', onclick:a[1]}, '+ ' + a[0]) : null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let seq = 0;
|
|
|
|
@@ -284,6 +356,7 @@ async function refresh() {
|
|
|
|
|
const my = ++seq;
|
|
|
|
|
try {
|
|
|
|
|
if (S.org && !SRV[S.tab] && S.tab !== 'account') await loadOrg();
|
|
|
|
|
S.headerAction = null;
|
|
|
|
|
const view = await (SRV[S.tab] ? views.server() : views[S.tab]());
|
|
|
|
|
if (my !== seq) return;
|
|
|
|
|
const v = document.getElementById('view');
|
|
|
|
@@ -321,8 +394,10 @@ views.ports = async () => {
|
|
|
|
|
const table = rows.length ? h('div', {class:'scroll'}, h('table', {},
|
|
|
|
|
h('thead', {}, h('tr', {}, ['', 'Name', 'Public address', 'Targets', 'Traffic', ''].map(t => h('th', {}, t)))),
|
|
|
|
|
h('tbody', {}, rows.map(m => mappingRow(m, st, traffic[m.id]))))) : h('div', {class:'empty'}, 'No ports open yet.');
|
|
|
|
|
return h('div', {}, h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => portForm()}, '+ Open a port')), unsetDash ? h('div', {class:'banner'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null,
|
|
|
|
|
h('div', {class:'card flush'}, table));
|
|
|
|
|
S.headerAction = ['Open a port', () => portForm()];
|
|
|
|
|
const q = h('input', {type:'search', placeholder:'Search resources…', oninput:() => { const t = q.value.toLowerCase(); table.querySelectorAll('tbody tr').forEach(tr => { tr.style.display = tr.textContent.toLowerCase().includes(t) ? '' : 'none'; }); }});
|
|
|
|
|
return h('div', {}, unsetDash ? h('div', {class:'banner'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null,
|
|
|
|
|
rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, h('div', {class:'card flush'}, table));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function spark(series) {
|
|
|
|
@@ -503,9 +578,10 @@ views.sites = async () => {
|
|
|
|
|
const add = act(async () => { const s = await api('POST', O('/sites'), {name:name.value}); name.value = ''; showCmd(s); refresh(); });
|
|
|
|
|
const showCmd = s => modal('Run Bamboo at ' + s.name, h('div', {}, h('p', {class:'dim'}, 'Run this on a machine inside that network. It only makes outbound connections. Add --docker to list its containers here.'),
|
|
|
|
|
cmdBox(`bamboo -hammerhead ${dashURL()} -token ${s.token} -docker`)));
|
|
|
|
|
S.headerAction = ['Add site', () => { const close = modal('Add a site', h('div', {class:'stack'},
|
|
|
|
|
h('p', {class:'dim'}, 'A site is a private network that holds your services. Every edge also has a built-in "this machine" site for services on the same box as Blacktip.'), field('Name', name),
|
|
|
|
|
h('div', {class:'actions'}, h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
|
|
|
|
|
return h('div', {},
|
|
|
|
|
card('Add a site', h('p', {class:'dim'}, 'A site is a private network that holds your services. Every edge also has a built-in "this machine" site for services on the same box as Blacktip.'),
|
|
|
|
|
h('div', {class:'grid'}, field('Name', name), h('button', {class:'btn', onclick:add}, 'Add site'))),
|
|
|
|
|
card('Sites', h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'Kind', 'Tunnels to', 'Containers', ''].map(t => h('th', {}, t)))),
|
|
|
|
|
h('tbody', {}, st.sites.map(s => h('tr', {},
|
|
|
|
|
h('td', {}, h('span', {class:'dot ' + (st.online[s.id] ? 'ok' : 'bad')})), h('td', {}, s.name),
|
|
|
|
@@ -523,9 +599,11 @@ views.sites = async () => {
|
|
|
|
|
// ----- domains -----
|
|
|
|
|
views.domains = async () => {
|
|
|
|
|
const st = S.st, name = h('input', {placeholder:'example.com'}), edgeSel = h('select', {}, st.edges.map(e => h('option', {value:e.id}, e.name))), ip = h('input', {placeholder:'public IP it points at'});
|
|
|
|
|
S.headerAction = ['Add domain', () => { const close = modal('Add a domain', h('div', {class:'stack'},
|
|
|
|
|
h('p', {class:'dim'}, 'Hostnames on http, https and tls ports must be under one of your domains. Create the DNS record at your DNS provider, then use Check DNS.'),
|
|
|
|
|
field('Domain', name), field('Edge', edgeSel), field('IP', ip, 'One of the edge\'s addresses'),
|
|
|
|
|
h('div', {class:'actions'}, h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/domains'), {name:name.value, edgeId:edgeSel.value, ip:ip.value}); close(); refresh(); })}, 'Add domain'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
|
|
|
|
|
return h('div', {},
|
|
|
|
|
card('Add a domain', h('p', {class:'dim'}, 'Hostnames on http, https and tls ports must be under one of your domains. Create the DNS record at your DNS provider, then use Check DNS.'),
|
|
|
|
|
h('div', {class:'grid'}, field('Domain', name), field('Edge', edgeSel), field('IP', ip, 'One of the edge\'s addresses'), h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/domains'), {name:name.value, edgeId:edgeSel.value, ip:ip.value}); refresh(); })}, 'Add'))),
|
|
|
|
|
card('Domains', st.domains.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Edge', 'DNS record to create', ''].map(t => h('th', {}, t)))),
|
|
|
|
|
h('tbody', {}, st.domains.map(d => h('tr', {}, h('td', {}, d.name), h('td', {}, (byId(st.edges, d.edgeId) || {name:'?'}).name),
|
|
|
|
|
h('td', {class:'mono small'}, d.ip ? `A ${d.name} ${d.ip}` : '—', h('br'), d.ip ? `A *.${d.name} ${d.ip} (for subdomains)` : ''),
|
|
|
|
@@ -584,8 +662,8 @@ views.egress = async () => {
|
|
|
|
|
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mail-egress/' + e.id), {...e, enabled:!e.enabled}); refresh(); })}, e.enabled ? 'Pause' : 'Enable'),
|
|
|
|
|
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Remove ${e.name}?`)) { await api('DELETE', O('/mail-egress/' + e.id)); refresh(); } })}, 'Remove'))));
|
|
|
|
|
})))) : h('div', {class:'empty'}, 'Nothing set up yet. Add one so your mail server sends from the edge\'s IP.');
|
|
|
|
|
S.headerAction = ['Add mail egress', () => mailForm()];
|
|
|
|
|
return h('div', {},
|
|
|
|
|
h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => mailForm()}, '+ Add mail egress')),
|
|
|
|
|
!remote.length ? h('div', {class:'banner'}, 'This runs on a Bamboo site: add the network your mail server lives in on the Sites page and start Bamboo there first.') : null,
|
|
|
|
|
h('div', {class:'card flush'}, table),
|
|
|
|
|
await dkimCard(),
|
|
|
|
@@ -605,8 +683,8 @@ async function dkimCard() {
|
|
|
|
|
h('td', {}, k.domain), h('td', {class:'mono'}, k.selector), h('td', {}, h('span', {class:'dot ' + chip(k)[0]}), chip(k)[1]),
|
|
|
|
|
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => show(k)}, 'Show DNS record'),
|
|
|
|
|
h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Stop signing mail for ${k.domain}?`)) { await api('DELETE', O('/dkim/' + k.id)); refresh(); } })}, 'Remove'))))))) : null,
|
|
|
|
|
h('div', {class:'grid', style:'margin-top:12px'}, field('Domain', dom), field('Selector (optional)', sel),
|
|
|
|
|
h('button', {class:'btn', onclick: act(async () => { const k = await api('POST', O('/dkim'), {domain:dom.value, selector:sel.value}); show(k); refresh(); })}, 'Create key')));
|
|
|
|
|
h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => dialogForm('Create a DKIM key', 'Make a key for each domain you send mail from, then publish its DNS record.', [field('Domain', dom), field('Selector (optional)', sel)], 'Create key',
|
|
|
|
|
async () => { const k = await api('POST', O('/dkim'), {domain:dom.value, selector:sel.value}); show(k); refresh(); })}, '+ Create key')));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mailForm(existing) {
|
|
|
|
@@ -681,11 +759,12 @@ async function alertsView(base, scoped, trusted) {
|
|
|
|
|
h('button', {class:'ghost', onclick:() => channelForm(base, c, trusted)}, 'Edit'),
|
|
|
|
|
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', base + '/channels/' + c.id); refresh(); })}, 'Remove')))))));
|
|
|
|
|
const debounce = h('input', {type:'number', min:1, max:120, value:d.settings.debounceMin}), repeat = h('input', {type:'number', min:0, max:168, value:d.settings.repeatHours});
|
|
|
|
|
S.headerAction = ['Add channel', () => channelForm(base, null, trusted)];
|
|
|
|
|
return h('div', {},
|
|
|
|
|
!d.emailConfigured ? h('div', {class:'banner'}, 'Email alerts need an email server. Set one up under Server → Settings, or use a webhook.') : null,
|
|
|
|
|
card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (a.fired ? 'bad' : 'warn')}), a.fired ? 'Firing' : 'Waiting…'), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) :
|
|
|
|
|
h('div', {class:'empty'}, '✓ Nothing is wrong.')),
|
|
|
|
|
card('Where alerts go', h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => channelForm(base, null, trusted)}, '+ Add channel')),
|
|
|
|
|
card('Where alerts go',
|
|
|
|
|
d.channels.length ? channels : h('div', {class:'empty'}, 'No channels yet. Add an email address or a webhook.')),
|
|
|
|
|
scoped ? card('Timing', h('p', {class:'dim'}, 'A problem has to last this long before you are told, so a restart or a brief blip stays quiet. You get one message when it starts and one when it clears.'),
|
|
|
|
|
h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours, 0 = never)', repeat),
|
|
|
|
@@ -702,17 +781,17 @@ views.access = async () => {
|
|
|
|
|
const email = h('input', {type:'email', placeholder:'person@example.com'}), roleSel = h('select', {}, roles.map(r => h('option', {value:r.id, selected:r.name === 'Member'}, r.name)));
|
|
|
|
|
const rname = h('input', {placeholder:'e.g. Staff'}), radmin = chk('Can manage the organization', false);
|
|
|
|
|
const kname = h('input', {placeholder:'e.g. CI deploy'});
|
|
|
|
|
S.headerAction = ['Invite someone', () => dialogForm('Invite someone', 'They get a link that works for 7 days.', [field('Email', email), field('Role', roleSel)], 'Create invitation', async () => {
|
|
|
|
|
const r = await api('POST', O('/invites'), {email:email.value, roleId:roleSel.value}); email.value = '';
|
|
|
|
|
modal('Invitation created', h('div', {}, h('p', {class:'dim'}, r.emailed ? 'An email was sent. You can also share this link:' : 'Send them this link (it works for 7 days):'), cmdBox(r.link))); refresh(); })];
|
|
|
|
|
return h('div', {},
|
|
|
|
|
card('Members', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Role', '2FA', ''].map(t => h('th', {}, t)))),
|
|
|
|
|
h('tbody', {}, members.map(mm => h('tr', {}, h('td', {}, mm.name), h('td', {}, mm.email, mm.sso ? h('span', {class:'tag', style:'margin-left:6px'}, 'sso') : null),
|
|
|
|
|
h('td', {}, h('select', {style:'width:auto', onchange: act(async e => { await api('PUT', O('/members/' + mm.userId), {roleId:e.target.value}); toast('Role changed'); })}, roles.map(r => h('option', {value:r.id, selected:r.id === mm.roleId}, r.name)))),
|
|
|
|
|
h('td', {}, mm.totp ? '✓' : '—'),
|
|
|
|
|
h('td', {}, mm.userId === S.me.id ? null : h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Remove this member?')) { await api('DELETE', O('/members/' + mm.userId)); refresh(); } })}, 'Remove'))))))),
|
|
|
|
|
card('Invite someone', h('div', {class:'grid'}, field('Email', email), field('Role', roleSel), h('button', {class:'btn', onclick: act(async () => {
|
|
|
|
|
const r = await api('POST', O('/invites'), {email:email.value, roleId:roleSel.value}); email.value = '';
|
|
|
|
|
modal('Invitation created', h('div', {}, h('p', {class:'dim'}, r.emailed ? 'An email was sent. You can also share this link:' : 'Send them this link (it works for 7 days):'), cmdBox(r.link))); refresh(); })}, 'Create invitation')),
|
|
|
|
|
invites.length ? h('table', {style:'margin-top:12px'}, h('tbody', {}, invites.map(i => h('tr', {}, h('td', {}, i.email), h('td', {class:'dim'}, (byId(roles, i.roleId) || {name:'?'}).name), h('td', {class:'dim'}, 'expires ' + new Date(i.expires).toLocaleDateString()),
|
|
|
|
|
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel')))))) : null),
|
|
|
|
|
invites.length ? card('Pending invitations', h('table', {}, h('tbody', {}, invites.map(i => h('tr', {}, h('td', {}, i.email), h('td', {class:'dim'}, (byId(roles, i.roleId) || {name:'?'}).name), h('td', {class:'dim'}, 'expires ' + new Date(i.expires).toLocaleDateString()),
|
|
|
|
|
h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel'))))))) : null,
|
|
|
|
|
card('Roles', h('p', {class:'dim'}, 'Admin roles manage the organization. Other roles are used to decide who may open protected apps.'),
|
|
|
|
|
h('table', {}, h('tbody', {}, roles.map(r => h('tr', {}, h('td', {}, r.name, r.builtin ? h('span', {class:'tag', style:'margin-left:6px'}, 'built-in') : null), h('td', {class:'dim'}, r.admin ? 'can manage the organization' : 'member'),
|
|
|
|
|
h('td', {}, r.builtin ? null : h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/roles/' + r.id)); refresh(); })}, 'Delete')))))),
|
|
|
|
@@ -771,11 +850,14 @@ serverViews.edges = async () => {
|
|
|
|
|
const orgChecks = orgs.map(o => ({o, c:chk(o.name, false)}));
|
|
|
|
|
const showCmd = e => modal('Run Blacktip on ' + e.name, h('div', {}, h('p', {class:'dim'}, 'Run this on the public server. The WireGuard UDP port (and any ports you open) must be reachable from the internet. Add -traefik /path/to/traefik to serve web resources with Traefik. Add -docker to list local containers.'),
|
|
|
|
|
cmdBox(`blacktip -hammerhead ${S.me.dashboardUrl || location.origin} -token ${e.token} -tunnel :${e.tunnelAddr.split(':').pop()} -docker`)));
|
|
|
|
|
S.headerAction = ['Add edge', () => { const close = modal('Add an edge', h('div', {class:'stack'},
|
|
|
|
|
h('p', {class:'dim'}, 'An edge (Blacktip) is a public server that owns IP addresses and opens ports.'),
|
|
|
|
|
field('Name', name), field('WireGuard endpoint (public host:UDP port)', addr, 'host:port'),
|
|
|
|
|
orgs.length > 1 ? h('div', {class:'stack'}, h('div', {class:'sub'}, 'Restrict to these organizations (none ticked = all):'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null,
|
|
|
|
|
h('div', {class:'actions'}, h('button', {class:'btn', onclick: act(async () => {
|
|
|
|
|
const e = await api('POST', '/api/edges', {name:name.value, tunnelAddr:addr.value, orgIds:orgChecks.filter(x => x.c.box.checked).map(x => x.o.id)}); close(); showCmd(e); refresh(); })}, 'Add edge'),
|
|
|
|
|
h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
|
|
|
|
|
return h('div', {},
|
|
|
|
|
card('Add an edge', h('p', {class:'dim'}, 'An edge (Blacktip) is a public server that owns IP addresses and opens ports.'),
|
|
|
|
|
h('div', {class:'grid'}, field('Name', name), field('WireGuard endpoint (public host:UDP port)', addr, 'host:port'), h('button', {class:'btn', onclick: act(async () => {
|
|
|
|
|
const e = await api('POST', '/api/edges', {name:name.value, tunnelAddr:addr.value, orgIds:orgChecks.filter(x => x.c.box.checked).map(x => x.o.id)}); showCmd(e); refresh(); })}, 'Add edge')),
|
|
|
|
|
orgs.length > 1 ? h('div', {style:'margin-top:10px'}, h('div', {class:'sub'}, 'Restrict to these organizations (none ticked = all):'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null),
|
|
|
|
|
card('Edges', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['', 'Name', 'WireGuard endpoint', 'IP addresses', 'Orgs', ''].map(t => h('th', {}, t)))),
|
|
|
|
|
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (e.online ? 'ok' : 'bad'), title:e.online ? 'online' : 'offline'})), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
|
|
|
|
|
h('td', {class:'mono small'}, (e.ips || []).join(', ') || '—'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
|
|
|
|
@@ -786,8 +868,8 @@ serverViews.edges = async () => {
|
|
|
|
|
serverViews.orgs = async () => {
|
|
|
|
|
const name = h('input', {placeholder:'Organization name'}), local = chk('Allow targeting the edge machine itself', false);
|
|
|
|
|
await boot0();
|
|
|
|
|
S.headerAction = ['New organization', () => dialogForm('New organization', null, [field('Name', name), local], 'Create', async () => { await api('POST', '/api/orgs', {name:name.value, allowLocal:local.box.checked}); S.me = await api('GET', '/api/me'); refresh(); })];
|
|
|
|
|
return h('div', {},
|
|
|
|
|
card('New organization', h('div', {class:'grid'}, field('Name', name), local, h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/orgs', {name:name.value, allowLocal:local.box.checked}); S.me = await api('GET', '/api/me'); refresh(); })}, 'Create'))),
|
|
|
|
|
card('Organizations', h('p', {class:'dim'}, '"Local" access lets an organization point ports at services on the edge machine itself (127.0.0.1), which includes Hammerhead. Only turn it on for organizations you fully trust.'),
|
|
|
|
|
h('table', {}, h('tbody', {}, S.me.orgs.map(o => h('tr', {}, h('td', {}, o.name),
|
|
|
|
|
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
|
|
|
|
@@ -800,9 +882,9 @@ async function boot0() { S.me = await api('GET', '/api/me'); }
|
|
|
|
|
serverViews.users = async () => {
|
|
|
|
|
const users = await api('GET', '/api/admin/users');
|
|
|
|
|
const f = {e:h('input', {type:'email'}), n:h('input'), p:h('input', {type:'password'})}, adm = chk('Server administrator', false);
|
|
|
|
|
S.headerAction = ['Add user', () => dialogForm('Add a user', 'To add someone to an organization by email, use an invitation on the Access page instead.', [field('Email', f.e), field('Name', f.n), field('Password', f.p), adm], 'Create',
|
|
|
|
|
async () => { await api('POST', '/api/admin/users', {email:f.e.value, name:f.n.value, password:f.p.value, serverAdmin:adm.box.checked}); refresh(); })];
|
|
|
|
|
return h('div', {},
|
|
|
|
|
card('Add a user', h('p', {class:'dim'}, 'To add someone to an organization by email, use an invitation on the Access tab instead.'),
|
|
|
|
|
h('div', {class:'grid'}, field('Email', f.e), field('Name', f.n), field('Password', f.p), adm, h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/admin/users', {email:f.e.value, name:f.n.value, password:f.p.value, serverAdmin:adm.box.checked}); refresh(); })}, 'Create'))),
|
|
|
|
|
card('Users', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Organizations', 'Flags', ''].map(t => h('th', {}, t)))),
|
|
|
|
|
h('tbody', {}, users.map(u => h('tr', {}, h('td', {}, u.name), h('td', {}, u.email), h('td', {class:'dim'}, u.orgs.join(', ') || '—'),
|
|
|
|
|
h('td', {}, u.serverAdmin ? h('span', {class:'tag'}, 'admin') : null, u.totp ? h('span', {class:'tag', style:'margin-left:4px'}, '2fa') : null, u.sso ? h('span', {class:'tag', style:'margin-left:4px'}, 'sso') : null),
|
|
|
|
@@ -829,10 +911,10 @@ serverViews.idps = async () => {
|
|
|
|
|
const body = {name:f.name.value, issuer:f.issuer.value, clientId:f.cid.value, clientSecret:f.sec.value, scopes:f.scopes.value, autoProvision:auto.box.checked, orgId:orgSel.value, allowedDomains:f.dom.value.split(',').map(s => s.trim()).filter(Boolean)};
|
|
|
|
|
await api(e.id ? 'PUT' : 'POST', '/api/admin/idps' + (e.id ? '/' + e.id : ''), body); close(); refresh(); })}, 'Save')), {sticky:true});
|
|
|
|
|
};
|
|
|
|
|
S.headerAction = ['Add provider', () => form()];
|
|
|
|
|
return card('Single sign-on', h('p', {class:'dim'}, 'Let people sign in with Google, Authentik, Keycloak, Entra ID or any OpenID Connect provider.'),
|
|
|
|
|
idps.length ? h('table', {}, h('tbody', {}, idps.map(i => h('tr', {}, h('td', {}, i.name), h('td', {class:'mono small dim'}, i.issuer), h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => form(i)}, 'Edit'),
|
|
|
|
|
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', '/api/admin/idps/' + i.id); refresh(); })}, 'Delete')))))) ) : h('div', {class:'empty'}, 'None configured.'),
|
|
|
|
|
h('button', {class:'btn', style:'margin-top:10px', onclick:() => form()}, 'Add provider'));
|
|
|
|
|
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', '/api/admin/idps/' + i.id); refresh(); })}, 'Delete')))))) ) : h('div', {class:'empty'}, 'None configured.'));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
serverViews.settings = async () => {
|
|
|
|
|