ssh-server-manager 0.4.1__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/PKG-INFO +1 -1
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/pyproject.toml +1 -1
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager/__init__.py +1 -1
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/assets/ui/app.js +1 -0
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/assets/ui/contexts.css +1 -0
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/assets/ui/contexts.js +1 -0
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/assets/ui/diagnostics.css +1 -0
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/assets/ui/diagnostics.js +146 -0
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/assets/ui/index.html +1 -0
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/assets/ui/notes.css +3 -0
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/assets/ui/notes.js +171 -0
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/assets/ui/styles.css +1 -0
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/assets/ui/themes.css +1 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager/cli.py +61 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager/db.py +20 -1
- ssh_server_manager-0.5.0/scripts/ssh_server_manager/ssh_runner.py +873 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager/validation.py +12 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager/webapp.py +41 -3
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager.egg-info/PKG-INFO +1 -1
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager.egg-info/SOURCES.txt +6 -0
- ssh_server_manager-0.5.0/tests/test_cli.py +41 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/tests/test_db.py +13 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/tests/test_runner.py +196 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/tests/test_ui_assets.py +57 -3
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/tests/test_webapp.py +63 -0
- ssh_server_manager-0.4.1/scripts/ssh_server_manager/assets/ui/app.js +0 -1
- ssh_server_manager-0.4.1/scripts/ssh_server_manager/assets/ui/contexts.css +0 -1
- ssh_server_manager-0.4.1/scripts/ssh_server_manager/assets/ui/contexts.js +0 -1
- ssh_server_manager-0.4.1/scripts/ssh_server_manager/assets/ui/index.html +0 -1
- ssh_server_manager-0.4.1/scripts/ssh_server_manager/assets/ui/styles.css +0 -1
- ssh_server_manager-0.4.1/scripts/ssh_server_manager/ssh_runner.py +0 -465
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/README.md +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager/askpass.py +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager/auth.py +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager/importer.py +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager/paths.py +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager/service.py +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager/ssh_config.py +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager/vault.py +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager.egg-info/dependency_links.txt +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager.egg-info/entry_points.txt +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager.egg-info/requires.txt +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/scripts/ssh_server_manager.egg-info/top_level.txt +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/setup.cfg +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/tests/test_askpass.py +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/tests/test_auth.py +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/tests/test_importer.py +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/tests/test_service.py +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/tests/test_ssh_config.py +0 -0
- {ssh_server_manager-0.4.1 → ssh_server_manager-0.5.0}/tests/test_website.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ssh-server-manager
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Local-first SSH host and credential manager for humans and AI agents — OS-keychain vault, loopback web UI, agent-safe by design
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://xiayh0107.github.io/servers-connect/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ssh-server-manager"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Local-first SSH host and credential manager for humans and AI agents — OS-keychain vault, loopback web UI, agent-safe by design"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e="ssh-manager-theme",t="ssh-manager-host-context",ta="ssh-manager-accent",tb=["indigo","teal","emerald","amber","rose","violet","graphite"];function a(e,t){try{return localStorage.getItem(e)||t}catch(e){return t}}function s(e,t){try{localStorage.setItem(e,t)}catch(e){}}const n={csrf:"",servers:[],credentials:[],contexts:[],auth:{},view:"workspace",hostQuery:"",hostContext:a(t,"*"),theme:a(e,"system"),accent:a(ta,"indigo"),credentialQuery:"",credentialKind:"*",files:E()};let r,i,o,l=null,d=null,c=0,u=0,p=0;const h=e=>document.querySelector(e);function v(e,t=!1){const n=h("#toast");n.textContent=e,n.className=t?"show error":"show",clearTimeout(r),r=setTimeout(()=>{n.className=""},4500)}async function f(e,t={}){const a=t.method||"GET",s={...t.headers||{}};t.body&&"string"!=typeof t.body&&(s["Content-Type"]="application/json",t.body=JSON.stringify(t.body)),"GET"!==a&&(s["X-CSRF-Token"]=n.csrf);const r=await fetch(e,{...t,method:a,headers:s,credentials:"same-origin"}),i=(r.headers.get("content-type")||"").includes("json")?await r.json():await r.text();if(!r.ok){const e=i.message||i.detail||i.error||`Request failed (${r.status})`;throw new Error(e)}return i}function m(e){return String(e??"").replace(/[&<>'"]/g,e=>({"&":"&","<":"<",">":">","'":"'",'"':"""}[e]))}function y(e){const t=Math.max(0,Math.floor(e/1e3));if(t<60)return"just now";const n=Math.floor(t/60);if(n<60)return`${n}m ago`;const a=Math.floor(n/60);return a<24?`${a}h ago`:`${Math.floor(a/24)}d ago`}function w(e){if(!e.last_test_status||!e.last_test_at)return{tone:"neutral",short:"Not checked",meta:"—",label:"Not checked",title:"No connection check has been run"};const t=Date.parse(e.last_test_at);if(!Number.isFinite(t))return{tone:"neutral",short:"Unknown",meta:"—",label:"Unknown",title:"The last check time is unavailable"};const n=Math.max(0,Date.now()-t),a=new Date(t).toLocaleString();if(n>12e4){const t="ok"===e.last_test_status?"reachable":"unreachable";return{tone:"stale",short:`Last ${t}`,meta:y(n),label:`Last ${t} ${y(n)}`,title:`Last checked ${a}`}}if("ok"===e.last_test_status){const t=null==e.last_test_latency_ms?"":` · ${e.last_test_latency_ms} ms`;return{tone:"ok",short:"Reachable",meta:null==e.last_test_latency_ms?y(n):`${e.last_test_latency_ms} ms`,label:`Reachable ${y(n)}${t}`,title:`Verified ${a}`}}return{tone:"failed",short:"Unreachable",meta:y(n),label:`Unavailable ${y(n)}`,title:`Failed ${a}${e.last_test_message?`: ${e.last_test_message}`:""}`}}function b(t,a=!1){const r=["system","light","dark","contrast"].includes(t)?t:"system";n.theme=r;const i="system"===r?window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light":r;document.documentElement.dataset.theme=i,h("#themeSelect")&&(h("#themeSelect").value=r),a&&s(e,r)}function tc(e,t=!1){const a=tb.includes(e)?e:"indigo";n.accent=a,"indigo"===a?delete document.documentElement.dataset.accent:document.documentElement.dataset.accent=a,h("#accentSelect")&&(h("#accentSelect").value=a),t&&s(ta,a)}function g(e){return`tag:${String(e).toLocaleLowerCase()}`}let tcs,tcm;function tc(e){return tcs!==n.contexts&&(tcs=n.contexts,tcm=new Map([...n.contexts].sort((e,t)=>e.name.localeCompare(t.name,void 0,{sensitivity:"base"})).map((e,t)=>[e.name.toLocaleLowerCase(),t%6]))),tcm.get(String(e).toLocaleLowerCase())??0}function C(){return o||=import("/assets/contexts.js")}function $(){return{getState:()=>n,getVisibleServers:x,api:f,refresh:q,toast:v,setHostContext:L,contextKey:g,tagColor:tc,showView:P,openServer:K,serverStatus:w}}function k(e){if("*"===n.hostContext)return!0;const t=e.tags||[];return"-"===n.hostContext?0===t.length:t.some(e=>g(e)===n.hostContext)}function x(){return n.servers.filter(k)}function S(){let e=0;n.servers.forEach(t=>{(t.tags||[]).length||(e+=1)});const t=new Map(n.contexts.map(e=>[g(e.name),{label:e.name,count:e.count}])),a=new Set(["*",...t.keys()]);e&&a.add("-"),a.has(n.hostContext)||(n.hostContext="*");const s=[`<option value="*">All hosts (${n.servers.length})</option>`];e&&s.push(`<option value="-">Unassigned (${e})</option>`),[...t.entries()].sort((e,t)=>e[1].label.localeCompare(t[1].label,void 0,{sensitivity:"base"})).forEach(([e,t])=>s.push(`<option value="${m(e)}">${m(t.label)} (${t.count})</option>`)),h("#hostContextSelect").innerHTML=s.join(""),h("#hostContextSelect").value=n.hostContext;const r=[...n.contexts].sort((e,t)=>e.name.localeCompare(t.name,void 0,{sensitivity:"base"}));h("#sidebarContextList").innerHTML=r.map((e,t)=>{const a=g(e.name),s=a===n.hostContext;return`<button type="button" class="sidebar-context-row${s?" active":""}" data-sidebar-filter-context="${m(a)}" aria-pressed="${s}"><i class="tc-${t%6}" aria-hidden="true"></i><span>${m(e.name)}</span><b>${e.count}</b></button>`}).join(""),h("#emptySidebarContexts").hidden=r.length>0;const i=x();h("#fileServerSelect").innerHTML=['<option value="">Choose a host…</option>'].concat(i.map(e=>`<option value="${m(e.id)}">${m(e.alias)}</option>`)).join(""),h("#fileServerSelect").disabled=0===i.length,h("#fileServerSelect").value=n.files.serverId}function L(e){n.hostContext=e,s(t,e);const a=n.servers.find(e=>e.id===n.files.serverId);a&&!k(a)&&(c+=1,n.files=E()),S(),T(),h("#serverCount").textContent=x().length,h("#emptyServers").hidden=x().length>0,O(),o&&o.then(e=>e.syncContextUI($())),I()}function E(e=""){return{serverId:e,path:"",requestedPath:"~",parent:null,entries:[],total:0,truncated:!1,unparsed:0,latencyMs:null,showHidden:!1,query:"",sortBy:"name",sortDirection:"asc",visibleLimit:250,loading:!1,error:""}}const _={workspace:{eyebrow:"REMOTE WORKSPACE",title:"Workspace",description:"Browse and reference remote files without opening a shell."},servers:{eyebrow:"CONNECTION INVENTORY",title:"Connections",description:"Manage endpoints, credentials, and jump hosts."},contexts:{eyebrow:"HOST ORGANIZATION",title:"Tags",description:"Label hosts for fast filtering by project, environment, or role."},credentials:{eyebrow:"SECURE STORAGE",title:"Credentials",description:"Reusable authentication profiles stored securely."}};function P(e){_[e]&&(n.view=e,document.body.dataset.view=e,document.querySelectorAll("[data-view-panel]").forEach(t=>{t.hidden=t.dataset.viewPanel!==e}),document.querySelectorAll(".nav-item").forEach(t=>{const n=t.dataset.view===e;t.classList.toggle("active",n),n?t.setAttribute("aria-current","page"):t.removeAttribute("aria-current")}),h("#hostNavigation").hidden="contexts"===e,h("#contextFilterControl").hidden="workspace"!==e&&"servers"!==e,h("#serverHeaderActions").hidden="servers"!==e,h("#contextHeaderActions").hidden="contexts"!==e,h("#credentialHeaderActions").hidden="credentials"!==e,h("#workspaceHeaderActions").hidden="workspace"!==e,h("#viewEyebrow").textContent=_[e].eyebrow,h("#viewTitle").textContent=_[e].title,h("#viewDescription").textContent=_[e].description)}function T(){const e=n.hostQuery.trim().toLocaleLowerCase(),t=x().filter(t=>!e||[t.alias,t.hostname,t.username,t.notes,...t.tags||[]].some(t=>String(t||"").toLocaleLowerCase().includes(e)));h("#emptyWorkspaceHosts").hidden=t.length>0,h("#workspaceHostList").innerHTML=t.map(e=>{const t=w(e),a=e.id===n.files.serverId,s=e.tags||[],r=s.slice(0,2).map(e=>`<button type="button" class="host-tag tc-${tc(e)}" data-sidebar-filter-context="${m(g(e))}" title="Show ${m(e)} hosts">${m(e)}</button>`).join(""),i=s.length>2?`<span class="host-tag-more">+${s.length-2}</span>`:"";return`<div class="workspace-host-row ${a?"active":""}"><button type="button" class="workspace-host" data-id="${m(e.id)}" aria-pressed="${a}" title="${m(t.label)} — ${m(t.title)}"><span class="host-status-dot ${t.tone}" aria-hidden="true"></span><span class="host-copy"><strong>${m(e.alias)}</strong><small>${m(e.username)}@${m(e.hostname)}</small></span></button><button type="button" class="workspace-host-context${s.length?"":" empty"}" data-sidebar-contexts data-id="${m(e.id)}" aria-label="Edit tags for ${m(e.alias)}" aria-haspopup="dialog" aria-expanded="false" title="${m(s.length?`Edit tags: ${s.join(", ")}`:"Add tag")}">${s.length?"•••":"+"}</button><div class="workspace-host-tags">${r}${i}</div></div>`}).join("")}function B(e,t){const a="directory"===e.type;if(a!==("directory"===t.type))return a?-1:1;let s=0;return s="size"===n.files.sortBy?Number(e.size)-Number(t.size):"modified"===n.files.sortBy?String(e.modified).localeCompare(String(t.modified)):String(e.name).localeCompare(String(t.name),void 0,{numeric:!0,sensitivity:"base"}),"asc"===n.files.sortDirection?s:-s}function H(){const e=n.files.query.trim().toLocaleLowerCase();return n.files.entries.filter(e=>n.files.showHidden||!e.hidden).filter(t=>!e||t.name.toLocaleLowerCase().includes(e)).slice().sort(B)}function M(e){if(!e)return;const t=w(e);h("#fileHostStatus").className=`compact-status ${t.tone}`,h("#fileHostStatus").innerHTML=`<i></i>${m(t.label)}`,h("#fileHostStatus").title=t.title}function N(){T(),O(),o&&o.then(e=>e.syncContextUI($())),M(n.servers.find(e=>e.id===n.files.serverId))}function I(){const e=n.servers.find(e=>e.id===n.files.serverId),t=Boolean(e);if(h("#fileBrowserEmpty").hidden=t,h("#fileBrowserContent").hidden=!t,h("#emptyChooseHostButton").hidden=0===n.servers.length,h("#workspaceActiveActions").hidden=!t,h("#workspaceAddHeaderButton").hidden=t,h("#workspaceHeaderActions").hidden="workspace"!==n.view,!t)return;h("#fileConnectionLabel").innerHTML=`<strong>${m(e.alias)}<small>Active SSH host</small></strong><span>${m(e.username)}@${m(e.hostname)} · port ${e.port}</span>`,M(e),h("#fileShowHidden").checked=n.files.showHidden,h("#fileSearchInput").value=n.files.query,h("#fileHomeButton").disabled=n.files.loading,h("#fileUpButton").disabled=n.files.loading||!n.files.parent,h("#fileRefreshButton").disabled=n.files.loading||!n.files.path,h("#copyDirectoryReferenceButton").disabled=n.files.loading||!n.files.path,function(e){if(!e)return void(h("#fileBreadcrumbs").innerHTML='<span class="breadcrumb-current">Home</span>');const t=e.split("/").filter(Boolean),n=[{label:"/",path:"/"}];let a="";for(const e of t)a+=`/${e}`,n.push({label:e,path:a});h("#fileBreadcrumbs").innerHTML=n.map((e,t)=>`${t?'<span class="breadcrumb-separator" aria-hidden="true">/</span>':""}${t===n.length-1?`<span class="breadcrumb-current" aria-current="page">${m(e.label)}</span>`:`<button type="button" class="breadcrumb-button" data-path="${m(e.path)}">${m(e.label)}</button>`}`).join("")}(n.files.path),document.querySelectorAll(".sort-button").forEach(e=>{const t=e.dataset.sort===n.files.sortBy;e.classList.toggle("active",t),e.querySelector("span").textContent=t?"asc"===n.files.sortDirection?"↑":"↓":""});const a=Boolean(n.files.error);h("#fileBrowserError").hidden=!a,h("#fileBrowserErrorMessage").textContent=n.files.error,h("#fileLoading").hidden=!n.files.loading;const s=n.files.showHidden?0:n.files.entries.filter(e=>e.hidden).length,r=n.files.query.trim().toLocaleLowerCase(),i=H(),o=i.slice(0,n.files.visibleLimit),l=!n.files.loading&&!a&&o.length>0,d=Math.max(0,i.length-o.length);if(h("#fileTableWrap").hidden=!l,h("#fileListMore").hidden=n.files.loading||a||0===d,h("#fileListMoreStatus").textContent=`${o.length} of ${i.length} items shown`,h("#fileLoadMoreButton").textContent=`Show ${Math.min(250,d)} more`,h("#fileListEmpty").hidden=n.files.loading||a||i.length>0,h("#copyVisibleReferencesButton").disabled=n.files.loading||a||0===o.length,h("#fileRows").innerHTML=o.map(e=>{const t="directory"===e.type?`<button type="button" class="file-name-button open-directory" data-path="${m(e.path)}">${m(e.name)}</button>`:`<span class="file-name">${m(e.name)}</span>`;return`<tr>\n <td><div class="file-name-cell">${function(e){return`<span class="file-icon ${["directory","symlink"].includes(e)?e:"file"}" aria-hidden="true"></span>`}(e.type)}<div>${t}<span class="file-kind">${m(e.type)}</span></div></div></td>\n <td class="file-size">${function(e,t){if("directory"===t)return"—";const n=Number(e)||0;if(n<1024)return`${n} B`;const a=["KB","MB","GB","TB"];let s=n,r=-1;do{s/=1024,r+=1}while(s>=1024&&r<a.length-1);return`${s>=10?s.toFixed(0):s.toFixed(1)} ${a[r]}`}(e.size,e.type)}</td>\n <td class="file-modified-column subtle">${m(function(e){const t=String(e||"—").trim().split(/\s+/);return t.length<3?t.join(" "):`${t[0]} ${t[1]} · ${t.slice(2).join(" ")}`}(e.modified))}</td>\n <td class="file-permissions-column">${function(e){const t=String(e||"----------").padEnd(10,"-"),n=[t.slice(1,4),t.slice(4,7),t.slice(7,10)],a=n.map(e=>e.replace(/-/g,"").toUpperCase()||"—"),s=n.map(e=>["-"!==e[0]?"read":"","-"!==e[1]?"write":"","-"!==e[2]?"execute":""].filter(Boolean).join("/")||"none");return`<span class="permission-summary" title="${m(`Owner: ${s[0]} · Group: ${s[1]} · Others: ${s[2]} · ${t}`)}"><b>Owner ${a[0]}</b><small>Group ${a[1]} · Others ${a[2]}</small></span>`}(e.permissions)}</td>\n <td><button type="button" class="row-icon-button copy-file-reference" data-path="${m(e.path)}" aria-label="Copy ${m(e.name)} reference" title="Copy Agent reference"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg></button></td>\n </tr>`}).join(""),n.files.loading)h("#fileListStatus").textContent="Connecting over SFTP…";else if(a)h("#fileListStatus").textContent="Connection failed";else if(n.files.path){const e=[d?`${o.length} of ${i.length} items`:`${i.length} item${1===i.length?"":"s"}`];r&&e.push("filtered from "+(n.files.entries.length-s)),s&&e.push(`${s} hidden`),n.files.truncated&&e.push(`showing the first ${n.files.entries.length}`),n.files.unparsed&&e.push(`${n.files.unparsed} unsupported name${1===n.files.unparsed?"":"s"} skipped`),null!=n.files.latencyMs&&e.push(`${n.files.latencyMs} ms`),h("#fileListStatus").textContent=e.join(" · ")}else h("#fileListStatus").textContent="Ready to connect";const c=n.files.entries.length>0&&0===i.length;h("#fileListEmptyTitle").textContent=c?"No matching files":"This directory is empty",h("#fileListEmptyMessage").textContent=c?"Try another filter or include hidden files.":"There is nothing to display here."}async function D(e="~"){if(!n.files.serverId)return;const t=++c,a=e||"~";n.files.requestedPath=a,n.files.visibleLimit=250,n.files.loading=!0,n.files.error="",h("#filePathInput").value=a,I();try{const e=new URLSearchParams({path:a}),s=await f(`/api/servers/${n.files.serverId}/files?${e}`);if(t!==c)return;n.files.path=s.path,n.files.requestedPath=s.path,n.files.parent=s.parent,n.files.entries=s.entries,n.files.total=s.total,n.files.truncated=s.truncated,n.files.unparsed=s.unparsed,n.files.latencyMs=s.latency_ms;const r=n.servers.find(e=>e.id===n.files.serverId);r&&(r.last_test_at=s.connection_checked_at,r.last_test_status="ok",r.last_test_latency_ms=s.latency_ms,r.last_test_error_code=null,r.last_test_message="SFTP connection succeeded",T(),O()),h("#filePathInput").value=s.path}catch(e){if(t!==c)return;n.files.error=e.message||String(e),v(n.files.error,!0);try{await q()}catch(e){}}finally{t===c&&(n.files.loading=!1,I())}}async function A(e){if(P("workspace"),e&&e===n.files.serverId&&n.files.path)return T(),void I();c+=1,n.files=E(e),h("#fileServerSelect").value=e,h("#filePathInput").value="~",T(),I(),e&&await D("~")}async function j(e){const t=n.servers.find(e=>e.id===n.files.serverId);t&&e&&(await navigator.clipboard.writeText(`${t.alias}:${e}`),v("Remote path copied for the Agent"))}async function F(){const e=n.servers.find(e=>e.id===n.files.serverId),t=H().slice(0,n.files.visibleLimit);e&&t.length&&(await navigator.clipboard.writeText(t.map(t=>`${e.alias}:${t.path}`).join("\n")),v(`${t.length} file reference${1===t.length?"":"s"} copied`))}function O(){h("#serverRows").innerHTML=x().map(e=>{const t=e.proxy_jumps||[],a=t.length?t.join(" → "):"Direct connection",s=n.credentials.find(t=>t.id===e.credential_id),r=e.credential_label||"OpenSSH default",i=s?.kind||"agent";return`<tr data-server-id="${m(e.id)}">\n <td><div class="connection-name"><span class="host-status-dot ${w(e).tone}" aria-hidden="true"></span><div><span class="alias">${m(e.alias)}</span><span class="subtle">${m(e.notes||"No notes")}</span></div></div></td>\n <td><div class="endpoint-cell"><code>${m(e.username)}@${m(e.hostname)}</code><span><b>SSH</b> Port ${e.port}</span></div></td>\n <td>${function(e){return e?.length?`<div class="tag-list">${e.map(e=>`<button type="button" class="table-context-tag tc-${tc(e)}" data-context="${m(e)}">${m(e)}</button>`).join("")}<button type="button" class="table-context-edit" data-edit-contexts aria-label="Edit tags" aria-haspopup="dialog" aria-expanded="false">+</button></div>`:'<button type="button" class="table-context-empty" data-edit-contexts aria-haspopup="dialog" aria-expanded="false">+ Add tag</button>'}(e.tags)}</td>\n <td><div class="credential-cell"><i aria-hidden="true">✓</i><span><strong>${m(r)}</strong><small>${m(i)}</small></span></div></td>\n <td><div class="route-cell"><strong>${t.length?"ProxyJump":"—"}</strong><small>${m(a)}</small></div></td>\n <td>${function(e){const t=w(e);return`<span class="status-block" title="${m(t.title)}"><span class="status-pill ${t.tone}"><i aria-hidden="true"></i>${m(t.short)}</span><small>${m(t.meta)}</small></span>`}(e)}</td>\n <td><div class="table-actions connection-actions">\n <button class="button small browse-files" data-id="${m(e.id)}"><span aria-hidden="true">›_</span> Browse</button>\n <details class="row-menu"><summary aria-label="More actions for ${m(e.alias)}">•••</summary><div><button type="button" class="test-server" data-id="${m(e.id)}">Test connection</button><button type="button" class="copy-command" data-alias="${m(e.alias)}">Copy command</button><button type="button" class="edit-server" data-id="${m(e.id)}">Edit host</button><button type="button" class="delete-server danger" data-id="${m(e.id)}">Delete host</button></div></details>\n </div></td>\n </tr>`}).join("")}function R(){const e=n.credentialQuery.trim().toLocaleLowerCase(),t=n.credentials.filter(t=>("*"===n.credentialKind||t.kind===n.credentialKind)&&(!e||[t.label,t.kind,t.key_path].some(t=>String(t||"").toLocaleLowerCase().includes(e))));h("#credentialRows").innerHTML=t.map(e=>{const t="password"===e.kind?e.has_secret?"Password stored":"Missing password":"key"===e.kind?e.has_passphrase?"Passphrase stored":"No passphrase":"Uses local agent",a="agent"===e.kind||"password"===e.kind&&e.has_secret||"key"===e.kind&&e.key_path,s=e.has_secret||e.has_passphrase,r={password:"Password",key:"Key",agent:"OpenSSH"}[e.kind]||e.kind,i=e.key_path?`<div class="key-location"><code title="${m(e.key_path)}">${m(e.key_path)}</code><button class="endpoint-copy copy-key-path" data-path="${m(e.key_path)}" aria-label="Copy key path">Copy</button></div>`:'<span class="subtle">—</span>',o=function(e){const t=n.servers.filter(t=>t.credential_id===e.id&&t.last_test_at).map(e=>Date.parse(e.last_test_at)).filter(Number.isFinite);if(!t.length)return{label:"Never",title:"No connection check has used this credential"};const a=Math.max(...t);return{label:y(Date.now()-a),title:new Date(a).toLocaleString()}}(e);return`<tr><td><span class="alias">${m(e.label)}</span></td><td><span class="credential-kind">${m(r)}</span></td><td><span class="credential-ready ${a?"ready":"incomplete"}"><i></i>${a?"Ready":"Incomplete"}</span></td><td>${m(t)}</td><td>${i}</td><td><span class="last-checked" title="${m(o.title)}">${m(o.label)}</span></td><td><div class="table-actions">${s?`<button class="button small subtle-button reveal-credential" data-id="${m(e.id)}">Reveal</button>`:""}<button class="button secondary small edit-credential" data-id="${m(e.id)}">Edit</button><button class="row-icon-button danger delete-credential" data-id="${m(e.id)}" aria-label="Delete ${m(e.label)}" title="Delete credential"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></svg></button></div></td></tr>`}).join(""),h("#emptyCredentials").hidden=t.length>0,h("#emptyCredentials").textContent=n.credentials.length?"No credentials match this search.":"No credential profiles configured."}async function q(){const e=await f("/api/bootstrap");n.csrf=e.csrf,n.servers=e.servers,n.credentials=e.credentials,n.contexts=e.contexts||[],n.auth=e.auth,h("#configPath").textContent=e.managed_config,h("#systemStatus").className="session-status ok",h("#systemStatus small").textContent="Ready on this device",function(){n.files.serverId&&!n.servers.some(e=>e.id===n.files.serverId)&&(c+=1,n.files=E()),S();const e=x();h("#serverCount").textContent=e.length,h("#credentialCount").textContent=n.credentials.length,h("#navServerCount").textContent=n.servers.length,h("#navContextCount").textContent=n.contexts.length,h("#navCredentialCount").textContent=n.credentials.length,h("#summaryTotalHosts").textContent=n.servers.length;const t=n.servers.filter(e=>"ok"===w(e).tone).length;h("#summaryReachableHosts").textContent=t,h("#summaryReachablePercent").textContent=n.servers.length?`${(t/n.servers.length*100).toFixed(1)}% recently verified`:"No hosts yet",h("#summaryCredentialCount").textContent=n.credentials.length,h("#summaryContextCount").textContent=n.contexts.length,h("#credentialProfileCount").textContent=n.credentials.length,h("#emptyServers").hidden=e.length>0,h("#emptyServers").textContent=n.servers.length?"No hosts with this tag.":"No managed servers yet. Import your SSH config or add one.",h("#emptyWorkspaceHosts").textContent=n.servers.length?"No hosts match this view.":"No managed hosts yet.",O(),R();const a=['<option value="">OpenSSH default / agent</option>'].concat(n.credentials.map(e=>`<option value="${m(e.id)}">${m(e.label)} (${m(e.kind)})</option>`));h("#serverCredential").innerHTML=a.join("");const s=[];s.push(`${n.auth.passkeys||0} passkey${1===n.auth.passkeys?"":"s"} enrolled`),s.push(n.auth.master_password_enrolled?"fallback master password enrolled":"no fallback master password"),h("#authDescription").textContent=s.join(" · "),h("#authStatusTitle").textContent=n.auth.passkeys>0?"Passkey active":n.auth.master_password_enrolled?"Fallback password active":"Protection setup required",h("#enrollPasskeyButton").disabled=!n.auth.webauthn_available||!window.PublicKeyCredential,h("#enrollMasterButton").disabled=n.auth.master_password_enrolled,T(),function(){const e=[...n.servers].sort((e,t)=>{const n=Date.parse(e.last_test_at||e.updated_at||e.created_at||0)||0;return(Date.parse(t.last_test_at||t.updated_at||t.created_at||0)||0)-n}).slice(0,3);document.querySelector(".recent-hosts-card").hidden=0===e.length,h("#recentHostRows").innerHTML=e.map(e=>{const t=w(e),n=e.tags?.[0];return`<button type="button" class="recent-host-row" data-id="${m(e.id)}"><span class="recent-host-icon" aria-hidden="true"></span><span class="recent-host-copy"><strong>${m(e.alias)}</strong><small>${m(e.username)}@${m(e.hostname)}</small></span>${n?`<span class="recent-host-tag">${m(n)}</span>`:""}<span class="recent-host-status ${t.tone}"><b><i></i>${m(t.short)}</b><small>${m(t.meta)}</small></span><span class="recent-host-arrow" aria-hidden="true">›</span></button>`}).join("")}(),I(),P(n.view),o&&o.then(e=>e.syncContextUI($()))}()}function K(e=null){h("#serverDialogTitle").textContent=e?"Edit server":"Add server",h("#serverId").value=e?.id||"",h("#serverAlias").value=e?.alias||"",h("#serverHostname").value=e?.hostname||"",h("#serverPort").value=e?.port||22,h("#serverUsername").value=e?.username||"",h("#serverCredential").value=e?.credential_id||"",h("#serverProxy").value=e?.proxy_jumps.join(", ")||"",h("#serverTags").value=(e?.tags||[]).join(", "),h("#serverNotes").value=e?.notes||"",V(),h("#serverDialog").showModal(),C().then(e=>e.enhanceTagPicker({input:h("#serverTags"),getContexts:()=>n.contexts,toast:v,tagColor:tc})).catch(e=>v(e.message||String(e),!0))}function U(){const e=h("#serverCredentialNewKind").value,t="password"===e,n="key"===e;h("#serverCredentialNewSecretField").hidden=!t,h("#serverCredentialNewKeyPathField").hidden=!n,h("#serverCredentialNewPassphraseField").hidden=!n,h("#serverCredentialNewSecret").required=t,h("#serverCredentialNewKeyPath").required=n}function V(){h("#serverCredentialComposer").hidden=!0,h("#serverCredentialNewLabel").value="",h("#serverCredentialNewKind").value="password",h("#serverCredentialNewSecret").value="",h("#serverCredentialNewKeyPath").value="",h("#serverCredentialNewPassphrase").value="",U()}function G(){h("#serverCredentialComposer").hidden=!1,U(),h("#serverCredentialNewLabel").focus()}async function Q(){const e=h("#serverCredentialNewKind").value,t={label:h("#serverCredentialNewLabel").value.trim(),kind:e};if(!t.label)throw new Error("Credential label is required");if("password"===e&&(t.secret=h("#serverCredentialNewSecret").value,!t.secret))throw new Error("Password is required");if("key"===e){if(t.key_path=h("#serverCredentialNewKeyPath").value.trim(),!t.key_path)throw new Error("Private-key path is required");h("#serverCredentialNewPassphrase").value&&(t.passphrase=h("#serverCredentialNewPassphrase").value)}const n=await f("/api/credentials",{method:"POST",body:t});await q(),h("#serverCredential").value=n.id,V(),v("Credential created and selected. Save the server to finish.")}function W(){const e=h("#credentialKind").value;h("#secretField").hidden="password"!==e,h("#keyPathField").hidden="key"!==e,h("#passphraseField").hidden="key"!==e,h("#clearPassphraseField").hidden="key"!==e||!h("#credentialId").value}function z(e=null){const t=Boolean(e);h("#credentialDialogTitle").textContent=t?"Edit credential":"Add credential",h("#credentialId").value=e?.id||"",h("#credentialLabel").value=e?.label||"",h("#credentialKind").value=e?.kind||"password",h("#credentialKind").disabled=t,h("#credentialSecret").value="",h("#credentialSecret").required=!t&&"password"===h("#credentialKind").value,h("#credentialKeyPath").value=e?.key_path||"",h("#credentialPassphrase").value="",h("#credentialClearPassphrase").checked=!1,h("#credentialEditHint").hidden=!t,W(),h("#credentialDialog").showModal()}async function J(){const e=await f("/api/import/preview",{method:"POST",body:{}}),t=e.items.filter(e=>"add"===e.action).length,n=e.items.filter(e=>"conflict"===e.action).length;if(!confirm(`Import ${t} new host(s)? ${n} conflict(s) will be skipped.`))return;const a=await f("/api/import/apply",{method:"POST",body:{overwrite:!1}});v(`Imported ${a.added.length} host(s); skipped ${a.skipped.length}`),await q()}async function X(){const e=h("#sidebarContextName").value.trim();if(!e)return h("#sidebarContextName").focus();await f("/api/contexts",{method:"POST",body:{name:e}}),h("#sidebarContextName").value="",h("#sidebarContextForm").hidden=!0,await q(),v(`Tag “${e}” created`)}function Y(){const e=n.servers.map(e=>({alias:e.alias,hostname:e.hostname,port:e.port,username:e.username,credential:e.credential_label||null,proxy_jumps:e.proxy_jumps||[],tags:e.tags||[],notes:e.notes||""})),t=URL.createObjectURL(new Blob([JSON.stringify(e,null,2)],{type:"application/json"})),a=document.createElement("a");a.href=t,a.download="ssh-connections.json",a.click(),setTimeout(()=>URL.revokeObjectURL(t),0),v(`Exported ${e.length} connection${1===e.length?"":"s"} without secrets`)}function Z(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),n=atob(t.padEnd(4*Math.ceil(t.length/4),"="));return Uint8Array.from(n,e=>e.charCodeAt(0))}function ee(e){const t=new Uint8Array(e);let n="";return t.forEach(e=>{n+=String.fromCharCode(e)}),btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function te(e){return e.challenge=Z(e.challenge),e.user?.id&&(e.user.id=Z(e.user.id)),e.excludeCredentials&&e.excludeCredentials.forEach(e=>{e.id=Z(e.id)}),e.allowCredentials&&e.allowCredentials.forEach(e=>{e.id=Z(e.id)}),e}function ae(e){const t={};for(const n of["clientDataJSON","attestationObject","authenticatorData","signature","userHandle"])e.response[n]&&(t[n]=ee(e.response[n]));return e.response.getTransports&&(t.transports=e.response.getTransports()),{id:e.id,rawId:ee(e.rawId),type:e.type,response:t}}async function se(){const e=te(await f("/api/auth/passkey/register/options",{method:"POST"})),t=await navigator.credentials.create({publicKey:e});await f("/api/auth/passkey/register/verify",{method:"POST",body:ae(t)}),v("Passkey enrolled"),await q()}function ne(e){return new Promise((t,n)=>{l=t,d=n,h("#masterDialogTitle").textContent=e?"Set fallback master password":"Authenticate to reveal",h("#masterConfirmField").hidden=!e,h("#masterPasswordConfirm").required=e,h("#masterPassword").autocomplete=e?"new-password":"current-password",h("#masterPassword").value="",h("#masterPasswordConfirm").value="",h("#masterDialog").dataset.enrolling=e?"true":"false",h("#masterDialog").showModal(),h("#masterPassword").focus()})}function re(){h("#masterDialog").open&&h("#masterDialog").close(),d&&d(new Error("Authentication cancelled")),l=null,d=null,h("#masterPassword").value="",h("#masterPasswordConfirm").value=""}function ie(){clearInterval(i),h("#revealedSecret").textContent="",h("#revealDialog").open&&h("#revealDialog").close()}async function oe(e){try{await e()}catch(e){v(e.message||String(e),!0)}}document.addEventListener("DOMContentLoaded",()=>{history.replaceState({},"","/"),b(n.theme),tc(n.accent),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{"system"===n.theme&&b("system")}),oe(q),setInterval(N,3e4),h(".primary-nav").addEventListener("click",e=>{const t=e.target.closest(".nav-item");if(!t)return;const n=t.dataset.view;P(n),"contexts"!==n&&"servers"!==n||oe(async()=>{(await C())["contexts"===n?"openContextManager":"enhanceConnections"]($())})}),h("#refreshButton").addEventListener("click",()=>oe(q)),h("#addServerButton").addEventListener("click",()=>K()),h("#workspaceAddHeaderButton").addEventListener("click",()=>K()),h("#sidebarAddServerButton").addEventListener("click",()=>K()),h("#emptyAddServerButton").addEventListener("click",()=>K()),h("#addCredentialButton").addEventListener("click",()=>z()),h("#emptyChooseHostButton").addEventListener("click",()=>oe(async()=>{!x().length&&n.servers.length&&L("*");const e=document.querySelector(".recent-host-row");e&&await A(e.dataset.id)})),h("#emptyImportButton").addEventListener("click",()=>oe(J)),h("#viewAllHostsButton").addEventListener("click",()=>{P("servers"),oe(async()=>(await C()).enhanceConnections($()))}),h("#recentHostRows").addEventListener("click",e=>{const t=e.target.closest(".recent-host-row");t&&oe(()=>A(t.dataset.id))}),h("#themeSelect").addEventListener("change",()=>b(h("#themeSelect").value,!0)),h("#accentSelect").addEventListener("change",()=>tc(h("#accentSelect").value,!0)),h("#hostContextSelect").addEventListener("change",()=>L(h("#hostContextSelect").value)),h("#hostSearchInput").addEventListener("input",()=>{n.hostQuery=h("#hostSearchInput").value,cancelAnimationFrame(p),p=requestAnimationFrame(()=>{p=0,T()})}),h("#workspaceHostList").addEventListener("click",e=>{const t=e.target.closest("[data-sidebar-filter-context]");if(t)return L(t.dataset.sidebarFilterContext);const n=e.target.closest("[data-sidebar-contexts]");if(n)return oe(async()=>{(await C()).openHostContextPicker($(),n.dataset.id)});const a=e.target.closest(".workspace-host");a&&A(a.dataset.id)}),h("#sidebarContextList").addEventListener("click",e=>{const t=e.target.closest("[data-sidebar-filter-context]");t&&L(t.dataset.sidebarFilterContext===n.hostContext?"*":t.dataset.sidebarFilterContext)}),h("#sidebarAddContextButton").addEventListener("click",()=>{h("#sidebarContextForm").hidden=!h("#sidebarContextForm").hidden,h("#sidebarContextForm").hidden||h("#sidebarContextName").focus()}),h("#sidebarContextForm").addEventListener("submit",e=>{e.preventDefault(),oe(X)}),h("#sidebarEditContextsButton").addEventListener("click",()=>oe(async()=>(await C()).openContextManager($()))),h("#createContextButton").addEventListener("click",()=>oe(async()=>{const e=await C();e.openContextManager($()),e.focusContextCreation()})),h("#fileServerSelect").addEventListener("change",()=>A(h("#fileServerSelect").value)),h("#fileHomeButton").addEventListener("click",()=>D("~")),h("#fileUpButton").addEventListener("click",()=>{n.files.parent&&D(n.files.parent)}),h("#fileRefreshButton").addEventListener("click",()=>D(n.files.path||"~")),h("#fileRetryButton").addEventListener("click",()=>D(n.files.requestedPath||"~")),h("#fileShowHidden").addEventListener("change",()=>{n.files.showHidden=h("#fileShowHidden").checked,n.files.visibleLimit=250,I()}),h("#fileSearchInput").addEventListener("input",()=>{n.files.query=h("#fileSearchInput").value,n.files.visibleLimit=250,cancelAnimationFrame(u),u=requestAnimationFrame(()=>{u=0,I()})}),h("#filePathForm").addEventListener("submit",e=>{e.preventDefault(),D(h("#filePathInput").value||"~")}),h("#fileBreadcrumbs").addEventListener("click",e=>{const t=e.target.closest(".breadcrumb-button");t&&D(t.dataset.path)}),h("#fileRows").addEventListener("click",e=>{const t=e.target.closest(".open-directory"),n=e.target.closest(".copy-file-reference");t&&D(t.dataset.path),n&&oe(()=>j(n.dataset.path))}),h("#fileTableWrap").addEventListener("click",e=>{const t=e.target.closest(".sort-button");t&&(n.files.sortBy===t.dataset.sort?n.files.sortDirection="asc"===n.files.sortDirection?"desc":"asc":(n.files.sortBy=t.dataset.sort,n.files.sortDirection="asc"),n.files.visibleLimit=250,I())}),h("#fileLoadMoreButton").addEventListener("click",()=>{n.files.visibleLimit+=250,I()}),h("#copyDirectoryReferenceButton").addEventListener("click",()=>oe(()=>j(n.files.path))),h("#copyVisibleReferencesButton").addEventListener("click",()=>oe(F)),h("#workspaceTestButton").addEventListener("click",()=>oe(async()=>{const e=n.files.serverId;if(e){h("#workspaceTestButton").disabled=!0;try{const t=await f(`/api/servers/${e}/test`,{method:"POST"});v(t.ok?`Connected in ${t.latency_ms} ms`:t.message,!t.ok),await q()}finally{h("#workspaceTestButton").disabled=!1}}})),h("#workspaceCopyCommandButton").addEventListener("click",()=>oe(async()=>{const e=n.servers.find(e=>e.id===n.files.serverId);e&&(await navigator.clipboard.writeText(`serverctl connect ${e.alias}`),v("SSH command copied"))})),h("#workspaceEditButton").addEventListener("click",()=>{const e=n.servers.find(e=>e.id===n.files.serverId);e&&K(e)}),h("#newServerCredentialButton").addEventListener("click",G),h("#cancelServerCredentialButton").addEventListener("click",V),h("#serverCredentialNewKind").addEventListener("change",U),h("#saveServerCredentialButton").addEventListener("click",()=>oe(Q)),h("#credentialKind").addEventListener("change",()=>{W(),h("#credentialSecret").required=!h("#credentialId").value&&"password"===h("#credentialKind").value}),h("#credentialSearchInput").addEventListener("input",()=>{n.credentialQuery=h("#credentialSearchInput").value,R()}),h("#credentialKindFilter").addEventListener("change",()=>{n.credentialKind=h("#credentialKindFilter").value,R()}),document.querySelectorAll(".close-dialog").forEach(e=>e.addEventListener("click",()=>e.closest("dialog").close())),document.querySelectorAll(".close-reveal").forEach(e=>e.addEventListener("click",ie)),document.querySelectorAll(".cancel-master").forEach(e=>e.addEventListener("click",re)),h("#masterDialog").addEventListener("cancel",e=>{e.preventDefault(),re()}),h("#masterForm").addEventListener("submit",e=>{e.preventDefault();const t=h("#masterPassword").value;if("true"===h("#masterDialog").dataset.enrolling&&t!==h("#masterPasswordConfirm").value)return void v("Master passwords do not match",!0);h("#masterDialog").close(),h("#masterPassword").value="",h("#masterPasswordConfirm").value="";const n=l;l=null,d=null,n(t)}),h("#serverForm").addEventListener("submit",e=>{e.preventDefault(),oe(async()=>{const e=h("#serverId").value,t={alias:h("#serverAlias").value,hostname:h("#serverHostname").value,port:Number(h("#serverPort").value),username:h("#serverUsername").value,credential_id:h("#serverCredential").value||null,proxy_jumps:h("#serverProxy").value.split(",").map(e=>e.trim()).filter(Boolean),tags:h("#serverTags").value.split(",").map(e=>e.trim()).filter(Boolean),notes:h("#serverNotes").value};await f(e?`/api/servers/${e}`:"/api/servers",{method:e?"PUT":"POST",body:t}),h("#serverDialog").close(),v(e?"Server updated":"Server added"),await q()})}),h("#credentialForm").addEventListener("submit",e=>{e.preventDefault(),oe(async()=>{const e=h("#credentialId").value,t=h("#credentialKind").value,n={label:h("#credentialLabel").value,kind:t};"password"===t&&h("#credentialSecret").value&&(n.secret=h("#credentialSecret").value),"key"===t&&(n.key_path=h("#credentialKeyPath").value,h("#credentialPassphrase").value&&(n.passphrase=h("#credentialPassphrase").value),e&&h("#credentialClearPassphrase").checked&&(n.clear_passphrase=!0)),await f(e?`/api/credentials/${e}`:"/api/credentials",{method:e?"PUT":"POST",body:n}),h("#credentialSecret").value="",h("#credentialPassphrase").value="",h("#credentialDialog").close(),v(e?"Credential updated":"Credential added"),await q()})}),h("#serverRows").addEventListener("click",e=>oe(async()=>{const t=e.target.closest("button");if(t)if(t.classList.contains("browse-files"))await A(t.dataset.id);else if(t.classList.contains("test-server")){t.disabled=!0;const e=await f(`/api/servers/${t.dataset.id}/test`,{method:"POST"});v(e.ok?`Connected in ${e.latency_ms} ms`:e.message,!e.ok),await q()}else if(t.classList.contains("copy-command"))await navigator.clipboard.writeText(`serverctl connect ${t.dataset.alias}`),v("Connection command copied");else if(t.classList.contains("edit-server"))K(n.servers.find(e=>e.id===t.dataset.id));else if(t.classList.contains("delete-server")){const e=n.servers.find(e=>e.id===t.dataset.id);confirm(`Delete server ${e.alias}?`)&&(await f(`/api/servers/${t.dataset.id}`,{method:"DELETE"}),v("Server deleted"),await q())}})),h("#credentialRows").addEventListener("click",e=>oe(async()=>{const t=e.target.closest("button");if(t)if(t.classList.contains("copy-key-path"))await navigator.clipboard.writeText(t.dataset.path),v("Key path copied");else if(t.classList.contains("reveal-credential"))await async function(e){if(n.auth.passkeys>0&&window.PublicKeyCredential)try{const t=te(await f("/api/auth/reveal/options",{method:"POST",body:{credential_id:e}})),n=await navigator.credentials.get({publicKey:t});return void await f("/api/auth/reveal/verify",{method:"POST",body:{credential_id:e,response:ae(n)}})}catch(e){if(!n.auth.master_password_enrolled)throw e;v("Passkey was unavailable; use the fallback master password",!0)}if(!n.auth.master_password_enrolled)throw new Error("Enroll a passkey or fallback master password first");const t=await ne(!1);await f("/api/auth/master/verify",{method:"POST",body:{credential_id:e,password:t}})}(t.dataset.id),function(e){clearInterval(i);let t=15;h("#revealedSecret").textContent=e,h("#revealCountdown").textContent=t,h("#revealDialog").showModal(),i=setInterval(()=>{t-=1,h("#revealCountdown").textContent=t,t<=0&&ie()},1e3)}((await f(`/api/credentials/${t.dataset.id}/reveal`,{method:"POST"})).value);else if(t.classList.contains("edit-credential"))z(n.credentials.find(e=>e.id===t.dataset.id));else if(t.classList.contains("delete-credential")){const e=n.credentials.find(e=>e.id===t.dataset.id);confirm(`Delete credential ${e.label}? Stored secrets will also be removed.`)&&(await f(`/api/credentials/${t.dataset.id}`,{method:"DELETE"}),v("Credential deleted"),await q())}})),h("#importButton").addEventListener("click",()=>oe(J)),h("#exportConnectionsButton").addEventListener("click",Y),h("#enrollPasskeyButton").addEventListener("click",()=>oe(se)),h("#enrollMasterButton").addEventListener("click",()=>oe(async()=>{const e=await ne(!0);await f("/api/auth/master/enroll",{method:"POST",body:{password:e}}),v("Fallback master password enrolled"),await q()})),h("#copySecretButton").addEventListener("click",()=>oe(async()=>{await navigator.clipboard.writeText(h("#revealedSecret").textContent),v("Secret copied to clipboard")})),document.addEventListener("keydown",e=>{const t=e.target,a=t instanceof HTMLInputElement||t instanceof HTMLTextAreaElement||t instanceof HTMLSelectElement;"/"===e.key&&"workspace"===n.view&&n.files.serverId&&!a&&(e.preventDefault(),h("#fileSearchInput").focus())})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.cx-toolbar{border:1px solid var(--line);background:color-mix(in srgb, var(--surface) 95%, transparent);border-radius:10px;overflow:hidden}.cx-toolbar-main{align-items:center;gap:12px;min-height:54px;padding:9px 14px;display:flex}.cx-host-search{flex:1;min-width:220px;margin:0;position:relative}.cx-host-search:before{content:"";border:1.5px solid var(--faint);border-radius:50%;width:10px;height:10px;position:absolute;top:9px;left:11px}.cx-host-search:after{content:"";background:var(--faint);width:6px;height:1.5px;position:absolute;top:20px;left:21px;transform:rotate(45deg)}.cx-host-search input,.cx-library input{border:1px solid var(--line-strong);background:var(--surface-soft);width:100%;height:35px;color:var(--ink);border-radius:7px;padding:7px 10px;font-size:10px}.cx-host-search input{padding-left:34px}.cx-host-search input:focus,.cx-library input:focus,.cx-picker-search input:focus{border-color:var(--accent);outline:3px solid var(--focus-ring)}.cx-shown{color:var(--muted);white-space:nowrap;font-size:9px}.cx-batch{border-top:1px solid var(--line);background:var(--accent-soft);flex-wrap:wrap;align-items:center;gap:8px;min-height:50px;padding:8px 13px;display:flex}.cx-batch-label{min-width:80px;color:var(--ink);font-size:10px;font-weight:750}.cx-batch-hint,.cx-context-host-count span,.cx-option-copy small,.cx-picker-head span,.cx-search-empty{color:var(--muted);font-size:8px}.cx-batch button,.cx-library button{background:var(--accent);min-height:33px;color:var(--on-accent);border:1px solid #0000;border-radius:7px;padding:6px 10px;font-size:9px;font-weight:740}.cx-batch button:hover,.cx-library button:hover{background:var(--accent-hover)}.cx-batch button.secondary,.cx-library button.secondary{border-color:var(--line-strong);color:var(--ink);background:0 0}.cx-batch button.secondary:hover,.cx-library button.secondary:hover{background:var(--control)}.cx-batch .cx-context-trigger{justify-content:space-between;align-items:center;gap:18px;min-width:145px;display:inline-flex}.cx-batch .cx-clear{color:var(--muted);background:0 0;border-color:#0000;margin-left:auto}.cx-batch .cx-clear:hover{background:var(--control);color:var(--ink)}.cx-toolbar[aria-busy=true]{opacity:.65}.cx-context-picker[aria-busy=true] button,.cx-toolbar[aria-busy=true] button{pointer-events:none}.cx-search-empty{text-align:center;margin:0;padding:38px 20px;font-size:10px}.cx-select-cell,.cx-select-column{min-width:44px;text-align:center!important;width:44px!important;padding-right:4px!important}.cx-select-cell input,.cx-select-column input{width:15px;height:15px;accent-color:var(--accent);margin:0}.cx-picker-create:hover,.cx-picker-option:hover,.management-table tr.cx-selected,.management-table tr.cx-selected:hover{background:var(--accent-soft)}.cx-context-picker,.cx-picker-search input{border:1px solid var(--line-strong);color:var(--ink)}.cx-context-picker{z-index:80;background:var(--surface);border-radius:10px;max-height:94vh;position:fixed;overflow:auto}.cx-picker-head{gap:3px;padding:13px 14px 9px;display:grid}.cx-picker-head strong{font-size:11px}.cx-picker-search{margin:0;padding:0 8px 8px;display:block}.cx-picker-search input{background:var(--surface-soft);border-radius:7px;width:100%;height:35px;padding:7px 9px;font-size:10px}.cx-picker-list{border-top:1px solid var(--line);max-height:min(300px,48vh);padding:4px 5px 5px;overflow-y:auto}.cx-picker-create,.cx-picker-option{width:100%;min-height:45px;color:var(--ink);text-align:left;background:0 0;border:0;border-radius:7px;align-items:center;gap:10px;padding:6px 8px;display:flex}.cx-create-icon,.cx-option-check{border:1px solid var(--line-strong);color:#0000;border-radius:5px;flex:0 0 20px;place-items:center;width:20px;height:20px;font-size:11px;font-weight:850;display:grid}.cx-create-icon{color:var(--accent);border-style:dashed;font-size:15px}.cx-picker-option.all .cx-option-check{border-color:var(--accent);background:var(--accent);color:var(--on-accent)}.cx-picker-option.some .cx-option-check{border-color:var(--accent-border);background:var(--accent-soft);color:var(--accent)}.cx-option-copy{gap:3px;min-width:0;display:grid}.cx-option-copy strong{text-overflow:ellipsis;white-space:nowrap;font-size:10px;overflow:hidden}.cx-picker-empty{color:var(--muted);text-align:center;margin:0;padding:24px 12px;font-size:9px}.cx-picker-manage{border:0;border-top:1px solid var(--line);background:var(--surface-soft);width:100%;min-height:39px;color:var(--muted);text-align:left;padding:7px 12px;font-size:9px;font-weight:720}.cx-picker-manage:hover{background:var(--control);color:var(--ink)}.cx-context-picker[aria-busy=true]{opacity:.65}.context-view{max-width:1480px}.cx-library{gap:18px;display:grid}.cx-library-surface{border:1px solid var(--line);background:color-mix(in srgb, var(--surface) 95%, transparent);border-radius:11px;overflow:hidden}.cx-library-toolbar{grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr));align-items:center;gap:clamp(14px,3vw,64px);min-height:108px;padding:16px 18px;display:grid}.cx-library .cx-assignment-guide{border:1px solid var(--line);background:var(--surface-soft);width:min(520px,100%);min-height:72px;color:var(--ink);text-align:left;border-radius:9px;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:12px;padding:11px 14px;display:grid}.cx-library .cx-assignment-guide:hover{border-color:var(--accent-border);background:var(--row-hover);color:var(--ink)}.cx-guide-icon{background:var(--accent-soft);width:42px;height:42px;color:var(--accent);border-radius:9px;place-items:center;font-size:18px;display:grid}.cx-assignment-guide>span:nth-child(2){gap:5px;min-width:0;display:grid}.cx-assignment-guide strong{font-size:11px}.cx-assignment-guide small{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:9px;overflow:hidden}.cx-assignment-guide>b{color:var(--muted);font-size:20px;font-weight:400}.cx-library-tools{justify-content:flex-end;align-items:center;gap:8px;min-width:0;display:flex}.cx-library-search{width:min(460px,100%);position:relative}.cx-library-search:before{content:"";z-index:1;border:1.5px solid var(--faint);border-radius:50%;width:10px;height:10px;position:absolute;top:11px;left:12px}.cx-library-search input{height:40px;padding-left:35px}.cx-create-context{border-top:1px solid var(--line);background:var(--accent-soft);grid-template-columns:minmax(180px,280px) auto auto;gap:7px;padding:10px 18px;display:grid}.cx-context-table{width:100%;overflow-x:auto}.cx-context-header,.cx-context-row{grid-template-columns:minmax(120px,1.2fr) minmax(90px,1.2fr) 70px minmax(100px,1fr) minmax(80px,1fr) 90px 46px;align-items:center;gap:13px;display:grid}.cx-context-header{border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:var(--surface-soft);min-height:43px;color:var(--faint);letter-spacing:.09em;text-transform:uppercase;padding:7px 18px;font-size:8px;font-weight:820}.cx-context-row{border-bottom:1px solid var(--line);min-height:74px;padding:11px 18px}.cx-context-row:last-child{border-bottom:0}.cx-context-row:hover{background:var(--row-hover)}.cx-context-row.selected{border:1px solid var(--accent-border);background:linear-gradient(100deg, var(--accent-soft), color-mix(in srgb, var(--accent-soft) 45%, transparent));border-radius:8px;margin:5px;padding-inline:13px}.cx-context-row.deleting{background:var(--danger-soft)}.cx-context-name{gap:4px;min-width:0;display:grid}.cx-library .cx-use-context{max-width:100%;min-height:25px;color:var(--ink);text-align:left;background:0 0;border:0;justify-self:start;padding:0;font-size:11px}.cx-library .cx-use-context:hover{color:var(--accent);background:0 0}.cx-context-state{color:var(--success);font-size:8px}.cx-context-description,.cx-context-updated{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:9px;overflow:hidden}.cx-context-host-count{gap:2px;display:grid}.cx-context-host-count strong{font-size:14px}.cx-context-usage{min-width:0;color:var(--muted);align-items:center;gap:7px;font-size:8px;display:flex}.cx-context-usage i{background:var(--faint);border-radius:50%;flex:0 0 5px;width:5px;height:5px}.cx-context-usage i.active,.cx-detail-alias>i.ok{background:var(--success)}.cx-context-label span,.cx-context-usage span,.cx-library .cx-use-context{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.cx-context-label span{border:1px solid var(--accent-border);max-width:100%;color:var(--accent);border-radius:5px;padding:4px 7px;font-size:8px;display:inline-flex}.cx-context-actions{justify-content:flex-end;gap:5px;display:flex}.cx-context-actions button{min-height:30px;padding:5px 8px}.cx-library button.danger{color:var(--danger);background:0 0;border-color:#0000}.cx-library .cx-confirm-delete:hover,.cx-library button.danger:hover{background:var(--danger-soft);color:var(--danger)}.cx-row-more{min-width:34px;padding-inline:5px!important}.cx-context-empty{min-height:190px;color:var(--muted);place-content:center;justify-items:center;gap:6px;display:grid}.cx-context-empty strong{color:var(--ink);font-size:12px}.cx-context-empty span{font-size:9px}.cx-library-footer{border-top:1px solid var(--line);min-height:46px;color:var(--muted);justify-content:flex-end;align-items:center;padding:9px 18px;font-size:9px;display:flex}.cx-library[aria-busy=true]{opacity:.68}.cx-library[aria-busy=true] button{pointer-events:none}.cx-context-detail{border:1px solid var(--line);background:color-mix(in srgb, var(--surface) 95%, transparent);border-radius:11px;padding:20px}.cx-detail-head{justify-content:space-between;align-items:center;gap:18px;display:flex}.cx-detail-title{align-items:center;gap:13px;min-width:0;display:flex}.cx-stack-icon{background:var(--accent-soft);width:42px;height:42px;color:var(--accent);border-radius:9px;flex:0 0 42px;place-items:center;font-size:22px;display:grid}.cx-detail-title h3{margin:0 0 5px;font-size:15px}.cx-detail-title h3 small{background:var(--control);color:var(--muted);vertical-align:2px;border-radius:999px;margin-left:7px;padding:3px 7px;font-size:8px}.cx-detail-title h3 small.active{background:var(--success-soft);color:var(--success)}.cx-detail-title p{color:var(--muted);margin:0;font-size:9px}.cx-detail-buttons{align-items:center;gap:7px;display:flex}.cx-detail-stats{grid-template-columns:minmax(100px,170px) minmax(130px,220px) minmax(0,1fr);margin:24px 0 18px;display:grid}.cx-detail-stats>span{border-right:1px solid var(--line);align-content:center;gap:4px;min-height:48px;padding:0 22px;display:grid}.cx-detail-stats>span:first-child{padding-left:2px}.cx-detail-stats>span:last-child{border-right:0}.cx-detail-stats strong{font-size:11px}.cx-detail-stats>span:first-child strong{font-size:18px}.cx-detail-stats small,.cx-detail-status b{color:var(--muted);font-size:8px}.cx-detail-hosts{border:1px solid var(--line);border-radius:9px;overflow-x:auto}.cx-detail-hosts>header{align-items:center;min-height:50px;padding:12px 16px;display:flex}.cx-detail-hosts h4{margin:0;font-size:11px}.cx-detail-host,.cx-detail-host-head{grid-template-columns:minmax(120px,1.2fr) minmax(150px,1.2fr) minmax(0,1fr) 100px 75px 130px;align-items:center;gap:12px;display:grid}.cx-detail-host-head{border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:var(--surface-soft);min-height:36px;color:var(--faint);letter-spacing:.08em;text-transform:uppercase;padding:7px 16px;font-size:8px;font-weight:800}.cx-detail-host{border-bottom:1px solid var(--line);min-height:70px;padding:10px 16px}.cx-detail-host:last-child{border-bottom:0}.cx-detail-host:hover{background:var(--row-hover)}.cx-detail-alias{grid-template-columns:6px minmax(0,1fr);align-items:center;gap:9px;min-width:0;display:grid}.cx-detail-alias>i{background:var(--faint);border-radius:50%;width:6px;height:6px}.cx-detail-alias>i.failed{background:var(--danger)}.cx-detail-alias>i.stale{background:var(--warning)}.cx-detail-alias span{gap:4px;min-width:0;display:grid}.cx-detail-alias strong{text-overflow:ellipsis;white-space:nowrap;font-size:10px;overflow:hidden}.cx-detail-alias small,.cx-detail-host code,.cx-detail-labels span{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:8px;overflow:hidden}.cx-detail-labels{gap:4px;display:flex;overflow:hidden}.cx-detail-labels span{background:var(--control);border-radius:5px;max-width:82px;padding:3px 6px;font-size:7px}.cx-detail-status{gap:3px;display:grid}.cx-detail-seen,.cx-detail-status small{color:var(--faint);font-size:8px}.cx-detail-status.ok b{color:var(--success)}.cx-detail-status.failed b{color:var(--danger)}.cx-detail-status.stale b{color:var(--warning)}.cx-detail-actions{justify-content:flex-end;gap:5px;display:flex}.cx-detail-actions button{min-width:35px;min-height:30px;padding:5px 8px}.cx-detail-empty{min-height:100px;color:var(--muted);place-items:center;font-size:9px;display:grid}.context-tag-picker{border:1px solid var(--line-strong);background:var(--surface);border-radius:8px;flex-wrap:wrap;align-items:center;gap:5px;min-height:42px;padding:5px 7px;display:flex;position:relative}.context-tag-picker:focus-within{border-color:var(--accent);outline:3px solid var(--focus-ring)}.ctp-chips{display:contents}.ctp-chip{border:1px solid var(--accent-border);background:var(--accent-soft);min-height:27px;color:var(--accent);border-radius:999px;align-items:center;gap:3px;padding:3px 3px 3px 8px;font-size:10px;font-weight:720;display:inline-flex}.context-tag-picker .ctp-remove{min-width:21px;min-height:21px;color:inherit;background:0 0;border:0;border-radius:999px;padding:0;font-size:15px}.context-tag-picker .ctp-remove:hover{background:var(--accent-border);color:var(--accent)}.context-tag-picker .ctp-editor{min-width:150px;min-height:28px;color:var(--ink);background:0 0;border:0;outline:0;flex:1;padding:3px;font-size:11px}.context-tag-picker .ctp-editor:focus{border:0;outline:0}.ctp-menu{z-index:20;border:1px solid var(--line-strong);background:var(--surface);border-radius:9px;max-height:240px;padding:5px;position:absolute;top:calc(100% + 5px);left:0;right:0;overflow-y:auto}.context-tag-picker .ctp-option{width:100%;min-height:38px;color:var(--ink);text-align:left;background:0 0;border:0;border-radius:7px;justify-content:space-between;align-items:center;gap:10px;padding:7px 9px;display:flex}.context-tag-picker .ctp-option.active,.context-tag-picker .ctp-option:hover{background:var(--accent-soft);color:var(--accent)}.ctp-option small{color:var(--muted);font-size:8px}@media (width<=1100px){.cx-context-header,.cx-context-row{grid-template-columns:minmax(110px,1fr) minmax(0,1fr) 60px minmax(90px,1fr) minmax(0,1fr) 80px 46px;gap:9px}.cx-detail-host,.cx-detail-host-head{grid-template-columns:minmax(120px,1fr) minmax(160px,1.15fr) minmax(110px,.8fr) 100px 70px 120px;gap:8px}}@media (width<=820px){.cx-library-toolbar{grid-template-columns:1fr;gap:12px}.cx-library .cx-assignment-guide,.cx-library-search{width:100%}.cx-context-table{overflow-x:auto}.cx-context-header,.cx-context-row{min-width:900px}.cx-detail-head{flex-direction:column;align-items:stretch}.cx-detail-buttons{flex-wrap:wrap}.cx-detail-stats{grid-template-columns:repeat(3,1fr)}.cx-detail-hosts{overflow-x:auto}.cx-detail-host,.cx-detail-host-head{min-width:900px}}@media (width<=640px){.cx-batch,.cx-toolbar-main{flex-wrap:wrap;align-items:stretch}.cx-host-search{min-width:75%}.cx-batch-hint{display:none}.cx-batch .cx-clear{margin-left:0}.cx-create-context{grid-template-columns:1fr auto}.cx-create-context .secondary{grid-column:1/-1}.cx-context-detail{padding:13px}.cx-detail-stats{grid-template-columns:1fr}.cx-detail-stats>span{border-right:0;border-bottom:1px solid var(--line);min-height:44px;padding:8px 2px}.cx-detail-stats>span:last-child{border-bottom:0}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
let e,t;const n=new WeakMap;function a(){if(document.querySelector("link[data-context-styles]"))return;const e=document.createElement("link");e.rel="stylesheet",e.href="/assets/contexts.css",e.dataset.contextStyles="true",document.head.append(e)}function s(e,t,n){const a=document.createElement(e);return t&&(a.className=t),null!=n&&(a.textContent=n),a}function o(e){return String(e??"").replace(/[&<>'"]/g,e=>({"&":"&","<":"<",">":">","'":"'",'"':"""}[e]))}function c(e){return(e()||[]).map(e=>"string"==typeof e?e:e.name).filter(Boolean).sort((e,t)=>e.localeCompare(t,void 0,{sensitivity:"base"}))}function i(e){return e.length>0&&e.length<=40&&!/[,\x00-\x1f\x7f]/.test(e)}function r(e){const t=new Set;return e.value.split(",").map(e=>e.trim()).filter(e=>{const n=e.toLocaleLowerCase();return!(!e||t.has(n)||(t.add(n),0))})}function l(e){e.source.value=e.selected.join(", "),e.chips.replaceChildren(),e.selected.forEach(t=>{const n=s("span",`ctp-chip tc-${e.tagColor?e.tagColor(t):0}`);n.append(s("span","",t));const a=s("button","ctp-remove","×");a.type="button",a.dataset.removeTag=t,a.setAttribute("aria-label",`Remove ${t}`),n.append(a),e.chips.append(n)}),e.editor.placeholder=e.selected.length?"Add another…":"Type or choose a tag…"}function d(e){const t=function(e){const t=e.editor.value.trim(),n=new Set(e.selected.map(e=>e.toLocaleLowerCase())),a=c(e.getContexts).filter(e=>!n.has(e.toLocaleLowerCase())).filter(e=>!t||e.toLocaleLowerCase().includes(t.toLocaleLowerCase())).map(e=>({name:e,create:!1}));return i(t)&&!c(e.getContexts).some(e=>e.toLocaleLowerCase()===t.toLocaleLowerCase())&&a.unshift({name:t,create:!0}),a.slice(0,8)}(e);if(e.choices=t,!e.open||!t.length)return e.menu.hidden=!0,void e.editor.setAttribute("aria-expanded","false");e.highlighted=Math.min(Math.max(e.highlighted,0),t.length-1),e.menu.replaceChildren(),t.forEach((t,n)=>{const a=s("button","ctp-option"+(n===e.highlighted?" active":""));a.type="button",a.role="option",a.dataset.pickTag=t.name,a.setAttribute("aria-selected",String(n===e.highlighted)),a.append(s("span","",t.create?`Create “${t.name}”`:t.name)),a.append(s("small","",t.create?"New tag":"Existing tag")),e.menu.append(a)}),e.menu.hidden=!1,e.editor.setAttribute("aria-expanded","true")}function p(e,t){e.toast&&e.toast(t,!0)}function u(e,t){const n=t.trim();return i(n)?e.selected.some(e=>e.toLocaleLowerCase()===n.toLocaleLowerCase())?(e.editor.value="",!0):e.selected.length>=20?(p(e,"A host can use at most 20 tags"),!1):(e.selected.push(n),e.editor.value="",e.highlighted=0,l(e),d(e),!0):(n&&p(e,"Tags must be 1–40 printable characters without commas"),!1)}export function enhanceTagPicker({input:e,getContexts:t,toast:o,tagColor:c}){a();const i=n.get(e);if(i)return i.getContexts=t,i.toast=o,i.tagColor=c,i.selected=r(e),i.editor.value="",i.open=!1,l(i),d(i),i.wrapper;e.hidden=!0;const p=s("div","context-tag-picker"),h=s("span","ctp-chips"),m=s("input","ctp-editor");m.type="text",m.maxLength=40,m.autocomplete="off",m.setAttribute("role","combobox"),m.setAttribute("aria-label","Add a tag"),m.setAttribute("aria-autocomplete","list"),m.setAttribute("aria-expanded","false");const g=s("div","ctp-menu");g.role="listbox",g.hidden=!0,p.append(h,m,g),e.after(p);const y={source:e,wrapper:p,chips:h,editor:m,menu:g,getContexts:t,toast:o,tagColor:c,selected:r(e),choices:[],highlighted:0,open:!1};return n.set(e,y),l(y),m.addEventListener("focus",()=>{y.open=!0,y.highlighted=0,d(y)}),m.addEventListener("input",()=>{const e=m.value.split(",");e.length>1&&(e.slice(0,-1).forEach(e=>u(y,e)),m.value=e.at(-1).trimStart()),y.open=!0,y.highlighted=0,d(y)}),m.addEventListener("keydown",e=>{if("ArrowDown"===e.key||"ArrowUp"===e.key){e.preventDefault(),y.open=!0;const t="ArrowDown"===e.key?1:-1,n=Math.max(y.choices.length,1);y.highlighted=(y.highlighted+t+n)%n,d(y)}else if("Enter"===e.key){e.preventDefault();const t=y.open?y.choices[y.highlighted]:null;u(y,t?.name||m.value)}else"Backspace"===e.key&&!m.value&&y.selected.length?(y.selected.pop(),l(y),d(y)):"Escape"===e.key&&(y.open=!1,d(y))}),p.addEventListener("mousedown",e=>{e.target.closest(".ctp-option")&&e.preventDefault()}),p.addEventListener("click",e=>{const t=e.target.closest("[data-remove-tag]"),n=e.target.closest("[data-pick-tag]");t?(y.selected=y.selected.filter(e=>e.toLocaleLowerCase()!==t.dataset.removeTag.toLocaleLowerCase()),l(y),m.focus(),d(y)):n?(u(y,n.dataset.pickTag),m.focus()):e.target!==p&&e.target!==h||m.focus()}),p.addEventListener("focusout",()=>setTimeout(()=>{p.contains(document.activeElement)||(y.open=!1,d(y))},0)),p}function h(e,t,n){const a=e.contextKey(n);return(t.tags||[]).some(t=>e.contextKey(t)===a)}let m;function g(){return m?.open?"bulk"===m.kind?k("edit"):"detail"===m.kind?[...document.querySelectorAll("[data-detail-contexts]")].find(e=>e.dataset.id===m.hostId)||null:"sidebar"===m.kind?[...document.querySelectorAll("[data-sidebar-contexts]")].find(e=>e.dataset.id===m.hostId)||null:[...document.querySelectorAll("#serverRows tr[data-server-id]")].find(e=>e.dataset.serverId===m.hostId)?.querySelector("[data-edit-contexts]")||null:null}function y(e=!1){if(!m?.open)return;const t=g();m.open=!1,m.root.hidden=!0,t?.setAttribute("aria-expanded","false"),e&&t?.focus()}function x(){if(!m?.open)return;const e=g();if(!e||e.hidden)return y();const t=e.getBoundingClientRect(),n=m.root,a=Math.min(340,window.innerWidth-16);n.style.width=`${a}px`;const s=Math.min(n.offsetHeight,window.innerHeight-16),o=Math.max(8,Math.min(t.left,window.innerWidth-a-8)),c=t.bottom+6,i=c+s<=window.innerHeight-8?c:Math.max(8,t.top-s-6);n.style.left=`${o}px`,n.style.top=`${i}px`}function f(){const e=new Set(m.options.getState().servers.map(e=>e.id));return[...m.hostIds].forEach(t=>{e.has(t)||m.hostIds.delete(t)}),m.options.getState().servers.filter(e=>m.hostIds.has(e.id))}function b(){if(!m?.open)return;const{options:e,root:t,input:n,list:a}=m,o=f();if(!o.length)return y();const c=n.value.trim(),r=c.toLocaleLowerCase(),l=e.getState().contexts||[],d=l.filter(e=>!r||e.name.toLocaleLowerCase().includes(r));t.querySelector("[data-cap-title]").textContent=`Tags for ${o.length} host${1===o.length?"":"s"}`,a.replaceChildren(),d.forEach(t=>{const n=o.filter(n=>h(e,n,t.name)).length,c=n===o.length?"all":n?"some":"none",i=s("button",`cx-picker-option ${c}`);i.type="button",i.dataset.contextName=t.name,i.setAttribute("role","checkbox"),i.setAttribute("aria-checked","all"===c?"true":"some"===c?"mixed":"false");const r=s("span","cx-option-check","all"===c?"✓":"some"===c?"−":"");r.setAttribute("aria-hidden","true");const l=s("span","cx-option-copy");l.append(s("strong","",t.name),s("small","",1===o.length?"all"===c?"Assigned":"Not assigned":"all"===c?"All selected hosts":"some"===c?`${n} of ${o.length} hosts`:"Not assigned")),i.append(r,l),a.append(i)});const p=l.some(e=>e.name.toLocaleLowerCase()===r);if(i(c)&&!p){const e=s("button","cx-picker-create");e.type="button",e.dataset.createContext=c;const t=s("span","cx-create-icon","+");t.setAttribute("aria-hidden","true");const n=s("span","cx-option-copy");n.append(s("strong","",`Create “${c}”`),s("small","",`Create and assign to ${o.length} host${1===o.length?"":"s"}`)),e.append(t,n),a.prepend(e)}a.children.length||a.append(s("p","cx-picker-empty",c?"No matching tags":"No tags yet. Type to create one.")),x()}async function v(e){if(!m.busy){m.busy=!0,m.root.setAttribute("aria-busy","true");try{await e()}catch(e){m.options.toast(e.message||String(e),!0)}finally{m.busy=!1,m.root.removeAttribute("aria-busy"),b()}}}async function w(e){const{options:t,hostIds:n}=m,a=f(),s=new Set(t.getState().servers.filter(n=>h(t,n,e)).map(e=>e.id)),o=a.every(e=>s.has(e.id));n.forEach(e=>o?s.delete(e):s.add(e)),await t.api("/api/contexts",{method:"PUT",body:{name:e,server_ids:[...s]}}),await t.refresh(),t.toast(`${o?"Removed":"Added"} “${e}” ${o?"from":"to"} ${a.length} host${1===a.length?"":"s"}`)}async function S(e){const{options:t,hostIds:n,input:a}=m,s=await t.api("/api/contexts",{method:"POST",body:{name:e,server_ids:[...n]}});a.value="",await t.refresh(),t.toast(`Created “${s.name}” and assigned ${s.count} host${1===s.count?"":"s"}`)}function $(e,t,n,a=""){if(function(){if(m)return;const e=s("div","cx-context-picker");e.hidden=!0,e.setAttribute("role","dialog"),e.setAttribute("aria-label","Edit host tags"),e.innerHTML='\n <div class="cx-picker-head"><strong data-cap-title></strong><span>Click to add or remove</span></div>\n <label class="cx-picker-search"><span class="sr-only">Search or create tag</span><input type="search" maxlength="40" placeholder="Search or create tag…" autocomplete="off"></label>\n <div class="cx-picker-list"></div>\n <button type="button" class="cx-picker-manage">Manage tags…</button>',document.body.append(e),m={root:e,input:e.querySelector("input"),list:e.querySelector(".cx-picker-list"),hostIds:new Set,hostId:"",kind:"",options:null,open:!1,busy:!1},m.input.addEventListener("input",b),m.input.addEventListener("keydown",e=>{if("Escape"===e.key)e.preventDefault(),y(!0);else if("Enter"===e.key){const t=m.list.querySelector("[data-create-context]"),n=m.list.querySelector("[data-context-name]");(t||n)&&(e.preventDefault(),v(t?()=>S(t.dataset.createContext):()=>w(n.dataset.contextName)))}}),e.addEventListener("keydown",e=>{"Escape"===e.key&&e.target!==m.input&&(e.preventDefault(),y(!0))}),e.addEventListener("click",e=>{const t=e.target.closest("[data-context-name]"),n=e.target.closest("[data-create-context]");if(t)v(()=>w(t.dataset.contextName));else if(n)v(()=>S(n.dataset.createContext));else if(e.target.closest(".cx-picker-manage")){const e=m.options;y(),openContextManager(e)}}),document.addEventListener("pointerdown",t=>{!m.open||e.contains(t.target)||g()?.contains(t.target)||y()},!0),window.addEventListener("resize",x),window.addEventListener("scroll",x,!0)}(),m.open&&m.kind===n&&("bulk"===n||m.hostId===a))return y();y(),m.options=e,m.hostIds=new Set(t),m.hostId=a,m.kind=n,m.input.value="",m.open=!0,m.root.hidden=!1,g()?.setAttribute("aria-expanded","true"),b(),requestAnimationFrame(()=>m.input.focus())}function k(e){return document.querySelector(`[data-cx="${e}"]`)}function C(){const e=t.query.trim().toLocaleLowerCase();return t.options.getVisibleServers().filter(t=>!e||[t.alias,t.hostname,t.username,t.notes,...t.tags||[]].some(t=>String(t||"").toLocaleLowerCase().includes(e)))}function L(){if(!t)return;const{options:e,selected:n}=t,a=new Set(e.getState().servers.map(e=>e.id));[...n].forEach(e=>{a.has(e)||n.delete(e)});const o=C(),c=new Set(o.map(e=>e.id));document.querySelectorAll("#serverRows tr[data-server-id]").forEach(e=>{const t=e.dataset.serverId;let a=e.querySelector(".cx-select-cell");if(!a){a=s("td","cx-select-cell");const n=document.createElement("input");n.type="checkbox",n.dataset.cxHost=t,n.setAttribute("aria-label",`Select ${e.querySelector(".alias")?.textContent||"host"}`),a.append(n),e.prepend(a)}e.hidden=!c.has(t),e.classList.toggle("cx-selected",n.has(t)),a.querySelector("input").checked=n.has(t)});const i=k("select-all"),r=o.filter(e=>n.has(e.id)).length;i&&(i.disabled=0===o.length,i.checked=o.length>0&&r===o.length,i.indeterminate=r>0&&r<o.length),k("selected").textContent=n.size?`${n.size} selected`:"Select hosts",k("shown").textContent=`${o.length} shown`,k("batch").hidden=0===n.size,t.empty.hidden=!t.query||o.length>0,m?.open&&"bulk"===m.kind&&(m.hostIds=new Set(n),n.size?b():y())}export function enhanceConnections(e){a(),t||function(e){const n=document.querySelector("#connectionToolbarMount"),a=document.querySelector("#serversView .management-table thead tr");if(!a.querySelector(".cx-select-column")){const e=s("th","cx-select-column"),t=document.createElement("input");t.type="checkbox",t.dataset.cx="select-all",t.setAttribute("aria-label","Select all visible hosts"),e.append(t),a.prepend(e)}const o=s("div","cx-toolbar");o.innerHTML='\n <div class="cx-toolbar-main">\n <label class="cx-host-search"><span class="sr-only">Search connections</span><input data-cx="search" type="search" placeholder="Search alias, endpoint, or tag…"></label>\n <span class="cx-shown" data-cx="shown"></span>\n </div>\n <div class="cx-batch" data-cx="batch" hidden>\n <span class="cx-batch-label" data-cx="selected">Selected hosts</span>\n <button type="button" class="cx-context-trigger" data-cx="edit" data-action="edit-contexts" aria-haspopup="dialog" aria-expanded="false">Edit tags <span aria-hidden="true">⌄</span></button>\n <button type="button" class="secondary" data-action="test-selected">Test selected</button>\n <span class="cx-batch-hint">Choose existing or create new</span>\n <button type="button" class="cx-clear" data-action="clear-selection">Clear selection</button>\n </div>',n.append(o);const c=s("p","cx-search-empty","No connections match this search.");c.hidden=!0,document.querySelector("#serversView .management-surface .table-wrap").after(c),t={options:e,toolbar:o,empty:c,selected:new Set,query:""},k("search").addEventListener("input",e=>{t.query=e.target.value,L()}),k("select-all").addEventListener("change",e=>{C().forEach(n=>e.target.checked?t.selected.add(n.id):t.selected.delete(n.id)),L()}),o.addEventListener("click",n=>{const a=n.target.closest("[data-action]")?.dataset.action;"edit-contexts"===a?$(t.options,t.selected,"bulk"):"test-selected"===a?async function(){const{options:e,selected:n,toolbar:a}=t,s=[...n];if(s.length){a.setAttribute("aria-busy","true");try{const t=[];let n=0;await Promise.all(Array.from({length:Math.min(4,s.length)},async function(){for(;n<s.length;){const a=s[n++];try{t.push(await e.api(`/api/servers/${a}/test`,{method:"POST"}))}catch(e){t.push(null)}}}));const a=t.filter(e=>e?.ok).length;await e.refresh(),e.toast(`${a} of ${s.length} selected host${1===s.length?"":"s"} reachable`,a!==s.length)}finally{a.removeAttribute("aria-busy")}}}().catch(t=>e.toast(t.message||String(t),!0)):"clear-selection"===a&&(t.selected.clear(),L())}),document.querySelector("#serverRows").addEventListener("change",e=>{const n=e.target.closest("[data-cx-host]");n&&(n.checked?t.selected.add(n.dataset.cxHost):t.selected.delete(n.dataset.cxHost),L())}),document.querySelector("#serverRows").addEventListener("click",n=>{const a=n.target.closest("[data-edit-contexts]"),s=n.target.closest("[data-context]");if(a){const e=a.closest("tr[data-server-id]")?.dataset.serverId;e&&$(t.options,[e],"row",e)}else s&&(e.setHostContext(e.contextKey(s.dataset.context)),e.showView("servers"),L())})}(e),t.options=e,L()}export function openHostContextPicker(e,t){a(),$(e,[t],"sidebar",t)}function E(t){return e.container.querySelector(`[data-cm="${t}"]`)}function A(){return e.options.getState().contexts||[]}function K(t){return A().find(n=>e.options.contextKey(n.name)===t)}function q(t){return e.options.getState().servers.filter(n=>h(e.options,n,t.name))}function T(e){const t=e.map(e=>Date.parse(e.updated_at||"")).filter(Number.isFinite);return t.length?new Date(Math.max(...t)).toLocaleDateString(void 0,{month:"short",day:"numeric",year:"numeric"}):"—"}function M(){if(!e)return;const t=e.query.trim().toLocaleLowerCase(),n=A(),a=n.filter(e=>!t||e.name.toLocaleLowerCase().includes(t));new Set(n.map(t=>e.options.contextKey(t.name))).has(e.selectedKey)||(e.selectedKey=n[0]?e.options.contextKey(n[0].name):"");const c=E("rows");c.replaceChildren(),a.forEach(t=>{const n=e.options.contextKey(t.name),a=q(t),o=s("div",`cx-context-row${e.deleteKey===n?" deleting":""}${e.selectedKey===n?" selected":""}`);o.role="row";const i=s("div","cx-context-name");if(i.role="cell",e.editKey===n){const e=document.createElement("input");e.value=t.name,e.maxLength=40,e.dataset.cmRename=n,e.setAttribute("aria-label",`Rename ${t.name}`),i.append(e)}else{const e=s("button","cx-use-context",t.name);e.type="button",e.dataset.action="select",e.dataset.contextKey=n,e.title=`Open ${t.name}`,i.append(e),i.append(s("small","cx-context-state",a.length?"Active":"No hosts assigned"))}const r=s("div","cx-context-description","Reusable host tag");r.role="cell";const l=s("div","cx-context-host-count");l.role="cell",l.append(s("strong","",String(a.length)),s("span","","host"+(1===a.length?"":"s")));const d=s("div","cx-context-usage");d.role="cell",d.append(s("i",a.length?"active":""),s("span","",a.length?`Used by ${a.length} host${1===a.length?"":"s"}`:"Not assigned"));const p=s("div","cx-context-label");p.role="cell",p.append(s("span",`tc-${e.options.tagColor(t.name)}`,t.name));const u=s("div","cx-context-updated",T(a));u.role="cell";const h=s("div","cx-context-actions");if(h.role="cell",e.editKey===n){const e=s("button","","Save");e.dataset.action="save-rename",e.dataset.contextKey=n;const t=s("button","secondary","Cancel");t.dataset.action="cancel",h.append(e,t)}else if(e.deleteKey===n){const e=s("button","cx-confirm-delete","Delete");e.dataset.action="confirm-delete",e.dataset.contextKey=n;const t=s("button","secondary","Cancel");t.dataset.action="cancel",h.append(e,t)}else{const e=s("button","secondary cx-row-more","•••");e.dataset.action="select",e.dataset.contextKey=n,e.setAttribute("aria-label",`Open ${t.name} details`),h.append(e)}o.append(i,r,l,d,p,u,h),c.append(o)}),E("empty").hidden=a.length>0,E("count").textContent=`${a.length} tag${1===a.length?"":"s"}`;const i=n.find(t=>e.options.contextKey(t.name)===e.selectedKey);!function(t,n,a){const s=E("detail");if(!t)return s.hidden=!0,void s.replaceChildren();s.hidden=!1;const c=e.deleteKey===a,i=n.map(t=>{const n=e.options.serverStatus(t),a=(t.tags||[]).map(t=>`<span class="tc-${e.options.tagColor(t)}">${o(t)}</span>`).join("");return`<div class="cx-detail-host"><div class="cx-detail-alias"><i class="${n.tone}"></i><span><strong>${o(t.alias)}</strong><small>${o(t.notes||"No notes")}</small></span></div><code>${o(t.username)}@${o(t.hostname)}:${t.port}</code><div class="cx-detail-labels">${a}</div><span class="cx-detail-status ${n.tone}"><b>${o(n.short)}</b><small>${o(n.meta)}</small></span><span class="cx-detail-seen">${o(function(e){const t=Date.parse(e||"");if(!Number.isFinite(t))return"—";const n=Math.max(0,Math.floor((Date.now()-t)/6e4));if(n<1)return"Just now";if(n<60)return`${n}m ago`;const a=Math.floor(n/60);return a<24?`${a}h ago`:`${Math.floor(a/24)}d ago`}(t.last_test_at))}</span><div class="cx-detail-actions"><button type="button" class="secondary" data-action="test-host" data-id="${o(t.id)}">Test</button><button type="button" class="secondary" data-detail-contexts data-id="${o(t.id)}" aria-haspopup="dialog" aria-expanded="false" title="Edit host tags">+</button><button type="button" class="secondary" data-action="edit-host" data-id="${o(t.id)}">Edit</button></div></div>`}).join("");s.innerHTML=`<header class="cx-detail-head"><div class="cx-detail-title"><span class="cx-stack-icon" aria-hidden="true">◇</span><div><h3>${o(t.name)} <small class="${n.length?"active":""}">${n.length?"Active":"Empty"}</small></h3><p>Reusable host tag</p></div></div><div class="cx-detail-buttons">${c?`<button type="button" class="secondary" data-action="cancel">Cancel</button><button type="button" class="danger" data-action="confirm-delete" data-context-key="${o(a)}">Delete now</button>`:`<button type="button" class="secondary" data-action="rename" data-context-key="${o(a)}">Rename</button><button type="button" class="secondary" data-action="manage-hosts" data-context-key="${o(a)}">Manage hosts</button><button type="button" class="danger" data-action="delete" data-context-key="${o(a)}">Delete</button>`}</div></header><div class="cx-detail-stats"><span><strong>${n.length}</strong><small>host${1===n.length?"":"s"}</small></span><span><small>Used in</small><strong>${n.length?"Host filtering":"Not assigned"}</strong></span><span><small>Updated</small><strong>${o(T(n))}</strong></span></div><section class="cx-detail-hosts"><header><h4>Hosts with this tag</h4></header><div class="cx-detail-host-head"><span>Alias</span><span>Endpoint</span><span>Tags</span><span>Status</span><span>Last seen</span><span>Actions</span></div>${i||'<div class="cx-detail-empty">No hosts use this tag yet. Use Manage hosts to add one.</div>'}</section>`}(i,i?q(i):[],e.selectedKey)}async function N(t){if(!e.busy){e.busy=!0,e.container.setAttribute("aria-busy","true");try{await t()}catch(t){e.options.toast(t.message||String(t),!0)}finally{e.busy=!1,e.container.removeAttribute("aria-busy")}}}async function D(){const t=E("create"),n=t.value.trim();if(!n)return t.focus();const a=await e.options.api("/api/contexts",{method:"POST",body:{name:n}});t.value="",E("creator").hidden=!0,await e.options.refresh(),e.selectedKey=e.options.contextKey(a.name),M(),e.options.toast(`Tag “${a.name}” created`)}async function H(t){const n=K(t),a=e.container.querySelector("[data-cm-rename]"),s=a?.value.trim();if(!n||!s||s===n.name)return e.editKey="",M();const o=e.options.getState().hostContext===t,c=await e.options.api("/api/contexts",{method:"PUT",body:{name:n.name,new_name:s}});await e.options.refresh(),o&&e.options.setHostContext(e.options.contextKey(c.name)),e.selectedKey===t&&(e.selectedKey=e.options.contextKey(c.name)),e.editKey="",M(),e.options.toast(`Renamed to “${c.name}”`)}function I(){e.options.showView("servers"),enhanceConnections(e.options)}export function openContextManager(t){a(),t.showView("contexts"),e||function(t){const n=document.querySelector("#contextManagerMount");n.className="cx-library",n.innerHTML='\n <section class="cx-library-surface">\n <header class="cx-library-toolbar"><button type="button" class="cx-assignment-guide" data-action="open-connections"><span class="cx-guide-icon" aria-hidden="true">↗</span><span><strong>Assign tags to hosts</strong><small>Select hosts in Connections and apply tags.</small></span><b aria-hidden="true">›</b></button><div class="cx-library-tools"><label class="cx-library-search"><span class="sr-only">Search tags</span><input data-cm="search" type="search" placeholder="Search tags…" aria-label="Search tags"></label></div></header>\n <div class="cx-create-context" data-cm="creator" hidden><input data-cm="create" maxlength="40" placeholder="New tag name" aria-label="New tag name"><button type="button" data-action="create">Create</button><button type="button" class="secondary" data-action="cancel-create">Cancel</button></div>\n <div class="cx-context-table" role="table" aria-label="Tag library">\n <div class="cx-context-header" role="row"><span role="columnheader">Name</span><span role="columnheader">Description</span><span role="columnheader">Hosts</span><span role="columnheader">Usage</span><span role="columnheader">Tag</span><span role="columnheader">Updated</span><span role="columnheader">Actions</span></div>\n <div data-cm="rows" role="rowgroup"></div>\n </div>\n <div class="cx-context-empty" data-cm="empty"><strong>No tags found</strong><span>Create a tag from the page header or clear your search.</span></div>\n <footer class="cx-library-footer"><span data-cm="count"></span></footer>\n </section>\n <section class="cx-context-detail" data-cm="detail" hidden></section>',e={container:n,options:t,query:"",editKey:"",deleteKey:"",selectedKey:"",busy:!1},E("create").addEventListener("keydown",e=>{"Enter"===e.key&&(e.preventDefault(),N(D))}),E("search").addEventListener("input",t=>{e.query=t.target.value,M()}),n.addEventListener("keydown",t=>{t.target.matches("[data-cm-rename]")&&("Enter"===t.key?(t.preventDefault(),N(()=>H(t.target.dataset.cmRename))):"Escape"===t.key&&(e.editKey="",M()))}),n.addEventListener("click",t=>{const n=t.target.closest("button");if(!n)return;const a=n.dataset.action,s=n.dataset.contextKey;if("create"===a)N(D);else if("cancel-create"===a)E("creator").hidden=!0,E("create").value="";else if("open-connections"===a)I();else if("select"===a)e.selectedKey=s,e.editKey="",e.deleteKey="",M();else if("manage-hosts"===a)e.options.setHostContext(s),I();else if("test-host"===a)N(async()=>{const t=await e.options.api(`/api/servers/${n.dataset.id}/test`,{method:"POST"});e.options.toast(t.ok?`Connected in ${t.latency_ms} ms`:t.message,!t.ok),await e.options.refresh(),M()});else if("edit-host"===a){const t=e.options.getState().servers.find(e=>e.id===n.dataset.id);t&&e.options.openServer(t)}else n.matches("[data-detail-contexts]")?$(e.options,[n.dataset.id],"detail",n.dataset.id):"rename"===a?(e.editKey=s,e.deleteKey="",M(),setTimeout(()=>e.container.querySelector("[data-cm-rename]")?.select(),0)):"save-rename"===a?N(()=>H(s)):"delete"===a?(e.deleteKey=s,e.editKey="",M()):"confirm-delete"===a?N(()=>async function(t){const n=K(t);if(!n)return;const a=e.options.getState().hostContext===t;await e.options.api("/api/contexts",{method:"DELETE",body:{name:n.name}}),await e.options.refresh(),a&&e.options.setHostContext("*"),e.selectedKey===t&&(e.selectedKey=""),e.deleteKey="",M(),e.options.toast(`Tag “${n.name}” deleted`)}(s)):"cancel"===a&&(e.editKey="",e.deleteKey="",M())})}(t),e.options=t,M()}export function focusContextCreation(){e&&(E("creator").hidden=!1,requestAnimationFrame(()=>E("create").focus()))}export function syncContextUI(n){m?.open&&(m.options=n,b()),t&&(t.options=n,L()),e&&(e.options=n,M())}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.diagnostics-dialog{border:1px solid var(--line-strong);background:var(--surface);width:min(680px,calc(100vw - 28px));max-width:none;color:var(--ink);border-radius:12px;padding:0;box-shadow:var(--shadow-dialog)}.diagnostics-dialog::backdrop{background:var(--overlay)}.diagnostics-dialog .dialog-heading{padding:16px 18px 12px}.diagnostics-dialog .dialog-actions{padding:11px 18px 15px}.diagnostics-body{padding:0 18px}.diagnostics-summary{border:1px solid var(--line);background:var(--surface-soft);border-radius:9px;align-items:center;justify-content:space-between;gap:16px;padding:12px 14px;display:flex}.diagnostics-summary>div{gap:4px;display:grid}.diagnostics-summary strong{font-size:13px}.diagnostics-summary span,.diagnostics-summary small{color:var(--muted);font-size:9px}.diagnostics-summary.ok{border-color:var(--success);background:var(--success-soft)}.diagnostics-summary.warning{border-color:var(--warning);background:var(--warning-soft)}.diagnostics-summary.failed{border-color:var(--danger);background:var(--danger-soft)}.diagnostics-checks{border:1px solid var(--line);border-radius:9px;margin-top:12px;overflow:hidden}.diagnostic-check{border-bottom:1px solid var(--line);padding:11px 14px}.diagnostic-check:last-child{border-bottom:0}.diagnostic-check-head{align-items:center;justify-content:space-between;gap:12px;display:flex}.diagnostic-check-head strong{font-size:10px}.diagnostic-check-head span{color:var(--muted);font-size:8px;text-transform:uppercase}.diagnostic-check.ok .diagnostic-check-head span{color:var(--success)}.diagnostic-check.failed .diagnostic-check-head span{color:var(--danger)}.diagnostic-check.skipped .diagnostic-check-head span{color:var(--muted)}.diagnostic-check p{color:var(--muted);margin:5px 0 0;font-size:9px;line-height:1.5}.diagnostic-details{flex-wrap:wrap;gap:5px 12px;margin-top:7px;display:flex}.diagnostic-details span{color:var(--muted);font-size:8px}.diagnostic-details b{color:var(--ink);font-weight:700;text-transform:capitalize}.diagnostics-loading{min-height:150px;color:var(--muted);place-content:center;justify-items:center;gap:10px;display:grid;font-size:9px}.diagnostics-loading>span{background:var(--accent);border-radius:50%;width:7px;height:7px;animation:diagnostics-pulse 1s infinite alternate}.diagnostics-loading>span:nth-child(2){animation-delay:.15s}.diagnostics-loading>span:nth-child(3){animation-delay:.3s}.diagnostics-loading p{margin:3px 0 0}.diagnostics-error{border:1px solid var(--danger);background:var(--danger-soft);border-radius:9px;padding:16px;color:var(--danger)}.diagnostics-error strong{font-size:11px}.diagnostics-error p{margin:6px 0 0;font-size:9px}.diagnostics-empty{padding:20px;color:var(--muted);font-size:9px}@keyframes diagnostics-pulse{from{opacity:.3;transform:translateY(0)}to{opacity:1;transform:translateY(-3px)}}@media (prefers-reduced-motion:reduce){.diagnostics-loading>span{animation:none}}@media (width<=640px){.diagnostics-summary{align-items:flex-start;flex-direction:column;gap:6px}.diagnostic-check-head{align-items:flex-start;flex-direction:column;gap:3px}}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
let csrf = "";
|
|
3
|
+
let dialog;
|
|
4
|
+
|
|
5
|
+
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({
|
|
6
|
+
"&": "&",
|
|
7
|
+
"<": "<",
|
|
8
|
+
">": ">",
|
|
9
|
+
'"': """,
|
|
10
|
+
"'": "'",
|
|
11
|
+
}[char]));
|
|
12
|
+
|
|
13
|
+
async function request(path, options = {}) {
|
|
14
|
+
const method = options.method || "GET";
|
|
15
|
+
const headers = { ...(options.headers || {}) };
|
|
16
|
+
if (method !== "GET") {
|
|
17
|
+
if (!csrf) {
|
|
18
|
+
const bootstrap = await fetch("/api/bootstrap", { credentials: "same-origin" });
|
|
19
|
+
const data = await bootstrap.json();
|
|
20
|
+
csrf = data.csrf;
|
|
21
|
+
}
|
|
22
|
+
headers["X-CSRF-Token"] = csrf;
|
|
23
|
+
}
|
|
24
|
+
const response = await fetch(path, { ...options, method, headers, credentials: "same-origin" });
|
|
25
|
+
const data = await response.json();
|
|
26
|
+
if (!response.ok) throw new Error(data.message || data.detail || data.error || `Request failed (${response.status})`);
|
|
27
|
+
return data;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ensureDialog() {
|
|
31
|
+
if (dialog) return dialog;
|
|
32
|
+
dialog = document.createElement("dialog");
|
|
33
|
+
dialog.className = "diagnostics-dialog";
|
|
34
|
+
dialog.setAttribute("aria-labelledby", "diagnosticsTitle");
|
|
35
|
+
dialog.innerHTML = `
|
|
36
|
+
<div class="dialog-heading">
|
|
37
|
+
<h2 id="diagnosticsTitle">Host diagnostics</h2>
|
|
38
|
+
<button type="button" class="icon-button diagnostics-close" aria-label="Close">×</button>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="diagnostics-body" aria-live="polite"></div>
|
|
41
|
+
<div class="dialog-actions"><button type="button" class="secondary diagnostics-close">Close</button></div>`;
|
|
42
|
+
dialog.addEventListener("click", (event) => {
|
|
43
|
+
if (event.target === dialog || event.target.closest(".diagnostics-close")) dialog.close();
|
|
44
|
+
});
|
|
45
|
+
document.body.append(dialog);
|
|
46
|
+
return dialog;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function setBody(html) {
|
|
50
|
+
ensureDialog().querySelector(".diagnostics-body").innerHTML = html;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function render(result) {
|
|
54
|
+
const summary = result.summary || {};
|
|
55
|
+
const tone = result.overall === "ok" ? "ok" : result.overall === "warning" ? "warning" : "failed";
|
|
56
|
+
const checked = result.checked_at ? new Date(result.checked_at).toLocaleString() : "—";
|
|
57
|
+
const checks = (result.checks || []).map((check) => {
|
|
58
|
+
const details = Object.entries(check.details || {})
|
|
59
|
+
.map(([key, value]) => `<span><b>${escapeHtml(key.split("_").join(" "))}</b> ${escapeHtml(Array.isArray(value) ? value.join(" → ") : value)}</span>`)
|
|
60
|
+
.join("");
|
|
61
|
+
const latency = check.latency_ms == null ? "" : ` · ${escapeHtml(check.latency_ms)} ms`;
|
|
62
|
+
const code = check.error_code ? ` · ${escapeHtml(check.error_code)}` : "";
|
|
63
|
+
return `<article class="diagnostic-check ${escapeHtml(check.status)}">
|
|
64
|
+
<div class="diagnostic-check-head"><strong>${escapeHtml(check.label)}</strong><span>${escapeHtml(check.status)}${latency}${code}</span></div>
|
|
65
|
+
<p>${escapeHtml(check.message)}</p>${details ? `<div class="diagnostic-details">${details}</div>` : ""}
|
|
66
|
+
</article>`;
|
|
67
|
+
}).join("");
|
|
68
|
+
setBody(`<div class="diagnostics-summary ${tone}">
|
|
69
|
+
<div><strong>${escapeHtml(result.alias)} · ${escapeHtml(result.overall)}</strong><span>${escapeHtml(summary.passed || 0)}/${escapeHtml(summary.total || 0)} checks passed</span></div>
|
|
70
|
+
<small>Checked ${escapeHtml(checked)}</small>
|
|
71
|
+
</div><section class="diagnostics-checks">${checks || "<p class=diagnostics-empty>No checks returned.</p>"}</section>`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function diagnose(id, label, button) {
|
|
75
|
+
if (!id || button?.dataset.busy === "true") return;
|
|
76
|
+
if (button) {
|
|
77
|
+
button.dataset.busy = "true";
|
|
78
|
+
button.disabled = true;
|
|
79
|
+
}
|
|
80
|
+
const current = ensureDialog();
|
|
81
|
+
current.querySelector("#diagnosticsTitle").textContent = `Checking ${label}`;
|
|
82
|
+
setBody('<div class="diagnostics-loading"><span></span><span></span><span></span><p>Running read-only SSH, SFTP, and remote-shell checks…</p></div>');
|
|
83
|
+
if (!current.open) current.showModal();
|
|
84
|
+
try {
|
|
85
|
+
const result = await request(`/api/servers/${encodeURIComponent(id)}/diagnose`, { method: "POST" });
|
|
86
|
+
current.querySelector("#diagnosticsTitle").textContent = `${result.alias} diagnostics`;
|
|
87
|
+
render(result);
|
|
88
|
+
document.querySelector("#refreshButton")?.click();
|
|
89
|
+
} catch (error) {
|
|
90
|
+
setBody(`<div class="diagnostics-error"><strong>Diagnostics failed</strong><p>${escapeHtml(error.message || error)}</p></div>`);
|
|
91
|
+
} finally {
|
|
92
|
+
if (button) {
|
|
93
|
+
button.disabled = false;
|
|
94
|
+
button.dataset.busy = "false";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function decorate() {
|
|
100
|
+
document.querySelectorAll("#serverRows tr[data-server-id]").forEach((row) => {
|
|
101
|
+
const actions = row.querySelector(".connection-actions");
|
|
102
|
+
if (!actions || actions.querySelector(".host-diagnostics")) return;
|
|
103
|
+
const button = document.createElement("button");
|
|
104
|
+
button.type = "button";
|
|
105
|
+
button.className = "button small subtle-button host-diagnostics";
|
|
106
|
+
button.textContent = "Diagnostics";
|
|
107
|
+
button.title = "Run read-only host diagnostics";
|
|
108
|
+
button.dataset.id = row.dataset.serverId;
|
|
109
|
+
actions.insertBefore(button, actions.querySelector(".row-menu"));
|
|
110
|
+
});
|
|
111
|
+
const workspaceActions = document.querySelector("#workspaceActiveActions");
|
|
112
|
+
if (workspaceActions && !workspaceActions.querySelector(".host-diagnostics")) {
|
|
113
|
+
const button = document.createElement("button");
|
|
114
|
+
button.type = "button";
|
|
115
|
+
button.className = "button subtle-button host-diagnostics";
|
|
116
|
+
button.textContent = "Diagnostics";
|
|
117
|
+
button.title = "Run read-only host diagnostics";
|
|
118
|
+
button.dataset.workspace = "true";
|
|
119
|
+
workspaceActions.append(button);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
document.addEventListener("click", (event) => {
|
|
124
|
+
const button = event.target.closest(".host-diagnostics");
|
|
125
|
+
if (!button) return;
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
const id = button.dataset.workspace
|
|
128
|
+
? document.querySelector("#fileServerSelect")?.value
|
|
129
|
+
: button.dataset.id;
|
|
130
|
+
const row = id
|
|
131
|
+
? [...document.querySelectorAll("#serverRows tr[data-server-id]")].find((item) => item.dataset.serverId === id)
|
|
132
|
+
: null;
|
|
133
|
+
const label = button.dataset.workspace
|
|
134
|
+
? document.querySelector("#fileConnectionLabel strong")?.textContent || "host"
|
|
135
|
+
: row?.querySelector(".alias")?.textContent || "host";
|
|
136
|
+
diagnose(id, label, button);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
140
|
+
decorate();
|
|
141
|
+
const rows = document.querySelector("#serverRows");
|
|
142
|
+
if (rows) new MutationObserver(decorate).observe(rows, { childList: true });
|
|
143
|
+
const workspace = document.querySelector("#workspaceActiveActions");
|
|
144
|
+
if (workspace) new MutationObserver(decorate).observe(workspace, { childList: true });
|
|
145
|
+
});
|
|
146
|
+
})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html lang=en><meta charset=utf-8><meta content="width=device-width,initial-scale=1" name=viewport><title>SSH Server Manager</title><link href=/assets/styles.css rel=stylesheet><script defer src=/assets/app.js></script><div class=app-shell><aside class=sidebar><div class=brand><span class=brand-mark aria-hidden=true><svg viewBox="0 0 24 24"><path d="M7 8l4 4-4 4"/><path d="M13 16h4"/></svg></span><div><strong>SSH Manager</strong><span>Local control plane</span></div></div><nav aria-label="Primary navigation" class=primary-nav><button type=button class="nav-item active" data-view=workspace aria-current=page><svg viewBox="0 0 24 24" aria-hidden=true><path d="M3 6.5h6l2 2h10v9.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 9h18"/></svg><span>Workspace</span></button><button type=button class=nav-item data-view=servers><svg viewBox="0 0 24 24" aria-hidden=true><rect height=6 rx=2 width=16 x=4 y=4 /><rect height=6 rx=2 width=16 x=4 y=14 /><path d="M8 7h.01M8 17h.01"/></svg><span>Connections</span><span class=nav-count id=navServerCount>0</span></button><button type=button class=nav-item data-view=contexts><svg viewBox="0 0 24 24" aria-hidden=true><path d="M4 7h10l6 5-6 5H4z"/><circle cx=8 cy=12 r=1 /></svg><span>Tags</span><span class=nav-count id=navContextCount>0</span></button><button type=button class=nav-item data-view=credentials><svg viewBox="0 0 24 24" aria-hidden=true><circle cx=8 cy=15 r=4 /><path d="M11 12l8-8M16 7l2 2M14 9l2 2"/></svg><span>Credentials</span><span class=nav-count id=navCredentialCount>0</span></button></nav><section class=host-navigation id=hostNavigation aria-labelledby=hostNavigationTitle><div class=sidebar-section-heading><span id=hostNavigationTitle>Hosts</span><button type=button class=sidebar-icon-button id=sidebarAddServerButton aria-label="Add server" title="Add server">+</button></div><label class=sidebar-search><svg viewBox="0 0 24 24" aria-hidden=true><circle cx=11 cy=11 r=7 /><path d="M16 16l4 4"/></svg><input id=hostSearchInput type=search autocomplete=off aria-label="Filter hosts" placeholder="Filter hosts"></label><div class=workspace-host-list id=workspaceHostList></div><p class=sidebar-empty id=emptyWorkspaceHosts>No hosts match this view.</section><section class=label-navigation id=labelNavigation aria-labelledby=labelNavigationTitle><div class=sidebar-section-heading><span id=labelNavigationTitle>Tags</span><span class=sidebar-heading-actions><button type=button class=sidebar-text-button id=sidebarEditContextsButton>Edit</button><button type=button class=sidebar-icon-button id=sidebarAddContextButton aria-label="Create tag" title="Create tag">+</button></span></div><form id=sidebarContextForm class=sidebar-context-form hidden><input id=sidebarContextName autocomplete=off placeholder="New tag name" aria-label="New tag name" maxlength=40><button type=submit aria-label="Create tag">Add</button></form><div class=sidebar-context-list id=sidebarContextList></div><p class=sidebar-empty id=emptySidebarContexts>No tags yet.</section><footer class=sidebar-footer><div class=session-status id=systemStatus><span></span><div><strong>Local session</strong><small>Starting…</small></div></div><button type=button class=sidebar-icon-button id=refreshButton aria-label="Refresh data" title="Refresh data"><svg viewBox="0 0 24 24" aria-hidden=true><path d="M20 6v5h-5"/><path d="M4 18v-5h5"/><path d="M6.1 9a7 7 0 0 1 11.6-2.6L20 11M4 13l2.3 4.6A7 7 0 0 0 17.9 15"/></svg></button></footer></aside><div class=app-content><header class=view-header><div class=view-heading><p id=viewEyebrow>REMOTE WORKSPACE<h1 id=viewTitle>Workspace</h1><span id=viewDescription>Browse and reference remote files without opening a shell.</span></div><div class=view-actions><label id=contextFilterControl class=context-control><span class=sr-only>Host tag</span><select id=hostContextSelect aria-label="Filter hosts by tag"><option value=*>All hosts</select></label><label class=theme-control><span class=sr-only>Color theme</span><select id=themeSelect aria-label="Color theme"><option value=system>System theme<option value=light>Light theme<option value=dark>Dark theme<option value=contrast>High contrast</select></label><label class=theme-control><span class=sr-only>Accent color</span><select id=accentSelect aria-label="Accent color"><option value=indigo>Indigo<option value=teal>Teal<option value=emerald>Emerald<option value=amber>Amber<option value=rose>Rose<option value=violet>Violet<option value=graphite>Graphite</select></label><div class=header-action-group id=workspaceHeaderActions hidden><span class=header-action-group id=workspaceActiveActions hidden><button type=button class="button subtle-button" id=workspaceTestButton>Test</button><button type=button class="button subtle-button" id=workspaceCopyCommandButton>Copy SSH command</button><button type=button class="button secondary" id=workspaceEditButton>Edit host</button></span><button type=button class="button primary-button" id=workspaceAddHeaderButton><span aria-hidden=true>+</span> Add host</button></div><div class=header-action-group id=serverHeaderActions hidden><button type=button class="button subtle-button" id=importButton>Import config</button><button type=button class="button primary-button" id=addServerButton><span aria-hidden=true>+</span> Add host</button></div><div class=header-action-group id=contextHeaderActions hidden><button type=button class="button primary-button" id=createContextButton><span aria-hidden=true>+</span> Create tag</button></div><div class=header-action-group id=credentialHeaderActions hidden><button type=button class="button primary-button" id=addCredentialButton>Add credential <span aria-hidden=true>⌄</span></button></div></div></header><main class=view-stage><section class="view-panel workspace-view" id=fileBrowserPanel data-view-panel=workspace aria-labelledby=viewTitle><div class=mobile-host-controls><label>Host<select id=fileServerSelect aria-label="Host to browse" disabled><option value="">Choose a host…</select></label></div><div class=workspace-zero-state id=fileBrowserEmpty><div class=workspace-empty-hero><span class=empty-terminal aria-hidden=true><i></i><svg viewBox="0 0 64 64"><path d="M17 20l14 12-14 12M34 44h13"/></svg></span><h2>No active host</h2><p>Select a host from the left sidebar to access its workspace.</div><div class=workspace-start-actions><button type=button class=start-card id=emptyChooseHostButton><span class=start-icon><svg viewBox="0 0 24 24"><path d="M4 6h6l2 2h8v10H4z"/><path d="M8 12h8"/></svg></span><span><strong>Open recent host</strong><small>Quickly access a recent connection</small></span><b aria-hidden=true>›</b></button><button type=button class=start-card id=emptyAddServerButton><span class="start-icon plus">+</span><span><strong>Add host</strong><small>Connect to a new server</small></span><b aria-hidden=true>›</b></button><button type=button class=start-card id=emptyImportButton><span class=start-icon><svg viewBox="0 0 24 24"><path d="M12 16V4M8 8l4-4 4 4"/><path d="M5 14v5h14v-5"/></svg></span><span><strong>Import config</strong><small>Import SSH config or a host list</small></span><b aria-hidden=true>›</b></button></div><section class=recent-hosts-card><header><h3>Recent hosts</h3></header><div class=recent-host-rows id=recentHostRows></div><button type=button class=recent-hosts-more id=viewAllHostsButton>View all hosts <span aria-hidden=true>↗</span></button></section></div><div class=remote-browser id=fileBrowserContent hidden><div class=remote-host-bar><span class=remote-host-icon aria-hidden=true><svg viewBox="0 0 24 24"><rect height=14 rx=3 width=16 x=4 y=5 /><path d="M8 10l3 2-3 2M13 15h3"/></svg></span><div class=file-connection-label id=fileConnectionLabel></div><span class="compact-status neutral" id=fileHostStatus><i></i>Not tested</span></div><div class=file-command-bar><div class=file-nav-buttons><button type=button class=toolbar-button id=fileHomeButton aria-label="Home directory" title="Home directory"><svg viewBox="0 0 24 24"><path d="M3 11l9-8 9 8"/><path d="M5 10v10h14V10M9 20v-6h6v6"/></svg></button><button type=button class=toolbar-button id=fileUpButton aria-label="Parent directory" title="Parent directory" disabled><svg viewBox="0 0 24 24"><path d="M12 19V5M6 11l6-6 6 6"/></svg></button></div><nav aria-label="Remote path" class=file-breadcrumbs id=fileBreadcrumbs></nav><div class=file-command-actions><button type=button class="toolbar-button text-toolbar-button" id=copyDirectoryReferenceButton disabled><svg viewBox="0 0 24 24"><rect height=11 rx=2 width=11 x=8 y=8 /><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg><span>Copy reference</span></button><button type=button class="toolbar-button text-toolbar-button" id=fileRefreshButton aria-label="Refresh directory" title="Refresh directory"><svg viewBox="0 0 24 24"><path d="M20 6v5h-5"/><path d="M4 18v-5h5"/><path d="M6.1 9a7 7 0 0 1 11.6-2.6L20 11M4 13l2.3 4.6A7 7 0 0 0 17.9 15"/></svg><span>Refresh</span></button></div></div><form id=filePathForm class=file-path-form><label><span>Path</span><input id=filePathInput autocomplete=off placeholder=/home/user/project spellcheck=false></label><button type=submit class=button>Go</button></form><div class=file-filter-bar><label class=file-search><svg viewBox="0 0 24 24" aria-hidden=true><circle cx=11 cy=11 r=7 /><path d="M16 16l4 4"/></svg><input id=fileSearchInput type=search autocomplete=off aria-label="Filter files" placeholder="Filter this folder"></label><label class=show-hidden-toggle><input id=fileShowHidden type=checkbox> Show hidden</label><button type=button class="toolbar-button text-toolbar-button" id=copyVisibleReferencesButton disabled>Copy visible</button><span class=file-list-status id=fileListStatus aria-live=polite role=status></span></div><div class=file-browser-error id=fileBrowserError hidden role=alert><span class=error-icon aria-hidden=true>!</span><div><strong>Could not load this directory</strong><span id=fileBrowserErrorMessage></span></div><button type=button class="button secondary small" id=fileRetryButton>Retry</button></div><div class=file-loading id=fileLoading hidden aria-label="Loading remote files"><span></span><span></span><span></span></div><div class=file-table-wrap id=fileTableWrap hidden><table class=file-table><thead><tr><th><button type=button class="sort-button active" data-sort=name>Name <span aria-hidden=true>↑</span></button><th><button type=button class=sort-button data-sort=size>Size <span aria-hidden=true></span></button><th class=file-modified-column><button type=button class=sort-button data-sort=modified>Modified <span aria-hidden=true></span></button><th class=file-permissions-column>Permissions<th><span class=sr-only>Actions</span><tbody id=fileRows></table></div><div class=file-list-more id=fileListMore hidden><span id=fileListMoreStatus></span><button type=button class="button secondary small" id=fileLoadMoreButton>Show more</button></div><div class=file-list-empty id=fileListEmpty hidden><strong id=fileListEmptyTitle>This directory is empty</strong><span id=fileListEmptyMessage>There is nothing to display here.</span></div></div></section><section class="view-panel management-view" id=serversView data-view-panel=servers hidden><div class=connection-summary aria-label="Connection overview"><article><span class="summary-icon hosts"><svg viewBox="0 0 24 24"><rect height=6 rx=2 width=14 x=5 y=4 /><rect height=6 rx=2 width=14 x=5 y=14 /><path d="M9 7h.01M9 17h.01"/></svg></span><div><small>Total hosts</small><strong id=summaryTotalHosts>0</strong><p>Across all tags</div></article><article><span class="summary-icon reachable"><svg viewBox="0 0 24 24"><path d="M12 3l8 4v5c0 5-3.4 8.2-8 9-4.6-.8-8-4-8-9V7z"/><path d="M9 12l2 2 4-5"/></svg></span><div><small>Reachable hosts</small><strong id=summaryReachableHosts>0</strong><p id=summaryReachablePercent>No fresh checks</div></article><article><span class="summary-icon credentials"><svg viewBox="0 0 24 24"><circle cx=8 cy=15 r=4 /><path d="M11 12l8-8M16 7l2 2"/></svg></span><div><small>Credentials</small><strong id=summaryCredentialCount>0</strong><p>Stored securely</div></article><article><span class="summary-icon contexts"><svg viewBox="0 0 24 24"><path d="M12 3l8 4-8 4-8-4zM4 12l8 4 8-4M4 17l8 4 8-4"/></svg></span><div><small>Tags</small><strong id=summaryContextCount>0</strong><p>Host labels</div></article></div><div class=connection-toolbar-mount id=connectionToolbarMount></div><div class=management-surface><div class=surface-heading><div><h2>Managed connections <span><b id=serverCount>0</b> hosts</span></h2><p class=sr-only>Select hosts to test or organize them in bulk.</div><div class=connection-meta><span class=sr-only><b id=credentialCount>0</b> credentials</span><code id=configPath>—</code><button type=button class="button secondary small" id=exportConnectionsButton><span aria-hidden=true>⇩</span> Export</button></div></div><div class=table-wrap><table class="management-table connection-table"><thead><tr><th>Alias<th>Endpoint<th>Tags<th>Credential<th>Route / proxy<th>Status<th><span class=sr-only>Actions</span><tbody id=serverRows></table></div><p class=empty id=emptyServers>No managed servers yet. Import your SSH config or add one.</div></section><section class="view-panel context-view" id=contextsView data-view-panel=contexts hidden><div id=contextManagerMount aria-live=polite>Loading tags…</div></section><section class="view-panel management-view" id=credentialsView data-view-panel=credentials hidden><div class=security-note><span aria-hidden=true><svg viewBox="0 0 24 24"><path d="M12 3l8 4v5c0 5-3.4 8.2-8 9-4.6-.8-8-4-8-9V7z"/><path d="M9 12l2 2 4-5"/></svg></span><div class=security-copy><strong>Credentials are encrypted and never shared.</strong><p>Secrets remain in your operating-system vault and never enter browser storage.</div></div><div class=management-surface><div class="surface-heading credential-heading"><div><h2>Credential profiles</h2><p><span id=credentialProfileCount>0</span> credentials</div><div class=credential-tools><label class=surface-search><svg viewBox="0 0 24 24"><circle cx=11 cy=11 r=7 /><path d="M16 16l4 4"/></svg><input id=credentialSearchInput type=search autocomplete=off aria-label="Search credentials" placeholder="Search credentials…"></label><select id=credentialKindFilter aria-label="Filter credential type"><option value=*>All types<option value=password>Password<option value=key>Key<option value=agent>OpenSSH / agent</select></div></div><div class=table-wrap><table class="management-table credential-table"><thead><tr><th>Name<th>Type<th>Status<th>Secret state<th>Key path<th>Last checked<th><span class=sr-only>Actions</span><tbody id=credentialRows></table></div><p class=empty id=emptyCredentials>No credential profiles configured.</div><section class=reauth-card><header><div><h2>Local reauthentication</h2><p>Unlock local credentials before revealing a stored secret.</div><div class=row-actions><button class="button secondary small" id=enrollPasskeyButton>Enroll passkey</button><button class="button secondary small" id=enrollMasterButton>Set fallback password</button></div></header><div class=reauth-status><span aria-hidden=true>✓</span><div><strong id=authStatusTitle>Checking protection…</strong><small id=authDescription>Checking protection…</small></div></div></section></section></main></div></div><div id=toast aria-live=polite role=status></div><dialog id=serverDialog><form id=serverForm method=dialog><div class=dialog-heading><h2 id=serverDialogTitle>Add server</h2><button type=button class="close-dialog icon-button" aria-label=Close>×</button></div><input id=serverId type=hidden><label>Alias<input id=serverAlias required pattern=[A-Za-z0-9][A-Za-z0-9._-]{0,62}></label><div class=two-columns><label>Hostname or IP<input id=serverHostname required></label><label>Port<input id=serverPort type=number required max=65535 min=1 value=22></label></div><label>Username<input id=serverUsername required></label><label>Credential<div class=credential-picker><select id=serverCredential><option value="">OpenSSH default / agent</select><button type=button class="secondary small" id=newServerCredentialButton>+ New</button></div></label><fieldset class=inline-composer hidden id=serverCredentialComposer><legend>New credential</legend><p class=hint>Create it here and it will be selected for this server automatically.</p><label>Label<input id=serverCredentialNewLabel autocomplete=off maxlength=100></label><label>Authentication type<select id=serverCredentialNewKind><option value=password>Password<option value=key>Private key file<option value=agent>OpenSSH default / ssh-agent</select></label><label id=serverCredentialNewSecretField>Password<input id=serverCredentialNewSecret type=password autocomplete=new-password></label><label id=serverCredentialNewKeyPathField hidden>Private-key path<input id=serverCredentialNewKeyPath placeholder=~/.ssh/id_ed25519></label><label id=serverCredentialNewPassphraseField hidden>Key passphrase (optional)<input id=serverCredentialNewPassphrase type=password autocomplete=new-password></label><div class=inline-composer-actions><button type=button class=secondary id=cancelServerCredentialButton>Cancel</button><button type=button id=saveServerCredentialButton>Save and use</button></div></fieldset><label>ProxyJump aliases<input id=serverProxy placeholder="jump-one, jump-two"></label><label>Tags<input id=serverTags placeholder="production, client-a" maxlength=839></label><label>Notes<textarea id=serverNotes rows=3></textarea></label><div class=dialog-actions><button type=button class="secondary close-dialog">Cancel</button><button type=submit>Save server</button></div></form></dialog><dialog id=credentialDialog><form id=credentialForm method=dialog><div class=dialog-heading><h2 id=credentialDialogTitle>Add credential</h2><button type=button class="close-dialog icon-button" aria-label=Close>×</button></div><input id=credentialId type=hidden><label>Label<input id=credentialLabel required maxlength=100></label><label>Authentication type<select id=credentialKind><option value=password>Password<option value=key>Private key file<option value=agent>OpenSSH default / ssh-agent</select></label><label id=secretField>Password<input id=credentialSecret type=password autocomplete=new-password></label><label id=keyPathField hidden>Private-key path<input id=credentialKeyPath placeholder=~/.ssh/id_ed25519></label><label id=passphraseField hidden>Key passphrase (optional)<input id=credentialPassphrase type=password autocomplete=new-password></label><label id=clearPassphraseField hidden class=checkbox-label><input id=credentialClearPassphrase type=checkbox> Remove the stored passphrase</label><p class=hint id=credentialEditHint hidden>Leave secret fields empty to keep their existing values.<div class=dialog-actions><button type=button class="secondary close-dialog">Cancel</button><button type=submit>Save credential</button></div></form></dialog><dialog id=revealDialog><div class=dialog-heading><h2>Revealed secret</h2><button type=button class="icon-button close-reveal" aria-label=Close>×</button></div><p>This value will be cleared from the page in <strong id=revealCountdown>15</strong> seconds.<div class=secret-box><code id=revealedSecret></code><button class=secondary id=copySecretButton>Copy</button></div><div class=dialog-actions><button type=button class="secondary close-reveal">Close now</button></div></dialog><dialog id=masterDialog><form id=masterForm method=dialog><div class=dialog-heading><h2 id=masterDialogTitle>Local authentication</h2><button type=button class="icon-button cancel-master" aria-label=Close>×</button></div><label>Master password<input id=masterPassword type=password autocomplete=current-password minlength=12 required></label><label id=masterConfirmField hidden>Confirm master password<input id=masterPasswordConfirm type=password autocomplete=new-password minlength=12></label><div class=dialog-actions><button type=button class="secondary cancel-master">Cancel</button><button type=submit>Authenticate</button></div></form></dialog>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
.server-notes-dialog{border:1px solid var(--line-strong);background:var(--surface);width:min(620px,calc(100vw - 28px));max-width:none;color:var(--ink);border-radius:12px;padding:24px;box-shadow:var(--shadow-dialog)}.server-notes-dialog::backdrop{background:var(--overlay)}.server-notes-dialog .dialog-heading{margin-bottom:14px}.notes-current-label{color:var(--muted);margin:0 0 5px;font-size:10px;font-weight:750}.notes-current{border:1px solid var(--line);background:var(--surface-soft);border-radius:8px;min-height:58px;max-height:160px;white-space:pre-wrap;overflow:auto;margin:0 0 16px;padding:10px;color:var(--muted);font:10px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.notes-editor{gap:7px;margin:0;display:grid;color:var(--ink);font-size:11px;font-weight:720}.notes-editor textarea{border:1px solid var(--line-strong);background:var(--surface);width:100%;resize:vertical;min-height:116px;color:var(--ink);border-radius:8px;padding:9px 10px;font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.notes-error{border:1px solid var(--danger-border);background:var(--danger-soft);border-radius:7px;margin:10px 0 0;padding:8px;color:var(--danger);font-size:10px}.server-notes-dialog .dialog-actions{margin-top:16px}.server-notes-dialog .notes-clear{margin-right:auto;color:var(--danger)}@media (prefers-reduced-motion:reduce){.server-notes-dialog textarea{transition:none}}
|
|
2
|
+
.connection-table{width:100%;min-width:1000px}.connection-table th:nth-child(-n+6){width:auto}.connection-table th:last-child,.connection-table td:last-child{width:320px}.connection-actions{width:100%;min-width:0;flex-wrap:wrap;row-gap:4px}
|
|
3
|
+
@media (width<=1180px){.connection-table th:last-child,.connection-table td:last-child{width:260px}.connection-actions .button{padding-inline:8px;font-size:9px}}
|