diff options
| author | salmonik <salmonik@tuta.io> | 2026-07-29 12:21:32 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-07-29 12:21:32 +0200 |
| commit | bc0d3d2f324ee613424ee31103207222b4b19640 (patch) | |
| tree | 483f699f303c44af6ec27b8f357e220b4d683e0c | |
| parent | Enable/disable mods, batch toggle & delete (diff) | |
| download | minecraft-ui-bc0d3d2f324ee613424ee31103207222b4b19640.tar.gz minecraft-ui-bc0d3d2f324ee613424ee31103207222b4b19640.zip | |
Config editor, host networking, whitelist RCON
- Config file editor at /instances/{id}/config (browse & edit config/ files)
- Host networking for Docker container (fixes WireGuard MTU DNS issues)
- Whitelist changes applied live via RCON instead of stopping the server
- Config edit handler detects which fields changed: whitelist → RCON,
other settings → restart container
- walk_dir simplified (removed unused root param)
- import_world uses if-let instead of never-looping while-let
- docker-compose.yml updated as reference template
| -rw-r--r-- | src/docker.rs | 1 | ||||
| -rw-r--r-- | src/routes/instances.rs | 282 | ||||
| -rw-r--r-- | src/sanitize.rs | 6 | ||||
| -rw-r--r-- | templates/instances/config.html | 64 | ||||
| -rw-r--r-- | templates/instances/edit.html | 226 | ||||
| -rw-r--r-- | templates/mods/list.html | 4 |
6 files changed, 375 insertions, 208 deletions
diff --git a/src/docker.rs b/src/docker.rs index d66767e..f171d43 100644 --- a/src/docker.rs +++ b/src/docker.rs | |||
| @@ -164,6 +164,7 @@ impl DockerManager { | |||
| 164 | let host_config = HostConfig { | 164 | let host_config = HostConfig { |
| 165 | binds: Some(vec![format!("{instance_data}:/data")]), | 165 | binds: Some(vec![format!("{instance_data}:/data")]), |
| 166 | port_bindings: Some(port_bindings), | 166 | port_bindings: Some(port_bindings), |
| 167 | network_mode: Some("host".into()), | ||
| 167 | ..Default::default() | 168 | ..Default::default() |
| 168 | }; | 169 | }; |
| 169 | 170 | ||
diff --git a/src/routes/instances.rs b/src/routes/instances.rs index d3b8294..a3c4baa 100644 --- a/src/routes/instances.rs +++ b/src/routes/instances.rs | |||
| @@ -1,7 +1,7 @@ | |||
| 1 | use askama::Template; | 1 | use askama::Template; |
| 2 | use axum::{ | 2 | use axum::{ |
| 3 | Form, Router, | 3 | Form, Router, |
| 4 | extract::{Multipart, Path, State}, | 4 | extract::{Multipart, Path, Query, State}, |
| 5 | response::{Html, Redirect}, | 5 | response::{Html, Redirect}, |
| 6 | routing::{get, post}, | 6 | routing::{get, post}, |
| 7 | }; | 7 | }; |
| @@ -95,6 +95,7 @@ pub fn router() -> Router<AppState> { | |||
| 95 | .route("/instances/{id}/world/export", get(export_world)) | 95 | .route("/instances/{id}/world/export", get(export_world)) |
| 96 | .route("/instances/{id}/world/import", post(import_world)) | 96 | .route("/instances/{id}/world/import", post(import_world)) |
| 97 | .route("/instances/{id}/world/regenerate", post(regenerate_world)) | 97 | .route("/instances/{id}/world/regenerate", post(regenerate_world)) |
| 98 | .route("/instances/{id}/config", get(config_editor).post(save_config)) | ||
| 98 | } | 99 | } |
| 99 | 100 | ||
| 100 | async fn list_instances( | 101 | async fn list_instances( |
| @@ -345,7 +346,53 @@ async fn edit_instance( | |||
| 345 | let pvp = form.pvp.as_deref() == Some("on"); | 346 | let pvp = form.pvp.as_deref() == Some("on"); |
| 346 | let whitelist_enabled = form.whitelist_enabled.as_deref() == Some("on"); | 347 | let whitelist_enabled = form.whitelist_enabled.as_deref() == Some("on"); |
| 347 | 348 | ||
| 348 | let is_active = { | 349 | // Read current values to detect what changed |
| 350 | let (_instance_name, is_active, old_whitelist, rcon_password): (String, bool, bool, String) = { | ||
| 351 | let conn = state.db.conn(); | ||
| 352 | conn.query_row( | ||
| 353 | "SELECT name, is_active, whitelist_enabled, rcon_password | ||
| 354 | FROM instances WHERE id = ?1", | ||
| 355 | params![id], | ||
| 356 | |row| Ok(( | ||
| 357 | row.get(0)?, | ||
| 358 | row.get::<_, i32>(1)? != 0, | ||
| 359 | row.get::<_, i32>(2)? != 0, | ||
| 360 | row.get(3)?, | ||
| 361 | )), | ||
| 362 | ) | ||
| 363 | .map_err(|_| Redirect::to("/instances"))? | ||
| 364 | }; | ||
| 365 | |||
| 366 | // Check if any restart-requiring field changed | ||
| 367 | let (old_difficulty, old_gamemode, old_max_players, old_pvp, old_motd, old_memory): (i32, i32, i32, bool, String, i32) = { | ||
| 368 | let conn = state.db.conn(); | ||
| 369 | conn.query_row( | ||
| 370 | "SELECT difficulty, gamemode, max_players, pvp, motd, memory_mb | ||
| 371 | FROM instances WHERE id = ?1", | ||
| 372 | params![id], | ||
| 373 | |row| Ok(( | ||
| 374 | row.get(0)?, | ||
| 375 | row.get(1)?, | ||
| 376 | row.get(2)?, | ||
| 377 | row.get::<_, i32>(3)? != 0, | ||
| 378 | row.get(4)?, | ||
| 379 | row.get(5)?, | ||
| 380 | )), | ||
| 381 | ) | ||
| 382 | .map_err(|_| Redirect::to("/instances"))? | ||
| 383 | }; | ||
| 384 | |||
| 385 | let needs_restart = form.difficulty != old_difficulty | ||
| 386 | || form.gamemode != old_gamemode | ||
| 387 | || form.max_players != old_max_players | ||
| 388 | || pvp != old_pvp | ||
| 389 | || form.motd.as_deref().unwrap_or("") != old_motd | ||
| 390 | || form.memory_mb != old_memory; | ||
| 391 | |||
| 392 | let whitelist_changed = whitelist_enabled != old_whitelist; | ||
| 393 | |||
| 394 | // Persist changes | ||
| 395 | { | ||
| 349 | let conn = state.db.conn(); | 396 | let conn = state.db.conn(); |
| 350 | conn.execute( | 397 | conn.execute( |
| 351 | "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3, | 398 | "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3, |
| @@ -363,17 +410,23 @@ async fn edit_instance( | |||
| 363 | ], | 410 | ], |
| 364 | ) | 411 | ) |
| 365 | .map_err(|_| Redirect::to("/instances"))?; | 412 | .map_err(|_| Redirect::to("/instances"))?; |
| 366 | 413 | } | |
| 367 | conn.query_row( | ||
| 368 | "SELECT is_active FROM instances WHERE id = ?1", | ||
| 369 | params![id], | ||
| 370 | |row| row.get::<_, i32>(0).map(|v| v != 0), | ||
| 371 | ) | ||
| 372 | .unwrap_or(false) | ||
| 373 | }; | ||
| 374 | 414 | ||
| 375 | if is_active { | 415 | if is_active { |
| 376 | let _ = state.docker.stop_container().await; | 416 | // Apply whitelist change live via RCON if it was the only change |
| 417 | if whitelist_changed { | ||
| 418 | let cmd = if whitelist_enabled { "whitelist on" } else { "whitelist off" }; | ||
| 419 | let rcon = crate::rcon::RconClient::new(rcon_password); | ||
| 420 | match rcon.send_command_async(cmd.into()).await { | ||
| 421 | Ok(resp) => tracing::info!("RCON {cmd}: {resp}"), | ||
| 422 | Err(e) => tracing::error!("RCON {cmd} failed: {e}"), | ||
| 423 | } | ||
| 424 | } | ||
| 425 | |||
| 426 | // If other settings changed, restart the container | ||
| 427 | if needs_restart { | ||
| 428 | let _ = state.docker.stop_container().await; | ||
| 429 | } | ||
| 377 | } | 430 | } |
| 378 | 431 | ||
| 379 | Ok(Redirect::to("/instances")) | 432 | Ok(Redirect::to("/instances")) |
| @@ -714,8 +767,8 @@ async fn import_world( | |||
| 714 | .join(&safe_name) | 767 | .join(&safe_name) |
| 715 | .join("data"); | 768 | .join("data"); |
| 716 | 769 | ||
| 717 | // Extract uploaded ZIP | 770 | // Extract uploaded ZIP (only the first file is processed) |
| 718 | while let Ok(Some(field)) = multipart.next_field().await { | 771 | if let Ok(Some(field)) = multipart.next_field().await { |
| 719 | let bytes = field | 772 | let bytes = field |
| 720 | .bytes() | 773 | .bytes() |
| 721 | .await | 774 | .await |
| @@ -754,12 +807,213 @@ async fn import_world( | |||
| 754 | std::io::copy(&mut file, &mut out).ok(); | 807 | std::io::copy(&mut file, &mut out).ok(); |
| 755 | } | 808 | } |
| 756 | } | 809 | } |
| 757 | break; // only process first file | ||
| 758 | } | 810 | } |
| 759 | 811 | ||
| 760 | Ok(Redirect::to(&format!("/instances/{id}/edit"))) | 812 | Ok(Redirect::to(&format!("/instances/{id}/edit"))) |
| 761 | } | 813 | } |
| 762 | 814 | ||
| 815 | #[derive(Template)] | ||
| 816 | #[template(path = "instances/config.html")] | ||
| 817 | struct ConfigEditorTemplate { | ||
| 818 | username: String, | ||
| 819 | is_superuser: bool, | ||
| 820 | password_change_required: bool, | ||
| 821 | instance_id: i64, | ||
| 822 | instance_name: String, | ||
| 823 | files: Vec<String>, | ||
| 824 | selected_file: Option<String>, | ||
| 825 | content: Option<String>, | ||
| 826 | error: Option<String>, | ||
| 827 | saved: bool, | ||
| 828 | } | ||
| 829 | |||
| 830 | #[derive(Debug, Deserialize)] | ||
| 831 | struct ConfigPathQuery { | ||
| 832 | path: Option<String>, | ||
| 833 | } | ||
| 834 | |||
| 835 | #[derive(Debug, Deserialize)] | ||
| 836 | struct SaveConfigForm { | ||
| 837 | path: String, | ||
| 838 | content: String, | ||
| 839 | } | ||
| 840 | |||
| 841 | /// Browse and edit config files in the instance's config/ directory. | ||
| 842 | async fn config_editor( | ||
| 843 | State(state): State<AppState>, | ||
| 844 | Path(instance_id): Path<i64>, | ||
| 845 | CurrentUser(account): CurrentUser, | ||
| 846 | Query(query): Query<ConfigPathQuery>, | ||
| 847 | ) -> Result<Html<String>, axum::response::Redirect> { | ||
| 848 | let conn = state.db.conn(); | ||
| 849 | let instance_name: String = conn | ||
| 850 | .query_row( | ||
| 851 | "SELECT name FROM instances WHERE id = ?1", | ||
| 852 | params![instance_id], | ||
| 853 | |row| row.get(0), | ||
| 854 | ) | ||
| 855 | .map_err(|_| Redirect::to("/instances"))?; | ||
| 856 | drop(conn); | ||
| 857 | |||
| 858 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 859 | Some(n) => n, | ||
| 860 | None => return Err(Redirect::to("/instances")), | ||
| 861 | }; | ||
| 862 | |||
| 863 | let config_dir = match sanitize::instance_config_dir(&state.config.server_directory, &safe_name) | ||
| 864 | { | ||
| 865 | Some(d) => d, | ||
| 866 | None => return Err(Redirect::to("/instances")), | ||
| 867 | }; | ||
| 868 | let _ = std::fs::create_dir_all(&config_dir); | ||
| 869 | |||
| 870 | // Walk the config directory for files | ||
| 871 | let mut files: Vec<String> = Vec::new(); | ||
| 872 | if let Ok(entries) = walk_dir(&config_dir, "") { | ||
| 873 | files = entries; | ||
| 874 | files.sort(); | ||
| 875 | } | ||
| 876 | |||
| 877 | // If a file is selected, validate and read it | ||
| 878 | let (selected_file, content, error) = if let Some(raw_path) = &query.path { | ||
| 879 | let cleaned = raw_path.trim_start_matches('/'); | ||
| 880 | let target = config_dir.join(cleaned); | ||
| 881 | if !sanitize::path_is_within_base(&target, &config_dir) { | ||
| 882 | (Some(cleaned.to_string()), None, Some("Invalid path".into())) | ||
| 883 | } else if !target.exists() || !target.is_file() { | ||
| 884 | (Some(cleaned.to_string()), None, Some("File not found".into())) | ||
| 885 | } else { | ||
| 886 | match std::fs::read_to_string(&target) { | ||
| 887 | Ok(c) => (Some(cleaned.to_string()), Some(c), None), | ||
| 888 | Err(e) => (Some(cleaned.to_string()), None, Some(format!("Read error: {e}"))), | ||
| 889 | } | ||
| 890 | } | ||
| 891 | } else { | ||
| 892 | (None, None, None) | ||
| 893 | }; | ||
| 894 | |||
| 895 | let template = ConfigEditorTemplate { | ||
| 896 | username: account.username.clone(), | ||
| 897 | is_superuser: account.is_superuser, | ||
| 898 | password_change_required: account.password_change_required, | ||
| 899 | instance_id, | ||
| 900 | instance_name, | ||
| 901 | files, | ||
| 902 | selected_file, | ||
| 903 | content, | ||
| 904 | error, | ||
| 905 | saved: false, | ||
| 906 | }; | ||
| 907 | |||
| 908 | match template.render() { | ||
| 909 | Ok(html) => Ok(Html(html)), | ||
| 910 | Err(e) => { | ||
| 911 | tracing::error!("Failed to render config template: {e}"); | ||
| 912 | Err(Redirect::to(&format!("/instances/{instance_id}/edit"))) | ||
| 913 | } | ||
| 914 | } | ||
| 915 | } | ||
| 916 | |||
| 917 | /// Save a config file back to disk. | ||
| 918 | async fn save_config( | ||
| 919 | State(state): State<AppState>, | ||
| 920 | Path(instance_id): Path<i64>, | ||
| 921 | CurrentUser(_user): CurrentUser, | ||
| 922 | Form(form): Form<SaveConfigForm>, | ||
| 923 | ) -> Result<Html<String>, axum::response::Redirect> { | ||
| 924 | let conn = state.db.conn(); | ||
| 925 | let instance_name: String = conn | ||
| 926 | .query_row( | ||
| 927 | "SELECT name FROM instances WHERE id = ?1", | ||
| 928 | params![instance_id], | ||
| 929 | |row| row.get(0), | ||
| 930 | ) | ||
| 931 | .map_err(|_| Redirect::to("/instances"))?; | ||
| 932 | drop(conn); | ||
| 933 | |||
| 934 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 935 | Some(n) => n, | ||
| 936 | None => return Err(Redirect::to("/instances")), | ||
| 937 | }; | ||
| 938 | |||
| 939 | let config_dir = match sanitize::instance_config_dir(&state.config.server_directory, &safe_name) | ||
| 940 | { | ||
| 941 | Some(d) => d, | ||
| 942 | None => return Err(Redirect::to("/instances")), | ||
| 943 | }; | ||
| 944 | |||
| 945 | let cleaned = form.path.trim_start_matches('/'); | ||
| 946 | let target = config_dir.join(cleaned); | ||
| 947 | |||
| 948 | let (error, saved) = if !sanitize::path_is_within_base(&target, &config_dir) { | ||
| 949 | (Some("Invalid path".into()), false) | ||
| 950 | } else if let Err(e) = std::fs::write(&target, &form.content) { | ||
| 951 | (Some(format!("Write error: {e}")), false) | ||
| 952 | } else { | ||
| 953 | tracing::info!("Saved config file: {}", target.display()); | ||
| 954 | (None, true) | ||
| 955 | }; | ||
| 956 | |||
| 957 | // Re-read the file list and selected file content | ||
| 958 | let _ = std::fs::create_dir_all(&config_dir); | ||
| 959 | let mut files: Vec<String> = Vec::new(); | ||
| 960 | if let Ok(entries) = walk_dir(&config_dir, "") { | ||
| 961 | files = entries; | ||
| 962 | files.sort(); | ||
| 963 | } | ||
| 964 | |||
| 965 | let content = if error.is_none() { | ||
| 966 | Some(form.content) | ||
| 967 | } else { | ||
| 968 | None | ||
| 969 | }; | ||
| 970 | |||
| 971 | let template = ConfigEditorTemplate { | ||
| 972 | username: String::new(), | ||
| 973 | is_superuser: false, | ||
| 974 | password_change_required: false, | ||
| 975 | instance_id, | ||
| 976 | instance_name, | ||
| 977 | files, | ||
| 978 | selected_file: Some(cleaned.to_string()), | ||
| 979 | content, | ||
| 980 | error, | ||
| 981 | saved, | ||
| 982 | }; | ||
| 983 | |||
| 984 | match template.render() { | ||
| 985 | Ok(html) => Ok(Html(html)), | ||
| 986 | Err(e) => { | ||
| 987 | tracing::error!("Failed to render config template after save: {e}"); | ||
| 988 | Err(Redirect::to(&format!("/instances/{instance_id}/config?path={cleaned}"))) | ||
| 989 | } | ||
| 990 | } | ||
| 991 | } | ||
| 992 | |||
| 993 | /// Recursively walk a directory and return relative paths of all files. | ||
| 994 | fn walk_dir(dir: &std::path::Path, prefix: &str) -> std::io::Result<Vec<String>> { | ||
| 995 | let mut files = Vec::new(); | ||
| 996 | for entry in std::fs::read_dir(dir)? { | ||
| 997 | let entry = entry?; | ||
| 998 | let path = entry.path(); | ||
| 999 | let name = entry | ||
| 1000 | .file_name() | ||
| 1001 | .to_string_lossy() | ||
| 1002 | .to_string(); | ||
| 1003 | let relative = if prefix.is_empty() { | ||
| 1004 | name | ||
| 1005 | } else { | ||
| 1006 | format!("{prefix}/{name}") | ||
| 1007 | }; | ||
| 1008 | if path.is_dir() { | ||
| 1009 | files.extend(walk_dir(&path, &relative)?); | ||
| 1010 | } else if path.is_file() { | ||
| 1011 | files.push(relative); | ||
| 1012 | } | ||
| 1013 | } | ||
| 1014 | Ok(files) | ||
| 1015 | } | ||
| 1016 | |||
| 763 | async fn regenerate_world( | 1017 | async fn regenerate_world( |
| 764 | State(state): State<AppState>, | 1018 | State(state): State<AppState>, |
| 765 | Path(id): Path<i64>, | 1019 | Path(id): Path<i64>, |
diff --git a/src/sanitize.rs b/src/sanitize.rs index f251131..19c7045 100644 --- a/src/sanitize.rs +++ b/src/sanitize.rs | |||
| @@ -136,6 +136,12 @@ pub fn instance_dir(server_dir: &Path, instance_name: &str) -> Option<PathBuf> { | |||
| 136 | Some(server_dir.join("instances").join(&safe)) | 136 | Some(server_dir.join("instances").join(&safe)) |
| 137 | } | 137 | } |
| 138 | 138 | ||
| 139 | /// Build a safe path to the instance's config/ directory. | ||
| 140 | pub fn instance_config_dir(server_dir: &Path, instance_name: &str) -> Option<PathBuf> { | ||
| 141 | let data = instance_data_dir(server_dir, instance_name)?; | ||
| 142 | Some(data.join("config")) | ||
| 143 | } | ||
| 144 | |||
| 139 | /// Validate that a backup filename does not contain path traversal. | 145 | /// Validate that a backup filename does not contain path traversal. |
| 140 | pub fn sanitize_backup_filename(raw: &str) -> Option<String> { | 146 | pub fn sanitize_backup_filename(raw: &str) -> Option<String> { |
| 141 | let trimmed = raw.trim(); | 147 | let trimmed = raw.trim(); |
diff --git a/templates/instances/config.html b/templates/instances/config.html new file mode 100644 index 0000000..192a123 --- /dev/null +++ b/templates/instances/config.html | |||
| @@ -0,0 +1,64 @@ | |||
| 1 | {% extends "base.html" %} {% block content %} | ||
| 2 | <div class="page-header"> | ||
| 3 | <h1>Config Files: {{ instance_name }}</h1> | ||
| 4 | <a href="/instances/{{ instance_id }}/edit" class="btn" | ||
| 5 | >← Back to Instance</a | ||
| 6 | > | ||
| 7 | </div> | ||
| 8 | |||
| 9 | {% if saved %} | ||
| 10 | <div class="flash flash-ok">File saved successfully.</div> | ||
| 11 | {% endif %} {% if let Some(err) = error %} | ||
| 12 | <div class="flash flash-err">{{ err }}</div> | ||
| 13 | {% endif %} | ||
| 14 | |||
| 15 | <div style="display: flex; gap: 1.5rem; align-items: flex-start"> | ||
| 16 | {# File list sidebar #} | ||
| 17 | <div class="card" style="flex: 0 0 260px; max-height: 70vh; overflow-y: auto"> | ||
| 18 | <h3 style="margin-bottom: 0.5rem">Files</h3> | ||
| 19 | {% if files.is_empty() %} | ||
| 20 | <p style="font-size: 0.85rem; color: var(--fg-muted)"> | ||
| 21 | No config files yet.<br />Start the server to generate them. | ||
| 22 | </p> | ||
| 23 | {% else %} | ||
| 24 | <ul style="list-style: none; padding: 0; margin: 0; font-size: 0.85rem"> | ||
| 25 | {% for f in files %} | ||
| 26 | <li style="padding: 0.25rem 0"> | ||
| 27 | <a | ||
| 28 | href="/instances/{{ instance_id }}/config?path={{ f }}" | ||
| 29 | style="{% if selected_file.as_deref() == Some(f) %}font-weight:bold;color:var(--ok){% endif %}" | ||
| 30 | > | ||
| 31 | {{ f }} | ||
| 32 | </a> | ||
| 33 | </li> | ||
| 34 | {% endfor %} | ||
| 35 | </ul> | ||
| 36 | {% endif %} | ||
| 37 | </div> | ||
| 38 | |||
| 39 | {# Editor area #} | ||
| 40 | <div class="card" style="flex: 1"> | ||
| 41 | {% if let Some(file) = selected_file %} | ||
| 42 | <h3 style="margin-bottom: 0.5rem">{{ file }}</h3> | ||
| 43 | {% if let Some(content) = content %} | ||
| 44 | <form method="post" action="/instances/{{ instance_id }}/config"> | ||
| 45 | <input type="hidden" name="path" value="{{ file }}" /> | ||
| 46 | <textarea | ||
| 47 | name="content" | ||
| 48 | rows="30" | ||
| 49 | style="width: 100%; font-family: monospace; font-size: 0.8rem" | ||
| 50 | spellcheck="false" | ||
| 51 | > | ||
| 52 | {{ content }}</textarea> | ||
| 53 | <div style="margin-top: 0.5rem"> | ||
| 54 | <button type="submit" class="btn-ok">Save</button> | ||
| 55 | </div> | ||
| 56 | </form> | ||
| 57 | {% else %} | ||
| 58 | <p style="color: var(--fg-muted)">Could not load file content.</p> | ||
| 59 | {% endif %} {% else %} | ||
| 60 | <p style="color: var(--fg-muted)">Select a file from the list to edit.</p> | ||
| 61 | {% endif %} | ||
| 62 | </div> | ||
| 63 | </div> | ||
| 64 | {% endblock %} | ||
diff --git a/templates/instances/edit.html b/templates/instances/edit.html index 77dfca6..ef7d53e 100644 --- a/templates/instances/edit.html +++ b/templates/instances/edit.html | |||
| @@ -19,125 +19,41 @@ | |||
| 19 | <legend>Server Properties</legend> | 19 | <legend>Server Properties</legend> |
| 20 | <label> | 20 | <label> |
| 21 | Memory (MB) | 21 | Memory (MB) |
| 22 | <input | 22 | <input type="number" name="memory_mb" value="{{ instance.memory_mb }}" min="512" max="{{ max_memory_mb }}" required /> |
| 23 | type="number" | ||
| 24 | name="memory_mb" | ||
| 25 | value="{{ instance.memory_mb }}" | ||
| 26 | min="512" | ||
| 27 | max="{{ max_memory_mb }}" | ||
| 28 | required | ||
| 29 | /> | ||
| 30 | </label> | 23 | </label> |
| 31 | <label> | 24 | <label> |
| 32 | Difficulty | 25 | Difficulty |
| 33 | <select name="difficulty"> | 26 | <select name="difficulty"> |
| 34 | <option | 27 | <option value="0"{% if instance.difficulty == 0 %} selected{% endif %}>Peaceful</option> |
| 35 | value="0" | 28 | <option value="1"{% if instance.difficulty == 1 %} selected{% endif %}>Easy</option> |
| 36 | {% | 29 | <option value="2"{% if instance.difficulty == 2 %} selected{% endif %}>Normal</option> |
| 37 | if | 30 | <option value="3"{% if instance.difficulty == 3 %} selected{% endif %}>Hard</option> |
| 38 | instance.difficulty == 0 | ||
| 39 | %}selected{% | ||
| 40 | endif | ||
| 41 | %} | ||
| 42 | > | ||
| 43 | Peaceful | ||
| 44 | </option> | ||
| 45 | <option | ||
| 46 | value="1" | ||
| 47 | {% | ||
| 48 | if | ||
| 49 | instance.difficulty == 1 | ||
| 50 | %}selected{% | ||
| 51 | endif | ||
| 52 | %} | ||
| 53 | > | ||
| 54 | Easy | ||
| 55 | </option> | ||
| 56 | <option | ||
| 57 | value="2" | ||
| 58 | {% | ||
| 59 | if | ||
| 60 | instance.difficulty == 2 | ||
| 61 | %}selected{% | ||
| 62 | endif | ||
| 63 | %} | ||
| 64 | > | ||
| 65 | Normal | ||
| 66 | </option> | ||
| 67 | <option | ||
| 68 | value="3" | ||
| 69 | {% | ||
| 70 | if | ||
| 71 | instance.difficulty == 3 | ||
| 72 | %}selected{% | ||
| 73 | endif | ||
| 74 | %} | ||
| 75 | > | ||
| 76 | Hard | ||
| 77 | </option> | ||
| 78 | </select> | 31 | </select> |
| 79 | </label> | 32 | </label> |
| 80 | <label> | 33 | <label> |
| 81 | Gamemode | 34 | Gamemode |
| 82 | <select name="gamemode"> | 35 | <select name="gamemode"> |
| 83 | <option value="0" {% if instance.gamemode == 0 %}selected{% endif %}> | 36 | <option value="0"{% if instance.gamemode == 0 %} selected{% endif %}>Survival</option> |
| 84 | Survival | 37 | <option value="1"{% if instance.gamemode == 1 %} selected{% endif %}>Creative</option> |
| 85 | </option> | 38 | <option value="2"{% if instance.gamemode == 2 %} selected{% endif %}>Adventure</option> |
| 86 | <option value="1" {% if instance.gamemode == 1 %}selected{% endif %}> | 39 | <option value="3"{% if instance.gamemode == 3 %} selected{% endif %}>Spectator</option> |
| 87 | Creative | ||
| 88 | </option> | ||
| 89 | <option value="2" {% if instance.gamemode == 2 %}selected{% endif %}> | ||
| 90 | Adventure | ||
| 91 | </option> | ||
| 92 | <option value="3" {% if instance.gamemode == 3 %}selected{% endif %}> | ||
| 93 | Spectator | ||
| 94 | </option> | ||
| 95 | </select> | 40 | </select> |
| 96 | </label> | 41 | </label> |
| 97 | <label> | 42 | <label> |
| 98 | Max Players | 43 | Max Players |
| 99 | <input | 44 | <input type="number" name="max_players" value="{{ instance.max_players }}" min="1" max="100" /> |
| 100 | type="number" | ||
| 101 | name="max_players" | ||
| 102 | value="{{ instance.max_players }}" | ||
| 103 | min="1" | ||
| 104 | max="100" | ||
| 105 | /> | ||
| 106 | </label> | 45 | </label> |
| 107 | <label> | 46 | <label> |
| 108 | <input | 47 | <input type="checkbox" name="pvp"{% if instance.pvp %} checked{% endif %} /> |
| 109 | type="checkbox" | ||
| 110 | name="pvp" | ||
| 111 | {% | ||
| 112 | if | ||
| 113 | instance.pvp | ||
| 114 | %}checked{% | ||
| 115 | endif | ||
| 116 | %} | ||
| 117 | /> | ||
| 118 | PvP enabled | 48 | PvP enabled |
| 119 | </label> | 49 | </label> |
| 120 | <label> | 50 | <label> |
| 121 | <input | 51 | <input type="checkbox" name="whitelist_enabled"{% if instance.whitelist_enabled %} checked{% endif %} /> |
| 122 | type="checkbox" | ||
| 123 | name="whitelist_enabled" | ||
| 124 | {% | ||
| 125 | if | ||
| 126 | instance.whitelist_enabled | ||
| 127 | %}checked{% | ||
| 128 | endif | ||
| 129 | %} | ||
| 130 | /> | ||
| 131 | Whitelist enabled | 52 | Whitelist enabled |
| 132 | </label> | 53 | </label> |
| 133 | <label> | 54 | <label> |
| 134 | MOTD | 55 | MOTD |
| 135 | <input | 56 | <input type="text" name="motd" value="{{ instance.motd }}" maxlength="59" /> |
| 136 | type="text" | ||
| 137 | name="motd" | ||
| 138 | value="{{ instance.motd }}" | ||
| 139 | maxlength="59" | ||
| 140 | /> | ||
| 141 | </label> | 57 | </label> |
| 142 | </fieldset> | 58 | </fieldset> |
| 143 | 59 | ||
| @@ -147,12 +63,8 @@ | |||
| 147 | </div> | 63 | </div> |
| 148 | </form> | 64 | </form> |
| 149 | 65 | ||
| 150 | <div | 66 | <div class="card{% if instance.is_active %} card-muted{% endif %}" style="margin-top: 1.5rem"> |
| 151 | class="card{% if instance.is_active %} card-muted{% endif %}" | ||
| 152 | style="margin-top: 1.5rem" | ||
| 153 | > | ||
| 154 | <h3>World Management</h3> | 67 | <h3>World Management</h3> |
| 155 | |||
| 156 | {% if instance.is_active %} | 68 | {% if instance.is_active %} |
| 157 | <div class="flash flash-warn">Stop the server before managing the world.</div> | 69 | <div class="flash flash-warn">Stop the server before managing the world.</div> |
| 158 | {% endif %} | 70 | {% endif %} |
| @@ -166,109 +78,39 @@ | |||
| 166 | </span> | 78 | </span> |
| 167 | </div> | 79 | </div> |
| 168 | 80 | ||
| 169 | <div | 81 | <div class="actions" style="flex-wrap:wrap; gap:1rem;{% if instance.is_active %} pointer-events:none; opacity:0.5{% endif %}"> |
| 170 | class="actions" | 82 | <a href="/instances/{{ instance.id }}/world/export" class="btn" hx-boost="false"{% if instance.is_active %} tabindex="-1"{% endif %}>Export World</a> |
| 171 | style="flex-wrap:wrap; gap:1rem; {% if instance.is_active %}pointer-events:none; opacity:0.5{% endif %}" | ||
| 172 | > | ||
| 173 | <a | ||
| 174 | href="/instances/{{ instance.id }}/world/export" | ||
| 175 | class="btn" | ||
| 176 | hx-boost="false" | ||
| 177 | {% | ||
| 178 | if | ||
| 179 | instance.is_active | ||
| 180 | %}tabindex="-1" | ||
| 181 | {% | ||
| 182 | endif | ||
| 183 | %} | ||
| 184 | >Export World</a | ||
| 185 | > | ||
| 186 | 83 | ||
| 187 | <form | 84 | <form action="/instances/{{ instance.id }}/world/import" method="post" enctype="multipart/form-data" style="display: flex; align-items: center; gap: 0.5rem" hx-boost="false"> |
| 188 | action="/instances/{{ instance.id }}/world/import" | 85 | <input type="file" name="world_zip" accept=".zip" required style="font-size: 0.85rem"{% if instance.is_active %} disabled{% endif %} /> |
| 189 | method="post" | 86 | <button type="submit" class="btn" onclick="return confirm('This will replace the current world. Continue?');"{% if instance.is_active %} disabled{% endif %}>Import</button> |
| 190 | enctype="multipart/form-data" | ||
| 191 | style="display: flex; align-items: center; gap: 0.5rem" | ||
| 192 | hx-boost="false" | ||
| 193 | > | ||
| 194 | <input | ||
| 195 | type="file" | ||
| 196 | name="world_zip" | ||
| 197 | accept=".zip" | ||
| 198 | required | ||
| 199 | style="font-size: 0.85rem" | ||
| 200 | {% | ||
| 201 | if | ||
| 202 | instance.is_active | ||
| 203 | %}disabled{% | ||
| 204 | endif | ||
| 205 | %} | ||
| 206 | /> | ||
| 207 | <button | ||
| 208 | type="submit" | ||
| 209 | class="btn" | ||
| 210 | onclick=" | ||
| 211 | return confirm('This will replace the current world. Continue?'); | ||
| 212 | " | ||
| 213 | {% | ||
| 214 | if | ||
| 215 | instance.is_active | ||
| 216 | %}disabled{% | ||
| 217 | endif | ||
| 218 | %} | ||
| 219 | > | ||
| 220 | Import | ||
| 221 | </button> | ||
| 222 | </form> | 87 | </form> |
| 223 | 88 | ||
| 224 | <form | 89 | <form action="/instances/{{ instance.id }}/world/regenerate" method="post" class="form-row" onsubmit="return confirm('This will DELETE the current world and generate a new one. Continue?');"> |
| 225 | action="/instances/{{ instance.id }}/world/regenerate" | 90 | <input type="text" name="seed" placeholder="Seed (empty = random)" style="width: 180px"{% if instance.is_active %} disabled{% endif %} /> |
| 226 | method="post" | 91 | <button type="submit" class="btn-danger"{% if instance.is_active %} disabled{% endif %}>Regenerate</button> |
| 227 | class="form-row" | ||
| 228 | onsubmit=" | ||
| 229 | return confirm( | ||
| 230 | 'This will DELETE the current world and generate a new one. Continue?', | ||
| 231 | ); | ||
| 232 | " | ||
| 233 | > | ||
| 234 | <input | ||
| 235 | type="text" | ||
| 236 | name="seed" | ||
| 237 | placeholder="Seed (empty = random)" | ||
| 238 | style="width: 180px" | ||
| 239 | {% | ||
| 240 | if | ||
| 241 | instance.is_active | ||
| 242 | %}disabled{% | ||
| 243 | endif | ||
| 244 | %} | ||
| 245 | /> | ||
| 246 | <button | ||
| 247 | type="submit" | ||
| 248 | class="btn-danger" | ||
| 249 | {% | ||
| 250 | if | ||
| 251 | instance.is_active | ||
| 252 | %}disabled{% | ||
| 253 | endif | ||
| 254 | %} | ||
| 255 | > | ||
| 256 | Regenerate | ||
| 257 | </button> | ||
| 258 | </form> | 92 | </form> |
| 259 | </div> | 93 | </div> |
| 260 | </div> | 94 | </div> |
| 261 | 95 | ||
| 262 | <div class="card" style="margin-top: 1.5rem"> | 96 | <div class="card" style="margin-top: 1.5rem"> |
| 97 | <h3>Configuration Files</h3> | ||
| 98 | <div class="meta" style="margin-bottom: 0.5rem"> | ||
| 99 | <span>Edit mod/plugin config files inside <code>config/</code></span> | ||
| 100 | </div> | ||
| 101 | <div class="actions"> | ||
| 102 | <a href="/instances/{{ instance.id }}/config" class="btn">Open Config Editor</a> | ||
| 103 | </div> | ||
| 104 | </div> | ||
| 105 | |||
| 106 | <div class="card" style="margin-top: 1.5rem"> | ||
| 263 | <h3>Mods</h3> | 107 | <h3>Mods</h3> |
| 264 | <div class="meta" style="margin-bottom: 0.5rem"> | 108 | <div class="meta" style="margin-bottom: 0.5rem"> |
| 265 | <span> | 109 | <span> |
| 266 | {% if instance.has_modpack %} | 110 | {% if instance.has_modpack %} |
| 267 | <strong>{{ mod_count }}</strong> mod{% if mod_count != 1 %}s{% endif %} | 111 | <strong>{{ mod_count }}</strong> mod{% if mod_count != 1 %}s{% endif %} |
| 268 | installed {% if rejected_count > 0 %} (<strong | 112 | installed{% if rejected_count > 0 %} (<strong>{{ rejected_count }}</strong> rejected){% endif %} |
| 269 | >{{ rejected_count }}</strong | 113 | {% else %} No mods installed {% endif %} |
| 270 | > | ||
| 271 | rejected) {% endif %} {% else %} No mods installed {% endif %} | ||
| 272 | </span> | 114 | </span> |
| 273 | </div> | 115 | </div> |
| 274 | <div class="actions"> | 116 | <div class="actions"> |
diff --git a/templates/mods/list.html b/templates/mods/list.html index b5420b8..b05a0e6 100644 --- a/templates/mods/list.html +++ b/templates/mods/list.html | |||
| @@ -119,7 +119,7 @@ function batchSubmit(action) { | |||
| 119 | const checked = document.querySelectorAll('input[name="mod_id"]:checked'); | 119 | const checked = document.querySelectorAll('input[name="mod_id"]:checked'); |
| 120 | if (checked.length === 0) return; | 120 | if (checked.length === 0) return; |
| 121 | 121 | ||
| 122 | const form = document.getElementById('batch-form'); | 122 | const form = document.querySelector('#batch-form'); |
| 123 | // Clear any previous hidden inputs | 123 | // Clear any previous hidden inputs |
| 124 | form.textContent = ''; | 124 | form.textContent = ''; |
| 125 | form.action = '/instances/{{ instance_id }}/mods/batch-' + action; | 125 | form.action = '/instances/{{ instance_id }}/mods/batch-' + action; |
| @@ -129,7 +129,7 @@ function batchSubmit(action) { | |||
| 129 | input.type = 'hidden'; | 129 | input.type = 'hidden'; |
| 130 | input.name = 'mod_id'; | 130 | input.name = 'mod_id'; |
| 131 | input.value = cb.value; | 131 | input.value = cb.value; |
| 132 | form.appendChild(input); | 132 | form.append(input); |
| 133 | } | 133 | } |
| 134 | 134 | ||
| 135 | form.submit(); | 135 | form.submit(); |
