Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 184b8f9a1d | |||
| 936b5d9e7f | |||
| 04ba73a29c | |||
| 5ded45c3f8 | |||
| 7b748a0f0b |
@@ -1,29 +1,86 @@
|
|||||||
async function load() {
|
async function apiGet() {
|
||||||
const r = await fetch("/api/bahmcloud_store", { credentials: "same-origin" });
|
const r = await fetch("/api/bahmcloud_store", { credentials: "same-origin" });
|
||||||
const data = await r.json();
|
return await r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiPost(payload) {
|
||||||
|
const r = await fetch("/api/bahmcloud_store", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
return await r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, attrs = {}, children = []) {
|
||||||
|
const n = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) {
|
||||||
|
if (k === "class") n.className = v;
|
||||||
|
else if (k === "onclick") n.onclick = v;
|
||||||
|
else n.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of children) {
|
||||||
|
n.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function card(pkg) {
|
||||||
|
const installedBadge = el("span", { class: "badge" }, [pkg.installed ? "Installed" : "Not installed"]);
|
||||||
|
|
||||||
|
const title = el("div", {}, [
|
||||||
|
el("strong", {}, [pkg.name]),
|
||||||
|
el("div", { class: "muted" }, [pkg.repo]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ver = el("div", { class: "muted" }, [
|
||||||
|
`Installed: ${pkg.installed_version || "-"} | Latest: ${pkg.latest_version || "-"}`
|
||||||
|
]);
|
||||||
|
|
||||||
|
const btnInstall = el("button", {
|
||||||
|
onclick: async () => {
|
||||||
|
btnInstall.disabled = true;
|
||||||
|
btnInstall.textContent = "Working...";
|
||||||
|
await apiPost({ op: "install", package_id: pkg.id });
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
}, [pkg.installed ? "Reinstall" : "Install"]);
|
||||||
|
|
||||||
|
const btnUpdate = el("button", {
|
||||||
|
onclick: async () => {
|
||||||
|
btnUpdate.disabled = true;
|
||||||
|
btnUpdate.textContent = "Working...";
|
||||||
|
await apiPost({ op: "update", package_id: pkg.id });
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
}, ["Update"]);
|
||||||
|
|
||||||
|
// Update-Button nur wenn installiert
|
||||||
|
btnUpdate.disabled = !pkg.installed;
|
||||||
|
|
||||||
|
const actions = el("div", { class: "actions" }, [btnInstall, btnUpdate]);
|
||||||
|
|
||||||
|
return el("div", { class: "card" }, [
|
||||||
|
el("div", { class: "row" }, [title, installedBadge]),
|
||||||
|
ver,
|
||||||
|
actions
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const status = document.getElementById("status");
|
||||||
const list = document.getElementById("list");
|
const list = document.getElementById("list");
|
||||||
|
|
||||||
|
status.textContent = "Loading...";
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
|
|
||||||
data.packages.forEach(p => {
|
const data = await apiGet();
|
||||||
const div = document.createElement("div");
|
status.textContent = `Store: ${data.store_url}`;
|
||||||
div.className = "card";
|
|
||||||
div.innerHTML = `
|
for (const pkg of data.packages) {
|
||||||
<div class="row">
|
list.appendChild(card(pkg));
|
||||||
<div>
|
}
|
||||||
<b>${p.name}</b>
|
|
||||||
<div class="muted">${p.repo}</div>
|
|
||||||
</div>
|
|
||||||
<div class="badge">${p.installed ? "Installed" : "Not installed"}</div>
|
|
||||||
</div>
|
|
||||||
<div class="muted">
|
|
||||||
Installed: ${p.installed_version || "-"} |
|
|
||||||
Latest: ${p.latest_version || "-"}
|
|
||||||
${p.release_url ? `| <a href="${p.release_url}" target="_blank">Release</a>` : ""}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
list.appendChild(div);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("refresh").onclick = load;
|
document.getElementById("refresh").onclick = load;
|
||||||
|
|||||||
@@ -9,12 +9,18 @@
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>Bahmcloud Store</h1>
|
<h1>Bahmcloud Store</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Installation & Updates laufen manuell über <b>Einstellungen → System → Updates</b>.<br/>
|
Installation erfolgt hier im Store (Buttons).<br/>
|
||||||
Diese Seite zeigt nur die Paketliste aus store.yaml (auto-refresh).
|
Updates erscheinen danach zusätzlich unter <b>Einstellungen → System → Updates</b>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
<button id="refresh">Refresh</button>
|
<button id="refresh">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="muted"></div>
|
||||||
<div id="list"></div>
|
<div id="list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/api/bahmcloud_store_static/app.js"></script>
|
<script src="/api/bahmcloud_store_static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
body { font-family: system-ui, sans-serif; margin:0; }
|
body { font-family: system-ui, sans-serif; margin:0; }
|
||||||
.wrap { padding: 16px; max-width: 1000px; margin: 0 auto; }
|
.wrap { padding: 16px; max-width: 1000px; margin: 0 auto; }
|
||||||
.card { border: 1px solid #ddd; border-radius: 10px; padding: 12px; margin: 10px 0; }
|
.card { border: 1px solid #ddd; border-radius: 10px; padding: 12px; margin: 10px 0; }
|
||||||
.row { display:flex; justify-content:space-between; gap: 12px; }
|
.row { display:flex; justify-content:space-between; gap: 12px; align-items: flex-start; }
|
||||||
.badge { border: 1px solid #bbb; border-radius: 999px; padding: 2px 8px; font-size: 12px; }
|
.badge { border: 1px solid #bbb; border-radius: 999px; padding: 2px 8px; font-size: 12px; height: fit-content; }
|
||||||
.muted { color: #666; font-size: 13px; }
|
.muted { color: #666; font-size: 13px; margin-top: 4px; }
|
||||||
|
.actions { display:flex; gap: 8px; margin-top: 10px; }
|
||||||
button { padding: 8px 12px; cursor:pointer; }
|
button { padding: 8px 12px; cursor:pointer; }
|
||||||
|
button[disabled] { opacity: 0.6; cursor: not-allowed; }
|
||||||
a { color: inherit; }
|
a { color: inherit; }
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class StoreConfig:
|
|||||||
class Package:
|
class Package:
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
type: str
|
type: str # "integration" | "store"
|
||||||
domain: str
|
domain: str
|
||||||
repo: str
|
repo: str
|
||||||
owner: str
|
owner: str
|
||||||
@@ -76,12 +76,19 @@ class BahmcloudStore:
|
|||||||
u = urlparse(repo_url.rstrip("/"))
|
u = urlparse(repo_url.rstrip("/"))
|
||||||
return f"{u.scheme}://{u.netloc}"
|
return f"{u.scheme}://{u.netloc}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _raw_manifest_url(repo: str, branch: str, source_path: str) -> str:
|
||||||
|
# Example:
|
||||||
|
# https://git.bahmcloud.de/bahmcloud/easy_proxmox/raw/branch/main/custom_components/easy_proxmox/manifest.json
|
||||||
|
return f"{repo.rstrip('/')}/raw/branch/{branch}/{source_path.rstrip('/')}/manifest.json"
|
||||||
|
|
||||||
async def _fetch_latest_version(self, pkg: Package) -> tuple[str | None, str | None]:
|
async def _fetch_latest_version(self, pkg: Package) -> tuple[str | None, str | None]:
|
||||||
"""
|
"""
|
||||||
Returns (latest_version, release_url)
|
Returns (latest_version, release_url)
|
||||||
Strategy:
|
Strategy:
|
||||||
1) releases/latest -> tag_name
|
1) releases/latest -> tag_name
|
||||||
2) tags?limit=1 -> first tag name
|
2) tags?limit=1 -> first tag name
|
||||||
|
3) fallback: read manifest.json from repo (version field)
|
||||||
"""
|
"""
|
||||||
session = async_get_clientsession(self.hass)
|
session = async_get_clientsession(self.hass)
|
||||||
base = self._base_from_repo(pkg.repo)
|
base = self._base_from_repo(pkg.repo)
|
||||||
@@ -94,7 +101,8 @@ class BahmcloudStore:
|
|||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
tag = data.get("tag_name")
|
tag = data.get("tag_name")
|
||||||
html_url = data.get("html_url")
|
html_url = data.get("html_url")
|
||||||
return (str(tag) if tag else None, str(html_url) if html_url else None)
|
if tag:
|
||||||
|
return (str(tag), str(html_url) if html_url else None)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -106,7 +114,21 @@ class BahmcloudStore:
|
|||||||
tags = await resp.json()
|
tags = await resp.json()
|
||||||
if tags and isinstance(tags, list):
|
if tags and isinstance(tags, list):
|
||||||
name = tags[0].get("name")
|
name = tags[0].get("name")
|
||||||
return (str(name) if name else None, None)
|
if name:
|
||||||
|
return (str(name), None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3) fallback: manifest.json version from repo
|
||||||
|
try:
|
||||||
|
manifest_url = self._raw_manifest_url(pkg.repo, pkg.branch, pkg.source_path)
|
||||||
|
async with session.get(manifest_url, timeout=20) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
text = await resp.text()
|
||||||
|
data = json.loads(text)
|
||||||
|
ver = data.get("version")
|
||||||
|
if ver:
|
||||||
|
return (str(ver), None)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -198,6 +220,9 @@ class BahmcloudStore:
|
|||||||
shutil.rmtree(target)
|
shutil.rmtree(target)
|
||||||
shutil.copytree(src, target)
|
shutil.copytree(src, target)
|
||||||
|
|
||||||
|
# Nach Installation: Entities neu aufbauen (damit es als Update auftaucht)
|
||||||
|
self.signal_entities_updated()
|
||||||
|
|
||||||
persistent_notification.async_create(
|
persistent_notification.async_create(
|
||||||
self.hass,
|
self.hass,
|
||||||
(
|
(
|
||||||
@@ -228,7 +253,7 @@ class BahmcloudStore:
|
|||||||
async def register_http_views(self) -> None:
|
async def register_http_views(self) -> None:
|
||||||
"""Register HTTP views for static panel assets and JSON API."""
|
"""Register HTTP views for static panel assets and JSON API."""
|
||||||
self.hass.http.register_view(_StaticView())
|
self.hass.http.register_view(_StaticView())
|
||||||
self.hass.http.register_view(_APIListView(self))
|
self.hass.http.register_view(_APIView(self))
|
||||||
|
|
||||||
|
|
||||||
class _StaticView(HomeAssistantView):
|
class _StaticView(HomeAssistantView):
|
||||||
@@ -236,12 +261,6 @@ class _StaticView(HomeAssistantView):
|
|||||||
IMPORTANT:
|
IMPORTANT:
|
||||||
Custom Panel JS modules are loaded WITHOUT Authorization headers.
|
Custom Panel JS modules are loaded WITHOUT Authorization headers.
|
||||||
Therefore static panel assets must be publicly accessible (no auth).
|
Therefore static panel assets must be publicly accessible (no auth).
|
||||||
|
|
||||||
Serves:
|
|
||||||
/api/bahmcloud_store_static/index.html
|
|
||||||
/api/bahmcloud_store_static/panel.js
|
|
||||||
/api/bahmcloud_store_static/app.js
|
|
||||||
/api/bahmcloud_store_static/styles.css
|
|
||||||
"""
|
"""
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
name = "bahmcloud_store_static"
|
name = "bahmcloud_store_static"
|
||||||
@@ -254,7 +273,6 @@ class _StaticView(HomeAssistantView):
|
|||||||
|
|
||||||
f = (base / path).resolve()
|
f = (base / path).resolve()
|
||||||
|
|
||||||
# Prevent path traversal
|
|
||||||
if not str(f).startswith(str(base)) or not f.exists() or not f.is_file():
|
if not str(f).startswith(str(base)) or not f.exists() or not f.is_file():
|
||||||
return web.Response(status=404, text="Not found")
|
return web.Response(status=404, text="Not found")
|
||||||
|
|
||||||
@@ -269,10 +287,11 @@ class _StaticView(HomeAssistantView):
|
|||||||
return web.Response(body=f.read_bytes(), content_type="application/octet-stream")
|
return web.Response(body=f.read_bytes(), content_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
class _APIListView(HomeAssistantView):
|
class _APIView(HomeAssistantView):
|
||||||
"""
|
"""
|
||||||
Store API MUST stay protected.
|
Auth-protected API:
|
||||||
UI loads data via fetch() with HA auth handled by frontend.
|
GET /api/bahmcloud_store -> list packages
|
||||||
|
POST /api/bahmcloud_store {op:...} -> install/update a package
|
||||||
"""
|
"""
|
||||||
requires_auth = True
|
requires_auth = True
|
||||||
name = "bahmcloud_store_api"
|
name = "bahmcloud_store_api"
|
||||||
@@ -300,3 +319,20 @@ class _APIListView(HomeAssistantView):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self.json({"packages": items, "store_url": self.store.config.store_url})
|
return self.json({"packages": items, "store_url": self.store.config.store_url})
|
||||||
|
|
||||||
|
async def post(self, request):
|
||||||
|
data = await request.json()
|
||||||
|
op = data.get("op")
|
||||||
|
package_id = data.get("package_id")
|
||||||
|
|
||||||
|
if op not in ("install", "update"):
|
||||||
|
return self.json({"error": "unknown op"}, status_code=400)
|
||||||
|
if not package_id:
|
||||||
|
return self.json({"error": "package_id missing"}, status_code=400)
|
||||||
|
|
||||||
|
pkg = self.store.packages.get(package_id)
|
||||||
|
if not pkg:
|
||||||
|
return self.json({"error": "unknown package_id"}, status_code=404)
|
||||||
|
|
||||||
|
await self.store.install_from_zip(pkg)
|
||||||
|
return self.json({"ok": True})
|
||||||
|
|||||||
@@ -17,16 +17,26 @@ async def async_setup_platform(
|
|||||||
store: BahmcloudStore = hass.data[DOMAIN]
|
store: BahmcloudStore = hass.data[DOMAIN]
|
||||||
entities: dict[str, BahmcloudPackageUpdate] = {}
|
entities: dict[str, BahmcloudPackageUpdate] = {}
|
||||||
|
|
||||||
|
def should_have_update_entity(pkg: Package) -> bool:
|
||||||
|
# Store selbst immer als Update
|
||||||
|
if pkg.type == "store":
|
||||||
|
return True
|
||||||
|
# Andere Pakete nur, wenn installiert
|
||||||
|
return store.is_installed(pkg.domain)
|
||||||
|
|
||||||
def rebuild_entities() -> None:
|
def rebuild_entities() -> None:
|
||||||
# create entities for any new package in store.yaml
|
# Create entities for packages that qualify
|
||||||
for pkg in store.packages.values():
|
for pkg in store.packages.values():
|
||||||
|
if not should_have_update_entity(pkg):
|
||||||
|
continue
|
||||||
|
|
||||||
uid = f"{DOMAIN}:{pkg.id}"
|
uid = f"{DOMAIN}:{pkg.id}"
|
||||||
if uid not in entities:
|
if uid not in entities:
|
||||||
ent = BahmcloudPackageUpdate(store, pkg.id)
|
ent = BahmcloudPackageUpdate(store, pkg.id)
|
||||||
entities[uid] = ent
|
entities[uid] = ent
|
||||||
async_add_entities([ent], update_before_add=True)
|
async_add_entities([ent], update_before_add=True)
|
||||||
|
|
||||||
# refresh states
|
# Refresh states
|
||||||
for ent in entities.values():
|
for ent in entities.values():
|
||||||
ent.async_write_ha_state()
|
ent.async_write_ha_state()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user