Skip to content

Commit

Permalink
GUI: キャンセル可能にする & 変換ログを表示する(いずれも初期的実装) (#461)
Browse files Browse the repository at this point in the history
変換中の画面に、(1) キャンセルボタンと (2) 変換ログの表示、を設けて、いずれも最低限機能するようにする。

Close #267, Close #460

注意:

- log crate によるシステムログは受信しない。Feedback 機構から得た変換ログのみ受信する。


![Untitled](https://github.com/MIERUNE/PLATEAU-GIS-Converter/assets/5351911/b31f61df-e889-4e3b-b1da-f6c91ef6477c)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit


- **新機能**
    - `nusamai::pipeline`から`feedback`のインポートを追加しました。
    - `ConversionTasksState`構造体を追加しました。
    - `LogMessage`構造体と`Error`列挙型の実装を追加しました。
    - `run`関数を`run_conversion`に変更し、追加のパラメータとキャンセルロジックを追加しました。
    - タスクのキャンセルに使用する`cancel_conversion`関数を追加しました。

- **バグ修正**
    - エラータイプを確認してからエラーメッセージを表示するようにエラー処理を変更しました。

- **リファクタ**
    - `PipelineError`列挙型のエラーメッセージをセマンティックに調整しました。
    - `ParseError`と`Canceled`のエラーメッセージを洗練しました。

- **スタイル**
    - ローディングアニメーションと見出しテキストのスタイリングクラスを調整しました。

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
  • Loading branch information
ciscorn authored Mar 12, 2024
1 parent 96b0826 commit e8fd56d
Show file tree
Hide file tree
Showing 11 changed files with 275 additions and 87 deletions.
10 changes: 10 additions & 0 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^4.2.10",
"svelte-check": "^3.6.4",
"svelte-virtual-scroll-list": "^1.3.0",
"tailwindcss": "^3.4.1",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
Expand Down
116 changes: 87 additions & 29 deletions app/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use log::LevelFilter;
use tauri_plugin_log::{LogTarget, RotationStrategy, TimezoneStrategy};
use thiserror::Error;

use nusamai::pipeline::feedback;
use nusamai::pipeline::Canceller;
use nusamai::sink::DataSinkProvider;
use nusamai::sink::{
Expand All @@ -30,7 +31,12 @@ const LOG_LEVEL: LevelFilter = LevelFilter::Debug;
#[cfg(not(debug_assertions))]
const LOG_LEVEL: LevelFilter = LevelFilter::Info;

struct ConversionTasksState {
canceller: Arc<Mutex<Canceller>>,
}

fn main() {
// System log plugin
let tauri_loggger = tauri_plugin_log::Builder::default()
.targets([LogTarget::Stdout, LogTarget::LogDir, LogTarget::Webview])
.max_file_size(1_000_000) // in bytes
Expand All @@ -40,23 +46,58 @@ fn main() {
.level_for("sqlx", LevelFilter::Info) // suppress sqlx logs, as it's too verbose in DEBUG level
.build();

// Build and run the Tauri app
tauri::Builder::default()
.plugin(tauri_loggger)
.invoke_handler(tauri::generate_handler![run])
.manage(ConversionTasksState {
canceller: Arc::new(Mutex::new(Canceller::default())),
})
.invoke_handler(tauri::generate_handler![run_conversion, cancel_conversion])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

#[derive(Error, Debug)]
#[derive(Clone, serde::Serialize)]
struct LogMessage {
message: String,
level: String,
error_message: Option<String>,
source: String,
}

impl From<&feedback::Message> for LogMessage {
fn from(msg: &feedback::Message) -> Self {
LogMessage {
message: msg.message.to_string(),
level: msg.level.to_string(),
error_message: msg.error.as_ref().map(|e| e.to_string()),
source: msg.source_component.to_string(),
}
}
}

// Everything returned from Tauri commands must implement serde::Serialize
#[derive(Error, Debug, serde::Serialize)]
#[serde(tag = "type", content = "message")]
enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("I/O error: {0}")]
Io(String),
#[error("Invalid path: {0}")]
InvalidPath(String),
#[error("Invalid setting: {0}")]
InvalidSetting(String),
#[error("Invalid mapping rules: {0}")]
InvalidMappingRules(String),
#[error("Conversion failed: {0}")]
ConversionFailed(String),
#[error("Conversion canceled")]
Canceled,
}

impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
Error::Io(err.to_string())
}
}

fn select_sink_provider(filetype: &str) -> Option<Box<dyn DataSinkProvider>> {
Expand All @@ -77,24 +118,19 @@ fn select_sink_provider(filetype: &str) -> Option<Box<dyn DataSinkProvider>> {
}
}

// Everything returned from Tauri commands must implement serde::Serialize
impl serde::Serialize for Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}

#[tauri::command]
fn run(
#[tauri::command(async)]
fn run_conversion(
input_paths: Vec<String>,
output_path: String,
filetype: String,
epsg: u16,
rules_path: String,
tasks_state: tauri::State<ConversionTasksState>,
window: tauri::Window,
) -> Result<(), Error> {
// Request cancellation of previous task if still running
tasks_state.canceller.lock().unwrap().cancel();

// Check the existence of the input paths
for path in input_paths.iter() {
if !PathBuf::from_str(path).unwrap().exists() {
Expand Down Expand Up @@ -122,9 +158,6 @@ fn run(

log::info!("Running pipeline with input: {:?}", input_paths);

// TODO: set cancellation handler
let canceller = Arc::new(Mutex::new(Canceller::default()));

let sink = {
let sink_provider = select_sink_provider(&filetype).ok_or_else(|| {
let msg = format!("Invalid sink type: {}", filetype);
Expand Down Expand Up @@ -203,22 +236,47 @@ fn run(
// start the pipeline
let (handle, watcher, inner_canceller) =
nusamai::pipeline::run(source, transformer, sink, schema.into());
*canceller.lock().unwrap() = inner_canceller;

std::thread::scope(|scope| {
// Store the canceller to the application state
*tasks_state.canceller.lock().unwrap() = inner_canceller;

let first_error = std::thread::scope(|scope| {
// log watcher
scope.spawn(move || {
for msg in watcher {
log::info!("Feedback message from the pipeline {:?}", msg);
}
});
});
scope
.spawn(move || {
for msg in watcher {
window
.emit("conversion-log", LogMessage::from(&msg))
.unwrap();
if let Some(err) = &msg.error {
return Some(Error::ConversionFailed(err.to_string()));
}
}
None
})
.join()
})
.unwrap();

// wait for the pipeline to finish
// Wait for the pipeline to finish
handle.join();
if canceller.lock().unwrap().is_canceled() {

// Return error if an error occurred in the pipeline
if let Some(err) = first_error {
return Err(err);
}

// Return the 'Canceled' error if the pipeline is canceled
if tasks_state.canceller.lock().unwrap().is_canceled() {
log::info!("Pipeline canceled");
return Err(Error::Canceled);
};

Ok(())
}

/// Request cancellation of the current conversion task
#[tauri::command]
fn cancel_conversion(tasks_state: tauri::State<ConversionTasksState>) {
tasks_state.canceller.lock().unwrap().cancel();
}
6 changes: 4 additions & 2 deletions app/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,13 @@
},
"windows": [
{
"title": "PLATEAU 都市デジタルツインGISコンバータ",
"title": "PLATEAU 都市デジタルツイン GISコンバータ",
"fullscreen": false,
"resizable": true,
"height": 800,
"width": 640
"width": 640,
"minHeight": 600,
"minWidth": 500
}
]
}
Expand Down
19 changes: 12 additions & 7 deletions app/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,32 +32,37 @@
isRunning = true;
try {
await invoke('run', {
await invoke('run_conversion', {
inputPaths,
outputPath,
filetype,
epsg,
rulesPath
});
isRunning = false;
await message(`変換が完了しました。\n'${outputPath}' に出力しました。`, { type: 'info' });
} catch (error) {
await message(`エラーが発生しました。\n\n${error}`, { type: 'error' });
} catch (error: any) {
if (error.type != 'Canceled') {
await message(`エラーが発生しました。\n\n${error.type}: ${error.message}`, {
title: '変換エラー',
type: 'error'
});
}
isRunning = false;
}
isRunning = false;
}
</script>

{#if isRunning}
<div class="grid place-items-center absolute w-screen h-screen z-20 bg-black/60">
<div class="absolute inset-0 bg-black/70 backdrop-blur-[2px] z-20">
<LoadingAnimation />
</div>
{/if}

<div class="grid place-items-center h-screen">
<div class="max-w-2xl flex flex-col gap-12">
<div class="flex items-center gap-1.5">
<h1 class="font-bold text-2xl">PLATEAU 都市デジタルツインGISコンバータ</h1>
<h1 class="font-bold text-2xl">PLATEAU 都市デジタルツイン GISコンバータ</h1>
<a href="/about" class="hover:text-accent1">
<Icon class="text-2xl mt-0.5" icon="mingcute:information-line" />
</a>
Expand Down
98 changes: 95 additions & 3 deletions app/src/routes/LoadingAnimation.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,103 @@
<div class="loader flex flex-col gap-3">
<p class="text-white font-semibold text-2xl">変換中 ...</p>
<script lang="ts">
import Icon from '@iconify/svelte';
import { invoke } from '@tauri-apps/api/tauri';
import { listen } from '@tauri-apps/api/event';
import { onMount } from 'svelte';
import VirtualScroll from 'svelte-virtual-scroll-list';
<div class="loader ball-pulse flex">
async function cancelConversion() {
await invoke('cancel_conversion');
}
let items: Array<{
id: number;
message: string;
level: string;
error_message?: string;
source: string;
}> = [];
let logView: VirtualScroll;
// Setup log monitor
onMount(() => {
let promise = listen<{
message: string;
level: string;
error_message?: string;
source: string;
}>('conversion-log', (event) => {
items.push({
id: items.length,
...event.payload
});
items = items;
logView.scrollToBottom();
});
return () => {
promise.then((unlisten) => {
unlisten();
});
};
});
</script>

<div class="flex flex-col gap-4 p-12 place-items-center">
<p class="text-white text-center font-semibold text-2xl">変換中 &hellip;</p>
<div class="loader ball-pulse flex justify-center">
<div />
<div />
<div />
</div>

<div
class="my-5 w-full h-96 max-h-96 bg-slate-900/70 text-slate-300 text-xs font-mono p-1 rounded"
>
<div class="w-full h-full">
<VirtualScroll bind:this={logView} data={items} key="id" let:data>
<div>
{#if data.level === 'ERROR'}
<span
class="inline-flex items-center rounded-md bg-red-400/10 px-1 py-0.5 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20"
>ERROR</span
>
{:else if data.level === 'WARN'}
<span
class="inline-flex items-center rounded-md bg-yellow-400/10 px-1 py-0.5 text-xs font-medium text-yellow-500 ring-1 ring-inset ring-yellow-400/20"
>WARN</span
>
{:else if data.level === 'INFO'}
<span
class="inline-flex items-center rounded-md bg-blue-400/10 px-1 py-0.5 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/30"
>INFO</span
>
{/if}

<span
class="inline-flex items-center rounded-md bg-gray-400/10 px-2 py-1 text-xs font-medium text-gray-400 ring-1 ring-inset ring-gray-400/20"
>{data.source}</span
>

{data.message}

{#if data.error_message}
<span class="text-red-600">{data.error_message}</span>
{/if}
</div>
</VirtualScroll>
</div>
</div>

<div>
<button
on:click={cancelConversion}
class="bg-red-300 flex items-center font-bold py-1.5 pl-3 pr-5 rounded-full gap-1 shawdow-2xl hover:bg-red-400"
>
<Icon class="text-lg" icon="ic:baseline-cancel" />
キャンセル
</button>
</div>
</div>

<style>
Expand Down
Loading

0 comments on commit e8fd56d

Please sign in to comment.