Merge branch 'step5' into web-dashboard

# Conflicts:
#	src/web/static/app.css
#	src/web/static/app.js
This commit is contained in:
2026-09-03 17:50:02 +00:00
9 changed files with 2857 additions and 3 deletions
File diff suppressed because it is too large Load Diff
+36
View File
@@ -105,6 +105,42 @@ impl fmt::Debug for WebState {
}
}
impl WebState {
/// Config reload on mtime (dashboard plan §4.2): when `config_path`'s
/// modification time differs from the cached one, re-run `Config::load`
/// and swap the live config. Returns `Ok(true)` when a reload happened,
/// `Ok(false)` when nothing changed or no file is configured, and the
/// load error when the file on disk no longer loads — the previous
/// config stays live and the cached mtime is left alone so the next call
/// tries again. Called by the settings page and by job starts.
pub fn reload_if_changed(
state: &crate::server::AppState,
) -> Result<bool, crate::config::ConfigError> {
let Some(path) = state.config_path.as_deref() else {
return Ok(false);
};
let mtime = std::fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok();
let mut cached = state
.web
.config_mtime
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if *cached == mtime {
return Ok(false);
}
let config = crate::config::Config::load(Some(path))?;
match state.config.write() {
Ok(mut live) => *live = std::sync::Arc::new(config),
Err(poisoned) => *poisoned.into_inner() = std::sync::Arc::new(config),
}
*cached = mtime;
tracing::info!(path = %path.display(), "reloaded configuration from disk");
Ok(true)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Flash {
pub kind: String,
+18
View File
@@ -107,3 +107,21 @@ details.explain { margin:1rem 0; }
.picks li { border-bottom:1px solid var(--rule); padding:.5rem 0; }
.budget meter { width:60%; max-width:14rem; height:.8rem; vertical-align:middle; margin-right:.5rem; }
.table-filter { margin:.5rem 0; padding:.3rem; width:20rem; max-width:100%; }
||||||| 849231e
/* step 5: settings */
.settings .card { border:1px solid var(--rule); padding:.75rem 1rem; margin:1rem 0; }
.settings .card h2 { margin:.2rem 0 .6rem; font-size:1.1rem; font-family:ui-monospace,monospace; }
.settings-form { display:block; }
.setting { display:grid; grid-template-columns:minmax(14rem,1fr) 2fr; gap:.5rem 1rem; padding:.5rem 0; border-top:1px solid var(--rule); }
.setting .help,.setting .default,.settings .meta { color:var(--muted); font-size:.85rem; margin:.2rem 0; }
.setting-input input,.setting-input select,.setting-input textarea { width:100%; max-width:36rem; }
.setting-input textarea { min-height:5rem; font-family:ui-monospace,monospace; }
.setting-input input:disabled,.setting-input textarea:disabled { opacity:.6; }
.setting .lines { white-space:pre-line; }
.settings .banner { padding:.6rem .8rem; border:1px solid var(--down); margin:1rem 0; }
.settings .reset,.settings .danger { font:inherit; font-size:.85rem; border:0; background:transparent; color:var(--accent); text-decoration:underline; padding:0; cursor:pointer; }
.settings .actions { position:sticky; bottom:0; background:var(--bg); padding:.7rem 0; border-top:1px solid var(--rule); }
.settings form.inline { display:none; }
.settings .add-provider { max-width:36rem; }
.history pre { margin:0; white-space:pre-wrap; font-size:.8rem; }
@media (max-width:40rem) { .setting { display:block; } }
+8
View File
@@ -56,4 +56,12 @@ document.querySelectorAll("table[data-filter]").forEach((table) => {
row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle);
});
});
||||||| 849231e
/* step 5: settings — "reset to default" fills the field with its default */
document.addEventListener("click", (event) => {
const button = event.target.closest("button[data-reset]");
if (!button) return;
const input = document.getElementById(button.dataset.reset);
if (!input) return;
input.value = button.dataset.default;
});
+41
View File
@@ -0,0 +1,41 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard settings">
<h1>Settings</h1>
<p class="meta">{% match config_path %}{% when Some with (path) %}Editing <code>{{ path }}</code>. Saved settings apply to the next run; a hand edit on disk shows up on the next page view.{% when None %}No config file is configured; start the server with <code>--config</code> to edit settings here. The values below are the built-in defaults plus the environment.{% endmatch %} · <a href="/dashboard/settings/history">History</a></p>
{% match load_error %}{% when Some with (error) %}<p class="error banner">! config.toml on disk does not load: {{ error }} — the previous configuration stays live.</p>{% when None %}{% endmatch %}
{% if !errors.is_empty() %}<ul class="error banner">{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}
<form method="post" action="/dashboard/settings" class="settings-form" id="settings-form">
{% for group in groups %}<section class="card" id="{{ group.id }}">
<h2>{{ group.title }}</h2>
{% match group.provider %}{% when Some with (provider) %}<p class="meta">Key: <code>{{ crate::config::ProviderConfig::api_key_env_var(provider.name) }}</code> in the env file. {% if provider.removable() %}Not named by any <code>[llm]</code> role. <button type="submit" form="remove-{{ provider.name }}" class="danger">Remove provider</button>{% else if provider.built_in %}Built in: declared by the defaults, so it can be edited and left unreferenced but not removed.{% else %}Named by <code>llm.{{ provider.roles() }}</code>; reassign the role before removing it.{% endif %}</p>{% when None %}{% endmatch %}
{% if group.is_weights() %}<p class="meta">Weights need not sum to 1; they are renormalized over the signals present for each article.</p>{% endif %}
{% for field in group.fields %}<div class="setting kind-{{ field.kind_name() }}">
<div class="setting-label"><label for="{{ field.input_id() }}"><code>{{ field.key }}</code></label>{% if field.restart_required %} <span class="badge">restart</span>{% endif %}{% match field.help %}{% when Some with (help) %}<p class="help">{{ help }}</p>{% when None %}{% endmatch %}</div>
<div class="setting-input">
{% if field.is_secret() %}<p class="secret">{% if field.is_set() %}set{% if field.is_env() %} (from <code>{{ field.env_var() }}</code>){% else if field.is_file() %} (in the config file — move it to the env file){% endif %}{% else %}not set — set <code>{{ field.env_var() }}</code> in the env file{% endif %}</p>
{% else if field.is_env() %}{% if field.is_text_list() %}<textarea id="{{ field.input_id() }}" disabled>{{ field.current }}</textarea>{% else %}<input id="{{ field.input_id() }}" type="text" value="{{ field.current }}" disabled>{% endif %}<p class="help">locked by <code>{{ field.env_var() }}</code> in the env file</p>
{% else if field.is_bool() %}<select id="{{ field.input_id() }}" name="{{ field.path }}"><option value="true"{% if field.current == "true" %} selected{% endif %}>true</option><option value="false"{% if field.current == "false" %} selected{% endif %}>false</option></select>
{% else if field.is_enum() %}<select id="{{ field.input_id() }}" name="{{ field.path }}">{% for option in field.options() %}<option value="{{ option }}"{% if field.current == option.as_str() %} selected{% endif %}>{% if option.is_empty() %}(none){% else %}{{ option }}{% endif %}</option>{% endfor %}</select>
{% else if field.is_text_list() %}<textarea id="{{ field.input_id() }}" name="{{ field.path }}" rows="4" spellcheck="false">{{ field.current }}</textarea>
{% else if field.is_number() %}<input id="{{ field.input_id() }}" name="{{ field.path }}" type="number" step="{% if field.is_integer() %}1{% else %}any{% endif %}" value="{{ field.current }}">
{% else %}<input id="{{ field.input_id() }}" name="{{ field.path }}" type="text" value="{{ field.current }}" spellcheck="false">
{% endif %}
{% if !field.is_secret() && !field.is_env() %}<p class="default">default: {% if field.default.is_empty() %}<em>unset</em>{% else if field.is_text_list() %}<code class="lines">{{ field.default }}</code>{% else %}<code>{{ field.default }}</code>{% endif %}{% if field.current != field.default %} <button type="button" class="reset" data-reset="{{ field.input_id() }}" data-default="{{ field.default }}">reset to default</button>{% endif %}</p>{% endif %}
</div>
</div>
{% endfor %}
</section>
{% endfor %}
<div class="actions">{% match config_path %}{% when Some with (_path) %}<button type="submit">Save settings</button>{% when None %}<button type="submit" disabled>Save settings</button>{% endmatch %}</div>
</form>
{% for group in groups %}{% match group.provider %}{% when Some with (provider) %}{% if provider.removable() %}<form method="post" action="/dashboard/settings/providers" id="remove-{{ provider.name }}" class="inline" data-confirm="Remove [providers.{{ provider.name }}] from config.toml?"><input type="hidden" name="action" value="remove"><input type="hidden" name="name" value="{{ provider.name }}"></form>{% endif %}{% when None %}{% endmatch %}{% endfor %}
<section class="card" id="add-provider">
<h2>Add provider</h2>
<form method="post" action="/dashboard/settings/providers" class="add-provider">
<input type="hidden" name="action" value="add">
<label>Name <input name="name" type="text" pattern="[a-z0-9_]+" placeholder="mistral" required{% if config_path.is_none() %} disabled{% endif %}></label>
<label>Kind <select name="kind">{% for kind in provider_kinds %}<option value="{{ kind }}">{{ kind }}</option>{% endfor %}</select></label>
<p class="help">Inserts <code>[providers.&lt;name&gt;]</code> with placeholder <code>base_url</code> and <code>model</code>; edit them above afterwards and put <code>DAILY_EPUB_PROVIDERS__&lt;NAME&gt;__API_KEY</code> in the env file.</p>
<button type="submit"{% if config_path.is_none() %} disabled{% endif %}>Add provider</button>
</form>
</section>
</section>{% endblock %}
@@ -0,0 +1,18 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<h1>Settings history</h1>
<p class="meta"><a href="/dashboard/settings">Settings</a> · every change made through the dashboard, newest first.</p>
{% if changes.is_empty() %}<p>No settings have been changed through the dashboard yet.</p>{% else %}<div class="scroll-x"><table class="history">
<thead><tr><th>When</th><th>Who</th><th>Key</th><th>Before</th><th>After</th></tr></thead>
<tbody>{% for change in changes %}<tr>
<td>{{ change.changed_at }}</td>
<td>{% match change.username %}{% when Some with (name) %}{{ name }}{% when None %}<em>removed user</em>{% endmatch %}</td>
<td><code>{{ change.key }}</code></td>
<td>{% match change.old_value %}{% when Some with (value) %}<pre>{{ value }}</pre>{% when None %}<em>absent</em>{% endmatch %}</td>
<td>{% match change.new_value %}{% when Some with (value) %}<pre>{{ value }}</pre>{% when None %}<em>removed</em>{% endmatch %}</td>
</tr>{% endfor %}</tbody>
</table></div>
{% include "_pagination.html" %}
{% if pagination.page < pagination.pages() %}<p><a href="/dashboard/settings/history?page={{ pagination.page + 1 }}">Older</a></p>{% endif %}
{% if pagination.page > 1 %}<p><a href="/dashboard/settings/history?page={{ pagination.page - 1 }}">Newer</a></p>{% endif %}
{% endif %}
</section>{% endblock %}