feat: ✨ added api key support #13
@@ -19,6 +19,7 @@ import secrets
|
|||||||
import logging
|
import logging
|
||||||
import posixpath
|
import posixpath
|
||||||
import queue
|
import queue
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -149,6 +150,20 @@ def init_db():
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE KEY uq_api_keys_prefix (key_prefix)
|
UNIQUE KEY uq_api_keys_prefix (key_prefix)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS ssh_tags (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
UNIQUE KEY uq_ssh_tags_name (name)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS ssh_host_tags (
|
||||||
|
host_id INT NOT NULL,
|
||||||
|
tag_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (host_id, tag_id),
|
||||||
|
CONSTRAINT fk_host_tags_host FOREIGN KEY (host_id)
|
||||||
|
REFERENCES ssh_hosts(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_host_tags_tag FOREIGN KEY (tag_id)
|
||||||
|
REFERENCES ssh_tags(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
with db_cursor() as (_, cur):
|
with db_cursor() as (_, cur):
|
||||||
for stmt in ddl.split(";"):
|
for stmt in ddl.split(";"):
|
||||||
@@ -272,6 +287,92 @@ def _like_escape(s: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_TAG_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_tag_name(raw: str) -> str | None:
|
||||||
|
name = re.sub(r"\s+", "", raw.strip().lower())
|
||||||
|
if not name or not _TAG_NAME_RE.match(name):
|
||||||
|
return None
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_host_tags(raw: Any) -> list[str] | None:
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return None
|
||||||
|
tags: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, str):
|
||||||
|
return None
|
||||||
|
norm = _normalize_tag_name(item)
|
||||||
|
if norm is None:
|
||||||
|
return None
|
||||||
|
if norm not in seen:
|
||||||
|
seen.add(norm)
|
||||||
|
tags.append(norm)
|
||||||
|
return sorted(tags)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_search_query(q: str) -> tuple[str, str]:
|
||||||
|
if q.lower().startswith("tag:"):
|
||||||
|
return "tag", q[4:].strip()
|
||||||
|
return "text", q
|
||||||
|
|
||||||
|
|
||||||
|
def _host_tag_list_sql() -> str:
|
||||||
|
return """
|
||||||
|
(SELECT GROUP_CONCAT(t.name ORDER BY t.name SEPARATOR ',')
|
||||||
|
FROM ssh_host_tags ht
|
||||||
|
INNER JOIN ssh_tags t ON t.id = ht.tag_id
|
||||||
|
WHERE ht.host_id = h.id) AS tag_list
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_host_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
out = dict(row)
|
||||||
|
tag_list = out.pop("tag_list", None)
|
||||||
|
out["tags"] = tag_list.split(",") if tag_list else []
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_host_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return [_serialize_host_row(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_id(cur, name: str) -> int:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO ssh_tags (name) VALUES (%s) ON DUPLICATE KEY UPDATE name = name",
|
||||||
|
(name,),
|
||||||
|
)
|
||||||
|
cur.execute("SELECT id FROM ssh_tags WHERE name = %s", (name,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return int(row["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def _set_host_tags(cur, host_id: int, tags: list[str]) -> None:
|
||||||
|
cur.execute("DELETE FROM ssh_host_tags WHERE host_id = %s", (host_id,))
|
||||||
|
for name in tags:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO ssh_host_tags (host_id, tag_id) VALUES (%s, %s)",
|
||||||
|
(host_id, _tag_id(cur, name)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _host_tag_filter_sql(tag_name: str) -> tuple[str, tuple[Any, ...]]:
|
||||||
|
return (
|
||||||
|
"h.id IN ("
|
||||||
|
"SELECT ht.host_id FROM ssh_host_tags ht "
|
||||||
|
"INNER JOIN ssh_tags t ON t.id = ht.tag_id "
|
||||||
|
"WHERE t.name = %s"
|
||||||
|
")",
|
||||||
|
(tag_name,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _folder_subtree_ids(cur, root_id: int) -> list[int]:
|
def _folder_subtree_ids(cur, root_id: int) -> list[int]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
@@ -989,10 +1090,11 @@ def _host_select_sql(extra_where: str = "") -> str:
|
|||||||
return f"""
|
return f"""
|
||||||
SELECT h.id, h.folder_id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id,
|
SELECT h.id, h.folder_id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id,
|
||||||
h.created_at, h.updated_at, h.last_connected_at,
|
h.created_at, h.updated_at, h.last_connected_at,
|
||||||
COALESCE(i.label, 'One-time') AS identity_label,
|
COALESCE(i.label, 'One-time') AS identity_label,
|
||||||
COALESCE(i.auth_type, h.inline_identity_auth_type) AS identity_auth_type,
|
COALESCE(i.auth_type, h.inline_identity_auth_type) AS identity_auth_type,
|
||||||
pf.label AS folder_label,
|
pf.label AS folder_label,
|
||||||
jh.label AS jump_host_label
|
jh.label AS jump_host_label,
|
||||||
|
{_host_tag_list_sql()}
|
||||||
FROM ssh_hosts h
|
FROM ssh_hosts h
|
||||||
LEFT JOIN ssh_identities i ON i.id = h.identity_id
|
LEFT JOIN ssh_identities i ON i.id = h.identity_id
|
||||||
LEFT JOIN ssh_folders pf ON pf.id = h.folder_id
|
LEFT JOIN ssh_folders pf ON pf.id = h.folder_id
|
||||||
@@ -1096,8 +1198,7 @@ def api_browse():
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return jsonify({"error": "invalid folder_id"}), 400
|
return jsonify({"error": "invalid folder_id"}), 400
|
||||||
q = (request.args.get("q") or "").strip()
|
q = (request.args.get("q") or "").strip()
|
||||||
esc = _like_escape(q) if q else ""
|
search_mode, search_term = _parse_search_query(q)
|
||||||
pat = f"%{esc}%" if q else ""
|
|
||||||
|
|
||||||
with db_cursor() as (_, cur):
|
with db_cursor() as (_, cur):
|
||||||
breadcrumb: list[dict[str, Any]] = []
|
breadcrumb: list[dict[str, Any]] = []
|
||||||
@@ -1108,6 +1209,30 @@ def api_browse():
|
|||||||
breadcrumb = _folder_breadcrumb_rows(cur, folder_id)
|
breadcrumb = _folder_breadcrumb_rows(cur, folder_id)
|
||||||
|
|
||||||
if q:
|
if q:
|
||||||
|
if search_mode == "tag":
|
||||||
|
tag_name = _normalize_tag_name(search_term)
|
||||||
|
if tag_name is None:
|
||||||
|
hosts: list[dict[str, Any]] = []
|
||||||
|
else:
|
||||||
|
tag_where, tag_args = _host_tag_filter_sql(tag_name)
|
||||||
|
cur.execute(
|
||||||
|
_host_select_sql(f"WHERE {tag_where}") + " ORDER BY h.label",
|
||||||
|
tag_args,
|
||||||
|
)
|
||||||
|
hosts = _serialize_host_rows(cur.fetchall())
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"breadcrumb": breadcrumb,
|
||||||
|
"folders": [],
|
||||||
|
"hosts": hosts,
|
||||||
|
"search_active": True,
|
||||||
|
"search_mode": "tag",
|
||||||
|
"search_tag": tag_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
esc = _like_escape(search_term)
|
||||||
|
pat = f"%{esc}%"
|
||||||
if folder_id is None:
|
if folder_id is None:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
_host_select_sql(
|
_host_select_sql(
|
||||||
@@ -1116,7 +1241,7 @@ def api_browse():
|
|||||||
+ " ORDER BY h.label",
|
+ " ORDER BY h.label",
|
||||||
(pat, pat),
|
(pat, pat),
|
||||||
)
|
)
|
||||||
hosts = cur.fetchall()
|
hosts = _serialize_host_rows(cur.fetchall())
|
||||||
else:
|
else:
|
||||||
ids = _folder_subtree_ids(cur, folder_id)
|
ids = _folder_subtree_ids(cur, folder_id)
|
||||||
if not ids:
|
if not ids:
|
||||||
@@ -1131,13 +1256,14 @@ def api_browse():
|
|||||||
+ " ORDER BY h.label",
|
+ " ORDER BY h.label",
|
||||||
(*ids, pat, pat),
|
(*ids, pat, pat),
|
||||||
)
|
)
|
||||||
hosts = cur.fetchall()
|
hosts = _serialize_host_rows(cur.fetchall())
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"breadcrumb": breadcrumb,
|
"breadcrumb": breadcrumb,
|
||||||
"folders": [],
|
"folders": [],
|
||||||
"hosts": hosts,
|
"hosts": hosts,
|
||||||
"search_active": True,
|
"search_active": True,
|
||||||
|
"search_mode": "text",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1159,7 +1285,7 @@ def api_browse():
|
|||||||
_host_select_sql("WHERE h.folder_id = %s") + " ORDER BY h.label",
|
_host_select_sql("WHERE h.folder_id = %s") + " ORDER BY h.label",
|
||||||
(folder_id,),
|
(folder_id,),
|
||||||
)
|
)
|
||||||
hosts = cur.fetchall()
|
hosts = _serialize_host_rows(cur.fetchall())
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
@@ -1176,10 +1302,19 @@ def api_browse():
|
|||||||
def list_hosts():
|
def list_hosts():
|
||||||
with db_cursor() as (_, cur):
|
with db_cursor() as (_, cur):
|
||||||
cur.execute(_host_select_sql("") + " ORDER BY h.label")
|
cur.execute(_host_select_sql("") + " ORDER BY h.label")
|
||||||
rows = cur.fetchall()
|
rows = _serialize_host_rows(cur.fetchall())
|
||||||
return jsonify({"items": rows})
|
return jsonify({"items": rows})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/tags", methods=["GET"])
|
||||||
|
@require_auth("read:hosts")
|
||||||
|
def list_tags():
|
||||||
|
with db_cursor() as (_, cur):
|
||||||
|
cur.execute("SELECT name FROM ssh_tags ORDER BY name")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return jsonify({"items": [r["name"] for r in rows]})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/hosts", methods=["POST"])
|
@app.route("/api/hosts", methods=["POST"])
|
||||||
@require_auth("write:hosts")
|
@require_auth("write:hosts")
|
||||||
def create_host():
|
def create_host():
|
||||||
@@ -1194,6 +1329,12 @@ def create_host():
|
|||||||
|
|
||||||
if not label or not hostname:
|
if not label or not hostname:
|
||||||
return jsonify({"error": "label, hostname required"}), 400
|
return jsonify({"error": "label, hostname required"}), 400
|
||||||
|
|
||||||
|
tags = None
|
||||||
|
if "tags" in body:
|
||||||
|
tags = _parse_host_tags(body.get("tags"))
|
||||||
|
if tags is None:
|
||||||
|
return jsonify({"error": "invalid tags"}), 400
|
||||||
|
|
||||||
# Validate identity or inline credentials
|
# Validate identity or inline credentials
|
||||||
inline_auth_type = None
|
inline_auth_type = None
|
||||||
@@ -1255,6 +1396,8 @@ def create_host():
|
|||||||
inline_auth_type, inline_blob, inline_key_pass),
|
inline_auth_type, inline_blob, inline_key_pass),
|
||||||
)
|
)
|
||||||
hid = cur.lastrowid
|
hid = cur.lastrowid
|
||||||
|
if tags is not None:
|
||||||
|
_set_host_tags(cur, int(hid), tags)
|
||||||
return jsonify({"id": hid}), 201
|
return jsonify({"id": hid}), 201
|
||||||
|
|
||||||
|
|
||||||
@@ -1345,16 +1488,29 @@ def update_host(hid: int):
|
|||||||
return jsonify({"error": "folder not found"}), 400
|
return jsonify({"error": "folder not found"}), 400
|
||||||
fields.append("folder_id = %s")
|
fields.append("folder_id = %s")
|
||||||
args.append(folder_id)
|
args.append(folder_id)
|
||||||
if not fields:
|
|
||||||
|
tags = None
|
||||||
|
if "tags" in body:
|
||||||
|
tags = _parse_host_tags(body.get("tags"))
|
||||||
|
if tags is None:
|
||||||
|
return jsonify({"error": "invalid tags"}), 400
|
||||||
|
|
||||||
|
if not fields and tags is None:
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
args.append(hid)
|
|
||||||
with db_cursor() as (_, cur):
|
with db_cursor() as (_, cur):
|
||||||
cur.execute(
|
cur.execute("SELECT id FROM ssh_hosts WHERE id = %s", (hid,))
|
||||||
f"UPDATE ssh_hosts SET {', '.join(fields)} WHERE id = %s",
|
if not cur.fetchone():
|
||||||
tuple(args),
|
|
||||||
)
|
|
||||||
if cur.rowcount == 0:
|
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
|
if fields:
|
||||||
|
update_args = list(args)
|
||||||
|
update_args.append(hid)
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE ssh_hosts SET {', '.join(fields)} WHERE id = %s",
|
||||||
|
tuple(update_args),
|
||||||
|
)
|
||||||
|
if tags is not None:
|
||||||
|
_set_host_tags(cur, hid, tags)
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+64
-4
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/api";
|
} from "@/api";
|
||||||
import LoginForm from "@/components/LoginForm.vue";
|
import LoginForm from "@/components/LoginForm.vue";
|
||||||
import TabContent from "@/components/TabContent.vue";
|
import TabContent from "@/components/TabContent.vue";
|
||||||
|
import TagInput from "@/components/TagInput.vue";
|
||||||
|
|
||||||
interface TabItem {
|
interface TabItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,6 +26,7 @@ const appVersion = ref("unknown");
|
|||||||
const identities = ref<IdentityRow[]>([]);
|
const identities = ref<IdentityRow[]>([]);
|
||||||
const allHosts = ref<HostRow[]>([]);
|
const allHosts = ref<HostRow[]>([]);
|
||||||
const allFolders = ref<FolderRow[]>([]);
|
const allFolders = ref<FolderRow[]>([]);
|
||||||
|
const allTags = ref<string[]>([]);
|
||||||
const browseFolders = ref<FolderRow[]>([]);
|
const browseFolders = ref<FolderRow[]>([]);
|
||||||
const browseHosts = ref<HostRow[]>([]);
|
const browseHosts = ref<HostRow[]>([]);
|
||||||
const breadcrumb = ref<{ id: number; label: string }[]>([]);
|
const breadcrumb = ref<{ id: number; label: string }[]>([]);
|
||||||
@@ -93,6 +95,7 @@ const hostForm = ref({
|
|||||||
label: "",
|
label: "",
|
||||||
hostname: "",
|
hostname: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
|
tags: "",
|
||||||
use_inline_identity: false,
|
use_inline_identity: false,
|
||||||
identity_id: 0 as number,
|
identity_id: 0 as number,
|
||||||
auth_type: "password" as "password" | "publickey",
|
auth_type: "password" as "password" | "publickey",
|
||||||
@@ -107,6 +110,7 @@ const editHostForm = ref({
|
|||||||
label: "",
|
label: "",
|
||||||
hostname: "",
|
hostname: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
|
tags: "",
|
||||||
use_inline_identity: false,
|
use_inline_identity: false,
|
||||||
identity_id: 0,
|
identity_id: 0,
|
||||||
auth_type: "password" as "password" | "publickey",
|
auth_type: "password" as "password" | "publickey",
|
||||||
@@ -134,6 +138,18 @@ function folderOptionLabel(id: number | null): string {
|
|||||||
return parts.join(" / ") || `#${id}`;
|
return parts.join(" / ") || `#${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseTagsInput(raw: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const tags: string[] = [];
|
||||||
|
for (const part of raw.split(",")) {
|
||||||
|
const name = part.trim().toLowerCase().replace(/\s+/g, "");
|
||||||
|
if (!name || seen.has(name)) continue;
|
||||||
|
seen.add(name);
|
||||||
|
tags.push(name);
|
||||||
|
}
|
||||||
|
return tags.sort();
|
||||||
|
}
|
||||||
|
|
||||||
function getSortedBrowseHosts(): HostRow[] {
|
function getSortedBrowseHosts(): HostRow[] {
|
||||||
const hosts = [...browseHosts.value];
|
const hosts = [...browseHosts.value];
|
||||||
if (hostSortOrder.value === "last_connected") {
|
if (hostSortOrder.value === "last_connected") {
|
||||||
@@ -173,12 +189,18 @@ function goToFolder(id: number | null) {
|
|||||||
void refreshBrowse();
|
void refreshBrowse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function searchByTag(tag: string) {
|
||||||
|
searchQuery.value = `tag:${tag}`;
|
||||||
|
void refreshBrowse();
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
loadErr.value = "";
|
loadErr.value = "";
|
||||||
try {
|
try {
|
||||||
identities.value = await api.listIdentities();
|
identities.value = await api.listIdentities();
|
||||||
allHosts.value = await api.listHosts();
|
allHosts.value = await api.listHosts();
|
||||||
allFolders.value = await api.listFoldersFlat();
|
allFolders.value = await api.listFoldersFlat();
|
||||||
|
allTags.value = await api.listTags();
|
||||||
if (!hostForm.value.identity_id && identities.value.length) {
|
if (!hostForm.value.identity_id && identities.value.length) {
|
||||||
hostForm.value.identity_id = identities.value[0].id;
|
hostForm.value.identity_id = identities.value[0].id;
|
||||||
}
|
}
|
||||||
@@ -488,6 +510,11 @@ async function submitHost() {
|
|||||||
} else {
|
} else {
|
||||||
body.identity_id = f.identity_id;
|
body.identity_id = f.identity_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tags = parseTagsInput(f.tags);
|
||||||
|
if (tags.length) {
|
||||||
|
body.tags = tags;
|
||||||
|
}
|
||||||
|
|
||||||
await api.createHost(body);
|
await api.createHost(body);
|
||||||
showHostForm.value = false;
|
showHostForm.value = false;
|
||||||
@@ -495,6 +522,7 @@ async function submitHost() {
|
|||||||
label: "",
|
label: "",
|
||||||
hostname: "",
|
hostname: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
|
tags: "",
|
||||||
use_inline_identity: false,
|
use_inline_identity: false,
|
||||||
identity_id: hostForm.value.identity_id,
|
identity_id: hostForm.value.identity_id,
|
||||||
auth_type: "password",
|
auth_type: "password",
|
||||||
@@ -542,6 +570,7 @@ function openEditHost(h: HostRow) {
|
|||||||
label: h.label,
|
label: h.label,
|
||||||
hostname: h.hostname,
|
hostname: h.hostname,
|
||||||
port: h.port,
|
port: h.port,
|
||||||
|
tags: (h.tags || []).join(", "),
|
||||||
use_inline_identity: hasInlineIdentity,
|
use_inline_identity: hasInlineIdentity,
|
||||||
identity_id: h.identity_id || 0,
|
identity_id: h.identity_id || 0,
|
||||||
auth_type: (h.identity_auth_type as "password" | "publickey") || "password",
|
auth_type: (h.identity_auth_type as "password" | "publickey") || "password",
|
||||||
@@ -580,6 +609,8 @@ async function submitEditHost() {
|
|||||||
} else {
|
} else {
|
||||||
body.identity_id = f.identity_id;
|
body.identity_id = f.identity_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.tags = parseTagsInput(f.tags);
|
||||||
|
|
||||||
await api.patchHost(f.id, body);
|
await api.patchHost(f.id, body);
|
||||||
showEditHost.value = false;
|
showEditHost.value = false;
|
||||||
@@ -718,7 +749,7 @@ async function deleteIdentityRow(id: number) {
|
|||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search in this folder…"
|
placeholder="Search…"
|
||||||
class="mt-2 w-full rounded-lg border border-slate-700 bg-surface-overlay px-2 py-1.5 text-xs text-white placeholder:text-slate-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
class="mt-2 w-full rounded-lg border border-slate-700 bg-surface-overlay px-2 py-1.5 text-xs text-white placeholder:text-slate-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||||
@input="onSearchInput"
|
@input="onSearchInput"
|
||||||
>
|
>
|
||||||
@@ -728,9 +759,11 @@ async function deleteIdentityRow(id: number) {
|
|||||||
>
|
>
|
||||||
Matches in
|
Matches in
|
||||||
{{
|
{{
|
||||||
currentFolderId == null
|
searchQuery.trim().toLowerCase().startsWith("tag:")
|
||||||
? "all folders"
|
? "all folders (tag search)"
|
||||||
: "this folder and below"
|
: currentFolderId == null
|
||||||
|
? "all folders"
|
||||||
|
: "this folder and below"
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<nav class="mt-2 flex flex-wrap items-center gap-1 text-[11px] text-slate-400">
|
<nav class="mt-2 flex flex-wrap items-center gap-1 text-[11px] text-slate-400">
|
||||||
@@ -846,6 +879,21 @@ async function deleteIdentityRow(id: number) {
|
|||||||
>
|
>
|
||||||
via {{ h.jump_host_label || `#${h.jump_host_id}` }}
|
via {{ h.jump_host_label || `#${h.jump_host_id}` }}
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="h.tags?.length"
|
||||||
|
class="mt-1 flex flex-wrap gap-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="tag in h.tags"
|
||||||
|
:key="tag"
|
||||||
|
type="button"
|
||||||
|
class="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400 hover:bg-slate-700 hover:text-accent"
|
||||||
|
:title="`Search tag:${tag}`"
|
||||||
|
@click.stop="searchByTag(tag)"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="searchActive"
|
v-if="searchActive"
|
||||||
class="mt-0.5 truncate text-[10px] text-slate-600"
|
class="mt-0.5 truncate text-[10px] text-slate-600"
|
||||||
@@ -1363,6 +1411,12 @@ async function deleteIdentityRow(id: number) {
|
|||||||
max="65535"
|
max="65535"
|
||||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
/>
|
/>
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">Tags</label>
|
||||||
|
<TagInput v-model="hostForm.tags" :suggestions="allTags" />
|
||||||
|
<p class="mt-1 text-[10px] text-slate-500">
|
||||||
|
Comma-separated. Search with
|
||||||
|
<code class="text-slate-400">tag:name</code>.
|
||||||
|
</p>
|
||||||
<label class="mt-3 block text-xs uppercase text-slate-500">Credentials</label>
|
<label class="mt-3 block text-xs uppercase text-slate-500">Credentials</label>
|
||||||
<div class="mt-1 flex gap-2">
|
<div class="mt-1 flex gap-2">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
@@ -1574,6 +1628,12 @@ async function deleteIdentityRow(id: number) {
|
|||||||
max="65535"
|
max="65535"
|
||||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
>
|
>
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">Tags</label>
|
||||||
|
<TagInput v-model="editHostForm.tags" :suggestions="allTags" />
|
||||||
|
<p class="mt-1 text-[10px] text-slate-500">
|
||||||
|
Comma-separated. Search with
|
||||||
|
<code class="text-slate-400">tag:name</code>.
|
||||||
|
</p>
|
||||||
<label class="mt-3 block text-xs uppercase text-slate-500">Folder</label>
|
<label class="mt-3 block text-xs uppercase text-slate-500">Folder</label>
|
||||||
<select
|
<select
|
||||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export const api = {
|
|||||||
return d.items;
|
return d.items;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listTags(): Promise<string[]> {
|
||||||
|
const res = await fetch("/api/tags", { credentials: "include" });
|
||||||
|
const d = await handle<{ items: string[] }>(res);
|
||||||
|
return d.items;
|
||||||
|
},
|
||||||
|
|
||||||
async listFoldersFlat(): Promise<FolderRow[]> {
|
async listFoldersFlat(): Promise<FolderRow[]> {
|
||||||
const res = await fetch("/api/folders", { credentials: "include" });
|
const res = await fetch("/api/folders", { credentials: "include" });
|
||||||
const d = await handle<{ items: FolderRow[] }>(res);
|
const d = await handle<{ items: FolderRow[] }>(res);
|
||||||
@@ -312,6 +318,7 @@ export interface HostRow {
|
|||||||
identity_auth_type: string;
|
identity_auth_type: string;
|
||||||
folder_label?: string | null;
|
folder_label?: string | null;
|
||||||
last_connected_at?: string | null;
|
last_connected_at?: string | null;
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IdentityRow {
|
export interface IdentityRow {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
suggestions: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const focused = ref(false);
|
||||||
|
|
||||||
|
function normalizeTag(raw: string): string {
|
||||||
|
return raw.trim().toLowerCase().replace(/\s+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedTags(): Set<string> {
|
||||||
|
return new Set(parseTagsInput(props.modelValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTagsInput(raw: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const tags: string[] = [];
|
||||||
|
for (const part of raw.split(",")) {
|
||||||
|
const name = normalizeTag(part);
|
||||||
|
if (!name || seen.has(name)) continue;
|
||||||
|
seen.add(name);
|
||||||
|
tags.push(name);
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPartial(): string {
|
||||||
|
const val = props.modelValue;
|
||||||
|
const idx = val.lastIndexOf(",");
|
||||||
|
return normalizeTag(idx >= 0 ? val.slice(idx + 1) : val);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredSuggestions = computed(() => {
|
||||||
|
const partial = currentPartial();
|
||||||
|
const selected = selectedTags();
|
||||||
|
return props.suggestions
|
||||||
|
.filter((tag) => {
|
||||||
|
if (selected.has(tag)) return false;
|
||||||
|
if (!partial) return true;
|
||||||
|
return tag.includes(partial);
|
||||||
|
})
|
||||||
|
.slice(0, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showSuggestions = computed(
|
||||||
|
() => focused.value && filteredSuggestions.value.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
function applySuggestion(tag: string) {
|
||||||
|
const val = props.modelValue;
|
||||||
|
const idx = val.lastIndexOf(",");
|
||||||
|
const prefix = idx >= 0 ? `${val.slice(0, idx + 1)} ` : "";
|
||||||
|
emit("update:modelValue", `${prefix}${tag}, `);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
focused.value = false;
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
:value="modelValue"
|
||||||
|
placeholder="buildagents, prod"
|
||||||
|
autocomplete="off"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||||
|
@focus="focused = true"
|
||||||
|
@blur="onBlur"
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
v-if="showSuggestions"
|
||||||
|
class="absolute z-20 mt-1 max-h-40 w-full overflow-auto rounded-lg border border-slate-700 bg-surface-raised py-1 shadow-lg"
|
||||||
|
>
|
||||||
|
<li v-for="tag in filteredSuggestions" :key="tag">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full px-3 py-1.5 text-left text-sm text-slate-200 hover:bg-slate-800"
|
||||||
|
@mousedown.prevent="applySuggestion(tag)"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user