account: add Wolf game streaming controls
This commit is contained in:
parent
62540dfb59
commit
668a01081e
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from .account_actions import register_account_actions
|
||||
from .account_overview import register_account_overview
|
||||
from .account_wolf import register_account_wolf
|
||||
|
||||
|
||||
def register(app) -> None:
|
||||
@ -11,3 +12,4 @@ def register(app) -> None:
|
||||
|
||||
register_account_overview(app)
|
||||
register_account_actions(app)
|
||||
register_account_wolf(app)
|
||||
|
||||
104
backend/atlas_portal/routes/account_wolf.py
Normal file
104
backend/atlas_portal/routes/account_wolf.py
Normal file
@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from .. import ariadne_client
|
||||
from ..keycloak import require_auth, require_account_access
|
||||
|
||||
|
||||
def _client_ip() -> str:
|
||||
for header in ("CF-Connecting-IP", "X-Forwarded-For", "X-Real-IP"):
|
||||
value = request.headers.get(header, "").strip()
|
||||
if value:
|
||||
return value.split(",", 1)[0].strip()
|
||||
return request.remote_addr or ""
|
||||
|
||||
|
||||
def _json_payload() -> dict[str, Any]:
|
||||
payload = request.get_json(silent=True)
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _require_account() -> tuple[bool, Any]:
|
||||
ok, resp = require_account_access()
|
||||
if not ok:
|
||||
return False, resp
|
||||
if not ariadne_client.enabled():
|
||||
return False, (jsonify({"error": "ariadne not configured"}), 503)
|
||||
return True, None
|
||||
|
||||
|
||||
def register_account_wolf(app) -> None:
|
||||
"""Register Wolf/Moonlight self-service account routes."""
|
||||
|
||||
@app.route("/api/account/wolf", methods=["GET"])
|
||||
@require_auth
|
||||
def account_wolf_status() -> Any:
|
||||
ok, resp = _require_account()
|
||||
if not ok:
|
||||
return resp
|
||||
return ariadne_client.proxy("GET", "/api/game-stream/status", params={"source_ip": _client_ip()})
|
||||
|
||||
@app.route("/api/account/wolf/firewall/unlock", methods=["POST"])
|
||||
@require_auth
|
||||
def account_wolf_firewall_unlock() -> Any:
|
||||
ok, resp = _require_account()
|
||||
if not ok:
|
||||
return resp
|
||||
payload = _json_payload()
|
||||
payload["ip"] = _client_ip()
|
||||
return ariadne_client.proxy("POST", "/api/game-stream/firewall/unlock", payload=payload)
|
||||
|
||||
@app.route("/api/account/wolf/firewall/revoke", methods=["POST"])
|
||||
@require_auth
|
||||
def account_wolf_firewall_revoke() -> Any:
|
||||
ok, resp = _require_account()
|
||||
if not ok:
|
||||
return resp
|
||||
payload = _json_payload()
|
||||
payload["ip"] = payload.get("ip") or _client_ip()
|
||||
return ariadne_client.proxy("POST", "/api/game-stream/firewall/revoke", payload=payload)
|
||||
|
||||
@app.route("/api/account/wolf/pairing/status", methods=["GET"])
|
||||
@require_auth
|
||||
def account_wolf_pairing_status() -> Any:
|
||||
ok, resp = _require_account()
|
||||
if not ok:
|
||||
return resp
|
||||
return ariadne_client.proxy("GET", "/api/game-stream/pairing/status", params={"source_ip": _client_ip()})
|
||||
|
||||
@app.route("/api/account/wolf/pairing/submit-pin", methods=["POST"])
|
||||
@require_auth
|
||||
def account_wolf_pairing_submit_pin() -> Any:
|
||||
ok, resp = _require_account()
|
||||
if not ok:
|
||||
return resp
|
||||
payload = _json_payload()
|
||||
payload["source_ip"] = _client_ip()
|
||||
return ariadne_client.proxy("POST", "/api/game-stream/pairing/submit-pin", payload=payload)
|
||||
|
||||
@app.route("/api/account/wolf/game-mode/start", methods=["POST"])
|
||||
@require_auth
|
||||
def account_wolf_game_mode_start() -> Any:
|
||||
ok, resp = _require_account()
|
||||
if not ok:
|
||||
return resp
|
||||
return ariadne_client.proxy("POST", "/api/admin/game-mode/start", payload=_json_payload())
|
||||
|
||||
@app.route("/api/account/wolf/game-mode/stop", methods=["POST"])
|
||||
@require_auth
|
||||
def account_wolf_game_mode_stop() -> Any:
|
||||
ok, resp = _require_account()
|
||||
if not ok:
|
||||
return resp
|
||||
return ariadne_client.proxy("POST", "/api/admin/game-mode/stop", payload=_json_payload())
|
||||
|
||||
@app.route("/api/account/wolf/admin/firewall/unlock", methods=["POST"])
|
||||
@require_auth
|
||||
def account_wolf_admin_firewall_unlock() -> Any:
|
||||
ok, resp = _require_account()
|
||||
if not ok:
|
||||
return resp
|
||||
return ariadne_client.proxy("POST", "/api/admin/game-stream/firewall/unlock", payload=_json_payload())
|
||||
76
backend/tests/test_account_wolf.py
Normal file
76
backend/tests/test_account_wolf.py
Normal file
@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Flask, jsonify
|
||||
|
||||
from atlas_portal.routes import account_wolf
|
||||
|
||||
|
||||
class DummyAriadne:
|
||||
def __init__(self, enabled: bool = True) -> None:
|
||||
self._enabled = enabled
|
||||
self.calls: list[tuple[str, str, object | None, dict | None]] = []
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def proxy(self, method: str, path: str, payload: object | None = None, params: dict | None = None):
|
||||
self.calls.append((method, path, payload, params))
|
||||
return jsonify({"path": path, "payload": payload, "params": params})
|
||||
|
||||
|
||||
def make_client(monkeypatch, *, ariadne: DummyAriadne | None = None, account_ok: bool = True):
|
||||
app = Flask(__name__)
|
||||
active_ariadne = ariadne or DummyAriadne()
|
||||
monkeypatch.setattr(account_wolf, "ariadne_client", active_ariadne)
|
||||
monkeypatch.setattr(account_wolf, "require_auth", lambda fn: fn)
|
||||
monkeypatch.setattr(
|
||||
account_wolf,
|
||||
"require_account_access",
|
||||
lambda: (True, None) if account_ok else (False, (jsonify({"error": "forbidden"}), 403)),
|
||||
)
|
||||
account_wolf.register_account_wolf(app)
|
||||
return app.test_client(), active_ariadne
|
||||
|
||||
|
||||
def test_wolf_status_proxies_source_ip(monkeypatch) -> None:
|
||||
client, ariadne = make_client(monkeypatch)
|
||||
|
||||
resp = client.get("/api/account/wolf", headers={"X-Forwarded-For": "1.2.3.4, 10.0.0.1"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "1.2.3.4"})]
|
||||
|
||||
|
||||
def test_wolf_unlock_uses_current_source_ip(monkeypatch) -> None:
|
||||
client, ariadne = make_client(monkeypatch)
|
||||
|
||||
resp = client.post("/api/account/wolf/firewall/unlock", json={"ttl_seconds": 120}, headers={"X-Real-IP": "5.6.7.8"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ttl_seconds": 120, "ip": "5.6.7.8"}, None)]
|
||||
|
||||
|
||||
def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None:
|
||||
client, ariadne = make_client(monkeypatch)
|
||||
|
||||
client.get("/api/account/wolf/pairing/status", headers={"X-Forwarded-For": "1.2.3.4"})
|
||||
client.post("/api/account/wolf/pairing/submit-pin", json={"pair_secret": "secret", "pin": "1234"}, headers={"X-Forwarded-For": "1.2.3.4"})
|
||||
client.post("/api/account/wolf/game-mode/start", json={"game": "steam"})
|
||||
client.post("/api/account/wolf/game-mode/stop", json={"game": "steam"})
|
||||
client.post("/api/account/wolf/admin/firewall/unlock", json={"ip": "8.8.8.8", "target_user": "olya"})
|
||||
|
||||
assert ariadne.calls == [
|
||||
("GET", "/api/game-stream/pairing/status", None, {"source_ip": "1.2.3.4"}),
|
||||
("POST", "/api/game-stream/pairing/submit-pin", {"pair_secret": "secret", "pin": "1234", "source_ip": "1.2.3.4"}, None),
|
||||
("POST", "/api/admin/game-mode/start", {"game": "steam"}, None),
|
||||
("POST", "/api/admin/game-mode/stop", {"game": "steam"}, None),
|
||||
("POST", "/api/admin/game-stream/firewall/unlock", {"ip": "8.8.8.8", "target_user": "olya"}, None),
|
||||
]
|
||||
|
||||
|
||||
def test_wolf_routes_require_account_and_ariadne(monkeypatch) -> None:
|
||||
client, _ariadne = make_client(monkeypatch, account_ok=False)
|
||||
assert client.get("/api/account/wolf").status_code == 403
|
||||
|
||||
client, _ariadne = make_client(monkeypatch, ariadne=DummyAriadne(enabled=False))
|
||||
assert client.get("/api/account/wolf").status_code == 503
|
||||
@ -66,6 +66,33 @@ export function useAccountDashboard() {
|
||||
error: "",
|
||||
});
|
||||
|
||||
const wolf = reactive({
|
||||
status: "loading",
|
||||
error: "",
|
||||
loading: false,
|
||||
unlocking: false,
|
||||
pairing: {},
|
||||
actioning: "",
|
||||
manualUnlocking: false,
|
||||
canControlGpu: false,
|
||||
moonlightHost: "moonlight.bstein.dev",
|
||||
sourceIp: "",
|
||||
unlockTtlSeconds: 28800,
|
||||
gpuPriority: "unknown",
|
||||
gameModeStatus: "unknown",
|
||||
gameModeActive: false,
|
||||
selectedGame: "steam",
|
||||
note: "",
|
||||
manualIp: "",
|
||||
manualUser: "",
|
||||
clients: [],
|
||||
pendingPairRequests: [],
|
||||
activeUnlocks: [],
|
||||
sessions: [],
|
||||
apps: [],
|
||||
pinInputs: {},
|
||||
});
|
||||
|
||||
const admin = reactive({
|
||||
enabled: false,
|
||||
loading: false,
|
||||
@ -91,6 +118,7 @@ export function useAccountDashboard() {
|
||||
onMounted(() => {
|
||||
if (auth.ready && auth.authenticated) {
|
||||
refreshOverview();
|
||||
refreshWolf();
|
||||
refreshAdminRequests();
|
||||
refreshAdminFlags();
|
||||
} else {
|
||||
@ -100,6 +128,7 @@ export function useAccountDashboard() {
|
||||
vaultwarden.status = "login required";
|
||||
wger.status = "login required";
|
||||
firefly.status = "login required";
|
||||
wolf.status = "login required";
|
||||
}
|
||||
});
|
||||
|
||||
@ -114,6 +143,7 @@ export function useAccountDashboard() {
|
||||
vaultwarden.status = "login required";
|
||||
wger.status = "login required";
|
||||
firefly.status = "login required";
|
||||
wolf.status = "login required";
|
||||
onboardingUrl.value = "/onboarding";
|
||||
admin.enabled = false;
|
||||
admin.requests = [];
|
||||
@ -121,6 +151,7 @@ export function useAccountDashboard() {
|
||||
return;
|
||||
}
|
||||
refreshOverview();
|
||||
refreshWolf();
|
||||
refreshAdminRequests();
|
||||
refreshAdminFlags();
|
||||
},
|
||||
@ -187,6 +218,46 @@ export function useAccountDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWolf() {
|
||||
if (!auth.authenticated) {
|
||||
wolf.status = "login required";
|
||||
return;
|
||||
}
|
||||
wolf.error = "";
|
||||
wolf.loading = true;
|
||||
try {
|
||||
const resp = await authFetch("/api/account/wolf", {
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`);
|
||||
|
||||
wolf.canControlGpu = Boolean(data.can_control_gpu);
|
||||
wolf.moonlightHost = data.moonlight?.host || "moonlight.bstein.dev";
|
||||
wolf.sourceIp = data.moonlight?.source_ip || "";
|
||||
wolf.unlockTtlSeconds = Number(data.moonlight?.unlock_ttl_seconds || 28800);
|
||||
wolf.gpuPriority = data.gpu?.priority || "unknown";
|
||||
wolf.gameModeStatus = data.gpu?.game_mode?.status || "unknown";
|
||||
wolf.gameModeActive = Boolean(data.gpu?.game_mode?.active);
|
||||
wolf.clients = Array.isArray(data.wolf?.clients) ? data.wolf.clients : [];
|
||||
wolf.pendingPairRequests = Array.isArray(data.wolf?.pending_pair_requests) ? data.wolf.pending_pair_requests : [];
|
||||
wolf.sessions = Array.isArray(data.wolf?.sessions) ? data.wolf.sessions : [];
|
||||
wolf.apps = Array.isArray(data.wolf?.apps) ? data.wolf.apps : [];
|
||||
wolf.activeUnlocks = Array.isArray(data.firewall?.active_unlocks) ? data.firewall.active_unlocks : [];
|
||||
for (const req of wolf.pendingPairRequests) {
|
||||
const key = req?.pair_secret || req?.name;
|
||||
if (key && !(key in wolf.pinInputs)) wolf.pinInputs[key] = "";
|
||||
}
|
||||
wolf.status = data.wolf?.api_enabled ? "ready" : "degraded";
|
||||
} catch (err) {
|
||||
wolf.status = "unavailable";
|
||||
wolf.error = formatActionError(err, "Wolf status unavailable");
|
||||
} finally {
|
||||
wolf.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAdminRequests() {
|
||||
if (!auth.authenticated) {
|
||||
admin.enabled = false;
|
||||
@ -382,6 +453,90 @@ export function useAccountDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
async function unlockWolf() {
|
||||
wolf.error = "";
|
||||
wolf.unlocking = true;
|
||||
try {
|
||||
const resp = await authFetch("/api/account/wolf/firewall/unlock", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`);
|
||||
await refreshWolf();
|
||||
} catch (err) {
|
||||
wolf.error = formatActionError(err, "Unlock failed");
|
||||
} finally {
|
||||
wolf.unlocking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pairWolf(pairSecret) {
|
||||
if (!pairSecret) return;
|
||||
wolf.error = "";
|
||||
wolf.pairing[pairSecret] = true;
|
||||
try {
|
||||
const pin = wolf.pinInputs[pairSecret] || "";
|
||||
const resp = await authFetch("/api/account/wolf/pairing/submit-pin", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pair_secret: pairSecret, pin }),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`);
|
||||
wolf.pinInputs[pairSecret] = "";
|
||||
await refreshWolf();
|
||||
} catch (err) {
|
||||
wolf.error = formatActionError(err, "Pairing failed");
|
||||
} finally {
|
||||
wolf.pairing[pairSecret] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setWolfGameMode(action) {
|
||||
wolf.error = "";
|
||||
wolf.actioning = action;
|
||||
try {
|
||||
const resp = await authFetch(`/api/account/wolf/game-mode/${action}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ game: wolf.selectedGame, note: wolf.note }),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`);
|
||||
await refreshWolf();
|
||||
} catch (err) {
|
||||
wolf.error = formatActionError(err, "GPU priority update failed");
|
||||
} finally {
|
||||
wolf.actioning = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function adminUnlockWolfIp() {
|
||||
if (!wolf.manualIp) return;
|
||||
wolf.error = "";
|
||||
wolf.manualUnlocking = true;
|
||||
try {
|
||||
const payload = { ip: wolf.manualIp };
|
||||
if (wolf.manualUser) payload.target_user = wolf.manualUser;
|
||||
const resp = await authFetch("/api/account/wolf/admin/firewall/unlock", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data?.error || data?.detail || `status ${resp.status}`);
|
||||
wolf.manualIp = "";
|
||||
wolf.manualUser = "";
|
||||
await refreshWolf();
|
||||
} catch (err) {
|
||||
wolf.error = formatActionError(err, "Manual unlock failed");
|
||||
} finally {
|
||||
wolf.manualUnlocking = false;
|
||||
}
|
||||
}
|
||||
|
||||
// WHY: Safari/private-mode clipboard support varies; @returns whether fallback copy completed.
|
||||
function fallbackCopy(text) {
|
||||
const textarea = document.createElement("textarea");
|
||||
@ -479,6 +634,7 @@ export function useAccountDashboard() {
|
||||
nextcloudMail,
|
||||
wger,
|
||||
firefly,
|
||||
wolf,
|
||||
admin,
|
||||
onboardingUrl,
|
||||
vaultwardenReady,
|
||||
@ -493,6 +649,11 @@ export function useAccountDashboard() {
|
||||
resetWger,
|
||||
resetFirefly,
|
||||
syncNextcloudMail,
|
||||
refreshWolf,
|
||||
unlockWolf,
|
||||
pairWolf,
|
||||
setWolfGameMode,
|
||||
adminUnlockWolfIp,
|
||||
approve,
|
||||
deny,
|
||||
copy,
|
||||
|
||||
@ -187,6 +187,39 @@ button.primary {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.wolf-controls select {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.wolf-card .v {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pair-row,
|
||||
.wolf-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(90px, 0.7fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.manual-unlock {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 0.8fr) auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.pair-name {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.copied {
|
||||
font-size: 12px;
|
||||
color: rgba(120, 255, 160, 0.9);
|
||||
@ -240,6 +273,12 @@ button.primary {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.pair-row,
|
||||
.wolf-controls,
|
||||
.manual-unlock {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.secret-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
@ -192,6 +192,121 @@
|
||||
</div>
|
||||
|
||||
<div class="account-stack">
|
||||
<div class="card module wolf-card" :style="{ order: 0 }">
|
||||
<div class="module-head">
|
||||
<h2>Wolf</h2>
|
||||
<span
|
||||
class="pill mono"
|
||||
:class="
|
||||
wolf.status === 'ready'
|
||||
? 'pill-ok'
|
||||
: wolf.status === 'degraded'
|
||||
? 'pill-warn'
|
||||
: wolf.status === 'unavailable' || wolf.status === 'error'
|
||||
? 'pill-bad'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{ wolf.loading ? "loading..." : wolf.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<div class="row">
|
||||
<span class="k mono">Moonlight</span>
|
||||
<span class="v mono">{{ wolf.moonlightHost }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="k mono">Your IP</span>
|
||||
<span class="v mono">{{ wolf.sourceIp || "unknown" }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="k mono">GPU priority</span>
|
||||
<span class="v mono">{{ wolf.gpuPriority }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="k mono">Firewall unlocks</span>
|
||||
<span class="v mono">{{ wolf.activeUnlocks.length }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="k mono">Paired devices</span>
|
||||
<span class="v mono">{{ wolf.clients.map((client) => client.name).join(", ") || "none" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" type="button" :disabled="wolf.unlocking" @click="unlockWolf">
|
||||
{{ wolf.unlocking ? "Unlocking..." : "Unlock Moonlight" }}
|
||||
</button>
|
||||
<button class="copy mono" type="button" :disabled="wolf.loading" @click="refreshWolf">refresh</button>
|
||||
</div>
|
||||
|
||||
<div v-if="wolf.pendingPairRequests.length" class="secret-box">
|
||||
<div class="secret-head">
|
||||
<div class="pill mono">Pairing</div>
|
||||
</div>
|
||||
<div v-for="req in wolf.pendingPairRequests" :key="req.pair_secret || req.name" class="pair-row">
|
||||
<div class="mono pair-name">{{ req.name || "pending device" }}</div>
|
||||
<input
|
||||
v-model="wolf.pinInputs[req.pair_secret]"
|
||||
class="input mono"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
placeholder="PIN"
|
||||
:disabled="wolf.pairing[req.pair_secret]"
|
||||
/>
|
||||
<button
|
||||
class="primary"
|
||||
type="button"
|
||||
:disabled="wolf.pairing[req.pair_secret]"
|
||||
@click="pairWolf(req.pair_secret)"
|
||||
>
|
||||
{{ wolf.pairing[req.pair_secret] ? "Pairing..." : "Pair" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="wolf.canControlGpu" class="secret-box">
|
||||
<div class="secret-head">
|
||||
<div class="pill mono">Admin</div>
|
||||
</div>
|
||||
<div class="wolf-controls">
|
||||
<select v-model="wolf.selectedGame" class="input mono">
|
||||
<option value="steam">Steam</option>
|
||||
<option value="arc-raiders">Arc Raiders</option>
|
||||
<option value="satisfactory">Satisfactory</option>
|
||||
<option value="wolf">Wolf</option>
|
||||
</select>
|
||||
<input v-model="wolf.note" class="input mono" type="text" placeholder="note" />
|
||||
<button
|
||||
class="primary"
|
||||
type="button"
|
||||
:disabled="Boolean(wolf.actioning)"
|
||||
@click="setWolfGameMode('start')"
|
||||
>
|
||||
{{ wolf.actioning === "start" ? "Starting..." : "Prioritize Wolf" }}
|
||||
</button>
|
||||
<button class="copy mono" type="button" :disabled="Boolean(wolf.actioning)" @click="setWolfGameMode('stop')">
|
||||
{{ wolf.actioning === "stop" ? "Stopping..." : "Restore AI" }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="wolf-controls manual-unlock">
|
||||
<input v-model="wolf.manualIp" class="input mono" type="text" placeholder="IP address" />
|
||||
<input v-model="wolf.manualUser" class="input mono" type="text" placeholder="user" />
|
||||
<button class="primary" type="button" :disabled="wolf.manualUnlocking" @click="adminUnlockWolfIp">
|
||||
{{ wolf.manualUnlocking ? "Unlocking..." : "Unlock IP" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="wolf.sessions.length" class="hint mono">
|
||||
Active sessions: {{ wolf.sessions.length }}
|
||||
</div>
|
||||
<div v-if="wolf.error" class="error-box">
|
||||
<div class="mono">{{ wolf.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card module" :style="{ order: vaultwardenOrder }">
|
||||
<div class="module-head">
|
||||
<h2>Vaultwarden</h2>
|
||||
@ -428,6 +543,7 @@ const {
|
||||
nextcloudMail,
|
||||
wger,
|
||||
firefly,
|
||||
wolf,
|
||||
admin,
|
||||
onboardingUrl,
|
||||
vaultwardenReady,
|
||||
@ -442,6 +558,11 @@ const {
|
||||
resetWger,
|
||||
resetFirefly,
|
||||
syncNextcloudMail,
|
||||
refreshWolf,
|
||||
unlockWolf,
|
||||
pairWolf,
|
||||
setWolfGameMode,
|
||||
adminUnlockWolfIp,
|
||||
approve,
|
||||
deny,
|
||||
copy,
|
||||
|
||||
@ -103,6 +103,7 @@ describe("account dashboard", () => {
|
||||
|
||||
expect(dashboard.mailu.status).toBe("login required");
|
||||
expect(dashboard.vaultwardenDisplayStatus.value).toBe("login required");
|
||||
expect(dashboard.wolf.status).toBe("login required");
|
||||
expect(dashboard.admin.enabled).toBe(false);
|
||||
wrapper.unmount();
|
||||
|
||||
@ -144,6 +145,7 @@ describe("account dashboard", () => {
|
||||
expect(dashboard.vaultwardenReady.value).toBe(true);
|
||||
expect(dashboard.vaultwardenOrder.value).toBe(3);
|
||||
expect(dashboard.jellyfin.syncStatus).toBe("ok");
|
||||
expect(dashboard.wolf.status).toBe("degraded");
|
||||
expect(dashboard.onboardingUrl.value).toBe("/onboarding?code=ada");
|
||||
expect(dashboard.admin.enabled).toBe(true);
|
||||
expect(dashboard.admin.flags).toEqual(["media", "budget"]);
|
||||
@ -161,6 +163,7 @@ describe("account dashboard", () => {
|
||||
const view = mount(AccountView);
|
||||
await flushPromises();
|
||||
expect(view.text()).toContain("Firefly III");
|
||||
expect(view.text()).toContain("Wolf");
|
||||
expect(view.text()).toContain("Admin Approvals");
|
||||
expect(view.text()).toContain("newuser");
|
||||
expect(view.find("a[href='https://sso.example.dev/account/password']").exists()).toBe(true);
|
||||
@ -198,6 +201,69 @@ describe("account dashboard", () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("loads and controls Wolf account state", async () => {
|
||||
const seen = [];
|
||||
installFetch((url, options = {}) => {
|
||||
seen.push({ url, body: options.body || "" });
|
||||
if (url.includes("/api/account/wolf/firewall/unlock")) return jsonResponse({ success: true });
|
||||
if (url.includes("/api/account/wolf/pairing/submit-pin")) return jsonResponse({ success: true });
|
||||
if (url.includes("/api/account/wolf/game-mode/start")) return jsonResponse({ status: "active" });
|
||||
if (url.includes("/api/account/wolf/game-mode/stop")) return jsonResponse({ status: "idle" });
|
||||
if (url.includes("/api/account/wolf/admin/firewall/unlock")) return jsonResponse({ success: true });
|
||||
if (url.includes("/api/account/wolf")) {
|
||||
return jsonResponse({
|
||||
can_control_gpu: true,
|
||||
moonlight: { host: "moonlight.bstein.dev", source_ip: "1.2.3.4", unlock_ttl_seconds: 28800 },
|
||||
gpu: { priority: "ai", game_mode: { status: "idle", active: false } },
|
||||
wolf: {
|
||||
api_enabled: true,
|
||||
clients: [{ name: "Desktop" }],
|
||||
pending_pair_requests: [{ name: "Laptop", pair_secret: "secret-1" }],
|
||||
sessions: [],
|
||||
apps: [{ name: "Steam" }],
|
||||
},
|
||||
firewall: { active_unlocks: [{ ip: "1.2.3.4" }] },
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/account/overview")) return jsonResponse(overview());
|
||||
if (url.includes("/api/admin/access/requests")) return jsonResponse({ requests: [] });
|
||||
if (url.includes("/api/admin/access/flags")) return jsonResponse({ flags: [] });
|
||||
return jsonResponse({});
|
||||
});
|
||||
const { dashboard, wrapper } = mountDashboard();
|
||||
await flushPromises();
|
||||
|
||||
expect(dashboard.wolf.status).toBe("ready");
|
||||
expect(dashboard.wolf.canControlGpu).toBe(true);
|
||||
expect(dashboard.wolf.clients[0].name).toBe("Desktop");
|
||||
expect(dashboard.wolf.pendingPairRequests[0].pair_secret).toBe("secret-1");
|
||||
|
||||
await dashboard.unlockWolf();
|
||||
dashboard.wolf.pinInputs["secret-1"] = "1234";
|
||||
await dashboard.pairWolf("secret-1");
|
||||
dashboard.wolf.selectedGame = "arc-raiders";
|
||||
dashboard.wolf.note = "now";
|
||||
await dashboard.setWolfGameMode("start");
|
||||
await dashboard.setWolfGameMode("stop");
|
||||
dashboard.wolf.manualIp = "5.6.7.8";
|
||||
dashboard.wolf.manualUser = "olya";
|
||||
await dashboard.adminUnlockWolfIp();
|
||||
|
||||
expect(JSON.parse(seen.find((item) => item.url.includes("/pairing/submit-pin")).body)).toEqual({
|
||||
pair_secret: "secret-1",
|
||||
pin: "1234",
|
||||
});
|
||||
expect(JSON.parse(seen.find((item) => item.url.includes("/game-mode/start")).body)).toEqual({
|
||||
game: "arc-raiders",
|
||||
note: "now",
|
||||
});
|
||||
expect(JSON.parse(seen.find((item) => item.url.includes("/admin/firewall/unlock")).body)).toEqual({
|
||||
ip: "5.6.7.8",
|
||||
target_user: "olya",
|
||||
});
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("handles service action warnings and failures", async () => {
|
||||
const rotateResponses = [
|
||||
{ password: "mail-a", sync_enabled: false },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user