Added demo mode
This commit is contained in:
parent
0ebabcf33f
commit
513d1162ed
7 changed files with 103 additions and 9 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1424,7 +1424,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "soccercloud"
|
name = "soccercloud"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-web",
|
"actix-web",
|
||||||
"clap",
|
"clap",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "soccercloud"
|
name = "soccercloud"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
12
DEVLOG.md
12
DEVLOG.md
|
|
@ -1,5 +1,17 @@
|
||||||
# DEVLOG
|
# DEVLOG
|
||||||
|
|
||||||
|
## 2026-02-13 - v0.1.1 Demo mode
|
||||||
|
|
||||||
|
### Scope completed
|
||||||
|
- Added `--demo` CLI argument for web mode only.
|
||||||
|
- Demo mode features:
|
||||||
|
- Limited to 6 simulations maximum
|
||||||
|
- FIFO rotation: creating a 7th simulation removes the oldest
|
||||||
|
- Manual deletion disabled (returns 403 Forbidden)
|
||||||
|
- Demo banner displayed in hero-card element
|
||||||
|
- Added `/api/config` endpoint to expose demo mode status to frontend.
|
||||||
|
- Frontend hides delete buttons when in demo mode.
|
||||||
|
|
||||||
## 2026-02-12 - v0.1.0 Initial Public Release
|
## 2026-02-12 - v0.1.0 Initial Public Release
|
||||||
|
|
||||||
### Scope completed
|
### Scope completed
|
||||||
|
|
|
||||||
21
data.js
21
data.js
|
|
@ -3,6 +3,7 @@ const state = {
|
||||||
simulations: [],
|
simulations: [],
|
||||||
selectedDetailId: null,
|
selectedDetailId: null,
|
||||||
pollHandle: null,
|
pollHandle: null,
|
||||||
|
demo: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
@ -148,10 +149,11 @@ function getCreatePayload() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function cardActions(sim) {
|
function cardActions(sim) {
|
||||||
|
const deleteButton = state.demo ? "" : `<button class="btn warn" data-action="delete" data-id="${sim.id}">Delete</button>`;
|
||||||
const common = `
|
const common = `
|
||||||
<button class="btn secondary" data-action="view" data-id="${sim.id}">View</button>
|
<button class="btn secondary" data-action="view" data-id="${sim.id}">View</button>
|
||||||
<button class="btn secondary" data-action="clone" data-id="${sim.id}">Clone</button>
|
<button class="btn secondary" data-action="clone" data-id="${sim.id}">Clone</button>
|
||||||
<button class="btn warn" data-action="delete" data-id="${sim.id}">Delete</button>
|
${deleteButton}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (sim.status === "pending") {
|
if (sim.status === "pending") {
|
||||||
|
|
@ -418,11 +420,24 @@ async function boot() {
|
||||||
initThemeControls();
|
initThemeControls();
|
||||||
bindEvents();
|
bindEvents();
|
||||||
try {
|
try {
|
||||||
setStatus("Loading teams and simulations...");
|
setStatus("Loading configuration, teams and simulations...");
|
||||||
|
const config = await request("/api/config");
|
||||||
|
state.demo = config.demo;
|
||||||
|
|
||||||
|
// Show/hide demo banner
|
||||||
|
const demoBanner = $("demoBanner");
|
||||||
|
if (demoBanner) {
|
||||||
|
if (state.demo) {
|
||||||
|
demoBanner.classList.add("visible");
|
||||||
|
} else {
|
||||||
|
demoBanner.classList.remove("visible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.teams = await request("/api/teams");
|
state.teams = await request("/api/teams");
|
||||||
renderTeamSelectors();
|
renderTeamSelectors();
|
||||||
await refreshSimulations();
|
await refreshSimulations();
|
||||||
setStatus("Connected to SoccerCloud backend on port 9009.");
|
setStatus(`Connected to SoccerCloud backend on port 9009.${state.demo ? " (Demo mode)" : ""}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`Startup failed: ${error.message}`);
|
setStatus(`Startup failed: ${error.message}`);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
16
index.html
16
index.html
|
|
@ -221,6 +221,21 @@
|
||||||
animation: rise 420ms ease both;
|
animation: rise 420ms ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.demo-banner {
|
||||||
|
background: linear-gradient(135deg, #b42318, #dc2626);
|
||||||
|
color: white;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-banner.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
@ -609,6 +624,7 @@
|
||||||
<main class="app">
|
<main class="app">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<article class="hero-card">
|
<article class="hero-card">
|
||||||
|
<div class="demo-banner" id="demoBanner">DEMO MODE - Limited to 6 simulations with auto-rotation. Manual deletion disabled.</div>
|
||||||
<img class="hero-logo" src="sc-logo.jpg" alt="SoccerCloud logo" />
|
<img class="hero-logo" src="sc-logo.jpg" alt="SoccerCloud logo" />
|
||||||
<p class="eyebrow">Rust Backend</p>
|
<p class="eyebrow">Rust Backend</p>
|
||||||
<h1>SoccerCloud Web Control Room</h1>
|
<h1>SoccerCloud Web Control Room</h1>
|
||||||
|
|
|
||||||
12
src/main.rs
12
src/main.rs
|
|
@ -35,6 +35,9 @@ struct Cli {
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
listen_open: bool,
|
listen_open: bool,
|
||||||
|
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
demo: bool,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +96,14 @@ fn main() -> io::Result<()> {
|
||||||
"--web cannot be combined with subcommands",
|
"--web cannot be combined with subcommands",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return run_web_server(base_seed, cli.speed, cli.listen_open);
|
return run_web_server(base_seed, cli.speed, cli.listen_open, cli.demo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.demo {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"--demo can only be used with --web",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
|
|
|
||||||
47
src/web.rs
47
src/web.rs
|
|
@ -23,15 +23,17 @@ struct SharedState {
|
||||||
struct WebState {
|
struct WebState {
|
||||||
base_seed: u64,
|
base_seed: u64,
|
||||||
speed: Speed,
|
speed: Speed,
|
||||||
|
demo: bool,
|
||||||
next_id: usize,
|
next_id: usize,
|
||||||
instances: Vec<SimulationInstance>,
|
instances: Vec<SimulationInstance>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebState {
|
impl WebState {
|
||||||
fn new(base_seed: u64, speed: Speed) -> Self {
|
fn new(base_seed: u64, speed: Speed, demo: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
base_seed,
|
base_seed,
|
||||||
speed,
|
speed,
|
||||||
|
demo,
|
||||||
next_id: 0,
|
next_id: 0,
|
||||||
instances: Vec::new(),
|
instances: Vec::new(),
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +120,11 @@ struct CreateSimulationResponse {
|
||||||
id: usize,
|
id: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ConfigResponse {
|
||||||
|
demo: bool,
|
||||||
|
}
|
||||||
|
|
||||||
fn sim_type_label(sim_type: SimulationType) -> &'static str {
|
fn sim_type_label(sim_type: SimulationType) -> &'static str {
|
||||||
match sim_type {
|
match sim_type {
|
||||||
SimulationType::Single => "Single Match",
|
SimulationType::Single => "Single Match",
|
||||||
|
|
@ -275,6 +282,18 @@ async fn sc_logo_jpg() -> impl Responder {
|
||||||
.body(include_bytes!("../sc-logo.jpg").as_slice())
|
.body(include_bytes!("../sc-logo.jpg").as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn api_config(state: web::Data<SharedState>) -> impl Responder {
|
||||||
|
let guard = match state.inner.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => {
|
||||||
|
return HttpResponse::InternalServerError().json(ErrorDto {
|
||||||
|
error: "state lock poisoned".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
HttpResponse::Ok().json(ConfigResponse { demo: guard.demo })
|
||||||
|
}
|
||||||
|
|
||||||
async fn api_teams() -> impl Responder {
|
async fn api_teams() -> impl Responder {
|
||||||
let items: Vec<TeamDto> = TEAMS
|
let items: Vec<TeamDto> = TEAMS
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -340,6 +359,13 @@ async fn api_create_simulation(
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Demo mode: FIFO rotation - remove oldest if at limit
|
||||||
|
const DEMO_MAX_INSTANCES: usize = 6;
|
||||||
|
if guard.demo && guard.instances.len() >= DEMO_MAX_INSTANCES {
|
||||||
|
// Remove the oldest simulation (first in the vec)
|
||||||
|
guard.instances.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
let id = guard.next_id;
|
let id = guard.next_id;
|
||||||
let seed = guard.next_seed();
|
let seed = guard.next_seed();
|
||||||
let auto_fill = payload.auto_fill.unwrap_or(true);
|
let auto_fill = payload.auto_fill.unwrap_or(true);
|
||||||
|
|
@ -399,6 +425,13 @@ async fn api_clone_simulation(
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Demo mode: FIFO rotation - remove oldest if at limit
|
||||||
|
const DEMO_MAX_INSTANCES: usize = 6;
|
||||||
|
if guard.demo && guard.instances.len() >= DEMO_MAX_INSTANCES {
|
||||||
|
// Remove the oldest simulation (first in the vec)
|
||||||
|
guard.instances.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
let new_id = guard.next_id;
|
let new_id = guard.next_id;
|
||||||
let new_seed = guard.next_seed();
|
let new_seed = guard.next_seed();
|
||||||
let clone = existing.clone_as(new_id, new_seed);
|
let clone = existing.clone_as(new_id, new_seed);
|
||||||
|
|
@ -422,6 +455,13 @@ async fn api_delete_simulation(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Disable manual deletion in demo mode
|
||||||
|
if guard.demo {
|
||||||
|
return HttpResponse::Forbidden().json(ErrorDto {
|
||||||
|
error: "Manual deletion is disabled in demo mode".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if guard.remove_simulation(id) {
|
if guard.remove_simulation(id) {
|
||||||
return HttpResponse::NoContent().finish();
|
return HttpResponse::NoContent().finish();
|
||||||
}
|
}
|
||||||
|
|
@ -463,9 +503,9 @@ async fn api_export_csv(path: web::Path<usize>, state: web::Data<SharedState>) -
|
||||||
.body(csv)
|
.body(csv)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_web_server(base_seed: u64, speed: Speed, listen_open: bool) -> io::Result<()> {
|
pub fn run_web_server(base_seed: u64, speed: Speed, listen_open: bool, demo: bool) -> io::Result<()> {
|
||||||
let shared = SharedState {
|
let shared = SharedState {
|
||||||
inner: Arc::new(Mutex::new(WebState::new(base_seed, speed))),
|
inner: Arc::new(Mutex::new(WebState::new(base_seed, speed, demo))),
|
||||||
};
|
};
|
||||||
|
|
||||||
let ticker = shared.clone();
|
let ticker = shared.clone();
|
||||||
|
|
@ -506,6 +546,7 @@ pub fn run_web_server(base_seed: u64, speed: Speed, listen_open: bool) -> io::Re
|
||||||
.route("/sc-logo.jpg", web::get().to(sc_logo_jpg))
|
.route("/sc-logo.jpg", web::get().to(sc_logo_jpg))
|
||||||
.service(
|
.service(
|
||||||
web::scope("/api")
|
web::scope("/api")
|
||||||
|
.route("/config", web::get().to(api_config))
|
||||||
.route("/teams", web::get().to(api_teams))
|
.route("/teams", web::get().to(api_teams))
|
||||||
.route("/simulations", web::get().to(api_list_simulations))
|
.route("/simulations", web::get().to(api_list_simulations))
|
||||||
.route("/simulations", web::post().to(api_create_simulation))
|
.route("/simulations", web::post().to(api_create_simulation))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue