new console
This commit is contained in:
@@ -32,6 +32,11 @@ pub async fn release_run(
|
|||||||
launcher.release_run(password, with_api, with_mcp).await
|
launcher.release_run(password, with_api, with_mcp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn release_console(launcher: State<'_, Launcher>, since: usize) -> CmdResult<crate::release::ConsoleTail> {
|
||||||
|
Ok(launcher.release_console(since))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult<Status> {
|
pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult<Status> {
|
||||||
Ok(launcher.status().await)
|
Ok(launcher.status().await)
|
||||||
|
|||||||
@@ -254,6 +254,8 @@ pub struct Launcher {
|
|||||||
stack_states: std::sync::Mutex<Vec<StackEvent>>,
|
stack_states: std::sync::Mutex<Vec<StackEvent>>,
|
||||||
sign_in_cancel: Notify,
|
sign_in_cancel: Notify,
|
||||||
install_cancel: std::sync::Mutex<Option<CancellationToken>>,
|
install_cancel: std::sync::Mutex<Option<CancellationToken>>,
|
||||||
|
/// What the running release has printed, for the console panel.
|
||||||
|
release_console: crate::release::Shared,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What an operator picked when installing from a drive: the site store an ODIAN drive
|
/// What an operator picked when installing from a drive: the site store an ODIAN drive
|
||||||
@@ -414,6 +416,7 @@ impl Launcher {
|
|||||||
stack_states: std::sync::Mutex::new(Vec::new()),
|
stack_states: std::sync::Mutex::new(Vec::new()),
|
||||||
sign_in_cancel: Notify::new(),
|
sign_in_cancel: Notify::new(),
|
||||||
install_cancel: std::sync::Mutex::new(None),
|
install_cancel: std::sync::Mutex::new(None),
|
||||||
|
release_console: Default::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2338,8 +2341,13 @@ impl Launcher {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start a release in its own console. Returns once it has started, not when it is done.
|
/// Start a release. Returns once it has started, not when it is done: its output arrives
|
||||||
|
/// in the console panel, which reads `release_console` as it fills.
|
||||||
pub async fn release_run(&self, password: String, with_api: bool, with_mcp: bool) -> Result<(), String> {
|
pub async fn release_run(&self, password: String, with_api: bool, with_mcp: bool) -> Result<(), String> {
|
||||||
|
// Two at once would interleave into one buffer and fight over the same build dirs.
|
||||||
|
if self.release_console.lock().is_ok_and(|c| c.is_running()) {
|
||||||
|
return Err("A release is already running: wait for it to finish.".to_string());
|
||||||
|
}
|
||||||
let plan = self.release_plan().await;
|
let plan = self.release_plan().await;
|
||||||
if let Some(why) = plan.blocked {
|
if let Some(why) = plan.blocked {
|
||||||
return Err(why);
|
return Err(why);
|
||||||
@@ -2373,10 +2381,20 @@ impl Launcher {
|
|||||||
with_api && plan.docker,
|
with_api && plan.docker,
|
||||||
with_mcp && plan.docker,
|
with_mcp && plan.docker,
|
||||||
&password,
|
&password,
|
||||||
|
&self.release_console,
|
||||||
)
|
)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything the release has printed since line `since`.
|
||||||
|
pub fn release_console(&self, since: usize) -> crate::release::ConsoleTail {
|
||||||
|
match self.release_console.lock() {
|
||||||
|
Ok(c) => c.tail(since),
|
||||||
|
// A poisoned lock must not take the window down with it.
|
||||||
|
Err(e) => e.into_inner().tail(since),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── operator-data sync ──────────────────────────────────────────────────
|
// ── operator-data sync ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Sync, unless this run is pretending to be another operator. Whatever it authored is
|
/// Sync, unless this run is pretending to be another operator. Whatever it authored is
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ pub fn run() {
|
|||||||
commands::get_status,
|
commands::get_status,
|
||||||
commands::release_plan,
|
commands::release_plan,
|
||||||
commands::release_run,
|
commands::release_run,
|
||||||
|
commands::release_console,
|
||||||
commands::get_market,
|
commands::get_market,
|
||||||
commands::apply_market,
|
commands::apply_market,
|
||||||
commands::check_market,
|
commands::check_market,
|
||||||
|
|||||||
+200
-7
@@ -9,7 +9,7 @@
|
|||||||
//! Comparing versions would not do: a version says what was cut, not from what.
|
//! Comparing versions would not do: a version says what was cut, not from what.
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
/// What a release could do right now, and what stops it.
|
/// What a release could do right now, and what stops it.
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
@@ -160,7 +160,101 @@ pub fn plan(
|
|||||||
ReleasePlan { docker, online, scripts, items, blocked }
|
ReleasePlan { docker, online, scripts, items, blocked }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cut and publish, in its own console.
|
/// The Release console's buffer: filled by the running release, read by the window.
|
||||||
|
///
|
||||||
|
/// It sits behind a plain (not async) lock because the threads draining the child's pipes
|
||||||
|
/// have no async context to lock from.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Console {
|
||||||
|
lines: Vec<String>,
|
||||||
|
/// How many lines fell off the front, so indices stay stable while it trims.
|
||||||
|
dropped: usize,
|
||||||
|
running: bool,
|
||||||
|
exit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Shared = std::sync::Arc<std::sync::Mutex<Console>>;
|
||||||
|
|
||||||
|
/// A release prints a great deal (cargo, npm, docker); keep the tail, not all of it.
|
||||||
|
const MAX_LINES: usize = 4000;
|
||||||
|
|
||||||
|
/// What the window asks for: everything it has not seen yet.
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct ConsoleTail {
|
||||||
|
/// Index of the first line returned.
|
||||||
|
pub from: usize,
|
||||||
|
pub lines: Vec<String>,
|
||||||
|
pub total: usize,
|
||||||
|
pub running: bool,
|
||||||
|
pub exit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Console {
|
||||||
|
fn start(&mut self) {
|
||||||
|
self.lines.clear();
|
||||||
|
self.dropped = 0;
|
||||||
|
self.running = true;
|
||||||
|
self.exit = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, line: String) {
|
||||||
|
self.lines.push(line);
|
||||||
|
if self.lines.len() > MAX_LINES {
|
||||||
|
let cut = self.lines.len() - MAX_LINES;
|
||||||
|
self.lines.drain(..cut);
|
||||||
|
self.dropped += cut;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self, exit: Option<i32>) {
|
||||||
|
self.running = false;
|
||||||
|
self.exit = exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_running(&self) -> bool {
|
||||||
|
self.running
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything from `since` on. A `since` already trimmed away starts at the oldest line
|
||||||
|
/// still held, rather than pretending the dropped ones are still coming.
|
||||||
|
pub fn tail(&self, since: usize) -> ConsoleTail {
|
||||||
|
let total = self.dropped + self.lines.len();
|
||||||
|
let from = since.clamp(self.dropped, total);
|
||||||
|
ConsoleTail {
|
||||||
|
from,
|
||||||
|
lines: self.lines[from - self.dropped..].to_vec(),
|
||||||
|
total,
|
||||||
|
running: self.running,
|
||||||
|
exit: self.exit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain one of the child's streams into the console, a line at a time.
|
||||||
|
fn pump(stream: impl std::io::Read, console: Shared) {
|
||||||
|
use std::io::BufRead;
|
||||||
|
let mut reader = std::io::BufReader::new(stream);
|
||||||
|
let mut raw = Vec::new();
|
||||||
|
loop {
|
||||||
|
raw.clear();
|
||||||
|
match reader.read_until(b'\n', &mut raw) {
|
||||||
|
Ok(0) | Err(_) => return,
|
||||||
|
Ok(_) => {
|
||||||
|
let text = String::from_utf8_lossy(&raw);
|
||||||
|
let text = text.trim_end_matches('\n').trim_end_matches('\r');
|
||||||
|
// A terminal shows only what follows the last carriage return, so a progress line
|
||||||
|
// that kept rewriting itself does not become a thousand lines in the panel.
|
||||||
|
let shown = text.rsplit('\r').next().unwrap_or(text);
|
||||||
|
match console.lock() {
|
||||||
|
Ok(mut c) => c.push(shown.to_string()),
|
||||||
|
Err(_) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cut and publish, into the launcher's own console panel.
|
||||||
///
|
///
|
||||||
/// The three scripts are run as a person runs them, in order, and each decides for itself
|
/// The three scripts are run as a person runs them, in order, and each decides for itself
|
||||||
/// whether it has anything to do: `release_pack.ps1` stops with "nothing changed" when the
|
/// whether it has anything to do: `release_pack.ps1` stops with "nothing changed" when the
|
||||||
@@ -178,6 +272,7 @@ pub fn run_release(
|
|||||||
with_api: bool,
|
with_api: bool,
|
||||||
with_mcp: bool,
|
with_mcp: bool,
|
||||||
sudo: &str,
|
sudo: &str,
|
||||||
|
console: &Shared,
|
||||||
) -> Result<PathBuf, String> {
|
) -> Result<PathBuf, String> {
|
||||||
let scripts = mcp_repo.join("scripts");
|
let scripts = mcp_repo.join("scripts");
|
||||||
let log = work_dir.join("release.log");
|
let log = work_dir.join("release.log");
|
||||||
@@ -229,24 +324,32 @@ pub fn run_release(
|
|||||||
|
|
||||||
// Everything inside try/finally, and everything recorded. A step that throws used to
|
// Everything inside try/finally, and everything recorded. A step that throws used to
|
||||||
// take the window with it - the release simply vanished, with nothing to read.
|
// take the window with it - the release simply vanished, with nothing to read.
|
||||||
|
//
|
||||||
|
// No `pause` here any more: this runs into the launcher's own panel, where there is no
|
||||||
|
// console to press Enter in. The exit code carries the verdict instead - swallowing a
|
||||||
|
// failure and exiting 0 would make a failed release look like a finished one.
|
||||||
let ps = format!(
|
let ps = format!(
|
||||||
"$ErrorActionPreference = 'Stop'
|
"$ErrorActionPreference = 'Stop'
|
||||||
|
# The pipe gets UTF-8 or Windows' own French messages arrive as mojibake.
|
||||||
|
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
|
||||||
$pw = $env:ODIAN_NODE_SUDO
|
$pw = $env:ODIAN_NODE_SUDO
|
||||||
$env:ODIAN_NODE_SUDO = $null
|
$env:ODIAN_NODE_SUDO = $null
|
||||||
Start-Transcript -Path '{}' -Force | Out-Null
|
Start-Transcript -Path '{}' -Force | Out-Null
|
||||||
|
$ok = $true
|
||||||
try {{
|
try {{
|
||||||
{}
|
{}
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host 'Release finished.' -ForegroundColor Green
|
Write-Host 'Release finished.'
|
||||||
}} catch {{
|
}} catch {{
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host \"FAILED: $_\" -ForegroundColor Red
|
Write-Host \"FAILED: $_\"
|
||||||
|
$ok = $false
|
||||||
}} finally {{
|
}} finally {{
|
||||||
Stop-Transcript | Out-Null
|
Stop-Transcript | Out-Null
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host 'Log: {}'
|
Write-Host 'Log: {}'
|
||||||
pause
|
|
||||||
}}
|
}}
|
||||||
|
if (-not $ok) {{ exit 1 }}
|
||||||
",
|
",
|
||||||
log.display(),
|
log.display(),
|
||||||
body,
|
body,
|
||||||
@@ -260,15 +363,50 @@ pub fn run_release(
|
|||||||
let mut cmd = Command::new("powershell");
|
let mut cmd = Command::new("powershell");
|
||||||
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]).arg(&file);
|
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]).arg(&file);
|
||||||
cmd.env("ODIAN_NODE_SUDO", sudo);
|
cmd.env("ODIAN_NODE_SUDO", sudo);
|
||||||
|
// Nothing can type into this, so stdin is closed: a script that ever asks a question
|
||||||
|
// then fails at once instead of hanging forever with no window to answer in.
|
||||||
|
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
cmd.creation_flags(0x0000_0010); // CREATE_NEW_CONSOLE: the developer watches it run
|
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW: the output belongs in the panel
|
||||||
}
|
}
|
||||||
cmd.spawn().map_err(|e| format!("cannot start the release: {e}"))?;
|
let mut child = cmd.spawn().map_err(|e| format!("cannot start the release: {e}"))?;
|
||||||
|
console.lock().map_err(|_| "the release console is unusable")?.start();
|
||||||
|
|
||||||
|
// Both streams, kept separate: merging them with `2>&1` inside PowerShell wraps every
|
||||||
|
// stderr line from a native exe in a NativeCommandError and makes a clean run look failed.
|
||||||
|
let mut pumps = Vec::new();
|
||||||
|
for stream in [child.stdout.take().map(Either::Out), child.stderr.take().map(Either::Err)] {
|
||||||
|
if let Some(s) = stream {
|
||||||
|
let c = console.clone();
|
||||||
|
pumps.push(std::thread::spawn(move || match s {
|
||||||
|
Either::Out(r) => pump(r, c),
|
||||||
|
Either::Err(r) => pump(r, c),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let c = console.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let code = child.wait().ok().and_then(|s| s.code());
|
||||||
|
// Drain what is still sitting in the pipes BEFORE saying it finished: the window stops
|
||||||
|
// asking once running goes false, and the last lines are the ones that say why.
|
||||||
|
for p in pumps {
|
||||||
|
let _ = p.join();
|
||||||
|
}
|
||||||
|
if let Ok(mut c) = c.lock() {
|
||||||
|
c.finish(code);
|
||||||
|
}
|
||||||
|
});
|
||||||
Ok(file)
|
Ok(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The two pipes have different types; this lets one thread body handle either.
|
||||||
|
enum Either {
|
||||||
|
Out(std::process::ChildStdout),
|
||||||
|
Err(std::process::ChildStderr),
|
||||||
|
}
|
||||||
|
|
||||||
/// Where the Engine API deploy script is: beside the other release scripts, or in the
|
/// Where the Engine API deploy script is: beside the other release scripts, or in the
|
||||||
/// release skill that carries it.
|
/// release skill that carries it.
|
||||||
fn api_script(scripts: &Path) -> Option<PathBuf> {
|
fn api_script(scripts: &Path) -> Option<PathBuf> {
|
||||||
@@ -363,4 +501,59 @@ mod tests {
|
|||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_console_hands_over_only_what_the_window_has_not_seen() {
|
||||||
|
let mut c = Console::default();
|
||||||
|
c.start();
|
||||||
|
for i in 0..3 {
|
||||||
|
c.push(format!("line {i}"));
|
||||||
|
}
|
||||||
|
let first = c.tail(0);
|
||||||
|
assert_eq!(first.from, 0);
|
||||||
|
assert_eq!(first.total, 3);
|
||||||
|
assert_eq!(first.lines.len(), 3);
|
||||||
|
assert!(first.running, "a started console is running");
|
||||||
|
|
||||||
|
// Asking again from where it got to yields nothing, not the whole buffer again.
|
||||||
|
let again = c.tail(first.total);
|
||||||
|
assert!(again.lines.is_empty());
|
||||||
|
assert_eq!(again.total, 3);
|
||||||
|
|
||||||
|
c.push("line 3".into());
|
||||||
|
let more = c.tail(3);
|
||||||
|
assert_eq!(more.lines, vec!["line 3".to_string()]);
|
||||||
|
|
||||||
|
c.finish(Some(1));
|
||||||
|
let done = c.tail(4);
|
||||||
|
assert!(!done.running);
|
||||||
|
assert_eq!(done.exit, Some(1), "a failed release must not look finished");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trimming_a_long_release_keeps_indices_honest() {
|
||||||
|
let mut c = Console::default();
|
||||||
|
c.start();
|
||||||
|
for i in 0..(MAX_LINES + 500) {
|
||||||
|
c.push(format!("line {i}"));
|
||||||
|
}
|
||||||
|
let t = c.tail(0);
|
||||||
|
assert_eq!(t.total, MAX_LINES + 500, "total counts every line ever printed");
|
||||||
|
assert_eq!(t.lines.len(), MAX_LINES, "only the tail is kept");
|
||||||
|
assert_eq!(t.from, 500, "it says where it really starts, not 0");
|
||||||
|
assert_eq!(t.lines[0], "line 500");
|
||||||
|
// A window that asked from beyond the end gets nothing rather than a panic.
|
||||||
|
assert!(c.tail(t.total + 99).lines.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_progress_line_that_rewrites_itself_stays_one_line() {
|
||||||
|
let stream: &[u8] = b"first\r\nbuilding 10%\rbuilding 90%\rbuilt\nplain\n";
|
||||||
|
let c: Shared = Default::default();
|
||||||
|
c.lock().unwrap().start();
|
||||||
|
pump(stream, c.clone());
|
||||||
|
let got = c.lock().unwrap().tail(0);
|
||||||
|
// CRLF is an ending, not content; a lone CR overwrites, exactly as a terminal shows it.
|
||||||
|
assert_eq!(got.lines, vec!["first".to_string(), "built".to_string(), "plain".to_string()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -6,6 +6,7 @@
|
|||||||
import KeyArt from "./components/KeyArt.svelte";
|
import KeyArt from "./components/KeyArt.svelte";
|
||||||
import MarketplaceDrawer from "./components/MarketplaceDrawer.svelte";
|
import MarketplaceDrawer from "./components/MarketplaceDrawer.svelte";
|
||||||
import NewsPanel from "./components/NewsPanel.svelte";
|
import NewsPanel from "./components/NewsPanel.svelte";
|
||||||
|
import ReleaseConsole from "./components/ReleaseConsole.svelte";
|
||||||
import ReleasePanel from "./components/ReleasePanel.svelte";
|
import ReleasePanel from "./components/ReleasePanel.svelte";
|
||||||
import SettingsDrawer from "./components/SettingsDrawer.svelte";
|
import SettingsDrawer from "./components/SettingsDrawer.svelte";
|
||||||
import SyncPanel from "./components/SyncPanel.svelte";
|
import SyncPanel from "./components/SyncPanel.svelte";
|
||||||
@@ -18,6 +19,9 @@
|
|||||||
// Dev mode drops the patch notes: they describe the INSTALLED version, which is not what
|
// Dev mode drops the patch notes: they describe the INSTALLED version, which is not what
|
||||||
// a dev run starts. With that column gone the art has the whole window to sit in.
|
// a dev run starts. With that column gone the art has the whole window to sit in.
|
||||||
const dev = $derived(launcher.devOn);
|
const dev = $derived(launcher.devOn);
|
||||||
|
// A release takes that column back while it runs: its output is the one thing worth
|
||||||
|
// reading there, and it is where a developer is already looking.
|
||||||
|
const showConsole = $derived(dev && launcher.consoleOpen);
|
||||||
|
|
||||||
onMount(() => launcher.start());
|
onMount(() => launcher.start());
|
||||||
</script>
|
</script>
|
||||||
@@ -26,9 +30,11 @@
|
|||||||
<KeyArt />
|
<KeyArt />
|
||||||
<TitleBar onOpenSettings={() => (settingsOpen = true)} onOpenMarket={() => launcher.openMarket()} />
|
<TitleBar onOpenSettings={() => (settingsOpen = true)} onOpenMarket={() => launcher.openMarket()} />
|
||||||
|
|
||||||
<main class="hub" class:dev>
|
<main class="hub" class:dev={dev && !showConsole}>
|
||||||
{#if !dev}
|
{#if !dev}
|
||||||
<div class="news-slot"><NewsPanel /></div>
|
<div class="news-slot"><NewsPanel /></div>
|
||||||
|
{:else if showConsole}
|
||||||
|
<div class="news-slot"><ReleaseConsole /></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="hero-slot"><Hero /></div>
|
<div class="hero-slot"><Hero /></div>
|
||||||
<div class="action-slot"><ActionPanel /></div>
|
<div class="action-slot"><ActionPanel /></div>
|
||||||
|
|||||||
@@ -118,6 +118,11 @@
|
|||||||
await launcher.loadRelease();
|
await launcher.loadRelease();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// While one runs, this button is the way BACK to its output rather than a way to start a
|
||||||
|
// second: closing the console otherwise left a running release with nowhere to be seen.
|
||||||
|
const releasing = $derived(launcher.consoleRunning);
|
||||||
|
const hasOutput = $derived(launcher.consoleLines.length > 0 && !launcher.consoleOpen);
|
||||||
|
|
||||||
async function browseCheckout() {
|
async function browseCheckout() {
|
||||||
const path = await launcher.pickFolder();
|
const path = await launcher.pickFolder();
|
||||||
if (path) await launcher.setDev({ dev_root: path });
|
if (path) await launcher.setDev({ dev_root: path });
|
||||||
@@ -304,10 +309,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dev-row">
|
<div class="dev-row">
|
||||||
<button class="btn dev-release" disabled={blocked || !s?.online} onclick={openRelease}>
|
{#if releasing}
|
||||||
Build & deploy…
|
<button class="btn dev-release" onclick={() => launcher.showConsole()}>Release running…</button>
|
||||||
</button>
|
{:else}
|
||||||
{#if !s?.online}
|
<button class="btn dev-release" disabled={blocked || !s?.online} onclick={openRelease}>
|
||||||
|
Build & deploy…
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if hasOutput && !releasing}
|
||||||
|
<button class="btn-link" onclick={() => launcher.showConsole()}>last output</button>
|
||||||
|
{:else if !s?.online}
|
||||||
<span class="dev-note">needs the DHP</span>
|
<span class="dev-note">needs the DHP</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
import { launcher } from "../lib/launcher.svelte";
|
||||||
|
|
||||||
|
let scroller: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
|
const lines = $derived(launcher.consoleLines);
|
||||||
|
const running = $derived(launcher.consoleRunning);
|
||||||
|
const exit = $derived(launcher.consoleExit);
|
||||||
|
|
||||||
|
// Follow the output, but only while the reader is already at the bottom: scrolling back to
|
||||||
|
// read an error must not be yanked away by the next line that arrives.
|
||||||
|
let follow = $state(true);
|
||||||
|
function onScroll() {
|
||||||
|
const el = scroller;
|
||||||
|
if (!el) return;
|
||||||
|
follow = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
void lines.length;
|
||||||
|
if (follow && scroller) scroller.scrollTop = scroller.scrollHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
// A step header is worth picking out of a wall of build output, as is a failure.
|
||||||
|
function kind(line: string): string {
|
||||||
|
if (/^\s*={3}/.test(line)) return "step";
|
||||||
|
if (/^FAILED:|^\s*Erreur|error[: ]/i.test(line)) return "bad";
|
||||||
|
if (/nothing to (release|deploy)|No pack cut|Release finished/i.test(line)) return "note";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="console glass" aria-label="Release output">
|
||||||
|
<header class="head">
|
||||||
|
<h2 class="eyebrow">Release</h2>
|
||||||
|
<span class="state mono" class:live={running} class:bad={!running && exit !== null && exit !== 0}>
|
||||||
|
{#if running}
|
||||||
|
running
|
||||||
|
{:else if exit === null}
|
||||||
|
idle
|
||||||
|
{:else if exit === 0}
|
||||||
|
finished
|
||||||
|
{:else}
|
||||||
|
failed ({exit})
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<span class="total mono">{lines.length}</span>
|
||||||
|
<button class="icon-btn" aria-label="Close release output" onclick={() => launcher.closeConsole()}>
|
||||||
|
<Icon name="close" size={14} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Both are deliberate: `log` is the role for streamed output, and it has to be
|
||||||
|
focusable or a keyboard cannot scroll back through a failed build. -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<div bind:this={scroller} class="body mono selectable" onscroll={onScroll} tabindex="0" role="log">
|
||||||
|
{#if lines.length}
|
||||||
|
{#each lines as line, i (i)}
|
||||||
|
<div class="line {kind(line)}">{line || " "}</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<p class="empty">Waiting for the first line…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !running && exit !== null}
|
||||||
|
<footer class="foot">
|
||||||
|
{exit === 0 ? "Closing this keeps the output until the next release." : "The full transcript is in state\\release\\release.log."}
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.console {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--glass-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 8px 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total,
|
||||||
|
.state {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The dot earns its place: from across the desk it is the only thing that says whether a
|
||||||
|
release is still going. */
|
||||||
|
.state.live::before,
|
||||||
|
.state.bad::before {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent, #4ade80);
|
||||||
|
content: "";
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.state.live {
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.state.bad {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.state.bad::before {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
padding: 0 16px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.step {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.bad {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.note {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
SignInResult,
|
SignInResult,
|
||||||
StackEvent,
|
StackEvent,
|
||||||
Status,
|
Status,
|
||||||
|
ReleaseConsoleTail,
|
||||||
ReleasePlan,
|
ReleasePlan,
|
||||||
RemoveProgress,
|
RemoveProgress,
|
||||||
SyncConflict,
|
SyncConflict,
|
||||||
@@ -73,6 +74,7 @@ function createTauriBackend(): Backend {
|
|||||||
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
|
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
|
||||||
releasePlan: () => invoke<ReleasePlan>("release_plan"),
|
releasePlan: () => invoke<ReleasePlan>("release_plan"),
|
||||||
releaseRun: (password, withApi, withMcp) => invoke<void>("release_run", { password, withApi, withMcp }),
|
releaseRun: (password, withApi, withMcp) => invoke<void>("release_run", { password, withApi, withMcp }),
|
||||||
|
releaseConsole: (since) => invoke<ReleaseConsoleTail>("release_console", { since }),
|
||||||
onTileSyncProgress: (handler) => subscribe<TileSyncProgress>("tile-sync-progress", handler),
|
onTileSyncProgress: (handler) => subscribe<TileSyncProgress>("tile-sync-progress", handler),
|
||||||
onRemoveProgress: (handler) => subscribe<RemoveProgress>("remove-progress", handler),
|
onRemoveProgress: (handler) => subscribe<RemoveProgress>("remove-progress", handler),
|
||||||
onStack: (handler) => subscribe<StackEvent>("stack", handler),
|
onStack: (handler) => subscribe<StackEvent>("stack", handler),
|
||||||
|
|||||||
@@ -418,9 +418,61 @@ class Launcher {
|
|||||||
return this.run("release", async () => {
|
return this.run("release", async () => {
|
||||||
await backend.releaseRun(password, withApi, withMcp);
|
await backend.releaseRun(password, withApi, withMcp);
|
||||||
this.releaseOpen = false;
|
this.releaseOpen = false;
|
||||||
|
// Straight into the console: the release is running with nowhere else to show itself.
|
||||||
|
this.consoleLines = [];
|
||||||
|
this.consoleTotal = 0;
|
||||||
|
this.consoleExit = null;
|
||||||
|
this.consoleRunning = true;
|
||||||
|
this.consoleOpen = true;
|
||||||
|
this.pollConsole();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- the release console -------------------------------------------------------------
|
||||||
|
/** Shown in the notes column in dev mode, where the patch notes sit otherwise. */
|
||||||
|
consoleOpen = $state(false);
|
||||||
|
consoleLines = $state<string[]>([]);
|
||||||
|
consoleRunning = $state(false);
|
||||||
|
consoleExit = $state<number | null>(null);
|
||||||
|
/** How many lines have been taken, so each poll asks only for what is new. */
|
||||||
|
private consoleTotal = 0;
|
||||||
|
private polling = false;
|
||||||
|
|
||||||
|
/** Polled rather than pushed: reopening the panel then needs no replay, and a slow window
|
||||||
|
* cannot fall behind a build that prints thousands of lines. */
|
||||||
|
private async pollConsole() {
|
||||||
|
if (this.polling) return;
|
||||||
|
this.polling = true;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const tail = await backend.releaseConsole(this.consoleTotal);
|
||||||
|
if (tail.from > this.consoleTotal && this.consoleLines.length) {
|
||||||
|
this.consoleLines.push(`... ${tail.from - this.consoleTotal} earlier lines dropped`);
|
||||||
|
}
|
||||||
|
if (tail.lines.length) this.consoleLines.push(...tail.lines);
|
||||||
|
this.consoleTotal = tail.total;
|
||||||
|
this.consoleRunning = tail.running;
|
||||||
|
this.consoleExit = tail.exit;
|
||||||
|
if (!tail.running) return;
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.consoleRunning = false;
|
||||||
|
} finally {
|
||||||
|
this.polling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reopen it after it was closed; a finished release keeps its output to be read. */
|
||||||
|
showConsole() {
|
||||||
|
this.consoleOpen = true;
|
||||||
|
if (this.consoleRunning) this.pollConsole();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeConsole() {
|
||||||
|
this.consoleOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Change part of the settings and save, for the dev controls on the hub. */
|
/** Change part of the settings and save, for the dev controls on the hub. */
|
||||||
setDev(change: Partial<Settings>) {
|
setDev(change: Partial<Settings>) {
|
||||||
const current = this.settings;
|
const current = this.settings;
|
||||||
|
|||||||
+34
-1
@@ -24,6 +24,7 @@ import {
|
|||||||
type MarketItem,
|
type MarketItem,
|
||||||
type MockControls,
|
type MockControls,
|
||||||
type MockScenario,
|
type MockScenario,
|
||||||
|
type ReleaseConsoleTail,
|
||||||
type ReleasePlan,
|
type ReleasePlan,
|
||||||
type Settings,
|
type Settings,
|
||||||
type SignInResult,
|
type SignInResult,
|
||||||
@@ -786,7 +787,39 @@ class MockBackend implements Backend {
|
|||||||
async releaseRun(password: string, withApi: boolean, withMcp: boolean): Promise<void> {
|
async releaseRun(password: string, withApi: boolean, withMcp: boolean): Promise<void> {
|
||||||
await this.wait(400);
|
await this.wait(400);
|
||||||
if (!password.trim()) throw "The node's sudo password is needed to cut a pack and to roll the API.";
|
if (!password.trim()) throw "The node's sudo password is needed to cut a pack and to roll the API.";
|
||||||
this.log("info", `Release started in its own window (browser mock: nothing runs). API ${withApi}, MCP ${withMcp}`);
|
// Nothing runs in the browser, so play a short script into the console instead: enough
|
||||||
|
// to see the panel fill, scroll and finish.
|
||||||
|
this.consoleLines = ["=== ODIAN pack 0.74.0 ===", "", "=== Preflight ==="];
|
||||||
|
this.consoleDone = null;
|
||||||
|
const rest = [
|
||||||
|
"engine at 1255873...",
|
||||||
|
"Nothing that ships has changed. No pack cut.",
|
||||||
|
"pack: nothing to release",
|
||||||
|
`=== Launcher 0.1.40 === (API ${withApi}, MCP ${withMcp})`,
|
||||||
|
"npx tauri build ...",
|
||||||
|
"Release finished.",
|
||||||
|
];
|
||||||
|
void (async () => {
|
||||||
|
for (const line of rest) {
|
||||||
|
await this.wait(700);
|
||||||
|
this.consoleLines.push(line);
|
||||||
|
}
|
||||||
|
this.consoleDone = 0;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
private consoleLines: string[] = [];
|
||||||
|
private consoleDone: number | null = 0;
|
||||||
|
|
||||||
|
async releaseConsole(since: number): Promise<ReleaseConsoleTail> {
|
||||||
|
await this.wait(60);
|
||||||
|
return {
|
||||||
|
from: Math.min(since, this.consoleLines.length),
|
||||||
|
lines: this.consoleLines.slice(since),
|
||||||
|
total: this.consoleLines.length,
|
||||||
|
running: this.consoleDone === null,
|
||||||
|
exit: this.consoleDone,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncNow(): Promise<void> {
|
async syncNow(): Promise<void> {
|
||||||
|
|||||||
@@ -306,6 +306,17 @@ export interface ReleasePlan {
|
|||||||
blocked: string | null;
|
blocked: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A slice of the running release's output: what the window has not shown yet. */
|
||||||
|
export interface ReleaseConsoleTail {
|
||||||
|
/** Index of the first line returned; higher than asked for when older lines were trimmed. */
|
||||||
|
from: number;
|
||||||
|
lines: string[];
|
||||||
|
total: number;
|
||||||
|
running: boolean;
|
||||||
|
/** Null while running; 0 means it finished, anything else means it failed. */
|
||||||
|
exit: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const DEV_TARGETS = ["client", "web", "both"] as const;
|
export const DEV_TARGETS = ["client", "web", "both"] as const;
|
||||||
export type DevTarget = (typeof DEV_TARGETS)[number];
|
export type DevTarget = (typeof DEV_TARGETS)[number];
|
||||||
|
|
||||||
@@ -375,6 +386,7 @@ export interface Backend {
|
|||||||
syncTiles(): Promise<void>;
|
syncTiles(): Promise<void>;
|
||||||
releasePlan(): Promise<ReleasePlan>;
|
releasePlan(): Promise<ReleasePlan>;
|
||||||
releaseRun(password: string, withApi: boolean, withMcp: boolean): Promise<void>;
|
releaseRun(password: string, withApi: boolean, withMcp: boolean): Promise<void>;
|
||||||
|
releaseConsole(since: number): Promise<ReleaseConsoleTail>;
|
||||||
/** What the operator has to decide, oldest first. */
|
/** What the operator has to decide, oldest first. */
|
||||||
getSyncConflicts(): Promise<SyncConflict[]>;
|
getSyncConflicts(): Promise<SyncConflict[]>;
|
||||||
/** Records one decision; resolves with what is left to decide. */
|
/** Records one decision; resolves with what is left to decide. */
|
||||||
|
|||||||
Reference in New Issue
Block a user