Migrate CI to Gitea and improve live map diagnostics
CI / Real Minecraft installs (manual) (push) Skipped
CI / Rust, web and container (push) Canceled after 0s
CI / Windows launcher (push) Canceled after 0s
Server integrations / paper (push) Canceled after 0s
Server integrations / Fabric and Forge / 1.20.1 (push) Canceled after 0s
Server integrations / Fabric and Forge / 1.21.1 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.1.2 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.2 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.3 (push) Canceled after 0s
CI / Real Minecraft installs (manual) (push) Skipped
CI / Rust, web and container (push) Canceled after 0s
CI / Windows launcher (push) Canceled after 0s
Server integrations / paper (push) Canceled after 0s
Server integrations / Fabric and Forge / 1.20.1 (push) Canceled after 0s
Server integrations / Fabric and Forge / 1.21.1 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.1.2 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.2 (push) Canceled after 0s
Server integrations / Fabric and Forge / 26.3 (push) Canceled after 0s
This commit is contained in:
1 parent
d72d7c2e80
commit
7809d47d39
32 files changed
+518
-325
No files matched your search
@@ -259,6 +259,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/map/tiles", post(worldmap::game_tiles))
|
||||
.route("/map/players", post(worldmap::game_players))
|
||||
.route("/map/overlay", post(worldmap::game_overlay))
|
||||
.route("/map/diagnostics", post(worldmap::game_diagnostics))
|
||||
.layer(DefaultBodyLimit::max(10 * 1024 * 1024));
|
||||
|
||||
Router::new()
|
||||
|
||||
@@ -89,6 +89,19 @@ pub async fn game_overlay(
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn game_diagnostics(
|
||||
GameServer(server): GameServer,
|
||||
State(state): State<AppState>,
|
||||
Json(value): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_enabled(server.map_enabled)?;
|
||||
if !value.is_object() || value.to_string().len() > 4096 {
|
||||
return Err(AppError::bad_request("invalid map diagnostics"));
|
||||
}
|
||||
state.worldmap.set_diagnostics(server.id, value);
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Launcher and admin viewers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -143,6 +156,7 @@ pub async fn admin_status(_: AdminUser, State(state): State<AppState>, Path(id):
|
||||
v["stats"] = serde_json::to_value(state.worldmap.stats(id))?;
|
||||
v["epoch"] = json!(state.worldmap.epoch(id));
|
||||
v["roster"] = serde_json::to_value(state.worldmap.live_players(id))?;
|
||||
v["diagnostics"] = state.worldmap.diagnostics(id);
|
||||
Ok(Json(v))
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ pub struct WorldMap {
|
||||
tokens: Mutex<HashMap<String, (i64, Instant)>>,
|
||||
overlays: Mutex<HashMap<i64, Value>>,
|
||||
stats: Mutex<HashMap<i64, Stats>>,
|
||||
diagnostics: Mutex<HashMap<i64, (Instant, Value)>>,
|
||||
}
|
||||
|
||||
/// A dimension id ("minecraft:the_nether") as a safe folder name ("the_nether"). Other namespaces keep theirs ("mod__space").
|
||||
@@ -133,6 +134,7 @@ impl WorldMap {
|
||||
tokens: Default::default(),
|
||||
overlays: Default::default(),
|
||||
stats: Default::default(),
|
||||
diagnostics: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +219,7 @@ impl WorldMap {
|
||||
std::fs::write(dir.join("epoch"), epoch.to_string())?;
|
||||
self.overlays.lock().unwrap().remove(&id);
|
||||
*self.stats.lock().unwrap().entry(id).or_default() = Stats::default();
|
||||
self.diagnostics.lock().unwrap().remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -231,12 +234,30 @@ impl WorldMap {
|
||||
self.players.lock().unwrap().remove(&id);
|
||||
self.overlays.lock().unwrap().remove(&id);
|
||||
self.stats.lock().unwrap().remove(&id);
|
||||
self.diagnostics.lock().unwrap().remove(&id);
|
||||
}
|
||||
|
||||
pub fn stats(&self, id: i64) -> Stats {
|
||||
self.stats.lock().unwrap().get(&id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set_diagnostics(&self, id: i64, value: Value) {
|
||||
self.diagnostics.lock().unwrap().insert(id, (Instant::now(), value));
|
||||
}
|
||||
|
||||
pub fn diagnostics(&self, id: i64) -> Value {
|
||||
self.diagnostics
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&id)
|
||||
.map(|(at, value)| {
|
||||
let mut value = value.clone();
|
||||
value["age_seconds"] = json!(at.elapsed().as_secs());
|
||||
value
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
// ---- players -------------------------------------------------------------------------
|
||||
|
||||
pub fn set_players(&self, id: i64, mut players: Vec<LivePlayer>) {
|
||||
|
||||
@@ -117,6 +117,20 @@ async fn players_and_overlay_are_served_to_viewers() {
|
||||
assert_eq!(info["players"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn map_diagnostics_are_admin_only_and_reset_with_tiles() {
|
||||
let w = world().await;
|
||||
let payload = json!({"stage":"waiting for readable chunks","error":"No readable full chunks","region_files":1});
|
||||
assert_eq!(w.t.call("POST", "/api/server/v1/map/diagnostics", Some(&w.server), Some(payload)).await.0, StatusCode::OK);
|
||||
assert_eq!(w.t.call("POST", "/api/server/v1/map/diagnostics", None, Some(json!({}))).await.0, StatusCode::UNAUTHORIZED);
|
||||
let (_, status) = w.t.call("GET", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.admin), None).await;
|
||||
assert_eq!(status["diagnostics"]["stage"], "waiting for readable chunks");
|
||||
assert!(status["diagnostics"]["age_seconds"].is_number());
|
||||
w.t.call("DELETE", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.admin), None).await;
|
||||
let (_, status) = w.t.call("GET", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.admin), None).await;
|
||||
assert!(status["diagnostics"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_map_can_be_switched_off_and_reset() {
|
||||
let w = world().await;
|
||||
|
||||
@@ -38,10 +38,10 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body { background: var(--bg); overflow-wrap: anywhere; }
|
||||
body { background: var(--bg); line-height: 1.5; overflow-wrap: normal; }
|
||||
|
||||
/* Flex and grid items may shrink below their content, so long names wrap or truncate inside their box instead of pushing it wider. */
|
||||
*, *::before, *::after { min-width: 0; }
|
||||
.row > *, .grid > *, .card > *, .page-head > *, .section-title > * { min-width: 0; }
|
||||
svg, img, canvas, video, input[type='checkbox'], input[type='radio'] { flex-shrink: 0; }
|
||||
|
||||
|
||||
@@ -147,12 +147,14 @@ button, .btn {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-weight: 520;
|
||||
line-height: 1.35;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
/* Labels may wrap when space runs out; compact buttons below stay on one line. */
|
||||
white-space: normal;
|
||||
overflow-wrap: normal;
|
||||
max-width: 100%;
|
||||
}
|
||||
button.primary, button.ghost, button.danger, button.icon, .btn { white-space: nowrap; }
|
||||
button.icon { white-space: nowrap; }
|
||||
button:hover { background: var(--surface-3); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
|
||||
@@ -204,6 +206,7 @@ label.field {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
label.field .help { color: var(--muted); font-weight: 400; font-size: 0.78rem; }
|
||||
|
||||
@@ -278,6 +281,7 @@ table.table {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.table tbody tr { transition: background 0.1s; }
|
||||
|
||||
@@ -72,6 +72,21 @@
|
||||
<div><span>Last tiles</span><strong>{status?.stats.last_upload_at ? timeAgo(status.stats.last_upload_at) : '—'}</strong></div>
|
||||
<div><span>Last players</span><strong>{status?.stats.last_players_at ? timeAgo(status.stats.last_players_at) : '—'}</strong></div>
|
||||
</div>
|
||||
<div class="diagnostics" role="status">
|
||||
{#if status?.diagnostics}
|
||||
<div class="diagnostic-head"><strong>Game server: {status.diagnostics.stage}</strong><span class:stale={status.diagnostics.age_seconds > 30}>{status.diagnostics.age_seconds > 30 ? 'No recent report' : `Reported ${status.diagnostics.age_seconds}s ago`}</span></div>
|
||||
<div class="diagnostic-grid">
|
||||
<span>Region files <strong>{status.diagnostics.region_files}</strong></span>
|
||||
<span>Rendered <strong>{status.diagnostics.regions_rendered}</strong></span>
|
||||
<span>Queued tiles <strong>{status.diagnostics.queued_tiles}</strong></span>
|
||||
<span>Uploaded <strong>{status.diagnostics.tiles_uploaded}</strong></span>
|
||||
</div>
|
||||
{#if status.diagnostics.error}<p class="diagnostic-error">{status.diagnostics.error}</p>{/if}
|
||||
<details><summary>Paths and upload details</summary><p>World: <code>{status.diagnostics.world_root}</code></p><p>Last region: {status.diagnostics.last_region || '—'}</p><p>Last overlay: {status.stats.last_overlay_at ? timeAgo(status.stats.last_overlay_at) : 'never'}</p></details>
|
||||
{:else}
|
||||
<p>No map report from the game server yet. Check <code>/map</code> in game, the server's <code>map.enabled</code> setting, and its panel token.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if status?.dimensions.length}
|
||||
<p class="dims muted small">{#each status.dimensions as d, i}{i ? ' · ' : ''}{d.label}: {d.tiles.toLocaleString()} tiles ({formatBytes(d.bytes)}){/each}</p>
|
||||
{/if}
|
||||
@@ -108,6 +123,15 @@
|
||||
.stats > div { display: flex; flex-direction: column; gap: 2px; padding: 0.55rem 0.75rem; border: 1px solid var(--line); border-radius: 10px; font-size: 0.78rem; color: var(--muted); }
|
||||
.stats strong { color: var(--text); font-size: 1.05rem; }
|
||||
.dims { margin: 0; }
|
||||
.diagnostics { padding: 0.85rem 1rem; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); line-height: 1.5; }
|
||||
.diagnostic-head { display: flex; gap: 0.75rem; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
||||
.diagnostic-head span { color: var(--good); font-size: 0.82rem; }
|
||||
.diagnostic-head span.stale, .diagnostic-error { color: var(--bad); }
|
||||
.diagnostic-grid { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; margin: 0.5rem 0; color: var(--muted); font-size: 0.85rem; }
|
||||
.diagnostic-grid strong { color: var(--text); margin-left: 0.25rem; }
|
||||
.diagnostics details { margin-top: 0.4rem; color: var(--muted); font-size: 0.85rem; }
|
||||
.diagnostics summary { cursor: pointer; }
|
||||
.diagnostics code { overflow-wrap: anywhere; }
|
||||
.stage { height: min(68vh, 640px); min-height: 380px; border-radius: 14px; overflow: hidden; border: 1px solid var(--line); --map-accent: var(--accent); }
|
||||
.pcard { height: 100%; display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem; border-radius: 18px; background: rgba(14, 19, 34, 0.94); border: 1px solid rgba(255,255,255,0.12); backdrop-filter: blur(14px); color: #f4f6ff; position: relative; align-self: flex-start; height: auto; width: 100%; }
|
||||
.pcard .who { display: flex; gap: 0.7rem; align-items: center; }
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<Globe size={14} /> View landing page
|
||||
</a>
|
||||
|
||||
<a class="docs" href="https://github.com/scopeddlol/SCOPENET-MC#readme" target="_blank" rel="noreferrer">
|
||||
<a class="docs" href="https://gitea.jadonsandbox.com/blade/scopenet-mc#readme" target="_blank" rel="noreferrer">
|
||||
<ExternalLink size={14} /> Documentation
|
||||
</a>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<li>
|
||||
<strong>Install the SCOPENET integration</strong>
|
||||
<span class="muted small">Use scopenet-paper for Paper, Purpur or Spigot. Fabric and Forge need the JAR for the exact Minecraft version: 1.20.1, 1.21.1, 26.1.2, 26.2 or 26.3.</span>
|
||||
<a class="dl" href="https://github.com/scopeddlol/SCOPENET-MC/releases/latest" target="_blank" rel="noreferrer"><Download size={14} /> Download from Releases</a>
|
||||
<a class="dl" href="https://gitea.jadonsandbox.com/blade/scopenet-mc/releases/latest" target="_blank" rel="noreferrer"><Download size={14} /> Download from Releases</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Configure it</strong>
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface MapStatus {
|
||||
players: number;
|
||||
epoch: number;
|
||||
stats: { tiles_received: number; bytes_received: number; last_upload_at: string | null; last_players_at: string | null; last_overlay_at: string | null };
|
||||
diagnostics: { stage: string; error: string; world_root: string; region_files: number; regions_rendered: number; queued_tiles: number; tiles_uploaded: number; last_region: string; last_upload_ms: number; age_seconds: number } | null;
|
||||
roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[];
|
||||
}
|
||||
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
</div>
|
||||
{:else if tab === 'downloads'}
|
||||
<div class="settings-grid card"><h2>Launcher downloads</h2><p>Upload installers or point players to an external release page.</p>
|
||||
<label>External download URL<input type="url" bind:value={config.external_download_url} placeholder="https://github.com/.../releases" /></label>
|
||||
<label>External download URL<input type="url" bind:value={config.external_download_url} placeholder="https://gitea.jadonsandbox.com/blade/scopenet-mc/releases" /></label>
|
||||
{#each ['windows', 'mac', 'linux'] as platform}
|
||||
{@const download = config.hosted_downloads.find((d) => d.platform === platform)}
|
||||
<div class="download-row"><div><strong>{platform[0].toUpperCase() + platform.slice(1)}</strong><p>{download ? `${download.filename} · ${formatBytes(download.size)}` : 'No installer uploaded'}</p></div><label class="btn secondary sm"><Upload size={15} /> {uploading ? 'Uploading…' : 'Upload installer'}<input type="file" hidden disabled={uploading} onchange={(e) => upload(platform, e.currentTarget.files?.[0])} /></label></div>
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
{/if}
|
||||
</label>
|
||||
<label class="field">Launcher download link
|
||||
<input bind:value={s.launcher_download_url} placeholder="https://github.com/you/repo/releases/latest" />
|
||||
<input bind:value={s.launcher_download_url} placeholder="https://gitea.jadonsandbox.com/blade/scopenet-mc/releases/latest" />
|
||||
<span class="help">Shown on the dashboard so you can share it easily.</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user