use super::escape::{collapse_slashes, encode_repo_url_path, esc, percent_decode};
use super::format::{format_duration_secs, format_relative_age, format_short_age};
use super::layout::wrap_page;
use crate::daemon::model::{sessions_for_index, Session, SessionLifecycle, Store};
use crate::daemon::paths::now_unix_secs;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IndexFilter {
Project(String),
Repo(String),
}
impl IndexFilter {
pub fn repo_path(&self) -> String {
match self {
Self::Project(name) => crate::daemon::paths::projects_dir().join(name).to_string_lossy().into_owned(),
Self::Repo(path) => path.clone(),
}
}
pub fn label(&self) -> String {
match self {
Self::Project(name) => format!("project · {name}"),
Self::Repo(path) => path.clone(),
}
}
}
pub fn parse_index_filter(path: &str) -> Option<IndexFilter> {
let path = path.split('?').next().unwrap_or(path);
if let Some(rest) = path.strip_prefix("/project") {
let name = collapse_slashes(&percent_decode(rest)).trim_matches('/').to_string();
if name.is_empty() || name.contains('/') {
return None;
}
return Some(IndexFilter::Project(name));
}
if let Some(rest) = path.strip_prefix("/repo") {
let mut p = collapse_slashes(&percent_decode(rest));
if p.is_empty() || p == "/" {
return None;
}
if !p.starts_with('/') {
p.insert(0, '/');
}
return Some(IndexFilter::Repo(p));
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexTab {
Run,
Jobs,
Projects,
Setup,
}
impl IndexTab {
pub fn from_path(path: &str) -> Option<Self> {
let path = path.split('?').next().unwrap_or(path).trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
match path {
"/" | "/run" => Some(Self::Run),
"/jobs" => Some(Self::Jobs),
"/projects" => Some(Self::Projects),
"/setup" | "/images" => Some(Self::Setup),
_ => None,
}
}
}
pub fn index_page(store: &Store) -> String {
index_page_for(store, None, IndexTab::Run)
}
pub fn index_page_with_filter(store: &Store, filter: Option<IndexFilter>) -> String {
index_page_for(store, filter, IndexTab::Projects)
}
pub fn index_page_for(store: &Store, filter: Option<IndexFilter>, tab: IndexTab) -> String {
let port = store.port;
let now = now_unix_secs();
let filter_repo = filter.as_ref().map(|f| f.repo_path());
let sessions = sessions_for_index(&store.sessions, now);
let mut rows = String::new();
for session in sessions {
if let Some(ref want) = filter_repo {
if &session.repo != want {
continue;
}
}
rows.push_str(&index_session_row(session, now));
}
if rows.is_empty() {
rows = if filter.is_some() {
"<tr><td colspan=\"7\" class=\"dim\">No jobs for this project or repository.</td></tr>\n".into()
} else {
"<tr><td colspan=\"7\" class=\"dim\">No jobs yet — run <code>scsh run</code> to start one.</td></tr>\n".into()
};
}
let active = |want: IndexTab| if tab == want { " active" } else { "" };
let selected = |want: IndexTab| if tab == want { "true" } else { "false" };
let tabindex = |want: IndexTab| if tab == want { "0" } else { "-1" };
let body = format!(
"<nav class=\"tabs\" role=\"tablist\">\
<button id=\"tabbtn-run\" role=\"tab\" aria-selected=\"{run_s}\" aria-controls=\"tab-run\" \
tabindex=\"{run_i}\" class=\"tab{run_a}\" data-tab=\"run\">Run</button>\
<button id=\"tabbtn-jobs\" role=\"tab\" aria-selected=\"{jobs_s}\" aria-controls=\"tab-jobs\" \
tabindex=\"{jobs_i}\" class=\"tab{jobs_a}\" data-tab=\"jobs\">Jobs</button>\
<button id=\"tabbtn-projects\" role=\"tab\" aria-selected=\"{proj_s}\" aria-controls=\"tab-projects\" \
tabindex=\"{proj_i}\" class=\"tab{proj_a}\" data-tab=\"projects\">Projects</button>\
<button id=\"tabbtn-setup\" role=\"tab\" aria-selected=\"{setup_s}\" aria-controls=\"tab-setup\" \
tabindex=\"{setup_i}\" class=\"tab{setup_a}\" data-tab=\"setup\">Setup</button>\
</nav>\n\
<section class=\"tab-panel{run_p}\" id=\"tab-run\" role=\"tabpanel\" aria-labelledby=\"tabbtn-run\">\n{start}</section>\n\
<section class=\"tab-panel{jobs_p}\" id=\"tab-jobs\" role=\"tabpanel\" aria-labelledby=\"tabbtn-jobs\">\n\
<div class=\"card card--accent-left-cyan\">\n\
<p class=\"section-label\">Jobs</p>\n{harness_stops}\
<div class=\"table-scroll\"><table>\n\
<thead><tr><th>Job</th><th>Status</th><th>Started</th><th>Duration</th>\
<th>Profile</th><th>Procs</th><th>Repo</th></tr></thead>\n\
<tbody id=\"sessions-body\">\n{rows}</tbody>\n</table></div>\n\
</div>\n\
</section>\n\
<section class=\"tab-panel{proj_p}\" id=\"tab-projects\" role=\"tabpanel\" aria-labelledby=\"tabbtn-projects\">\n{dirs}</section>\n\
<section class=\"tab-panel{setup_p}\" id=\"tab-setup\" role=\"tabpanel\" aria-labelledby=\"tabbtn-setup\">\n{images}</section>\n",
run_a = active(IndexTab::Run),
jobs_a = active(IndexTab::Jobs),
proj_a = active(IndexTab::Projects),
setup_a = active(IndexTab::Setup),
run_s = selected(IndexTab::Run),
jobs_s = selected(IndexTab::Jobs),
proj_s = selected(IndexTab::Projects),
setup_s = selected(IndexTab::Setup),
run_i = tabindex(IndexTab::Run),
jobs_i = tabindex(IndexTab::Jobs),
proj_i = tabindex(IndexTab::Projects),
setup_i = tabindex(IndexTab::Setup),
run_p = active(IndexTab::Run),
jobs_p = active(IndexTab::Jobs),
proj_p = active(IndexTab::Projects),
setup_p = active(IndexTab::Setup),
rows = rows,
harness_stops = harness_stop_strip(store, now),
dirs = dirs_panel(store, now, filter.as_ref()),
start = start_panel(),
images = images_panel()
);
wrap_page("scsh", port, None, "", &body)
}
fn harness_stop_strip(store: &Store, now: u64) -> String {
use crate::daemon::model::{ProcKind, ProcStatus};
let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for s in store.sessions.values() {
if s.lifecycle_status(now) != SessionLifecycle::Running {
continue;
}
for p in &s.procs {
let live = p.status == ProcStatus::Running || p.status == ProcStatus::Waiting;
if live && p.kind == ProcKind::Skill {
if let Some(h) = p.harness.as_deref().filter(|h| !h.is_empty()) {
*counts.entry(h.to_string()).or_insert(0) += 1;
}
}
}
}
if counts.is_empty() {
return String::new();
}
let mut buttons = String::new();
for (harness, n) in &counts {
buttons.push_str(&format!(
"<button type=\"button\" class=\"chamfer btn btn--red btn--sm\" data-harness-stop=\"{h}\" title=\"Stop every running {h} container, in every job\"><span>✕ stop all {h} ({n})</span></button>\n",
h = esc(harness),
));
}
format!("<div class=\"harness-stops\">{buttons}</div>\n")
}
fn images_panel() -> String {
let mut cards = String::new();
for h in crate::config::Harness::ALL {
cards.push_str(&setup_skeleton_card(h));
}
let mut rows = String::new();
rows.push_str(&images_skeleton_row("base", crate::runtime::BASE_IMAGE_TAG, true));
for h in crate::config::Harness::ALL {
rows.push_str(&images_skeleton_row(h.as_str(), &crate::runtime::image_tag(h), true));
}
format!(
r##"<div class="card card--accent-left-orange">
<p class="section-label">Harness setup</p>
<p class="dim">Prepare and verify the agents scsh can run. Choose a runtime, build missing
images, and sign in on the host. Image freshness alone is not readiness — login is checked
separately. Model probes run only when you click <strong>Test</strong> (real provider calls).</p>
<div class="setup-toolbar">
<div id="images-runtimes" class="images-runtimes"></div>
<span id="setup-checked" class="dim"></span>
<a href="#" id="setup-refresh">refresh</a>
<button type="button" class="chamfer btn btn--cyan btn--sm" id="setup-test-all" title="One primary smoke model per ready harness"><span>Test all defaults</span></button>
</div>
<p id="setup-summary" class="setup-summary dim">checking agents…</p>
<div id="setup-cards" class="setup-cards">{cards}</div>
</div>
<div class="card card--accent-left-purple">
<p class="section-label">Images setup</p>
<p class="dim">Image tags, sizes, timestamps, base rebuilds, and force rebuilds for the
selected runtime.</p>
<div class="table-scroll"><table>
<thead><tr><th></th><th>Image</th><th>Status</th><th>Created</th><th>Size</th><th></th></tr></thead>
<tbody id="images-body">
{rows}</tbody>
</table></div>
<div class="images-controls">
<button type="button" class="chamfer btn btn--cyan btn--sm" id="images-build-selected" disabled><span>Build selected</span></button>
<button type="button" class="chamfer btn btn--orange btn--sm" id="images-build-all"><span>Build all</span></button>
<label><input type="checkbox" id="images-rebuild-base"> also rebuild the base image (--no-cache)</label>
<label><input type="checkbox" id="images-force"> force rebuild even when up to date</label>
<a href="#" id="images-refresh">refresh</a>
<span id="images-note" class="dim">checking container runtime…</span>
</div>
</div>
"##,
cards = cards,
rows = rows
)
}
fn setup_skeleton_card(h: crate::config::Harness) -> String {
format!(
r#"<article class="setup-card" data-harness="{id}" data-pending="1">
<header class="setup-card-head">
<strong class="setup-card-name">{name}</strong>
<span class="chamfer session-status checking"><span>checking…</span></span>
</header>
<div class="setup-card-layers">
<div><span class="setup-layer-label">Image</span> <span class="setup-layer-value dim">checking…</span></div>
<div><span class="setup-layer-label">Login</span> <span class="setup-layer-value dim">checking…</span></div>
</div>
<ul class="setup-models dim"><li class="setup-models-hint">Check models, then <strong>Test selected</strong></li></ul>
<div class="setup-card-actions"><span class="dim setup-next">Real container probe — may incur provider cost</span></div>
</article>
"#,
id = esc(h.as_str()),
name = esc(h.display_name()),
)
}
fn images_skeleton_row(name: &str, tag: &str, selectable: bool) -> String {
let checkbox = if selectable {
format!(r#"<input type="checkbox" class="image-select" value="{name}" disabled>"#, name = esc(name))
} else {
String::new()
};
format!(
r#"<tr data-image="{name}" data-pending="1"><td class="image-select-cell">{checkbox}</td><td><code>{tag}</code></td><td class="image-status-cell"><span class="chamfer session-status checking"><span>checking…</span></span></td><td class="dim image-created-cell">—</td><td class="dim image-size-cell">—</td><td class="image-action-cell"></td></tr>
"#,
name = esc(name),
tag = esc(tag),
checkbox = checkbox
)
}
fn start_panel() -> &'static str {
r##"<div class="card card--accent-left-green">
<p class="section-label">Run</p>
<p class="dim">Open a git repository — an absolute path, or the bare name of a project under
<code>~/.scsh/projects/</code> — to configure and start a harness-definition job in it; the
daemon runs it just like <code>scsh run</code>. The repo must be committed, clean, and have a
gitignored scratch dir (<code>tmp/</code> or <code>.harness/tmp</code>). One job per repository at a time.</p>
<p class="dim">Or <strong>create a new project</strong>: a fresh git repository under
<code>~/.scsh/projects/<name></code>, born runnable (its first commit gitignores
<code>/tmp</code>) — tests and demos start right here, no terminal needed.</p>
<div class="start-controls">
<div class="chamfer input-wrap">
<input class="input" type="text" id="repo-path" placeholder="/path/to/a/git/repo, or a project name (type, paste, or Pick…)">
</div>
<div class="start-actions">
<button type="button" class="chamfer btn btn--purple btn--sm" id="repo-pick"><span>Pick…</span></button>
<button type="button" class="chamfer btn btn--cyan btn--sm" id="repo-open"><span>Open</span></button>
</div>
<span id="repo-note" class="dim"></span>
</div>
<div class="start-controls">
<div class="chamfer input-wrap">
<input class="input" type="text" id="project-name" placeholder="project name (letters, digits, - or _; no dots/slashes)" autocomplete="off" spellcheck="false">
</div>
<div class="start-actions">
<button type="button" class="chamfer btn btn--green btn--sm" id="project-create"><span>New project</span></button>
</div>
</div>
<div id="repo-blockers" class="blockers" hidden></div>
<div id="defs-panel" hidden>
<p class="section-label">Definitions</p>
<p class="dim">Harness definitions in <code id="open-repo-path"></code> — pick one to configure and start.</p>
<div id="defs-list"></div>
<div id="def-form"></div>
</div>
</div>
"##
}
fn dirs_panel(store: &Store, now: u64, filter: Option<&IndexFilter>) -> String {
let banner = match filter {
Some(f) => format!(
"<p class=\"filter-banner\" data-repo-filter=\"{path}\">Showing <strong>{label}</strong> · \
<a class=\"filter-clear\" href=\"/projects\">Show all</a></p>\n",
path = esc(&f.repo_path()),
label = esc(&f.label()),
),
None => String::new(),
};
format!(
r##"<div class="card card--accent-left-magenta">
<p class="section-label">Projects</p>
{banner}<p class="dim">Current jobs, grouped by where they run: a project under <code>~/.scsh/projects/</code> shows its
name; anything else shows its repository path. Click a name to filter.</p>
<div class="table-scroll"><table>
<thead><tr><th>Project / repository</th><th>Jobs</th></tr></thead>
<tbody id="repos-body">{rows}</tbody>
</table></div>
</div>
{internal}
"##,
banner = banner,
rows = repo_jobs_rows(store, now, filter),
internal = internal_panel(store, now, filter),
)
}
fn internal_panel(store: &Store, now: u64, filter: Option<&IndexFilter>) -> String {
if filter.is_some() {
return String::new();
}
let mut jobs: Vec<&Session> = store.sessions.values().filter(|s| is_internal_repo(&s.repo)).collect();
if jobs.is_empty() {
return String::new();
}
let activity = |s: &Session| s.ended_at.unwrap_or(s.started_at);
let mut groups: std::collections::BTreeMap<&str, Vec<&Session>> = std::collections::BTreeMap::new();
for s in jobs.drain(..) {
groups.entry(s.profile.as_deref().unwrap_or("default")).or_default().push(s);
}
let mut ordered: Vec<(&str, Vec<&Session>)> = groups.into_iter().collect();
for (_, g) in &mut ordered {
g.sort_by_key(|s| {
(std::cmp::Reverse(s.lifecycle_status(now) == SessionLifecycle::Running), std::cmp::Reverse(activity(s)))
});
}
ordered.sort_by_key(|(_, g)| {
(
std::cmp::Reverse(g.iter().any(|s| s.lifecycle_status(now) == SessionLifecycle::Running)),
std::cmp::Reverse(g.iter().map(|s| activity(s)).max().unwrap_or(0)),
)
});
let body = ordered
.iter()
.map(|(task, g)| {
let links = g
.iter()
.map(|s| {
let lc = s.lifecycle_status(now);
format!(
"<div class=\"repo-job\"><span class=\"chamfer session-status {cls}\"><span>{label}</span></span> <a class=\"job-id\" href=\"/job/{id}\">{id}</a> <span class=\"dim\">{age}</span></div>",
id = esc(&s.id),
cls = lc.css_class(),
label = lc.label(),
age = format_short_age(now.saturating_sub(activity(s))),
)
})
.collect::<Vec<_>>()
.join("");
format!(
"<div class=\"repo-jobgroup\"><span class=\"repo-jobgroup-name\">{task}</span>{links}</div>",
task = esc(task),
links = links,
)
})
.collect::<Vec<_>>()
.join("");
format!(
r##"<div class="card card--accent-left-purple" id="internal-jobs-card">
<p class="section-label">Internal</p>
<p class="dim">System jobs — image builds and annotate catch-up — not tied to a project or repository.</p>
<div id="internal-body">{body}</div>
</div>
"##,
body = body,
)
}
fn is_internal_repo(repo: &str) -> bool {
repo == crate::daemon::server::IMAGE_BUILDS_REPO || repo == crate::daemon::server::INTERNAL_REPO
}
fn repo_jobs_rows(store: &Store, now: u64, filter: Option<&IndexFilter>) -> String {
let filter_repo = filter.map(|f| f.repo_path());
let mut by_repo: std::collections::BTreeMap<&str, Vec<&Session>> = std::collections::BTreeMap::new();
for path in store.open_repos.keys() {
if filter_repo.as_ref().is_some_and(|want| path != want) {
continue;
}
by_repo.entry(path).or_default();
}
for s in store.sessions.values() {
if s.repo.is_empty() || is_internal_repo(&s.repo) {
continue;
}
if filter_repo.as_ref().is_some_and(|want| &s.repo != want) {
continue;
}
by_repo.entry(&s.repo).or_default().push(s);
}
if by_repo.is_empty() {
return if filter.is_some() {
"<tr><td colspan=\"2\" class=\"dim\">No jobs for this project or repository.</td></tr>".into()
} else {
"<tr><td colspan=\"2\" class=\"dim\">No jobs yet — open or create a project under Run.</td></tr>".to_string()
};
}
let activity = |s: &Session| s.ended_at.unwrap_or(s.started_at);
let projects_root = format!("{}/", crate::daemon::paths::projects_dir().display());
let mut rows = String::new();
for (repo, jobs) in by_repo {
let cells = if jobs.is_empty() {
"<span class=\"dim\">no jobs yet</span>".to_string()
} else {
let mut groups: std::collections::BTreeMap<&str, Vec<&Session>> = std::collections::BTreeMap::new();
for s in jobs {
groups.entry(s.profile.as_deref().unwrap_or("default")).or_default().push(s);
}
let mut ordered: Vec<(&str, Vec<&Session>)> = groups.into_iter().collect();
for (_, g) in &mut ordered {
g.sort_by_key(|s| {
(std::cmp::Reverse(s.lifecycle_status(now) == SessionLifecycle::Running), std::cmp::Reverse(activity(s)))
});
}
ordered.sort_by_key(|(_, g)| {
(
std::cmp::Reverse(g.iter().any(|s| s.lifecycle_status(now) == SessionLifecycle::Running)),
std::cmp::Reverse(g.iter().map(|s| activity(s)).max().unwrap_or(0)),
)
});
ordered
.iter()
.map(|(task, g)| {
let links = g
.iter()
.map(|s| {
let lc = s.lifecycle_status(now);
format!(
"<div class=\"repo-job\"><span class=\"chamfer session-status {cls}\"><span>{label}</span></span> <a class=\"job-id\" href=\"/job/{id}\">{id}</a> <span class=\"dim\">{age}</span></div>",
id = esc(&s.id),
cls = lc.css_class(),
label = lc.label(),
age = format_short_age(now.saturating_sub(activity(s))),
)
})
.collect::<Vec<_>>()
.join("");
format!(
"<div class=\"repo-jobgroup\"><span class=\"repo-jobgroup-name\">{task}</span>{links}</div>",
task = esc(task),
links = links,
)
})
.collect::<Vec<_>>()
.join("")
};
let href = repo_filter_href(repo, &projects_root);
rows.push_str(&format!(
"<tr data-repo=\"{repo}\"><td class=\"repo-path\" title=\"{repo}\"><a class=\"repo-filter-link\" href=\"{href}\">{label}</a></td><td>{cells}</td></tr>",
repo = esc(repo),
href = esc(&href),
label = esc(&repo_display_label(repo, &projects_root)),
cells = cells,
));
}
rows
}
fn repo_filter_href(repo: &str, projects_root: &str) -> String {
match repo.strip_prefix(projects_root) {
Some(name) if !name.is_empty() && !name.contains('/') => format!("/project/{name}"),
_ => format!("/repo{}", encode_repo_url_path(repo)),
}
}
fn repo_display_label(repo: &str, projects_root: &str) -> String {
match repo.strip_prefix(projects_root) {
Some(name) => format!("project · {name}"),
None => repo.to_string(),
}
}
fn index_session_row(session: &Session, now: u64) -> String {
let lifecycle = session.lifecycle_status(now);
let id = esc(&session.id);
let profile = esc(session.profile.as_deref().unwrap_or("default"));
let n_procs = session.procs.len();
let status = format!(
r#"<span class="chamfer session-status {}"><span>{}</span></span>"#,
lifecycle.css_class(),
esc(lifecycle.label())
);
let started_rel = format_relative_age(now.saturating_sub(session.started_at));
let started = format!(
"<span class=\"session-started\" data-started=\"{}\">\
<span class=\"session-started-abs\">…</span><br>\
<span class=\"dim session-started-rel\">{}</span></span>",
session.started_at,
esc(&started_rel)
);
let duration = index_duration_label(session, now, lifecycle);
format!(
"<tr data-session-id=\"{id}\"><td><a class=\"job-id\" href=\"/job/{id}\">{id}</a></td>\
<td class=\"session-status-cell\">{status}</td>\
<td class=\"session-started-cell\">{started}</td>\
<td class=\"session-duration-cell\">{duration}</td>\
<td>{profile}</td><td class=\"session-procs-cell\">{chips}<span class=\"chip-count\" data-tip=\"{n_procs} run{plural} in this job\">{n_procs}</span></td><td class=\"dim repo-path\">{repo}</td></tr>\n",
id = id,
status = status,
started = started,
duration = esc(&duration),
profile = profile,
chips = harness_chips_html(session),
n_procs = n_procs,
plural = if n_procs == 1 { "" } else { "s" },
repo = esc(&session.repo),
)
}
fn harness_chips_html(session: &Session) -> String {
use crate::daemon::model::{ProcKind, ProcStatus};
let mut chips = String::new();
for p in &session.procs {
if p.kind != ProcKind::Skill {
continue;
}
let Some(h) = p.harness.as_deref().filter(|h| !h.is_empty()) else {
continue;
};
let letter = h.chars().next().map(|c| c.to_ascii_uppercase()).unwrap_or('?');
let done = matches!(p.status, ProcStatus::Ok | ProcStatus::Fail | ProcStatus::Skipped);
let skill = p.skill_name.as_deref().unwrap_or(&p.label);
let base = format!("{h} · {skill}");
let (tip, running_attr) = match p.status {
ProcStatus::Running => match p.started_at {
Some(t) => (base, format!(" data-tip-running=\"{t}\"")),
None => (format!("{base}\nrunning"), String::new()),
},
ProcStatus::Waiting => (format!("{base}\nwaiting"), String::new()),
ProcStatus::Ok => (format!("{base}\ndone"), String::new()),
ProcStatus::Fail => (format!("{base}\nfailed"), String::new()),
ProcStatus::Skipped => (format!("{base}\nskipped"), String::new()),
};
chips.push_str(&format!(
"<span class=\"hchip hchip--{h}{done}\" data-tip=\"{tip}\"{running_attr}>{letter}</span>",
h = esc(h),
done = if done { " hchip--done" } else { "" },
tip = esc(&tip),
));
}
chips
}
fn index_duration_label(session: &Session, now: u64, lifecycle: SessionLifecycle) -> String {
match session.duration_secs(now) {
Some(secs) if lifecycle == SessionLifecycle::Running => format!("{} so far", format_duration_secs(secs)),
Some(secs) => format_duration_secs(secs),
None => "—".to_string(),
}
}