new version
This commit is contained in:
@@ -16,15 +16,22 @@ def init_db():
|
|||||||
conn = _conn()
|
conn = _conn()
|
||||||
conn.executescript('''
|
conn.executescript('''
|
||||||
CREATE TABLE IF NOT EXISTS players (
|
CREATE TABLE IF NOT EXISTS players (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
online INTEGER NOT NULL DEFAULT 0,
|
online INTEGER NOT NULL DEFAULT 0,
|
||||||
last_seen TEXT,
|
last_seen TEXT,
|
||||||
updating INTEGER NOT NULL DEFAULT 0,
|
updating INTEGER NOT NULL DEFAULT 0,
|
||||||
registered INTEGER NOT NULL DEFAULT 0,
|
registered INTEGER NOT NULL DEFAULT 0,
|
||||||
workspace_name TEXT,
|
workspace_name TEXT,
|
||||||
player_type TEXT,
|
player_type TEXT,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL,
|
||||||
|
last_pushed TEXT,
|
||||||
|
last_ip_address TEXT,
|
||||||
|
status_last_updated TEXT,
|
||||||
|
screen_resolution TEXT,
|
||||||
|
hardware_version TEXT,
|
||||||
|
hostname TEXT,
|
||||||
|
eth0_ip TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS logs (
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
@@ -37,6 +44,21 @@ def init_db():
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(timestamp DESC);
|
CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(timestamp DESC);
|
||||||
''')
|
''')
|
||||||
|
# Migrate existing databases that predate the new columns
|
||||||
|
new_cols = [
|
||||||
|
('last_pushed', 'TEXT'),
|
||||||
|
('last_ip_address', 'TEXT'),
|
||||||
|
('status_last_updated', 'TEXT'),
|
||||||
|
('screen_resolution', 'TEXT'),
|
||||||
|
('hardware_version', 'TEXT'),
|
||||||
|
('hostname', 'TEXT'),
|
||||||
|
('eth0_ip', 'TEXT'),
|
||||||
|
]
|
||||||
|
for col, typ in new_cols:
|
||||||
|
try:
|
||||||
|
conn.execute(f'ALTER TABLE players ADD COLUMN {col} {typ}')
|
||||||
|
except Exception:
|
||||||
|
pass # column already exists
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -44,21 +66,33 @@ def init_db():
|
|||||||
def upsert_player(p):
|
def upsert_player(p):
|
||||||
state = p.get('state', {})
|
state = p.get('state', {})
|
||||||
workspace = p.get('workspace', {})
|
workspace = p.get('workspace', {})
|
||||||
|
ps = p.get('player_status') or {}
|
||||||
|
res = ps.get('screen_resolution')
|
||||||
|
eth0 = (ps.get('public_ip') or {}).get('eth0') or {}
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
conn = _conn()
|
conn = _conn()
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
INSERT INTO players (id, name, online, last_seen, updating, registered,
|
INSERT INTO players (id, name, online, last_seen, updating, registered,
|
||||||
workspace_name, player_type, updated_at)
|
workspace_name, player_type, updated_at,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
last_pushed, last_ip_address, status_last_updated,
|
||||||
|
screen_resolution, hardware_version, hostname, eth0_ip)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
online = excluded.online,
|
online = excluded.online,
|
||||||
last_seen = excluded.last_seen,
|
last_seen = excluded.last_seen,
|
||||||
updating = excluded.updating,
|
updating = excluded.updating,
|
||||||
registered = excluded.registered,
|
registered = excluded.registered,
|
||||||
workspace_name = excluded.workspace_name,
|
workspace_name = excluded.workspace_name,
|
||||||
player_type = excluded.player_type,
|
player_type = excluded.player_type,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at,
|
||||||
|
last_pushed = excluded.last_pushed,
|
||||||
|
last_ip_address = excluded.last_ip_address,
|
||||||
|
status_last_updated = excluded.status_last_updated,
|
||||||
|
screen_resolution = excluded.screen_resolution,
|
||||||
|
hardware_version = excluded.hardware_version,
|
||||||
|
hostname = excluded.hostname,
|
||||||
|
eth0_ip = excluded.eth0_ip
|
||||||
''', (
|
''', (
|
||||||
p['id'],
|
p['id'],
|
||||||
p['name'],
|
p['name'],
|
||||||
@@ -69,6 +103,13 @@ def upsert_player(p):
|
|||||||
workspace.get('name'),
|
workspace.get('name'),
|
||||||
p.get('player_type'),
|
p.get('player_type'),
|
||||||
now,
|
now,
|
||||||
|
p.get('last_pushed'),
|
||||||
|
p.get('last_ip_address'),
|
||||||
|
ps.get('status_last_updated'),
|
||||||
|
f"{res[0]}x{res[1]}" if isinstance(res, list) and len(res) == 2 else None,
|
||||||
|
ps.get('hardware_version'),
|
||||||
|
ps.get('hostname'),
|
||||||
|
eth0.get('ip_v4'),
|
||||||
))
|
))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
11
app/web.py
11
app/web.py
@@ -1,4 +1,4 @@
|
|||||||
from flask import Flask, jsonify, render_template
|
from flask import Flask, jsonify, render_template, request
|
||||||
from app import database as db
|
from app import database as db
|
||||||
|
|
||||||
|
|
||||||
@@ -29,4 +29,13 @@ def create_app():
|
|||||||
def api_logs():
|
def api_logs():
|
||||||
return jsonify(db.get_recent_logs(100))
|
return jsonify(db.get_recent_logs(100))
|
||||||
|
|
||||||
|
@app.route('/api/sync', methods=['POST'])
|
||||||
|
def api_sync():
|
||||||
|
from app.scheduler import poll_yodeck
|
||||||
|
try:
|
||||||
|
poll_yodeck()
|
||||||
|
return jsonify({'ok': True})
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
8
apprequest.txt
Normal file
8
apprequest.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
I'd like to develop an application.
|
||||||
|
The application should load information about players using this API: https://app.yodeck.com/api-docs/#tag/Screens/operation/getScreen . It should store this information in a database.
|
||||||
|
My Zabbix server needs to consume this information via SNMP. It needs to add players that are not yet existing in Zabbix automatically. Players already existing get updated. Especially the status is important.
|
||||||
|
The key to identify each player is the "id" field of yodeck. It should convert into "yodeck#" + id. like: yodeck#0815 . This value is the "host name" in zabbix. The "name" field from yodeck is the "visible name" in zabbix.
|
||||||
|
The most important fields from Yodeck are from "state" object which contains: online, last_seen, updating, registered. The data needs to be updated every 10 minutes via API and every minute from Zabbix.
|
||||||
|
the application should be deployable as docker image. The database of the application needs to be persistent.
|
||||||
|
The application should log in the database when it has loaded data from the Yodeck API and when it has been transfering data to Zabbix.
|
||||||
|
A simple web frontend of that application to show its status would be helpful. Status should show data from the internal log.
|
||||||
108
json.txt
Normal file
108
json.txt
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
{
|
||||||
|
"count": 431,
|
||||||
|
"next": "https://app.yodeck.com/api/v2/screens/tags/?limit=100&offset=200",
|
||||||
|
"previous": "https://app.yodeck.com/api/v2/screens/tags/?limit=100&offset=0",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"screen_content": {
|
||||||
|
"source_id": 45,
|
||||||
|
"source_name": "musicPlaylist1(auto-playlist-73-fit)",
|
||||||
|
"source_type": "playlist"
|
||||||
|
},
|
||||||
|
"id": 123,
|
||||||
|
"uuid": "71a9ef47-28d4-4e03-af9e-3056d328c3dd",
|
||||||
|
"name": "name_example",
|
||||||
|
"description": "a short description",
|
||||||
|
"player_type": "Web Player",
|
||||||
|
"workspace": {
|
||||||
|
"id": 42,
|
||||||
|
"name": "Reception"
|
||||||
|
},
|
||||||
|
"created_at": "2025-02-14T15:49:38Z",
|
||||||
|
"last_modified": "2025-09-24T12:42:35.934783Z",
|
||||||
|
"last_ip_address": "137.34.126.1",
|
||||||
|
"last_pushed": "2025-07-30T09:51:55Z",
|
||||||
|
"reg_code": "123432125",
|
||||||
|
"takeover_content": {
|
||||||
|
"source_id": 123,
|
||||||
|
"source_type": "playlist",
|
||||||
|
"source_name": "musicPlaylist1",
|
||||||
|
"start": "2024-03-27T13:31:54.123453Ζ",
|
||||||
|
"end": "2024-03-27T13:41:54.123453Ζ"
|
||||||
|
},
|
||||||
|
"screenshot_url": "https://app.yodeck.com/screenshots/bdb1e075dfa949df473b7818b8913ec8.png",
|
||||||
|
"state": {
|
||||||
|
"online": true,
|
||||||
|
"last_seen": "2024-05-10T11:21:21Z",
|
||||||
|
"updating": false,
|
||||||
|
"registered": true
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"player": {
|
||||||
|
"tv_power_status": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gpio_enable": false,
|
||||||
|
"player_status": {
|
||||||
|
"screen_ID": "54bea4d1-aac6-4866-98db-7d86b041741a",
|
||||||
|
"status_last_updated": "2025-04-29T10:51:55.268924+03:00",
|
||||||
|
"software_version": "71.192.2.4",
|
||||||
|
"screen_resolution": [
|
||||||
|
1920,
|
||||||
|
1080
|
||||||
|
],
|
||||||
|
"storage": "24.08 GB free of 25.93 GB",
|
||||||
|
"uptime": 329699.67,
|
||||||
|
"ram": {
|
||||||
|
"total_ram": "1.71 GB",
|
||||||
|
"availabe": "1.24 GB",
|
||||||
|
"used": "477.71 MB"
|
||||||
|
},
|
||||||
|
"hardware_version": "Raspberry Pi 4 Model B Rev 1.5",
|
||||||
|
"tv_status": true,
|
||||||
|
"player_window_size": [
|
||||||
|
1920,
|
||||||
|
1080
|
||||||
|
],
|
||||||
|
"hostname": "ds-83251ed6",
|
||||||
|
"browser_session_data_size": "1021.54 bytes",
|
||||||
|
"wifi_status": "connected",
|
||||||
|
"hdmi_connected": true,
|
||||||
|
"power_supply": "0x0",
|
||||||
|
"color_depth": "16",
|
||||||
|
"cpu_temperature": 46.3,
|
||||||
|
"cpu_load": {
|
||||||
|
"last_minute": "0.31",
|
||||||
|
"last_5_minutes": "0.22",
|
||||||
|
"last_15_minutes": "0.19"
|
||||||
|
},
|
||||||
|
"swaps": {
|
||||||
|
"size": "100.00 MB",
|
||||||
|
"used": "0 byte"
|
||||||
|
},
|
||||||
|
"public_ip": {
|
||||||
|
"eth0": {
|
||||||
|
"ip_v4_mask": "255.255.0.0",
|
||||||
|
"mac": "d8:3a:dd:08:f3:21",
|
||||||
|
"ip_v4": "10.123.10.145",
|
||||||
|
"ip_v6": [
|
||||||
|
"fe80::6826:d14:48e:4765"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"wlan0": {
|
||||||
|
"ip_v4_mask": "UNKNOWN",
|
||||||
|
"mac": "d8:3a:dd:08:f3:22",
|
||||||
|
"ip_v4": "UNKNOWN",
|
||||||
|
"ip_v6": "UNKNOWN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lockdown": false,
|
||||||
|
"encrypted": false,
|
||||||
|
"browser_version": "",
|
||||||
|
"yodeck_chrome_extension": "",
|
||||||
|
"player_installed_as_pwa_app": "",
|
||||||
|
"firmware_version": "xt5-9.0.110"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
portiner.txt
Normal file
1
portiner.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
5428a46cddc7613af12bbb0b81e7a1165b49bee3
|
||||||
@@ -15,8 +15,15 @@
|
|||||||
.badge-zabbix_sync { background: #6f42c1; }
|
.badge-zabbix_sync { background: #6f42c1; }
|
||||||
.badge-snmp_transfer { background: #20c997; }
|
.badge-snmp_transfer { background: #20c997; }
|
||||||
.badge-error { background: #dc3545; }
|
.badge-error { background: #dc3545; }
|
||||||
.scroll-table { max-height: 420px; overflow-y: auto; }
|
.scroll-table { max-height: 420px; overflow: auto; }
|
||||||
thead.sticky-top th { position: sticky; top: 0; z-index: 1; }
|
thead.sticky-top th { position: sticky; top: 0; z-index: 1; }
|
||||||
|
.clickable-card { cursor: pointer; transition: box-shadow .15s; }
|
||||||
|
.clickable-card:hover { box-shadow: 0 0 0 2px #dc354580; }
|
||||||
|
.clickable-card.active { box-shadow: 0 0 0 2px #dc3545; background: #fff5f5; }
|
||||||
|
#player-table th { cursor: pointer; user-select: none; white-space: nowrap; }
|
||||||
|
#player-table th:hover { background: #343a40cc; }
|
||||||
|
#player-table th.sort-asc::after { content: ' ▲'; font-size: .7em; }
|
||||||
|
#player-table th.sort-desc::after { content: ' ▼'; font-size: .7em; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -43,9 +50,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-3">
|
<div class="col-6 col-md-3">
|
||||||
<div class="card stat-card text-center py-3 h-100">
|
<div class="card stat-card text-center py-3 h-100 clickable-card" id="offline-card" role="button" title="Click to filter offline players">
|
||||||
<div class="display-6 fw-bold text-danger">{{ total - online }}</div>
|
<div class="display-6 fw-bold text-danger">{{ total - online }}</div>
|
||||||
<div class="text-muted small">Offline</div>
|
<div class="text-muted small">Offline <span class="text-muted" style="font-size:.7rem">(click to filter)</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-3">
|
<div class="col-6 col-md-3">
|
||||||
@@ -62,29 +69,40 @@
|
|||||||
<!-- Player table -->
|
<!-- Player table -->
|
||||||
<div class="card shadow-sm mb-4">
|
<div class="card shadow-sm mb-4">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span class="fw-semibold">Players</span>
|
<span class="fw-semibold">Players <span id="filter-badge" class="badge bg-danger ms-1 d-none">Offline only ✕</span></span>
|
||||||
<span class="badge bg-secondary">{{ total }}</span>
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge bg-secondary" id="player-count">{{ total }}</span>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" id="sync-btn" onclick="triggerSync()">↻ Sync now</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="scroll-table">
|
<div class="scroll-table">
|
||||||
<table class="table table-sm table-hover mb-0">
|
<table class="table table-sm table-hover mb-0" id="player-table">
|
||||||
<thead class="table-dark sticky-top">
|
<thead class="table-dark sticky-top">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Yodeck ID</th>
|
<th>Yodeck ID</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
|
<th>Hostname</th>
|
||||||
<th>Workspace</th>
|
<th>Workspace</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Last Seen (UTC)</th>
|
<th>Last Seen (UTC)</th>
|
||||||
|
<th>Last Pushed</th>
|
||||||
|
<th>Status Updated</th>
|
||||||
|
<th>Resolution</th>
|
||||||
|
<th>Hardware</th>
|
||||||
|
<th>Last IP</th>
|
||||||
|
<th>eth0 IP</th>
|
||||||
<th>Updating</th>
|
<th>Updating</th>
|
||||||
<th>Registered</th>
|
<th>Registered</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="player-tbody">
|
||||||
{% for p in players %}
|
{% for p in players %}
|
||||||
<tr>
|
<tr data-online="{{ '1' if p.online else '0' }}">
|
||||||
<td class="text-muted font-monospace">{{ p.id }}</td>
|
<td class="text-muted font-monospace">{{ p.id }}</td>
|
||||||
<td>{{ p.name }}</td>
|
<td>{{ p.name }}</td>
|
||||||
|
<td class="font-monospace small">{{ p.hostname or '—' }}</td>
|
||||||
<td>{{ p.workspace_name or '—' }}</td>
|
<td>{{ p.workspace_name or '—' }}</td>
|
||||||
<td>{{ p.player_type or '—' }}</td>
|
<td>{{ p.player_type or '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -95,6 +113,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-monospace small">{{ (p.last_seen or '—')[:19] }}</td>
|
<td class="font-monospace small">{{ (p.last_seen or '—')[:19] }}</td>
|
||||||
|
<td class="font-monospace small">{{ (p.last_pushed or '—')[:19] }}</td>
|
||||||
|
<td class="font-monospace small">{{ (p.status_last_updated or '—')[:19] }}</td>
|
||||||
|
<td class="small">{{ p.screen_resolution or '—' }}</td>
|
||||||
|
<td class="small">{{ p.hardware_version or '—' }}</td>
|
||||||
|
<td class="font-monospace small">{{ p.last_ip_address or '—' }}</td>
|
||||||
|
<td class="font-monospace small">{{ p.eth0_ip or '—' }}</td>
|
||||||
<td>{% if p.updating %}<span class="text-warning fw-semibold">Yes</span>{% else %}No{% endif %}</td>
|
<td>{% if p.updating %}<span class="text-warning fw-semibold">Yes</span>{% else %}No{% endif %}</td>
|
||||||
<td>{% if p.registered %}<span class="text-success">✓</span>{% else %}<span class="text-danger">✗</span>{% endif %}</td>
|
<td>{% if p.registered %}<span class="text-success">✓</span>{% else %}<span class="text-danger">✗</span>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -146,5 +170,76 @@
|
|||||||
<a href="/api/logs" class="text-muted">logs</a>
|
<a href="/api/logs" class="text-muted">logs</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let offlineFilter = false;
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
const rows = document.querySelectorAll('#player-tbody tr');
|
||||||
|
let visible = 0;
|
||||||
|
rows.forEach(r => {
|
||||||
|
const show = !offlineFilter || r.dataset.online === '0';
|
||||||
|
r.style.display = show ? '' : 'none';
|
||||||
|
if (show) visible++;
|
||||||
|
});
|
||||||
|
document.getElementById('player-count').textContent = visible;
|
||||||
|
document.getElementById('filter-badge').classList.toggle('d-none', !offlineFilter);
|
||||||
|
document.getElementById('offline-card').classList.toggle('active', offlineFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('offline-card').addEventListener('click', () => {
|
||||||
|
offlineFilter = !offlineFilter;
|
||||||
|
applyFilter();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('filter-badge').addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
offlineFilter = false;
|
||||||
|
applyFilter();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Column sorting
|
||||||
|
let sortCol = -1, sortAsc = true;
|
||||||
|
|
||||||
|
document.querySelectorAll('#player-table thead th').forEach((th, idx) => {
|
||||||
|
th.addEventListener('click', () => {
|
||||||
|
if (sortCol === idx) {
|
||||||
|
sortAsc = !sortAsc;
|
||||||
|
} else {
|
||||||
|
sortCol = idx;
|
||||||
|
sortAsc = true;
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#player-table thead th').forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
||||||
|
th.classList.add(sortAsc ? 'sort-asc' : 'sort-desc');
|
||||||
|
|
||||||
|
const tbody = document.getElementById('player-tbody');
|
||||||
|
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const av = a.cells[idx]?.textContent.trim() ?? '';
|
||||||
|
const bv = b.cells[idx]?.textContent.trim() ?? '';
|
||||||
|
const an = parseFloat(av), bn = parseFloat(bv);
|
||||||
|
const cmp = (!isNaN(an) && !isNaN(bn)) ? an - bn : av.localeCompare(bv);
|
||||||
|
return sortAsc ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
rows.forEach(r => tbody.appendChild(r));
|
||||||
|
applyFilter();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function triggerSync() {
|
||||||
|
const btn = document.getElementById('sync-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '↻ Syncing…';
|
||||||
|
fetch('/api/sync', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
btn.textContent = d.ok ? '✓ Done' : '✗ Failed';
|
||||||
|
setTimeout(() => location.reload(), 1500);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
btn.textContent = '✗ Error';
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
1
yodeck_api_token.txt
Normal file
1
yodeck_api_token.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
yodeck:fXQKm1hLvJY88necL3GiLVntpmyNS5BpKp8MpDK8GH2UvCPrg8BeHwpBSQaEtF0q
|
||||||
Reference in New Issue
Block a user