back: - remove tui deadcode - redirect all unknown links to index.html - fix integration tests - fix linking errors by removing wrong libc.a - split update_item into concrete endpoints - simplify ProcOutput (retain only stderr header) - fix PanelResult front: - load all assets from /core/ - improve current tab highlighting - show, edit and delete job and result info - allow to add and assign jobspull/1/head
parent
862fb6b338
commit
aad7772a71
56 changed files with 645 additions and 1357 deletions
@ -1,6 +1,5 @@ |
||||
<nav mat-tab-nav-bar animationDuration="0ms" mat-align-tabs="center"> |
||||
<a mat-tab-link routerLink="/agents" routerLinkActive="active" ariaCurrentWhenActive="page">Agents</a> |
||||
<a mat-tab-link routerLink="/jobs" routerLinkActive="active" ariaCurrentWhenActive="page">Jobs</a> |
||||
<a mat-tab-link routerLink="/results" routerLinkActive="active" ariaCurrentWhenActive="page">Results</a> |
||||
<a mat-tab-link *ngFor="let tab of tabs" routerLink={{tab.link}} routerLinkActive #rla="routerLinkActive" |
||||
[active]="rla.isActive" [routerLinkActiveOptions]="{ exact: true }">{{tab.name}}</a> |
||||
</nav> |
||||
<router-outlet></router-outlet> |
@ -1,8 +1,12 @@ |
||||
export interface JobModel { |
||||
import { ApiModel } from "."; |
||||
|
||||
export interface JobModel extends ApiModel { |
||||
alias: string, |
||||
argv: string, |
||||
id: string, |
||||
exec_type: string, |
||||
platform: string, |
||||
payload: Uint8Array | null, |
||||
payload: number[] | null, |
||||
payload_path: string | null, |
||||
schedule: string | null, |
||||
} |
@ -1,12 +1,12 @@ |
||||
import { UTCDate } from "."; |
||||
import { UTCDate, ApiModel } from "."; |
||||
|
||||
export interface ResultModel { |
||||
export interface ResultModel extends ApiModel { |
||||
agent_id: string, |
||||
alias: string, |
||||
created: UTCDate, |
||||
id: string, |
||||
job_id: string, |
||||
result: Uint8Array, |
||||
result: number[], |
||||
state: "Queued" | "Running" | "Finished", |
||||
retcode: number | null, |
||||
updated: UTCDate, |
||||
|
@ -0,0 +1,12 @@ |
||||
<h2 mat-dialog-title>Assign job</h2> |
||||
<mat-dialog-content> |
||||
<mat-selection-list #jobsList [(ngModel)]="selected_rows"> |
||||
<mat-list-option *ngFor="let row of rows" [value]="row"> |
||||
{{row}} |
||||
</mat-list-option> |
||||
</mat-selection-list> |
||||
</mat-dialog-content> |
||||
<mat-dialog-actions align="end"> |
||||
<button mat-raised-button mat-dialog-close (click)="assignSelectedJobs()">Assign</button> |
||||
<button mat-button mat-dialog-close>Cancel</button> |
||||
</mat-dialog-actions> |
@ -0,0 +1,33 @@ |
||||
import { Component, Inject } from '@angular/core'; |
||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog'; |
||||
import { HttpClient } from '@angular/common/http'; |
||||
import { ApiTableService } from '../../services'; |
||||
import { JobModel } from '../../models'; |
||||
import { MatListOption } from '@angular/material/list'; |
||||
|
||||
@Component({ |
||||
selector: 'assign-job-dialog', |
||||
templateUrl: 'assign-job-dialog.html', |
||||
styleUrls: [] |
||||
}) |
||||
export class AssignJobDialogComponent { |
||||
rows: string[] = []; |
||||
selected_rows: string[] = []; |
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public agent_id: string, private http: HttpClient) { |
||||
new ApiTableService(http, "jobs").getMany().then(result => { |
||||
if (result.status == "ok") { |
||||
const jobs = result.data as JobModel[] |
||||
this.rows = jobs.map(j => `${j.id} ${j.alias}`) |
||||
} else { |
||||
alert(result.data as string) |
||||
} |
||||
}).catch(err => alert(err)) |
||||
} |
||||
|
||||
assignSelectedJobs() { |
||||
const job_ids = this.selected_rows.map(row => row.split(' ', 1)[0]).join(' '); |
||||
const request = `${this.agent_id} ${job_ids}` |
||||
new ApiTableService(this.http, "map").create(request).catch(err => alert(err)) |
||||
} |
||||
} |
@ -1 +1,4 @@ |
||||
export * from './agent_info.component'; |
||||
export * from './agent_info.component'; |
||||
export * from './result_info.component'; |
||||
export * from './job_info.component'; |
||||
export * from './assign_job.component'; |
@ -1,3 +1,14 @@ |
||||
.info-dlg-field { |
||||
width: 100%; |
||||
} |
||||
|
||||
div.info-dialog-forms-box { |
||||
width: 100%; |
||||
margin-right: 10px; |
||||
} |
||||
|
||||
div.info-dialog-forms-box-smol { |
||||
width: 30%; |
||||
float: left; |
||||
margin-right: 10px; |
||||
} |
@ -0,0 +1,50 @@ |
||||
<h2 mat-dialog-title *ngIf="is_preview">Job info</h2> |
||||
<h2 mat-dialog-title *ngIf="!is_preview">Editing job info</h2> |
||||
<mat-dialog-content> |
||||
<div class="info-dialog-forms-box-smol"> |
||||
<mat-form-field class="info-dlg-field" cdkFocusInitial> |
||||
<mat-label>ID</mat-label> |
||||
<input matInput disabled value="{{data.id}}"> |
||||
</mat-form-field> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Alias</mat-label> |
||||
<input matInput [readonly]="is_preview" [(ngModel)]="data.alias"> |
||||
</mat-form-field> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Args</mat-label> |
||||
<input matInput [readonly]="is_preview" [(ngModel)]="data.argv"> |
||||
</mat-form-field> |
||||
</div> |
||||
<div class="info-dialog-forms-box-smol"> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Type</mat-label> |
||||
<input matInput [readonly]="is_preview" [(ngModel)]="data.exec_type"> |
||||
</mat-form-field> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Platform</mat-label> |
||||
<input matInput [readonly]="is_preview" [(ngModel)]="data.platform"> |
||||
</mat-form-field> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Schedule</mat-label> |
||||
<input matInput [readonly]="is_preview" [(ngModel)]="data.schedule"> |
||||
</mat-form-field> |
||||
</div> |
||||
<div class="info-dialog-forms-box-smol"> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Payload path</mat-label> |
||||
<input matInput [readonly]="is_preview" [(ngModel)]="data.payload_path"> |
||||
</mat-form-field> |
||||
</div> |
||||
<div class="info-dialog-forms-box"> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Payload</mat-label> |
||||
<textarea matInput cdkTextareaAutosize [readonly]="is_preview" [(ngModel)]="decodedPayload"> |
||||
</textarea> |
||||
</mat-form-field> |
||||
</div> |
||||
</mat-dialog-content> |
||||
<mat-dialog-actions align="end"> |
||||
<button mat-raised-button *ngIf="is_preview" (click)="is_preview = false">Edit</button> |
||||
<button mat-raised-button *ngIf="!is_preview" (click)="updateJob()">Save</button> |
||||
<button mat-button mat-dialog-close>Cancel</button> |
||||
</mat-dialog-actions> |
@ -0,0 +1,30 @@ |
||||
import { Component, Inject } from '@angular/core'; |
||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog'; |
||||
import { JobModel } from '../../models/job.model'; |
||||
import { EventEmitter } from '@angular/core'; |
||||
|
||||
@Component({ |
||||
selector: 'job-info-dialog', |
||||
templateUrl: 'job-info-dialog.html', |
||||
styleUrls: ['info-dialog.component.less'] |
||||
}) |
||||
export class JobInfoDialogComponent { |
||||
is_preview = true; |
||||
decodedPayload: string; |
||||
onSave = new EventEmitter(); |
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public data: JobModel) { |
||||
if (data.payload !== null) { |
||||
this.decodedPayload = new TextDecoder().decode(new Uint8Array(data.payload)) |
||||
} else { |
||||
this.decodedPayload = "" |
||||
} |
||||
} |
||||
|
||||
updateJob() { |
||||
if (this.decodedPayload.length > 0) { |
||||
this.data.payload = Array.from(new TextEncoder().encode(this.decodedPayload)) |
||||
} |
||||
this.onSave.emit(this.data); |
||||
} |
||||
} |
@ -0,0 +1,53 @@ |
||||
<h2 mat-dialog-title>Result</h2> |
||||
<mat-dialog-content> |
||||
<div class="info-dialog-forms-box-smol"> |
||||
<mat-form-field class="info-dlg-field" cdkFocusInitial> |
||||
<mat-label>ID</mat-label> |
||||
<input matInput readonly value="{{data.id}}"> |
||||
</mat-form-field> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Job ID</mat-label> |
||||
<input matInput readonly value="{{data.job_id}}"> |
||||
</mat-form-field> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Agent ID</mat-label> |
||||
<input matInput readonly value="{{data.agent_id}}"> |
||||
</mat-form-field> |
||||
</div> |
||||
<div class="info-dialog-forms-box-smol"> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Alias</mat-label> |
||||
<input matInput readonly value="{{data.alias}}"> |
||||
</mat-form-field> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>State</mat-label> |
||||
<input matInput readonly value="{{data.state}}"> |
||||
</mat-form-field> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Return code</mat-label> |
||||
<input matInput readonly value="{{data.retcode}}"> |
||||
</mat-form-field> |
||||
</div> |
||||
<div class="info-dialog-forms-box-smol"> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Created</mat-label> |
||||
<input matInput readonly value="{{data.created.secs_since_epoch * 1000 | date:'long'}}"> |
||||
</mat-form-field> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Updated</mat-label> |
||||
<input matInput readonly value="{{data.updated.secs_since_epoch * 1000 | date:'long'}}"> |
||||
</mat-form-field> |
||||
</div> |
||||
<div class="info-dialog-forms-box"> |
||||
<p> |
||||
<mat-form-field class="info-dlg-field"> |
||||
<mat-label>Result</mat-label> |
||||
<textarea matInput cdkTextareaAutosize readonly value="{{decodedResult}}"> |
||||
</textarea> |
||||
</mat-form-field> |
||||
</p> |
||||
</div> |
||||
</mat-dialog-content> |
||||
<mat-dialog-actions align="end"> |
||||
<button mat-button mat-dialog-close>Close</button> |
||||
</mat-dialog-actions> |
@ -0,0 +1,20 @@ |
||||
import { Component, Inject } from '@angular/core'; |
||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog'; |
||||
import { ResultModel } from '../../models/result.model'; |
||||
|
||||
@Component({ |
||||
selector: 'result-info-dialog', |
||||
templateUrl: 'result-info-dialog.html', |
||||
styleUrls: ['info-dialog.component.less'] |
||||
}) |
||||
export class ResultInfoDialogComponent { |
||||
decodedResult: string; |
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public data: ResultModel) { |
||||
if (data.result !== null) { |
||||
this.decodedResult = new TextDecoder().decode(new Uint8Array(data.result)) |
||||
} else { |
||||
this.decodedResult = "" |
||||
} |
||||
} |
||||
} |
@ -1,7 +1,3 @@ |
||||
export function epochToStr(epoch: number): string { |
||||
return new Date(epoch * 1000).toLocaleString('en-GB') |
||||
} |
||||
|
||||
export function emitErr(e: any) { |
||||
alert(e) |
||||
} |
@ -1,4 +1,4 @@ |
||||
export const environment = { |
||||
production: true, |
||||
server: "" |
||||
server: "", |
||||
}; |
||||
|
@ -1,16 +1,18 @@ |
||||
<!doctype html> |
||||
<html lang="en"> |
||||
|
||||
<head> |
||||
<meta charset="utf-8"> |
||||
<title>Fe</title> |
||||
<base href="/"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||
<link rel="icon" type="image/x-icon" href="favicon.ico"> |
||||
<link rel="preconnect" href="https://fonts.gstatic.com"> |
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet"> |
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> |
||||
</head> |
||||
|
||||
<body class="mat-typography"> |
||||
<app-root></app-root> |
||||
</body> |
||||
</html> |
||||
|
||||
</html> |
@ -1,61 +0,0 @@ |
||||
use crate::tui::windows::{ConfirmWnd, MainWnd, WndId}; |
||||
use crate::CLIENT; |
||||
use u_lib::models::{Agent, AssignedJob, JobMeta}; |
||||
use u_lib::UResult; |
||||
use uuid::Uuid; |
||||
|
||||
pub trait Id<T> { |
||||
fn id(&self) -> T; |
||||
} |
||||
|
||||
#[macro_export] |
||||
macro_rules! impl_id { |
||||
($id_type:tt => $($type:tt),+) => { |
||||
$( |
||||
impl Id<$id_type> for $type { |
||||
fn id(&self) -> $id_type { |
||||
self.id |
||||
} |
||||
})+ |
||||
}; |
||||
} |
||||
|
||||
impl_id!(Uuid => Agent, JobMeta, AssignedJob); |
||||
impl_id!(WndId => MainWnd, ConfirmWnd); |
||||
|
||||
#[async_trait] |
||||
pub trait CRUD: Id<Uuid> |
||||
where |
||||
Self: Sized, |
||||
{ |
||||
async fn read() -> UResult<Vec<Self>>; |
||||
|
||||
async fn delete(uid: Uuid) -> UResult<i32> { |
||||
CLIENT.del(Some(uid)).await |
||||
} |
||||
//TODO: other crud
|
||||
} |
||||
|
||||
#[async_trait] |
||||
impl CRUD for Agent { |
||||
async fn read() -> UResult<Vec<Agent>> { |
||||
CLIENT.get_agents(None).await.map(|r| r.into_builtin_vec()) |
||||
} |
||||
} |
||||
|
||||
#[async_trait] |
||||
impl CRUD for AssignedJob { |
||||
async fn read() -> UResult<Vec<AssignedJob>> { |
||||
CLIENT |
||||
.get_agent_jobs(None) |
||||
.await |
||||
.map(|r| r.into_builtin_vec()) |
||||
} |
||||
} |
||||
|
||||
#[async_trait] |
||||
impl CRUD for JobMeta { |
||||
async fn read() -> UResult<Vec<JobMeta>> { |
||||
CLIENT.get_jobs(None).await.map(|r| r.into_builtin_vec()) |
||||
} |
||||
} |
@ -1,187 +0,0 @@ |
||||
mod impls; |
||||
mod utils; |
||||
mod windows; |
||||
|
||||
use crate::argparse::TUIArgs; |
||||
use anyhow::Result as AResult; |
||||
use backtrace::Backtrace; |
||||
use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}; |
||||
use crossterm::execute; |
||||
use crossterm::terminal::{ |
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, |
||||
}; |
||||
use once_cell::sync::Lazy; |
||||
use std::{ |
||||
env, |
||||
io::{stdout, Stdout}, |
||||
panic::set_hook, |
||||
process::exit, |
||||
sync::atomic::{AtomicBool, Ordering}, |
||||
thread, |
||||
time::Duration, |
||||
}; |
||||
use tui::{backend::CrosstermBackend, Terminal}; |
||||
use utils::{AsyncChannel, Channel}; |
||||
use windows::{MainWnd, SharedWnd, WndId, WM}; |
||||
|
||||
pub type Backend = CrosstermBackend<Stdout>; |
||||
pub type Frame<'f> = tui::Frame<'f, Backend>; |
||||
|
||||
const EVENT_GEN_PERIOD: Duration = Duration::from_millis(70); |
||||
|
||||
static GENERAL_EVENT_CHANNEL: Lazy<Channel<GEvent>> = Lazy::new(|| Channel::new()); |
||||
static ACTIVE_LOOP: AtomicBool = AtomicBool::new(true); |
||||
|
||||
enum GEvent { |
||||
CreateWnd(SharedWnd), |
||||
CloseWnd { wid: WndId, force: bool }, |
||||
Exit, |
||||
Key(KeyCode), |
||||
Tick, |
||||
} |
||||
|
||||
fn get_terminal() -> AResult<Terminal<Backend>> { |
||||
let backend = CrosstermBackend::new(stdout()); |
||||
Ok(Terminal::new(backend)?) |
||||
} |
||||
|
||||
pub async fn init_tui(args: &TUIArgs) -> AResult<()> { |
||||
let gui = !args.nogui; |
||||
init_logger(); |
||||
info!("Initializing u_panel"); |
||||
|
||||
init_signal_handlers(gui); |
||||
init_panic_handler(gui); |
||||
term_setup(gui)?; |
||||
info!("Starting loop"); |
||||
|
||||
let result = init_loop(args).await; |
||||
info!("Exiting"); |
||||
term_teardown(gui)?; |
||||
result |
||||
} |
||||
|
||||
async fn init_loop(args: &TUIArgs) -> AResult<()> { |
||||
let gui = !args.nogui; |
||||
if gui { |
||||
WM::lock().await.clear()?; |
||||
} |
||||
WM::lock().await.push_new::<MainWnd>().await; |
||||
|
||||
thread::spawn(move || { |
||||
while is_running() { |
||||
if event::poll(EVENT_GEN_PERIOD).unwrap() { |
||||
match event::read().unwrap() { |
||||
Event::Key(key) => { |
||||
GENERAL_EVENT_CHANNEL.send(GEvent::Key(key.code)); |
||||
GENERAL_EVENT_CHANNEL.send(GEvent::Tick); |
||||
} |
||||
_ => (), |
||||
} |
||||
} else { |
||||
GENERAL_EVENT_CHANNEL.send(GEvent::Tick) |
||||
} |
||||
} |
||||
}); |
||||
|
||||
while is_running() { |
||||
match GENERAL_EVENT_CHANNEL.recv() { |
||||
GEvent::Tick => { |
||||
let mut wh = WM::lock().await; |
||||
wh.update().await?; |
||||
if gui { |
||||
wh.draw().await?; |
||||
} |
||||
} |
||||
GEvent::CloseWnd { wid, force } => { |
||||
WM::lock().await.close(wid, force).await; |
||||
} |
||||
GEvent::Key(key) => { |
||||
info!(?key, "pressed"); |
||||
if let KeyCode::Char('q') = key { |
||||
break_global(); |
||||
} else { |
||||
WM::lock().await.send_handle_kbd(key).await; |
||||
} |
||||
} |
||||
GEvent::CreateWnd(wnd) => { |
||||
WM::lock().await.push_dyn(wnd).await; |
||||
} |
||||
GEvent::Exit => { |
||||
break_global(); |
||||
} |
||||
} |
||||
} |
||||
Ok(()) |
||||
} |
||||
|
||||
#[inline] |
||||
fn is_running() -> bool { |
||||
ACTIVE_LOOP.load(Ordering::Relaxed) |
||||
} |
||||
|
||||
fn break_global() { |
||||
ACTIVE_LOOP.store(false, Ordering::Relaxed); |
||||
} |
||||
|
||||
fn init_signal_handlers(gui: bool) { |
||||
use signal_hook::{ |
||||
consts::{SIGINT, SIGTERM}, |
||||
iterator::Signals, |
||||
}; |
||||
|
||||
thread::spawn(move || { |
||||
let mut signals = Signals::new(&[SIGINT, SIGTERM]).unwrap(); |
||||
for sig in signals.forever() { |
||||
match sig { |
||||
SIGINT => break_global(), |
||||
SIGTERM => { |
||||
break_global(); |
||||
term_teardown(gui).ok(); |
||||
exit(3); |
||||
} |
||||
_ => (), |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
|
||||
fn init_logger() { |
||||
use tracing_appender::rolling::{RollingFileAppender, Rotation}; |
||||
use tracing_subscriber::EnvFlter;
|
||||
|
||||
if env::var("RUST_LOG").is_err() { |
||||
env::set_var("RUST_LOG", "info") |
||||
} |
||||
|
||||
tracing_subscriber::fmt::fmt() |
||||
.with_env_filter(EnvFilter::from_default_env()) |
||||
.with_writer(|| RollingFileAppender::new(Rotation::NEVER, ".", "u_panel.log")) |
||||
.init(); |
||||
} |
||||
|
||||
fn init_panic_handler(gui: bool) { |
||||
set_hook(Box::new(move |panic_info| { |
||||
term_teardown(gui).ok(); |
||||
eprintln!("CRITICAL PANIK ENCOUNTRD OMFG!11! go see logz lul"); |
||||
error!("{}\n{:?}", panic_info, Backtrace::new()); |
||||
exit(254); |
||||
})); |
||||
} |
||||
|
||||
fn term_setup(gui: bool) -> AResult<()> { |
||||
enable_raw_mode()?; |
||||
if gui { |
||||
execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?; |
||||
} |
||||
Ok(()) |
||||
} |
||||
|
||||
fn term_teardown(gui: bool) -> AResult<()> { |
||||
disable_raw_mode()?; |
||||
if gui { |
||||
get_terminal()?.show_cursor()?; |
||||
execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture)?; |
||||
} |
||||
Ok(()) |
||||
} |
@ -1,58 +0,0 @@ |
||||
use async_channel::{ |
||||
unbounded as unbounded_async, Receiver as AsyncReceiver, RecvError, SendError, |
||||
Sender as AsyncSender, |
||||
}; |
||||
use crossbeam::channel::{unbounded, Receiver, Sender}; |
||||
|
||||
pub struct Channel<T> { |
||||
pub tx: Sender<T>, |
||||
pub rx: Receiver<T>, |
||||
} |
||||
|
||||
impl<T> Channel<T> { |
||||
pub fn new() -> Self { |
||||
let (tx, rx) = unbounded::<T>(); |
||||
Self { tx, rx } |
||||
} |
||||
|
||||
pub fn send(&self, msg: T) { |
||||
self.tx.send(msg).unwrap() |
||||
} |
||||
|
||||
pub fn recv(&self) -> T { |
||||
self.rx.recv().unwrap() |
||||
} |
||||
} |
||||
|
||||
impl<T> Default for Channel<T> { |
||||
fn default() -> Self { |
||||
Channel::new() |
||||
} |
||||
} |
||||
|
||||
#[derive(Clone)] |
||||
pub struct AsyncChannel<T> { |
||||
pub tx: AsyncSender<T>, |
||||
pub rx: AsyncReceiver<T>, |
||||
} |
||||
|
||||
impl<T> AsyncChannel<T> { |
||||
pub fn new() -> Self { |
||||
let (tx, rx) = unbounded_async::<T>(); |
||||
Self { tx, rx } |
||||
} |
||||
|
||||
pub async fn send(&self, msg: T) -> Result<(), SendError<T>> { |
||||
self.tx.send(msg).await |
||||
} |
||||
|
||||
pub async fn recv(&self) -> Result<T, RecvError> { |
||||
self.rx.recv().await |
||||
} |
||||
} |
||||
|
||||
impl<T> Default for AsyncChannel<T> { |
||||
fn default() -> Self { |
||||
AsyncChannel::new() |
||||
} |
||||
} |
@ -1,127 +0,0 @@ |
||||
use super::{ReturnVal, Window, WndId}; |
||||
use crate::tui::Frame; |
||||
use anyhow::Result as AResult; |
||||
use crossterm::event::KeyCode; |
||||
use tui::layout::{Alignment, Constraint, Direction, Layout, Rect}; |
||||
use tui::style::{Color, Modifier, Style}; |
||||
use tui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; |
||||
|
||||
#[derive(Default)] |
||||
pub struct ConfirmWnd { |
||||
pub id: WndId, |
||||
msg: String, |
||||
variants: Vec<String>, |
||||
state: ListState, |
||||
} |
||||
|
||||
impl ConfirmWnd { |
||||
pub fn new(msg: impl Into<String>, variants: &[&str]) -> Self { |
||||
let variants = if !variants.is_empty() { |
||||
variants |
||||
} else { |
||||
&["Yes", "No"] |
||||
}; |
||||
let mut this = Self { |
||||
msg: msg.into(), |
||||
variants: variants.into_iter().map(|s| s.to_string()).collect(), |
||||
..Default::default() |
||||
}; |
||||
this.state.select(Some(0)); |
||||
this |
||||
} |
||||
|
||||
pub fn on_right(&mut self) { |
||||
let selected = self.state.selected().unwrap_or(0); |
||||
self.state |
||||
.select(Some((selected + 1).rem_euclid(self.variants.len()))); |
||||
} |
||||
|
||||
pub fn on_left(&mut self) { |
||||
let selected = self.state.selected().unwrap_or(0); |
||||
let vars_len = self.variants.len(); |
||||
self.state |
||||
.select(Some((selected + vars_len - 1).rem_euclid(vars_len))); |
||||
} |
||||
} |
||||
|
||||
#[async_trait] |
||||
impl Window for ConfirmWnd { |
||||
async fn handle_kbd(&mut self, k: KeyCode) -> AResult<()> { |
||||
match k { |
||||
KeyCode::Right => self.on_right(), |
||||
KeyCode::Left => self.on_left(), |
||||
KeyCode::Enter => self.close(false, true).await, |
||||
KeyCode::Esc => self.close(false, false).await, |
||||
_ => (), |
||||
} |
||||
Ok(()) |
||||
} |
||||
|
||||
async fn handle_update(&mut self) -> AResult<()> { |
||||
Ok(()) |
||||
} |
||||
|
||||
async fn handle_close(&mut self) -> bool { |
||||
true |
||||
} |
||||
|
||||
fn draw(&mut self, f: &mut Frame) { |
||||
let size = f.size(); |
||||
let rect = centered_rect(60, 40, size); |
||||
let popup = Block::default().title("Popup").borders(Borders::ALL); |
||||
f.render_widget(Clear, rect); |
||||
f.render_widget(popup, rect); |
||||
|
||||
let chunks = Layout::default() |
||||
.direction(Direction::Vertical) |
||||
.constraints(vec![Constraint::Percentage(40), Constraint::Percentage(30)]) |
||||
.split(rect); |
||||
let msg = Paragraph::new(self.msg.as_ref()); |
||||
f.render_widget(msg, chunks[0]); |
||||
|
||||
let options = self |
||||
.variants |
||||
.iter() |
||||
.map(AsRef::as_ref) |
||||
.map(ListItem::new) |
||||
.collect::<Vec<ListItem>>(); |
||||
let list = |
||||
List::new(options).highlight_style(Style::default().bg(Color::Gray).fg(Color::Black)); |
||||
f.render_stateful_widget(list, chunks[1], &mut self.state); |
||||
} |
||||
|
||||
fn retval(&self) -> ReturnVal { |
||||
let value = self |
||||
.variants |
||||
.get(self.state.selected().unwrap()) |
||||
.unwrap() |
||||
.to_owned(); |
||||
ReturnVal::String(value) |
||||
} |
||||
} |
||||
|
||||
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { |
||||
let popup_layout = Layout::default() |
||||
.direction(Direction::Vertical) |
||||
.constraints( |
||||
[ |
||||
Constraint::Percentage((100 - percent_y) / 2), |
||||
Constraint::Percentage(percent_y), |
||||
Constraint::Percentage((100 - percent_y) / 2), |
||||
] |
||||
.as_ref(), |
||||
) |
||||
.split(r); |
||||
|
||||
Layout::default() |
||||
.direction(Direction::Horizontal) |
||||
.constraints( |
||||
[ |
||||
Constraint::Percentage((100 - percent_x) / 2), |
||||
Constraint::Percentage(percent_x), |
||||
Constraint::Percentage((100 - percent_x) / 2), |
||||
] |
||||
.as_ref(), |
||||
) |
||||
.split(popup_layout[1])[1] |
||||
} |
@ -1,325 +0,0 @@ |
||||
use super::{ConfirmWnd, ReturnVal, Window}; |
||||
use crate::tui::{impls::CRUD, windows::WndId, Frame}; |
||||
use anyhow::Result as AResult; |
||||
use crossterm::event::KeyCode; |
||||
use std::{fmt::Display, str::FromStr}; |
||||
use strum::VariantNames; |
||||
use tokio::join; |
||||
use tui::widgets::ListState; |
||||
use u_lib::models::{Agent, AssignedJob, JobMeta}; |
||||
use uuid::Uuid; |
||||
|
||||
use tui::layout::{Constraint, Direction, Layout}; |
||||
use tui::style::{Color, Modifier, Style}; |
||||
use tui::text::Spans; |
||||
use tui::widgets::{Block, Borders, List, ListItem, Tabs}; |
||||
|
||||
#[derive(strum::Display, strum::EnumVariantNames, strum::EnumString)] |
||||
pub enum UiTabs { |
||||
Agents, |
||||
Jobs, |
||||
Map, |
||||
} |
||||
|
||||
impl UiTabs { |
||||
pub fn variants() -> &'static [&'static str] { |
||||
Self::VARIANTS |
||||
} |
||||
|
||||
pub fn index(&self) -> usize { |
||||
let ss = self.to_string(); |
||||
Self::VARIANTS.iter().position(|el| **el == ss).unwrap() |
||||
} |
||||
|
||||
pub fn next(&self) -> Self { |
||||
let next_idx = (self.index() + 1).rem_euclid(Self::VARIANTS.len()); |
||||
Self::from_str(Self::VARIANTS[next_idx]).unwrap() |
||||
} |
||||
|
||||
pub fn prev(&self) -> Self { |
||||
let vlen = Self::VARIANTS.len(); |
||||
let next_idx = (self.index() + vlen - 1).rem_euclid(vlen); |
||||
Self::from_str(Self::VARIANTS[next_idx]).unwrap() |
||||
} |
||||
} |
||||
|
||||
pub struct StatefulList<T: CRUD> { |
||||
pub updated: bool, |
||||
pub inner: Vec<T>, |
||||
pub state: ListState, |
||||
} |
||||
|
||||
impl<T: CRUD> StatefulList<T> { |
||||
pub async fn update(&mut self) -> AResult<()> { |
||||
if !self.updated { |
||||
let new_values = T::read().await?; |
||||
self.inner = new_values; |
||||
self.updated = true; |
||||
} |
||||
Ok(()) |
||||
} |
||||
|
||||
pub async fn delete(&mut self) -> AResult<()> { |
||||
if let Some(s) = self.state.selected() { |
||||
let uid = self.inner[s].id(); |
||||
T::delete(uid).await?; |
||||
} |
||||
Ok(()) |
||||
} |
||||
|
||||
pub fn get(&self, id: Uuid) -> Option<&T> { |
||||
for item in self.inner.iter() { |
||||
if item.id() == id { |
||||
return Some(item); |
||||
} |
||||
} |
||||
None |
||||
} |
||||
} |
||||
|
||||
impl<T: CRUD> Default for StatefulList<T> { |
||||
fn default() -> Self { |
||||
let mut state = ListState::default(); |
||||
state.select(Some(0)); |
||||
StatefulList { |
||||
updated: false, |
||||
inner: vec![], |
||||
state, |
||||
} |
||||
} |
||||
} |
||||
|
||||
pub struct MainWnd { |
||||
pub id: WndId, |
||||
pub active_tab: UiTabs, |
||||
pub last_error: Option<String>, |
||||
pub agents: StatefulList<Agent>, |
||||
pub jobs: StatefulList<JobMeta>, |
||||
pub map: StatefulList<AssignedJob>, |
||||
} |
||||
|
||||
impl Default for MainWnd { |
||||
fn default() -> Self { |
||||
MainWnd { |
||||
active_tab: UiTabs::Agents, |
||||
last_error: None, |
||||
agents: Default::default(), |
||||
jobs: Default::default(), |
||||
map: Default::default(), |
||||
id: Default::default(), |
||||
} |
||||
} |
||||
} |
||||
|
||||
impl MainWnd { |
||||
pub fn next_tab(&mut self) { |
||||
self.active_tab = self.active_tab.next() |
||||
} |
||||
|
||||
pub fn prev_tab(&mut self) { |
||||
self.active_tab = self.active_tab.prev() |
||||
} |
||||
|
||||
fn check_err(&mut self, res: AResult<()>) -> bool { |
||||
if let Err(e) = res { |
||||
self.last_error = Some(e.to_string()); |
||||
true |
||||
} else { |
||||
false |
||||
} |
||||
} |
||||
|
||||
pub async fn check_updates(&mut self) { |
||||
if !self.agents.updated || !self.jobs.updated || !self.map.updated { |
||||
let state = self.tab_list_state(); |
||||
if let None = state.selected() { |
||||
state.select(Some(0)) |
||||
} |
||||
|
||||
let (a, j, m) = join! { |
||||
self.agents.update(), |
||||
self.jobs.update(), |
||||
self.map.update() |
||||
}; |
||||
for res in [a, j, m] { |
||||
self.check_err(res); |
||||
} |
||||
} |
||||
} |
||||
|
||||
pub fn tab_data(&self) -> Vec<String> { |
||||
match self.active_tab { |
||||
UiTabs::Agents => self |
||||
.agents |
||||
.inner |
||||
.iter() |
||||
.map(|i| format!("{}: {}-{}", crop(i.id, 6), i.username, i.hostname)) |
||||
.collect(), |
||||
UiTabs::Jobs => self |
||||
.jobs |
||||
.inner |
||||
.iter() |
||||
.map(|i| format!("{}: {}", crop(i.id, 6), i.alias.clone().unwrap_or_default())) |
||||
.collect(), |
||||
UiTabs::Map => self |
||||
.map |
||||
.inner |
||||
.iter() |
||||
.map(|i| { |
||||
let job = self.jobs.get(i.job_id).unwrap(); |
||||
let job_id = crop(i.job_id, 6); |
||||
let job_ident = if let Some(alias) = job.alias.as_ref() { |
||||
format!("{} ({})", alias, job_id) |
||||
} else { |
||||
format!("{}", job_id) |
||||
}; |
||||
let agent = self.agents.get(i.agent_id).unwrap(); |
||||
let agent_id = crop(i.agent_id, 6); |
||||
let agent_ident = if let Some(alias) = agent.alias.as_ref() { |
||||
format!("{} ({})", alias, agent_id) |
||||
} else { |
||||
format!("{}-{} ({})", agent.username, agent.hostname, agent_id) |
||||
}; |
||||
format!("{}: {} for {}", crop(i.id, 6), job_ident, agent_ident) |
||||
}) |
||||
.collect(), |
||||
} |
||||
} |
||||
|
||||
pub fn tab_list_state(&mut self) -> &mut ListState { |
||||
match self.active_tab { |
||||
UiTabs::Agents => &mut self.agents.state, |
||||
UiTabs::Jobs => &mut self.jobs.state, |
||||
UiTabs::Map => &mut self.map.state, |
||||
} |
||||
} |
||||
|
||||
pub fn update_tab(&mut self) { |
||||
match self.active_tab { |
||||
UiTabs::Agents => { |
||||
self.agents.updated = false; |
||||
self.jobs.updated = false; |
||||
self.map.updated = false; |
||||
} |
||||
UiTabs::Jobs => self.jobs.updated = false, |
||||
UiTabs::Map => self.map.updated = false, |
||||
} |
||||
} |
||||
|
||||
pub fn on_down(&mut self) { |
||||
let (list_len, list_state) = match self.active_tab { |
||||
UiTabs::Agents => (self.agents.inner.len(), &mut self.agents.state), |
||||
UiTabs::Jobs => (self.jobs.inner.len(), &mut self.jobs.state), |
||||
UiTabs::Map => (self.map.inner.len(), &mut self.map.state), |
||||
}; |
||||
if list_len == 0 { |
||||
list_state.select(None); |
||||
} else { |
||||
let selected = list_state.selected().unwrap_or(list_len - 1); |
||||
list_state.select(Some((selected + 1).rem_euclid(list_len))); |
||||
} |
||||
} |
||||
|
||||
pub fn on_up(&mut self) { |
||||
let (list_len, list_state) = match self.active_tab { |
||||
UiTabs::Agents => (self.agents.inner.len(), &mut self.agents.state), |
||||
UiTabs::Jobs => (self.jobs.inner.len(), &mut self.jobs.state), |
||||
UiTabs::Map => (self.map.inner.len(), &mut self.map.state), |
||||
}; |
||||
if list_len == 0 { |
||||
list_state.select(None); |
||||
} else { |
||||
let selected = list_state.selected().unwrap_or(1); |
||||
list_state.select(Some((selected + list_len - 1).rem_euclid(list_len))); |
||||
} |
||||
} |
||||
|
||||
pub async fn delete(&mut self) { |
||||
let res = match self.active_tab { |
||||
UiTabs::Agents => self.agents.delete().await, |
||||
UiTabs::Jobs => self.jobs.delete().await, |
||||
UiTabs::Map => self.map.delete().await, |
||||
}; |
||||
if !self.check_err(res) { |
||||
self.on_up(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
#[async_trait] |
||||
impl Window for MainWnd { |
||||
async fn handle_kbd(&mut self, k: KeyCode) -> AResult<()> { |
||||
match k { |
||||
KeyCode::Esc => self.close(false, false).await, |
||||
KeyCode::Left => self.prev_tab(), |
||||
KeyCode::Right => self.next_tab(), |
||||
KeyCode::Up => self.on_up(), |
||||
KeyCode::Down => self.on_down(), |
||||
KeyCode::Delete => { |
||||
if let ReturnVal::String(ref s) = |
||||
ConfirmWnd::new("Delete?", &["Yes", "No"]).wait_retval() |
||||
{ |
||||
if s == "Yes" { |
||||
self.delete().await; |
||||
self.update_tab(); |
||||
} |
||||
} |
||||
} |
||||
KeyCode::F(5) => self.update_tab(), |
||||
_ => (), |
||||
}; |
||||
Ok(()) |
||||
} |
||||
|
||||
async fn handle_update(&mut self) -> AResult<()> { |
||||
self.check_updates().await; |
||||
Ok(()) |
||||
} |
||||
|
||||
async fn handle_close(&mut self) -> bool { |
||||
true |
||||
} |
||||
|
||||
fn draw(&mut self, f: &mut Frame) { |
||||
let size = f.size(); |
||||
let chunks = Layout::default() |
||||
.direction(Direction::Vertical) |
||||
.margin(1) |
||||
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref()) |
||||
.split(size); |
||||
let titles = UiTabs::variants() |
||||
.iter() |
||||
.cloned() |
||||
.map(Spans::from) |
||||
.collect(); |
||||
let tabs = Tabs::new(titles) |
||||
.block( |
||||
Block::default() |
||||
.title("The whole that you need to know") |
||||
.borders(Borders::ALL), |
||||
) |
||||
.style(Style::default().fg(Color::White)) |
||||
.highlight_style(Style::default().fg(Color::Yellow)) |
||||
.divider("-") |
||||
.select(self.active_tab.index()); |
||||
f.render_widget(tabs, chunks[0]); |
||||
|
||||
let tab_data = self |
||||
.tab_data() |
||||
.into_iter() |
||||
.map(ListItem::new) |
||||
.collect::<Vec<ListItem>>(); |
||||
let list = List::new(tab_data) |
||||
.block(Block::default().borders(Borders::ALL)) |
||||
.highlight_style(Style::default().add_modifier(Modifier::BOLD)); |
||||
f.render_stateful_widget(list, chunks[1], self.tab_list_state()); |
||||
} |
||||
|
||||
fn retval(&self) -> ReturnVal { |
||||
ReturnVal::None |
||||
} |
||||
} |
||||
|
||||
fn crop<T: Display>(data: T, retain: usize) -> String { |
||||
data.to_string()[..retain].to_string() |
||||
} |
@ -1,223 +0,0 @@ |
||||
mod confirm; |
||||
mod main_wnd; |
||||
pub use confirm::ConfirmWnd; |
||||
pub use main_wnd::MainWnd; |
||||
|
||||
use crate::tui::{ |
||||
get_terminal, impls::Id, AsyncChannel, Backend, Channel, Frame, GEvent, GENERAL_EVENT_CHANNEL, |
||||
}; |
||||
use anyhow::Result as AResult; |
||||
use crossterm::event::KeyCode; |
||||
use once_cell::sync::{Lazy, OnceCell}; |
||||
use std::collections::BTreeMap; |
||||
use std::sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard}; |
||||
use tokio::sync::{Mutex, MutexGuard}; |
||||
use tokio::task::{self, JoinHandle}; |
||||
use tui::Terminal; |
||||
|
||||
static WINDOWS: Lazy<Arc<Mutex<WM>>> = |
||||
Lazy::new(|| Arc::new(Mutex::new(WM::new(get_terminal().unwrap())))); |
||||
|
||||
static LAST_WND_ID: OnceCell<StdMutex<WndId>> = OnceCell::new(); |
||||
|
||||
static RETVAL: Lazy<Channel<ReturnVal>> = Lazy::new(|| Channel::new()); |
||||
|
||||
pub type SharedWnd = Arc<Mutex<dyn Window>>; |
||||
|
||||
pub enum ReturnVal { |
||||
String(String), |
||||
None, |
||||
} |
||||
|
||||
#[derive(Clone, Debug)] |
||||
pub enum WndEvent { |
||||
Key(KeyCode), |
||||
} |
||||
|
||||
#[derive(PartialEq, PartialOrd, Eq, Ord, Hash, Copy, Clone, Debug)] |
||||
pub struct WndId(u64); |
||||
|
||||
impl WndId { |
||||
fn last_wid() -> StdMutexGuard<'static, WndId> { |
||||
LAST_WND_ID |
||||
.get_or_init(|| StdMutex::new(WndId(0))) |
||||
.lock() |
||||
.unwrap() |
||||
} |
||||
} |
||||
|
||||
impl Default for WndId { |
||||
fn default() -> Self { |
||||
let mut wid = Self::last_wid(); |
||||
wid.0 += 1; |
||||
*wid |
||||
} |
||||
} |
||||
|
||||
#[async_trait] |
||||
pub trait Window: Id<WndId> + Send { |
||||
async fn handle_event(&mut self, ev: WndEvent) { |
||||
match ev { |
||||
WndEvent::Key(k) => { |
||||
self.handle_kbd(k).await.unwrap(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
async fn close(&mut self, force: bool, save_retval: bool) { |
||||
let rv = if save_retval { |
||||
self.retval() |
||||
} else { |
||||
ReturnVal::None |
||||
}; |
||||
RETVAL.send(rv); |
||||
GENERAL_EVENT_CHANNEL.send(GEvent::CloseWnd { |
||||
wid: self.id(), |
||||
force, |
||||
}); |
||||
} |
||||
|
||||
fn retval(&self) -> ReturnVal; |
||||
|
||||
fn wait_retval(self) -> ReturnVal |
||||
where |
||||
Self: Sized + 'static, |
||||
{ |
||||
GENERAL_EVENT_CHANNEL.send(GEvent::CreateWnd(Arc::new(Mutex::new(self)))); |
||||
RETVAL.recv() |
||||
} |
||||
|
||||
async fn handle_update(&mut self) -> AResult<()>; |
||||
|
||||
async fn handle_close(&mut self) -> bool; |
||||
|
||||
async fn handle_kbd(&mut self, k: KeyCode) -> AResult<()>; |
||||
|
||||
fn draw(&mut self, f: &mut Frame); |
||||
} |
||||
|
||||
pub struct WndLoop { |
||||
chan: AsyncChannel<WndEvent>, |
||||
wnd_loop: JoinHandle<()>, |
||||
window: SharedWnd, |
||||
} |
||||
|
||||
impl WndLoop { |
||||
pub async fn with_wnd(window: SharedWnd) -> Self { |
||||
let wnd = window.clone(); |
||||
let chan = AsyncChannel::<WndEvent>::new(); |
||||
let ch = chan.clone(); |
||||
let wnd_loop = async move { |
||||
loop { |
||||
match ch.recv().await { |
||||
Ok(ev) => wnd.lock().await.handle_event(ev).await, |
||||
Err(_) => break, |
||||
} |
||||
} |
||||
}; |
||||
WndLoop { |
||||
chan, |
||||
window, |
||||
wnd_loop: task::spawn(wnd_loop), |
||||
} |
||||
} |
||||
|
||||
pub async fn send(&self, ev: WndEvent) { |
||||
let wnd_id = self.window.lock().await.id(); |
||||
let event = ev.clone(); |
||||
debug!(?event, ?wnd_id, "sending"); |
||||
self.chan.send(ev).await.expect("send failed"); |
||||
} |
||||
} |
||||
|
||||
pub struct WM { |
||||
queue: BTreeMap<WndId, WndLoop>, |
||||
term: Terminal<Backend>, |
||||
} |
||||
|
||||
impl WM { |
||||
fn get_last_wnd(&self) -> &WndLoop { |
||||
let last_id = self.queue.keys().rev().next().expect("No windows found"); |
||||
self.queue.get(last_id).unwrap() |
||||
} |
||||
|
||||
pub async fn lock<'l>() -> MutexGuard<'l, Self> { |
||||
WINDOWS.lock().await |
||||
} |
||||
|
||||
fn new(term: Terminal<Backend>) -> Self { |
||||
Self { |
||||
term, |
||||
queue: BTreeMap::new(), |
||||
} |
||||
} |
||||
|
||||
pub async fn push<W: Window + 'static>(&mut self, window: W) -> SharedWnd { |
||||
self.push_dyn(Arc::new(Mutex::new(window))).await |
||||
} |
||||
|
||||
pub async fn push_new<W: Window + Default + 'static>(&mut self) -> SharedWnd { |
||||
self.push(W::default()).await |
||||
} |
||||
|
||||
pub async fn push_dyn(&mut self, window: SharedWnd) -> SharedWnd { |
||||
let wid = window.lock().await.id(); |
||||
self.queue |
||||
.insert(wid, WndLoop::with_wnd(window.clone()).await); |
||||
window |
||||
} |
||||
|
||||
pub fn clear(&mut self) -> AResult<()> { |
||||
self.term.clear()?; |
||||
Ok(()) |
||||
} |
||||
|
||||
pub async fn draw(&mut self) -> AResult<()> { |
||||
for wid in self.queue.keys().collect::<Vec<&WndId>>() { |
||||
let mut wnd_locked = match self.queue.get(&wid) { |
||||
Some(w) => match w.window.try_lock() { |
||||
Ok(w) => w, |
||||
Err(_) => { |
||||
warn!("Can't lock window {:?}", wid); |
||||
continue; |
||||
} |
||||
}, |
||||
None => { |
||||
warn!("Can't redraw window {:?}, not found", wid); |
||||
continue; |
||||
} |
||||
}; |
||||
self.term.draw(move |f| wnd_locked.draw(f))?; |
||||
} |
||||
Ok(()) |
||||
} |
||||
|
||||
pub async fn close(&mut self, wid: WndId, force: bool) { |
||||
let wnd = match self.queue.get(&wid) { |
||||
Some(w) => w.clone(), |
||||
None => { |
||||
warn!("Can't close window {:?}, not found", wid); |
||||
return; |
||||
} |
||||
}; |
||||
if wnd.window.lock().await.handle_close().await || force { |
||||
let WndLoop { wnd_loop, .. } = self.queue.remove(&wid).unwrap(); |
||||
wnd_loop.abort(); |
||||
} |
||||
|
||||
if self.queue.is_empty() { |
||||
GENERAL_EVENT_CHANNEL.send(GEvent::Exit); |
||||
} |
||||
} |
||||
|
||||
pub async fn send_handle_kbd(&self, k: KeyCode) { |
||||
let current_wnd = self.get_last_wnd(); |
||||
current_wnd.send(WndEvent::Key(k)).await; |
||||
} |
||||
|
||||
pub async fn update(&mut self) -> AResult<()> { |
||||
let current_wnd = self.get_last_wnd(); |
||||
current_wnd.window.lock().await.handle_update().await?; |
||||
Ok(()) |
||||
} |
||||
} |
Loading…
Reference in new issue