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 jobs
plazmoid 2 years ago
parent 862fb6b338
commit 47c35f7574
  1. 115
      bin/u_panel/src/argparse.rs
  2. 4
      bin/u_panel/src/main.rs
  3. 0
      bin/u_panel/src/server/error.rs
  4. 3
      bin/u_panel/src/server/fe/angular.json
  5. 5
      bin/u_panel/src/server/fe/src/app/app.component.html
  6. 5
      bin/u_panel/src/server/fe/src/app/app.component.ts
  7. 21
      bin/u_panel/src/server/fe/src/app/app.module.ts
  8. 4
      bin/u_panel/src/server/fe/src/app/core/models/agent.model.ts
  9. 8
      bin/u_panel/src/server/fe/src/app/core/models/index.ts
  10. 8
      bin/u_panel/src/server/fe/src/app/core/models/job.model.ts
  11. 6
      bin/u_panel/src/server/fe/src/app/core/models/result.model.ts
  12. 18
      bin/u_panel/src/server/fe/src/app/core/services/api.service.ts
  13. 11
      bin/u_panel/src/server/fe/src/app/core/tables/agent.component.html
  14. 58
      bin/u_panel/src/server/fe/src/app/core/tables/agent.component.ts
  15. 2
      bin/u_panel/src/server/fe/src/app/core/tables/dialogs/agent-info-dialog.html
  16. 4
      bin/u_panel/src/server/fe/src/app/core/tables/dialogs/agent_info.component.ts
  17. 12
      bin/u_panel/src/server/fe/src/app/core/tables/dialogs/assign-job-dialog.html
  18. 33
      bin/u_panel/src/server/fe/src/app/core/tables/dialogs/assign_job.component.ts
  19. 5
      bin/u_panel/src/server/fe/src/app/core/tables/dialogs/index.ts
  20. 11
      bin/u_panel/src/server/fe/src/app/core/tables/dialogs/info-dialog.component.less
  21. 50
      bin/u_panel/src/server/fe/src/app/core/tables/dialogs/job-info-dialog.html
  22. 30
      bin/u_panel/src/server/fe/src/app/core/tables/dialogs/job_info.component.ts
  23. 53
      bin/u_panel/src/server/fe/src/app/core/tables/dialogs/result-info-dialog.html
  24. 20
      bin/u_panel/src/server/fe/src/app/core/tables/dialogs/result_info.component.ts
  25. 27
      bin/u_panel/src/server/fe/src/app/core/tables/job.component.html
  26. 48
      bin/u_panel/src/server/fe/src/app/core/tables/job.component.ts
  27. 19
      bin/u_panel/src/server/fe/src/app/core/tables/result.component.html
  28. 19
      bin/u_panel/src/server/fe/src/app/core/tables/result.component.ts
  29. 60
      bin/u_panel/src/server/fe/src/app/core/tables/table.component.ts
  30. 4
      bin/u_panel/src/server/fe/src/app/core/utils.ts
  31. 2
      bin/u_panel/src/server/fe/src/environments/environment.prod.ts
  32. 2
      bin/u_panel/src/server/fe/src/environments/environment.ts
  33. 6
      bin/u_panel/src/server/fe/src/index.html
  34. 52
      bin/u_panel/src/server/mod.rs
  35. 61
      bin/u_panel/src/tui/impls.rs
  36. 187
      bin/u_panel/src/tui/mod.rs
  37. 58
      bin/u_panel/src/tui/utils.rs
  38. 127
      bin/u_panel/src/tui/windows/confirm.rs
  39. 325
      bin/u_panel/src/tui/windows/main_wnd.rs
  40. 223
      bin/u_panel/src/tui/windows/mod.rs
  41. 8
      bin/u_server/src/db.rs
  42. 5
      bin/u_server/src/handlers.rs
  43. 14
      bin/u_server/src/u_server.rs
  44. 2
      images/musl-libs.Dockerfile
  45. 9
      integration/integration_tests.py
  46. 2
      integration/tests/helpers/panel.rs
  47. 2
      integration/tests/integration/behaviour.rs
  48. 18
      lib/u_lib/src/api.rs
  49. 16
      lib/u_lib/src/datatypes.rs
  50. 40
      lib/u_lib/src/models/agent.rs
  51. 26
      lib/u_lib/src/models/jobs/assigned.rs
  52. 6
      lib/u_lib/src/models/jobs/meta.rs
  53. 18
      lib/u_lib/src/models/schema.rs
  54. 10
      lib/u_lib/src/runner.rs
  55. 113
      lib/u_lib/src/utils/proc_output.rs
  56. 4
      scripts/build_musl_libs.sh

@ -1,4 +1,4 @@
use anyhow::Result as AnyResult; use serde_json::{from_str, to_value, Value};
use structopt::StructOpt; use structopt::StructOpt;
use u_lib::{ use u_lib::{
api::ClientHandler, api::ClientHandler,
@ -21,16 +21,9 @@ enum Cmd {
Jobs(JobCRUD), Jobs(JobCRUD),
Map(JobMapCRUD), Map(JobMapCRUD),
Ping, Ping,
//TUI(TUIArgs),
Serve, Serve,
} }
#[derive(StructOpt, Debug)]
pub struct TUIArgs {
#[structopt(long)]
pub nogui: bool,
}
#[derive(StructOpt, Debug)] #[derive(StructOpt, Debug)]
enum JobCRUD { enum JobCRUD {
Create { Create {
@ -77,58 +70,60 @@ fn parse_uuid(src: &str) -> Result<Uuid, String> {
Uuid::parse_str(src).map_err(|e| e.to_string()) Uuid::parse_str(src).map_err(|e| e.to_string())
} }
pub async fn process_cmd(client: ClientHandler, args: Args) -> UResult<String> { pub fn into_value<M: AsMsg>(data: M) -> Value {
fn to_json<Msg: AsMsg>(data: AnyResult<Msg>) -> String { to_value(data).unwrap()
let result = match data { }
Ok(r) => PanelResult::Ok(r),
Err(e) => PanelResult::Err(e.downcast().expect("unknown error type")),
};
serde_json::to_string(&result).unwrap()
}
Ok(match args.cmd { pub async fn process_cmd(client: ClientHandler, args: Args) -> PanelResult<Value> {
Cmd::Agents(action) => match action { let catcher: UResult<Value> = (|| async {
RUD::Read { uid } => to_json(client.get_agents(uid).await), Ok(match args.cmd {
RUD::Update { item } => { Cmd::Agents(action) => match action {
let agent = serde_json::from_str::<Agent>(&item)?; RUD::Read { uid } => into_value(client.get_agents(uid).await?),
to_json(client.update_item(agent).await) RUD::Update { item } => {
let agent = from_str::<Agent>(&item)?;
into_value(client.update_agent(agent).await?)
}
RUD::Delete { uid } => into_value(client.del(uid).await?),
},
Cmd::Jobs(action) => match action {
JobCRUD::Create { job } => {
let raw_job = from_str::<JobMeta>(&job)?;
let job = raw_job.validated()?;
into_value(client.upload_jobs(job).await?)
}
JobCRUD::RUD(RUD::Read { uid }) => into_value(client.get_jobs(uid).await?),
JobCRUD::RUD(RUD::Update { item }) => {
let raw_job = from_str::<JobMeta>(&item)?;
let job = raw_job.validated()?;
into_value(client.update_job(job).await?)
}
JobCRUD::RUD(RUD::Delete { uid }) => into_value(client.del(uid).await?),
},
Cmd::Map(action) => match action {
JobMapCRUD::Create {
agent_uid,
job_idents,
} => into_value(client.set_jobs(agent_uid, job_idents).await?),
JobMapCRUD::RUD(RUD::Read { uid }) => into_value(client.get_agent_jobs(uid).await?),
JobMapCRUD::RUD(RUD::Update { item }) => {
let assigned = from_str::<AssignedJob>(&item)?;
into_value(client.update_result(assigned).await?)
}
JobMapCRUD::RUD(RUD::Delete { uid }) => into_value(client.del(uid).await?),
},
Cmd::Ping => into_value(client.ping().await?),
Cmd::Serve => {
crate::server::serve(client)
.await
.map_err(|e| UError::PanelError(format!("{e:?}")))?;
Value::Null
} }
RUD::Delete { uid } => to_json(client.del(uid).await), })
}, })()
Cmd::Jobs(action) => match action { .await;
JobCRUD::Create { job } => {
let raw_job = serde_json::from_str::<JobMeta>(&job)?; match catcher {
let job = raw_job.into_builder().build()?; Ok(r) => PanelResult::Ok(r),
to_json(client.upload_jobs(job).await) Err(e) => PanelResult::Err(e),
} }
JobCRUD::RUD(RUD::Read { uid }) => to_json(client.get_jobs(uid).await),
JobCRUD::RUD(RUD::Update { item }) => {
let job = serde_json::from_str::<JobMeta>(&item)?;
to_json(client.update_item(job).await)
}
JobCRUD::RUD(RUD::Delete { uid }) => to_json(client.del(uid).await),
},
Cmd::Map(action) => match action {
JobMapCRUD::Create {
agent_uid,
job_idents,
} => to_json(client.set_jobs(agent_uid, job_idents).await),
JobMapCRUD::RUD(RUD::Read { uid }) => to_json(client.get_agent_jobs(uid).await),
JobMapCRUD::RUD(RUD::Update { item }) => {
let assigned = serde_json::from_str::<AssignedJob>(&item)?;
to_json(client.update_item(assigned).await)
}
JobMapCRUD::RUD(RUD::Delete { uid }) => to_json(client.del(uid).await),
},
Cmd::Ping => to_json(client.ping().await),
/*Cmd::TUI(args) => crate::tui::init_tui(&args)
.await
.map_err(|e| UError::PanelError(e.to_string()))?,*/
Cmd::Serve => {
crate::server::serve(client)
.await
.map_err(|e| UError::PanelError(format!("{e:?}")))?;
String::new()
}
})
} }

@ -1,6 +1,5 @@
mod argparse; mod argparse;
mod server; mod server;
//mod tui;
#[macro_use] #[macro_use]
extern crate tracing; extern crate tracing;
@ -27,7 +26,8 @@ async fn main() -> AnyResult<()> {
let args = Args::from_args(); let args = Args::from_args();
init_logger(None::<&str>); init_logger(None::<&str>);
let result = process_cmd(client, args).await?;
let result = process_cmd(client, args).await.to_string();
println!("{result}"); println!("{result}");
Ok(()) Ok(())
} }

@ -38,6 +38,7 @@
}, },
"configurations": { "configurations": {
"production": { "production": {
"baseHref": "/core/",
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
@ -110,4 +111,4 @@
} }
}, },
"defaultProject": "fe" "defaultProject": "fe"
} }

@ -1,6 +1,5 @@
<nav mat-tab-nav-bar animationDuration="0ms" mat-align-tabs="center"> <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 *ngFor="let tab of tabs" routerLink={{tab.link}} routerLinkActive #rla="routerLinkActive"
<a mat-tab-link routerLink="/jobs" routerLinkActive="active" ariaCurrentWhenActive="page">Jobs</a> [active]="rla.isActive" [routerLinkActiveOptions]="{ exact: true }">{{tab.name}}</a>
<a mat-tab-link routerLink="/results" routerLinkActive="active" ariaCurrentWhenActive="page">Results</a>
</nav> </nav>
<router-outlet></router-outlet> <router-outlet></router-outlet>

@ -6,4 +6,9 @@ import { Component, ViewChild, AfterViewInit } from '@angular/core';
styleUrls: ['./app.component.less'] styleUrls: ['./app.component.less']
}) })
export class AppComponent { export class AppComponent {
tabs = [
{ name: 'Agents', link: '/agents' },
{ name: 'Jobs', link: '/jobs' },
{ name: 'Results', link: '/results' }
];
} }

@ -14,7 +14,16 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AgentComponent, JobComponent, ResultComponent } from './core/tables'; import { AgentComponent, JobComponent, ResultComponent } from './core/tables';
import { AgentInfoDialogComponent } from './core/tables/dialogs'; import {
AgentInfoDialogComponent,
AssignJobDialogComponent,
JobInfoDialogComponent,
ResultInfoDialogComponent
} from './core/tables/dialogs';
import { APP_BASE_HREF } from '@angular/common';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatListModule } from '@angular/material/list';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -22,7 +31,10 @@ import { AgentInfoDialogComponent } from './core/tables/dialogs';
AgentComponent, AgentComponent,
JobComponent, JobComponent,
ResultComponent, ResultComponent,
AgentInfoDialogComponent AgentInfoDialogComponent,
JobInfoDialogComponent,
ResultInfoDialogComponent,
AssignJobDialogComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
@ -36,10 +48,13 @@ import { AgentInfoDialogComponent } from './core/tables/dialogs';
MatDialogModule, MatDialogModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
MatIconModule, MatIconModule,
MatTooltipModule,
MatSnackBarModule,
MatListModule,
FormsModule, FormsModule,
BrowserAnimationsModule BrowserAnimationsModule
], ],
providers: [], providers: [{ provide: APP_BASE_HREF, useValue: '/' }],
bootstrap: [AppComponent] bootstrap: [AppComponent]
}) })
export class AppModule { } export class AppModule { }

@ -1,6 +1,6 @@
import { UTCDate } from "."; import { UTCDate, ApiModel } from ".";
export interface AgentModel { export interface AgentModel extends ApiModel {
alias: string | null, alias: string | null,
hostname: string, hostname: string,
host_info: string, host_info: string,

@ -5,4 +5,10 @@ export * from './job.model';
export interface UTCDate { export interface UTCDate {
secs_since_epoch: number, secs_since_epoch: number,
nanos_since_epoch: number nanos_since_epoch: number
} }
export abstract class ApiModel { }
export interface Empty extends ApiModel { }
export type Area = "agents" | "jobs" | "map";

@ -1,8 +1,12 @@
export interface JobModel { import { ApiModel } from ".";
export interface JobModel extends ApiModel {
alias: string, alias: string,
argv: string, argv: string,
id: string, id: string,
exec_type: string, exec_type: string,
platform: 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, agent_id: string,
alias: string, alias: string,
created: UTCDate, created: UTCDate,
id: string, id: string,
job_id: string, job_id: string,
result: Uint8Array, result: number[],
state: "Queued" | "Running" | "Finished", state: "Queued" | "Running" | "Finished",
retcode: number | null, retcode: number | null,
updated: UTCDate, updated: UTCDate,

@ -1,23 +1,23 @@
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment'; import { environment } from 'src/environments/environment';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { ApiModel, Empty, Area } from '../models';
interface ServerResponse<T> { interface ServerResponse<T extends ApiModel> {
status: "ok" | "err", status: "ok" | "err",
data: T | string data: T | string
} }
export class ApiTableService<T> { export class ApiTableService<T extends ApiModel> {
area: string; area: Area;
constructor(private http: HttpClient, area: string) { constructor(private http: HttpClient, area: Area) {
this.area = area; this.area = area;
} }
requestUrl = `${environment.server}/cmd/`; requestUrl = `${environment.server}/cmd/`;
async req<R>(cmd: string): Promise<ServerResponse<R>> { async req<R extends ApiModel>(cmd: string): Promise<ServerResponse<R>> {
return await firstValueFrom(this.http.post<ServerResponse<R>>(this.requestUrl, cmd)) return await firstValueFrom(this.http.post<ServerResponse<R>>(this.requestUrl, cmd))
} }
@ -39,15 +39,15 @@ export class ApiTableService<T> {
return await this.req(`${this.area} read`) return await this.req(`${this.area} read`)
} }
async update(item: T): Promise<ServerResponse<void>> { async update(item: T): Promise<ServerResponse<Empty>> {
return await this.req(`${this.area} update '${JSON.stringify(item)}'`) return await this.req(`${this.area} update '${JSON.stringify(item)}'`)
} }
async delete(id: string): Promise<ServerResponse<void>> { async delete(id: string): Promise<ServerResponse<Empty>> {
return await this.req(`${this.area} delete ${id}`) return await this.req(`${this.area} delete ${id}`)
} }
async create(item: string): Promise<ServerResponse<void>> { async create(item: string): Promise<ServerResponse<string[]>> {
return await this.req(`${this.area} create ${item}`) return await this.req(`${this.area} create ${item}`)
} }
} }

@ -10,8 +10,8 @@
</mat-form-field> </mat-form-field>
<button id="refresh_btn" mat-raised-button color="primary" (click)="loadTableData()">Refresh</button> <button id="refresh_btn" mat-raised-button color="primary" (click)="loadTableData()">Refresh</button>
<table mat-table [dataSource]="table_data" class="data-table" matSort matSortActive="id" matSortDisableClear <table mat-table fixedLayout="true" [dataSource]="table_data" class="data-table" matSort matSortActive="id"
matSortDirection="desc"> matSortDisableClear matSortDirection="desc">
<ng-container matColumnDef="id"> <ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>ID</th> <th mat-header-cell *matHeaderCellDef>ID</th>
@ -51,9 +51,14 @@
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th> <th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let row"> <td mat-cell *matCellDef="let row">
<button mat-icon-button routerLink='' [queryParams]="{id: row.id}"> <button mat-icon-button (click)="assignJobs(row.id)">
<mat-icon>add_task</mat-icon>
</button>
|
<button mat-icon-button routerLink='.' [queryParams]="{id: row.id}">
<mat-icon>more_horiz</mat-icon> <mat-icon>more_horiz</mat-icon>
</button> </button>
|
<button mat-icon-button (click)="deleteItem(row.id)"> <button mat-icon-button (click)="deleteItem(row.id)">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>
</button> </button>

@ -2,54 +2,35 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { TablesComponent } from './table.component'; import { TablesComponent } from './table.component';
import { AgentModel } from '../models'; import { AgentModel } from '../models';
import { AgentInfoDialogComponent } from './dialogs/agent_info.component'; import { AgentInfoDialogComponent } from './dialogs/agent_info.component';
import { HttpClient } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { MatDialog } from '@angular/material/dialog'; import { AssignJobDialogComponent } from './dialogs';
import { epochToStr } from '../utils';
import { ActivatedRoute, Router } from '@angular/router';
import { emitErr } from '../utils';
import { Subscription } from 'rxjs';
@Component({ @Component({
selector: 'agent-table', selector: 'agent-table',
templateUrl: './agent.component.html', templateUrl: './agent.component.html',
styleUrls: ['./table.component.less'] styleUrls: ['./table.component.less']
}) })
export class AgentComponent extends TablesComponent<AgentModel> implements OnDestroy, OnInit { export class AgentComponent extends TablesComponent<AgentModel> implements OnInit {
dialogSubscr!: Subscription; //dialogSubscr!: Subscription;
area = 'agents' as const; area = 'agents' as const;
displayedColumns = ['id', 'alias', 'username', 'hostname', 'last_active', 'actions'] displayedColumns = ['id', 'alias', 'username', 'hostname', 'last_active', 'actions']
constructor(
public override _httpClient: HttpClient,
public override info_dlg: MatDialog,
public route: ActivatedRoute,
public router: Router
) {
super(_httpClient, info_dlg);
}
override ngOnInit(): void {
super.ngOnInit()
this.dialogSubscr = this.route.queryParams.subscribe(params => {
const id = params['id']
if (id) {
this.show_item_dialog(id);
}
})
}
show_item_dialog(id: string) { show_item_dialog(id: string) {
this.data_source!.getOne(id).then(resp => { this.data_source!.getOne(id).then(resp => {
if (resp.status === 'ok') { if (resp.status === 'ok') {
const dialog = this.info_dlg.open(AgentInfoDialogComponent, { const dialog = this.infoDialog.open(AgentInfoDialogComponent, {
data: resp.data as AgentModel, data: resp.data as AgentModel,
width: '500px', width: '1000px',
}); });
const saveSub = dialog.componentInstance.onSave.subscribe(result => { const saveSub = dialog.componentInstance.onSave.subscribe(result => {
this.data_source!.update(result).then(_ => this.loadTableData()).catch(emitErr) this.data_source!.update(result).then(_ => {
this.openSnackBar('Saved', false)
this.loadTableData()
})
.catch((err: HttpErrorResponse) => this.openSnackBar(err.error))
}) })
dialog.afterClosed().subscribe(result => { dialog.afterClosed().subscribe(result => {
@ -57,18 +38,15 @@ export class AgentComponent extends TablesComponent<AgentModel> implements OnDes
this.router.navigate(['.'], { relativeTo: this.route }) this.router.navigate(['.'], { relativeTo: this.route })
}) })
} else { } else {
emitErr(resp.data) this.openSnackBar(resp.data)
} }
}).catch(emitErr) }).catch((err: HttpErrorResponse) => this.openSnackBar(err.error))
}
deleteItem(id: string) {
if (confirm(`Delete ${id}?`)) {
this.data_source!.delete(id).catch(emitErr)
}
} }
ngOnDestroy(): void { assignJobs(id: string) {
this.dialogSubscr.unsubscribe() const dialog = this.infoDialog.open(AssignJobDialogComponent, {
data: id,
width: '1000px',
});
} }
} }

@ -28,7 +28,7 @@
<p> <p>
<mat-form-field class="info-dlg-field"> <mat-form-field class="info-dlg-field">
<mat-label>Host info</mat-label> <mat-label>Host info</mat-label>
<textarea matInput [readonly]="is_preview" [(ngModel)]="data.host_info"> <textarea matInput cdkTextareaAutosize [readonly]="is_preview" [(ngModel)]="data.host_info">
</textarea> </textarea>
</mat-form-field> </mat-form-field>
</p> </p>

@ -2,7 +2,6 @@ import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { AgentModel } from '../../models/agent.model'; import { AgentModel } from '../../models/agent.model';
import { EventEmitter } from '@angular/core'; import { EventEmitter } from '@angular/core';
import { Input } from '@angular/core';
@Component({ @Component({
selector: 'agent-info-dialog', selector: 'agent-info-dialog',
@ -13,11 +12,10 @@ export class AgentInfoDialogComponent {
is_preview = true; is_preview = true;
onSave = new EventEmitter(); onSave = new EventEmitter();
constructor(@Inject(MAT_DIALOG_DATA) public data: AgentModel) { } constructor(@Inject(MAT_DIALOG_DATA) public data: AgentModel) { }
updateAgent() { updateAgent() {
console.log(this.data); console.log(this.data);
this.onSave.emit(this.data); this.onSave.emit(this.data);
} }
} }

@ -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 { .info-dlg-field {
width: 100%; 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 = ""
}
}
}

@ -8,10 +8,12 @@
<mat-label>Filter</mat-label> <mat-label>Filter</mat-label>
<input matInput (keyup)="apply_filter($event)" #input> <input matInput (keyup)="apply_filter($event)" #input>
</mat-form-field> </mat-form-field>
<button id="refresh_btn" mat-raised-button color="primary" (click)="loadTableData()">Refresh</button> <button id="refresh_btn" mat-raised-button color="basic" (click)="loadTableData()">Refresh</button>
<button id="new_btn" mat-raised-button color="primary" routerLink='.' [queryParams]="{new: true}">Add
job</button>
<table mat-table [dataSource]="table_data" class="data-table" matSort matSortActive="id" matSortDisableClear <table mat-table fixedLayout="true" [dataSource]="table_data" class="data-table" matSort matSortActive="id"
matSortDirection="desc"> matSortDisableClear matSortDirection="desc">
<ng-container matColumnDef="id"> <ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>ID</th> <th mat-header-cell *matHeaderCellDef>ID</th>
@ -41,10 +43,10 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="payload"> <ng-container matColumnDef="schedule">
<th mat-header-cell *matHeaderCellDef>Payload</th> <th mat-header-cell *matHeaderCellDef>Schedule</th>
<td mat-cell *matCellDef="let row"> <td mat-cell *matCellDef="let row">
{{row.payload}} {{row.schedule}}
</td> </td>
</ng-container> </ng-container>
@ -55,6 +57,19 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let row">
<button mat-icon-button routerLink='.' [queryParams]="{id: row.id}">
<mat-icon>more_horiz</mat-icon>
</button>
|
<button mat-icon-button (click)="deleteItem(row.id)">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row class="data-table-row" *matRowDef="let row; columns: displayedColumns;"></tr> <tr mat-row class="data-table-row" *matRowDef="let row; columns: displayedColumns;"></tr>
<tr class="mat-row" *matNoDataRow> <tr class="mat-row" *matNoDataRow>

@ -1,6 +1,8 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { TablesComponent } from './table.component'; import { TablesComponent } from './table.component';
import { JobModel } from '../models'; import { JobModel } from '../models';
import { JobInfoDialogComponent } from './dialogs';
import { HttpErrorResponse } from '@angular/common/http';
@Component({ @Component({
selector: 'job-table', selector: 'job-table',
@ -9,7 +11,49 @@ import { JobModel } from '../models';
}) })
export class JobComponent extends TablesComponent<JobModel> { export class JobComponent extends TablesComponent<JobModel> {
area = 'jobs' as const; area = 'jobs' as const;
displayedColumns = ['id', 'alias', 'argv', 'platform', 'payload', 'exec_type'] displayedColumns = ['id', 'alias', 'platform', 'schedule', 'exec_type', 'actions']
show_item_dialog(id: string) { } show_item_dialog(id: string | null) {
const show_dlg = (id: string, edit: boolean) => {
this.data_source!.getOne(id).then(resp => {
if (resp.status === 'ok') {
var dialog = this.infoDialog.open(JobInfoDialogComponent, {
data: resp.data as JobModel,
width: '1000px',
});
if (edit) {
dialog.componentInstance.is_preview = false
}
const saveSub = dialog.componentInstance.onSave.subscribe(result => {
this.data_source!.update(result)
.then(_ => {
this.openSnackBar("Saved", false)
this.loadTableData()
})
.catch((err: HttpErrorResponse) => this.openSnackBar(err.error))
})
dialog.afterClosed().subscribe(result => {
saveSub.unsubscribe()
this.router.navigate(['.'], { relativeTo: this.route })
})
} else {
this.openSnackBar(resp.data)
}
}).catch((err: HttpErrorResponse) => this.openSnackBar(err.error))
}
if (id) {
show_dlg(id, false)
} else {
this.data_source!.create('"{}"').then(resp => {
if (resp.status === 'ok') {
show_dlg(resp.data[0], true)
} else {
this.openSnackBar(resp.data)
}
}).catch((err: HttpErrorResponse) => this.openSnackBar(err.error))
}
}
} }

@ -10,8 +10,8 @@
</mat-form-field> </mat-form-field>
<button id="refresh_btn" mat-raised-button color="primary" (click)="loadTableData()">Refresh</button> <button id="refresh_btn" mat-raised-button color="primary" (click)="loadTableData()">Refresh</button>
<table mat-table [dataSource]="table_data" class="data-table" matSort matSortActive="id" matSortDisableClear <table mat-table fixedLayout="true" [dataSource]="table_data" class="data-table" matSort matSortActive="id"
matSortDirection="desc"> matSortDisableClear matSortDirection="desc">
<ng-container matColumnDef="id"> <ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>ID</th> <th mat-header-cell *matHeaderCellDef>ID</th>
@ -37,7 +37,7 @@
<ng-container matColumnDef="job_id"> <ng-container matColumnDef="job_id">
<th mat-header-cell *matHeaderCellDef>Job</th> <th mat-header-cell *matHeaderCellDef>Job</th>
<td mat-cell *matCellDef="let row"> <td mat-cell *matCellDef="let row">
{{row.job_id}} <a routerLink='/jobs' [queryParams]="{id: row.job_id}">{{row.job_id}}</a>
</td> </td>
</ng-container> </ng-container>
@ -55,6 +55,19 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let row">
<button mat-icon-button routerLink='.' [queryParams]="{id: row.id}">
<mat-icon>more_horiz</mat-icon>
</button>
|
<button mat-icon-button (click)="deleteItem(row.id)">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row class="data-table-row" *matRowDef="let row; columns: displayedColumns;"></tr> <tr mat-row class="data-table-row" *matRowDef="let row; columns: displayedColumns;"></tr>
<tr class="mat-row" *matNoDataRow> <tr class="mat-row" *matNoDataRow>

@ -1,7 +1,8 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { TablesComponent } from './table.component'; import { TablesComponent } from './table.component';
import { ResultModel } from '../models'; import { ResultModel } from '../models';
import { epochToStr } from '../utils'; import { ResultInfoDialogComponent } from './dialogs';
import { HttpErrorResponse } from '@angular/common/http';
@Component({ @Component({
selector: 'results-table', selector: 'results-table',
@ -17,10 +18,24 @@ export class ResultComponent extends TablesComponent<ResultModel> {
'agent_id', 'agent_id',
'job_id', 'job_id',
'state', 'state',
'last_updated' 'last_updated',
'actions'
]; ];
show_item_dialog(id: string) { show_item_dialog(id: string) {
this.data_source!.getOne(id).then(resp => {
if (resp.status === 'ok') {
const dialog = this.infoDialog.open(ResultInfoDialogComponent, {
data: resp.data as ResultModel,
width: '1000px',
});
dialog.afterClosed().subscribe(result => {
this.router.navigate(['.'], { relativeTo: this.route })
})
} else {
this.openSnackBar(resp.data)
}
}).catch((err: HttpErrorResponse) => this.openSnackBar(err.message))
} }
} }

@ -1,26 +1,45 @@
import { Component, OnInit, Directive } from '@angular/core'; import { OnInit, Directive } from '@angular/core';
import { timer, of as observableOf } from 'rxjs';
import { catchError, map, startWith, switchMap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { ApiTableService } from '../'; import { ApiTableService } from '../';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ApiModel, Area } from '../models';
import { ActivatedRoute, Router } from '@angular/router';
import { interval } from 'rxjs';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
@Directive() @Directive()
export abstract class TablesComponent<T> implements OnInit { export abstract class TablesComponent<T extends ApiModel> implements OnInit {
abstract area: "agents" | "jobs" | "map"; abstract area: Area;
data_source!: ApiTableService<T>; data_source!: ApiTableService<T>;
table_data!: MatTableDataSource<T>; table_data!: MatTableDataSource<T>;
isLoadingResults = true; isLoadingResults = true;
constructor(public _httpClient: HttpClient, public info_dlg: MatDialog) { constructor(
public httpClient: HttpClient,
public infoDialog: MatDialog,
public route: ActivatedRoute,
public router: Router,
public snackBar: MatSnackBar
) {
this.table_data = new MatTableDataSource; this.table_data = new MatTableDataSource;
} }
ngOnInit() { ngOnInit() {
this.data_source = new ApiTableService(this._httpClient, this.area); this.data_source = new ApiTableService(this.httpClient, this.area);
this.loadTableData(); this.loadTableData();
this.route.queryParams.subscribe(params => {
const id = params['id']
const new_agent = params['new']
if (id) {
this.show_item_dialog(id);
}
if (new_agent) {
this.show_item_dialog(null);
}
})
//interval(10000).subscribe(_ => this.loadTableData());
} }
async loadTableData() { async loadTableData() {
@ -41,12 +60,25 @@ export abstract class TablesComponent<T> implements OnInit {
this.table_data.filter = filterValue.trim().toLowerCase(); this.table_data.filter = filterValue.trim().toLowerCase();
} }
abstract displayedColumns: string[]; deleteItem(id: string) {
abstract show_item_dialog(id: string): void; if (confirm(`Delete ${id}?`)) {
} this.data_source!.delete(id).catch(this.openSnackBar)
}
}
type ColumnDef<C> = { openSnackBar(message: any, error: boolean = true) {
def: string, const msg = JSON.stringify(message)
name: string, const _config = (duration: number): MatSnackBarConfig => {
cell: (cell: C) => string return {
horizontalPosition: 'right',
verticalPosition: 'bottom',
duration
}
}
const cfg = error ? _config(0) : _config(2000)
this.snackBar.open(msg, 'Ok', cfg);
}
abstract displayedColumns: string[];
abstract show_item_dialog(id: string | null): void;
} }

@ -1,7 +1,3 @@
export function epochToStr(epoch: number): string { export function epochToStr(epoch: number): string {
return new Date(epoch * 1000).toLocaleString('en-GB') return new Date(epoch * 1000).toLocaleString('en-GB')
} }
export function emitErr(e: any) {
alert(e)
}

@ -1,4 +1,4 @@
export const environment = { export const environment = {
production: true, production: true,
server: "" server: "",
}; };

@ -4,7 +4,7 @@
export const environment = { export const environment = {
production: false, production: false,
server: "http://127.0.0.1:8080" server: "http://127.0.0.1:8080",
}; };
/* /*

@ -1,16 +1,18 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Fe</title> <title>Fe</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.gstatic.com"> <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/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head> </head>
<body class="mat-typography"> <body class="mat-typography">
<app-root></app-root> <app-root></app-root>
</body> </body>
</html>
</html>

@ -1,22 +1,9 @@
/* mod error;
Tabs: Agents, Tasks, Summary
every tab has list page and item page with more info/actions
Agents:
| id | alias | ..see struct | tasks done
| stripped | alias | ... | clickable number of assigned jobs
almost all fields are editable, rows are deletable
*/
mod errors;
use crate::{process_cmd, Args}; use crate::{process_cmd, Args};
use actix_cors::Cors; use actix_cors::Cors;
use actix_web::{get, middleware::Logger, post, web, App, HttpResponse, HttpServer, Responder}; use actix_web::{get, middleware::Logger, post, web, App, HttpResponse, HttpServer, Responder};
use errors::Error; use error::Error;
use futures_util::StreamExt; use futures_util::StreamExt;
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use std::borrow::Cow; use std::borrow::Cow;
@ -34,16 +21,16 @@ impl Files {
} }
} }
#[get("/")] async fn spa_main() -> impl Responder {
async fn main_page() -> impl Responder {
let index = Files::get_static("index.html").unwrap(); let index = Files::get_static("index.html").unwrap();
HttpResponse::Ok().body(index) HttpResponse::Ok().body(index)
} }
#[get("/{path}")] #[get("/core/{path}")]
async fn static_files_adapter(path: web::Path<(String,)>) -> impl Responder { async fn resources_adapter(path: web::Path<(String,)>) -> impl Responder {
let path = path.into_inner().0; let path = path.into_inner().0;
let mimetype = mime_guess::from_path(&path).first_or_octet_stream(); let mimetype = mime_guess::from_path(&path).first_or_octet_stream();
match Files::get_static(path) { match Files::get_static(path) {
Some(data) => HttpResponse::Ok() Some(data) => HttpResponse::Ok()
.content_type(mimetype.to_string()) .content_type(mimetype.to_string())
@ -58,23 +45,33 @@ async fn send_cmd(
client: web::Data<ClientHandler>, client: web::Data<ClientHandler>,
) -> Result<impl Responder, Error> { ) -> Result<impl Responder, Error> {
let mut bytes = web::BytesMut::new(); let mut bytes = web::BytesMut::new();
while let Some(item) = body.next().await { while let Some(item) = body.next().await {
bytes.extend_from_slice( bytes.extend_from_slice(
&item.map_err(|e| Error::JustError(format!("payload loading failure: {e}")))?, &item.map_err(|e| Error::JustError(format!("payload loading failure: {e}")))?,
); );
} }
let cmd = String::from_utf8(bytes.to_vec()) let cmd = String::from_utf8(bytes.to_vec())
.map_err(|_| Error::JustError("cmd contains non-utf8 data".to_string()))?; .map_err(|_| Error::JustError("cmd contains non-utf8 data".to_string()))?;
let mut cmd = shlex::split(&cmd).ok_or(Error::JustError("argparse failed".to_string()))?; let mut cmd = shlex::split(&cmd).ok_or(Error::JustError("argparse failed".to_string()))?;
info!("cmd: {:?}", cmd); info!("cmd: {:?}", cmd);
cmd.insert(0, String::from("u_panel")); cmd.insert(0, String::from("u_panel"));
let parsed_cmd = Args::from_iter_safe(cmd)?; let parsed_cmd = Args::from_iter_safe(cmd)?;
Ok( let result = process_cmd(client.as_ref().clone(), parsed_cmd).await;
match process_cmd(client.as_ref().clone(), parsed_cmd).await { let result_string = result.to_string();
Ok(r) => HttpResponse::Ok().body(r),
Err(e) => HttpResponse::BadRequest().body(e.to_string()), let response = if result.is_ok() {
}, HttpResponse::Ok().body(result_string)
) } else if result.is_err() {
HttpResponse::BadRequest().body(result_string)
} else {
unreachable!()
};
Ok(response)
} }
pub async fn serve(client: ClientHandler) -> anyhow::Result<()> { pub async fn serve(client: ClientHandler) -> anyhow::Result<()> {
@ -89,9 +86,10 @@ pub async fn serve(client: ClientHandler) -> anyhow::Result<()> {
.wrap(Logger::default()) .wrap(Logger::default())
.wrap(Cors::permissive()) .wrap(Cors::permissive())
.app_data(web::Data::new(client.clone())) .app_data(web::Data::new(client.clone()))
.service(main_page)
.service(send_cmd) .service(send_cmd)
.service(static_files_adapter) .service(resources_adapter)
.service(web::resource("/").to(spa_main))
.service(web::resource("/{_}").to(spa_main))
}) })
.bind(addr)? .bind(addr)?
.run() .run()

@ -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(())
}
}

@ -42,14 +42,14 @@ impl UDB {
.unwrap() .unwrap()
} }
pub fn insert_jobs(&self, job_metas: &[JobMeta]) -> Result<()> { pub fn insert_jobs(&self, job_metas: &[JobMeta]) -> Result<Vec<Uuid>> {
use schema::jobs; use schema::jobs;
diesel::insert_into(jobs::table) diesel::insert_into(jobs::table)
.values(job_metas) .values(job_metas)
.execute(&self.conn) .get_results(&self.conn)
.map_err(with_err_ctx("Can't insert jobs"))?; .map(|rows| rows.iter().map(|job: &JobMeta| job.id).collect())
Ok(()) .map_err(with_err_ctx("Can't insert jobs"))
} }
pub fn get_jobs(&self, ouid: Option<Uuid>) -> Result<Vec<JobMeta>> { pub fn get_jobs(&self, ouid: Option<Uuid>) -> Result<Vec<JobMeta>> {

@ -35,7 +35,8 @@ impl Endpoints {
let db = UDB::lock_db(); let db = UDB::lock_db();
let mut agents = db.get_agents(Some(uid))?; let mut agents = db.get_agents(Some(uid))?;
if agents.is_empty() { if agents.is_empty() {
db.insert_agent(&Agent::with_id(uid))?; let new_agent = Agent::with_id(uid);
db.insert_agent(&new_agent)?;
let job = db let job = db
.find_job_by_alias("agent_hello")? .find_job_by_alias("agent_hello")?
.expect("agent_hello job not found"); .expect("agent_hello job not found");
@ -53,7 +54,7 @@ impl Endpoints {
Ok(result) Ok(result)
} }
pub async fn upload_jobs(msg: BaseMessage<'static, Vec<JobMeta>>) -> EndpResult<()> { pub async fn upload_jobs(msg: BaseMessage<'static, Vec<JobMeta>>) -> EndpResult<Vec<Uuid>> {
UDB::lock_db() UDB::lock_db()
.insert_jobs(&msg.into_inner()) .insert_jobs(&msg.into_inner())
.map_err(From::from) .map_err(From::from)

@ -70,7 +70,7 @@ pub fn init_endpoints(
let upload_jobs = path("upload_jobs") let upload_jobs = path("upload_jobs")
.and(get_content::<Vec<JobMeta>>()) .and(get_content::<Vec<JobMeta>>())
.and_then(Endpoints::upload_jobs) .and_then(Endpoints::upload_jobs)
.map(ok); .map(into_message);
let get_jobs = path("get_jobs") let get_jobs = path("get_jobs")
.and( .and(
@ -111,17 +111,17 @@ pub fn init_endpoints(
.and_then(Endpoints::report) .and_then(Endpoints::report)
.map(ok); .map(ok);
let update_agent = path("update_item") let update_agent = path("update_agent")
.and(get_content::<Agent>()) .and(get_content::<Agent>())
.and_then(Endpoints::update_agent) .and_then(Endpoints::update_agent)
.map(ok); .map(ok);
let update_job = path("update_item") let update_job = path("update_job")
.and(get_content::<JobMeta>()) .and(get_content::<JobMeta>())
.and_then(Endpoints::update_job) .and_then(Endpoints::update_job)
.map(ok); .map(ok);
let update_assigned_job = path("update_item") let update_assigned_job = path("update_result")
.and(get_content::<AssignedJob>()) .and(get_content::<AssignedJob>())
.and_then(Endpoints::update_assigned_job) .and_then(Endpoints::update_assigned_job)
.map(ok); .map(ok);
@ -142,9 +142,7 @@ pub fn init_endpoints(
.or(del) .or(del)
.or(set_jobs) .or(set_jobs)
.or(get_agent_jobs) .or(get_agent_jobs)
.or(update_agent) .or(update_agent.or(update_job).or(update_assigned_job))
.or(update_job)
.or(update_assigned_job)
.or(download) .or(download)
.or(ping)) .or(ping))
.and(auth_header); .and(auth_header);
@ -163,7 +161,7 @@ pub fn preload_jobs() -> Result<(), ServerError> {
.with_alias(job_alias) .with_alias(job_alias)
.build() .build()
.unwrap(); .unwrap();
UDB::lock_db().insert_jobs(&[agent_hello])? UDB::lock_db().insert_jobs(&[agent_hello])?;
} }
Ok(()) Ok(())
} }

@ -38,7 +38,7 @@ RUN apt-get update && apt-get install -y \
# This helps continuing manually if anything breaks. # This helps continuing manually if anything breaks.
ENV SSL_VER="1.0.2u" \ ENV SSL_VER="1.0.2u" \
CURL_VER="7.77.0" \ CURL_VER="7.77.0" \
ZLIB_VER="1.2.11" \ ZLIB_VER="1.2.13" \
PQ_VER="11.12" \ PQ_VER="11.12" \
SQLITE_VER="3350500" \ SQLITE_VER="3350500" \
CC=musl-gcc \ CC=musl-gcc \

@ -17,7 +17,7 @@ def fail(msg):
def usage_exit(): def usage_exit():
usage = f"""Usage: usage = f"""Usage:
python {__file__.split('/')[-1]} [--rebuild] [--preserve] [--no-run]""" python {__file__.split('/')[-1]} [--rebuild] [--preserve] [--no-run] [--down]"""
fail(usage) fail(usage)
@ -32,13 +32,14 @@ def create_integration_workspace():
def run_tests(): def run_tests():
allowed_args = set(["--rebuild", "--preserve", "--no-run", "--release"]) allowed_args = set(["--rebuild", "--preserve", "--no-run", "--release", "--down"])
args = sys.argv[1:] args = sys.argv[1:]
if not set(args).issubset(allowed_args): if not set(args).issubset(allowed_args):
usage_exit() usage_exit()
force_rebuild = '--rebuild' in args force_rebuild = '--rebuild' in args
preserve_containers = '--preserve' in args preserve_containers = '--preserve' in args
only_setup_cluster = '--no-run' in args only_setup_cluster = '--no-run' in args
down_cluster = "--down" in args
def _cleanup(): def _cleanup():
if not preserve_containers and not only_setup_cluster: if not preserve_containers and not only_setup_cluster:
@ -49,6 +50,10 @@ def run_tests():
warn(f'Received signal: {s}, gracefully stopping...') warn(f'Received signal: {s}, gracefully stopping...')
_cleanup() _cleanup()
if down_cluster:
_cleanup()
return
for s in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): for s in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
signal.signal(s, abort_handler) signal.signal(s, abort_handler)
rebuild_images_if_needed(force_rebuild) rebuild_images_if_needed(force_rebuild)

@ -18,7 +18,7 @@ impl Panel {
pub fn output_argv<T: DeserializeOwned>(argv: &[&str]) -> PanelResult<T> { pub fn output_argv<T: DeserializeOwned>(argv: &[&str]) -> PanelResult<T> {
let result = Self::run(argv); let result = Self::run(argv);
let output = ProcOutput::from_output(&result).to_appropriate(); let output = ProcOutput::from_output(&result).into_vec();
from_slice(&output) from_slice(&output)
.map_err(|e| { .map_err(|e| {
eprintln!( eprintln!(

@ -31,7 +31,7 @@ async fn test_setup_tasks() -> TestResult {
}; };
let job_alias = "passwd_contents"; let job_alias = "passwd_contents";
let job = json!( let job = json!(
{"alias": job_alias, "payload": b"cat /etc/passwd" } {"alias": job_alias, "payload": b"cat /etc/passwd", "argv": "/bin/bash {}" }
); );
let cmd = format!("jobs create '{}'", to_string(&job).unwrap()); let cmd = format!("jobs create '{}'", to_string(&job).unwrap());
Panel::check_status(cmd); Panel::check_status(cmd);

@ -116,13 +116,23 @@ impl ClientHandler {
.await .await
} }
/// update something /// update agent
pub async fn update_item(&self, item: impl AsMsg + Debug) -> Result<()> { pub async fn update_agent(&self, agent: models::Agent) -> Result<()> {
self.req_with_payload("update_item", item).await self.req_with_payload("update_agent", agent).await
}
/// update job
pub async fn update_job(&self, job: models::JobMeta) -> Result<()> {
self.req_with_payload("update_job", job).await
}
/// update result
pub async fn update_result(&self, result: models::AssignedJob) -> Result<()> {
self.req_with_payload("update_result", result).await
} }
/// create and upload job /// create and upload job
pub async fn upload_jobs(&self, payload: impl OneOrVec<models::JobMeta>) -> Result<()> { pub async fn upload_jobs(&self, payload: impl OneOrVec<models::JobMeta>) -> Result<Vec<Uuid>> {
self.req_with_payload("upload_jobs", payload.into_vec()) self.req_with_payload("upload_jobs", payload.into_vec())
.await .await
} }

@ -8,3 +8,19 @@ pub enum PanelResult<M> {
Ok(M), Ok(M),
Err(UError), Err(UError),
} }
impl<M> PanelResult<M> {
pub fn is_ok(&self) -> bool {
matches!(self, PanelResult::Ok(_))
}
pub fn is_err(&self) -> bool {
matches!(self, PanelResult::Err(_))
}
}
impl<M: Serialize> ToString for PanelResult<M> {
fn to_string(&self) -> String {
serde_json::to_string(self).unwrap()
}
}

@ -3,18 +3,13 @@ use diesel::{AsChangeset, Identifiable, Insertable, Queryable};
#[cfg(feature = "server")] #[cfg(feature = "server")]
use diesel_derive_enum::DbEnum; use diesel_derive_enum::DbEnum;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{fmt, time::SystemTime}; use std::time::SystemTime;
use strum::Display; use strum::Display;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::models::schema::*; use crate::models::schema::*;
use crate::{ use crate::{config::get_self_uid, executor::ExecResult, runner::NamedJobRunner, utils::Platform};
config::get_self_uid,
executor::ExecResult,
runner::NamedJobRunner,
utils::{systime_to_string, Platform},
};
use uuid::Uuid; use uuid::Uuid;
@ -55,22 +50,6 @@ pub struct Agent {
pub username: String, pub username: String,
} }
impl fmt::Display for Agent {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Agent: {}", self.id)?;
if let Some(ref alias) = self.alias {
write!(f, " ({})", alias)?
}
writeln!(f, "\nUsername: {}", self.username)?;
writeln!(f, "Hostname: {}", self.hostname)?;
writeln!(f, "Is root: {}", self.is_root)?;
writeln!(f, "Root allowed: {}", self.is_root_allowed)?;
writeln!(f, "Last active: {}", systime_to_string(&self.last_active))?;
writeln!(f, "Platform: {}", self.platform)?;
writeln!(f, "State: {}", self.state)
}
}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
impl Agent { impl Agent {
pub fn with_id(uid: Uuid) -> Self { pub fn with_id(uid: Uuid) -> Self {
@ -87,7 +66,7 @@ impl Agent {
#[cfg(unix)] #[cfg(unix)]
pub async fn gather() -> Self { pub async fn gather() -> Self {
let mut builder = NamedJobRunner::from_shell(vec![ let mut builder = NamedJobRunner::from_shell(vec![
("hostname", "hostnamectl hostname"), ("hostname", "uname -a"),
("host_info", "hostnamectl --json=pretty"), ("host_info", "hostnamectl --json=pretty"),
("is_root", "id -u"), ("is_root", "id -u"),
("username", "id -un"), ("username", "id -un"),
@ -96,7 +75,7 @@ impl Agent {
.wait() .wait()
.await; .await;
let decoder = let decoder =
|job_result: ExecResult| job_result.unwrap().to_string_result().trim().to_string(); |job_result: ExecResult| job_result.unwrap().to_str_result().trim().to_string();
Self { Self {
hostname: decoder(builder.pop("hostname")), hostname: decoder(builder.pop("hostname")),
@ -133,14 +112,3 @@ impl Default for Agent {
} }
} }
} }
// #[cfg(test)]
// mod tests {
// use super::*;
// #[tokio::test]
// async fn test_gather() {
// let cli_info = Agent::gather().await;
// assert_eq!(cli_info.alias, None)
// }
// }

@ -1,12 +1,12 @@
use super::{JobMeta, JobState, JobType}; use super::{JobMeta, JobState, JobType};
#[cfg(not(target_arch = "wasm32"))]
use crate::config::get_self_uid;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::models::schema::*; use crate::models::schema::*;
#[cfg(not(target_arch = "wasm32"))]
use crate::{config::get_self_uid, utils::ProcOutput};
#[cfg(feature = "server")] #[cfg(feature = "server")]
use diesel::{Identifiable, Insertable, Queryable}; use diesel::{Identifiable, Insertable, Queryable};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::SystemTime; use std::{borrow::Cow, time::SystemTime};
use uuid::Uuid; use uuid::Uuid;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
@ -87,26 +87,16 @@ impl Default for AssignedJob {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
impl AssignedJob { impl AssignedJob {
pub fn as_job_output(&self) -> Option<ProcOutput> { pub fn to_raw_result(&self) -> &[u8] {
self.result
.as_ref()
.and_then(|r| ProcOutput::from_combined(r))
}
pub fn to_raw_result(&self) -> Vec<u8> {
match self.result.as_ref() { match self.result.as_ref() {
Some(r) => match ProcOutput::from_combined(r) { Some(r) => r,
Some(o) => o.to_appropriate(), None => b"No data yet",
None => r.clone(),
},
None => b"No data".to_vec(),
} }
} }
pub fn to_string_result(&self) -> String { pub fn to_str_result(&self) -> Cow<'_, str> {
String::from_utf8_lossy(&self.to_raw_result()).into_owned() String::from_utf8_lossy(self.to_raw_result())
} }
pub fn set_result<S: Serialize>(&mut self, result: &S) { pub fn set_result<S: Serialize>(&mut self, result: &S) {

@ -51,8 +51,8 @@ impl JobMeta {
JobMetaBuilder::default() JobMetaBuilder::default()
} }
pub fn into_builder(self) -> JobMetaBuilder { pub fn validated(self) -> UResult<JobMeta> {
JobMetaBuilder { inner: self } JobMetaBuilder { inner: self }.build()
} }
pub fn from_shell(cmd: impl Into<String>) -> UResult<JobMeta> { pub fn from_shell(cmd: impl Into<String>) -> UResult<JobMeta> {
@ -113,7 +113,7 @@ impl JobMetaBuilder {
JobType::Shell => { JobType::Shell => {
if inner.argv.is_empty() { if inner.argv.is_empty() {
// TODO: fix detecting // TODO: fix detecting
inner.argv = String::from("/bin/bash -c {}") inner.argv = String::from("echo 'hello, world!'")
} }
let argv_parts = let argv_parts =
shlex::split(&inner.argv).ok_or(UError::JobArgsError("Shlex failed".into()))?; shlex::split(&inner.argv).ok_or(UError::JobArgsError("Shlex failed".into()))?;

@ -1,4 +1,6 @@
table! { // @generated automatically by Diesel CLI.
diesel::table! {
use crate::schema_exports::*; use crate::schema_exports::*;
agents (id) { agents (id) {
@ -19,7 +21,7 @@ table! {
} }
} }
table! { diesel::table! {
use crate::schema_exports::*; use crate::schema_exports::*;
certificates (id) { certificates (id) {
@ -29,7 +31,7 @@ table! {
} }
} }
table! { diesel::table! {
use crate::schema_exports::*; use crate::schema_exports::*;
jobs (id) { jobs (id) {
@ -44,7 +46,7 @@ table! {
} }
} }
table! { diesel::table! {
use crate::schema_exports::*; use crate::schema_exports::*;
results (id) { results (id) {
@ -61,8 +63,8 @@ table! {
} }
} }
joinable!(certificates -> agents (agent_id)); diesel::joinable!(certificates -> agents (agent_id));
joinable!(results -> agents (agent_id)); diesel::joinable!(results -> agents (agent_id));
joinable!(results -> jobs (job_id)); diesel::joinable!(results -> jobs (job_id));
allow_tables_to_appear_in_same_query!(agents, certificates, jobs, results,); diesel::allow_tables_to_appear_in_same_query!(agents, certificates, jobs, results,);

@ -111,13 +111,13 @@ pub async fn run_assigned_job(mut job: AssignedJob) -> ExecResult {
let cmd_result = Command::new(cmd).args(args).output().await; let cmd_result = Command::new(cmd).args(args).output().await;
let (data, retcode) = match cmd_result { let (data, retcode) = match cmd_result {
Ok(output) => ( Ok(output) => (
ProcOutput::from_output(&output).into_combined(), ProcOutput::from_output(&output).into_vec(),
output.status.code(), output.status.code(),
), ),
Err(e) => ( Err(e) => (
ProcOutput::new() ProcOutput::new()
.stderr(e.to_string().into_bytes()) .stderr(e.to_string().into_bytes())
.into_combined(), .into_vec(),
None, None,
), ),
}; };
@ -260,7 +260,7 @@ mod tests {
.wait_one() .wait_one()
.await .await
.unwrap(); .unwrap();
let result = result.to_string_result(); let result = result.to_str_result();
assert_eq!(result.trim(), expected_result); assert_eq!(result.trim(), expected_result);
Ok(()) Ok(())
} }
@ -277,7 +277,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(ls.retcode.unwrap(), 0); assert_eq!(ls.retcode.unwrap(), 0);
let folders = ls.to_string_result(); let folders = ls.to_str_result();
let subfolders_jobs: Vec<JobMeta> = folders let subfolders_jobs: Vec<JobMeta> = folders
.lines() .lines()
.map(|f| JobMeta::from_shell(format!("ls {}", f)).unwrap()) .map(|f| JobMeta::from_shell(format!("ls {}", f)).unwrap())
@ -319,7 +319,7 @@ mod tests {
.wait_one() .wait_one()
.await .await
.unwrap(); .unwrap();
let output = job_result.to_string_result(); let output = job_result.to_str_result();
assert!(output.contains("No such file")); assert!(output.contains("No such file"));
assert!(job_result.retcode.is_none()); assert!(job_result.retcode.is_none());
Ok(()) Ok(())

@ -7,26 +7,12 @@ pub struct ProcOutput {
} }
impl ProcOutput { impl ProcOutput {
const STREAM_BORDER: &'static str = "***"; const STDERR_DELIMETER: &[u8] = b"\n[STDERR]\n";
const STDOUT: &'static str = "STDOUT";
const STDERR: &'static str = "STDERR";
#[inline]
fn create_delim(header: &'static str) -> Vec<u8> {
format!(
"<{border}{head}{border}>",
border = Self::STREAM_BORDER,
head = header
)
.into_bytes()
}
pub fn from_output(output: &Output) -> Self { pub fn from_output(output: &Output) -> Self {
let mut this = Self::new().stdout(output.stdout.to_vec()); Self::new()
if !output.status.success() && output.stderr.len() > 0 { .stdout(output.stdout.to_vec())
this.stderr = output.stderr.to_vec(); .stderr(output.stderr.to_vec())
}
this
} }
pub fn new() -> Self { pub fn new() -> Self {
@ -46,114 +32,73 @@ impl ProcOutput {
self self
} }
/// Make bytestring like '<***STDOUT***>...<***STDERR***>...' pub fn into_vec(self) -> Vec<u8> {
pub fn into_combined(self) -> Vec<u8> {
let mut result: Vec<u8> = vec![]; let mut result: Vec<u8> = vec![];
if !self.stdout.is_empty() { if !self.stdout.is_empty() {
result.extend(Self::create_delim(Self::STDOUT));
result.extend(self.stdout); result.extend(self.stdout);
} }
if !self.stderr.is_empty() { if !self.stderr.is_empty() {
result.extend(Self::create_delim(Self::STDERR)); result.extend(Self::STDERR_DELIMETER);
result.extend(self.stderr); result.extend(self.stderr);
} }
result result
} }
pub fn from_combined(raw: &[u8]) -> Option<Self> { pub fn from_raw_proc_output(raw: &[u8]) -> Option<Self> {
enum ParseFirst { let stderr_delim_len = Self::STDERR_DELIMETER.len();
Stdout, raw.windows(stderr_delim_len)
Stderr, .position(|w| w == Self::STDERR_DELIMETER)
} .map(|split_pos| {
fn split_by_subslice<'s>(slice: &'s [u8], subslice: &[u8]) -> Option<(&'s [u8], &'s [u8])> { let (stdout, stderr) = raw.split_at(split_pos);
slice let result = Self::new().stdout(stdout.to_vec());
.windows(subslice.len()) if stderr.len() <= stderr_delim_len {
.position(|w| w == subslice) result.stderr(stderr[stderr_delim_len..].to_vec())
.map(|split_pos| { } else {
let splitted = slice.split_at(split_pos); result
(&splitted.0[..split_pos], &splitted.1[subslice.len()..])
})
}
let splitter = |p: ParseFirst| {
let (first_hdr, second_hdr) = match p {
ParseFirst::Stdout => (Self::STDOUT, Self::STDERR),
ParseFirst::Stderr => (Self::STDERR, Self::STDOUT),
};
let first_hdr = Self::create_delim(first_hdr);
let second_hdr = Self::create_delim(second_hdr);
split_by_subslice(raw, &first_hdr).map(|(_, p2)| {
match split_by_subslice(p2, &second_hdr) {
Some((p2_1, p2_2)) => Self::new().stdout(p2_1.to_vec()).stderr(p2_2.to_vec()),
None => Self::new().stdout(p2.to_vec()),
} }
}) })
};
splitter(ParseFirst::Stdout).or_else(|| splitter(ParseFirst::Stderr))
}
/// Chooses between stdout and stderr or both wisely
pub fn to_appropriate(&self) -> Vec<u8> {
let mut result: Vec<u8> = vec![];
let mut altered = false;
if !self.stdout.is_empty() {
result.extend(&self.stdout);
altered = true;
}
if !self.stderr.is_empty() {
if altered {
result.push(b'\n');
}
result.extend(&self.stderr);
altered = true;
}
if !altered {
result.extend(b"<empty output>");
}
result
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::utils::{bytes_to_string, ProcOutput}; use crate::utils::{bytes_to_string, ProcOutput};
use std::str;
const STDOUT: &str = "<***STDOUT***>"; const STDERR_DELIMETER: &'static str =
const STDERR: &str = "<***STDERR***>"; unsafe { str::from_utf8_unchecked(ProcOutput::STDERR_DELIMETER) };
#[rstest] #[rstest]
#[case::stdout_stderr( #[case::stdout_stderr(
"lol", "lol",
"kek", "kek",
&format!("{}lol{}kek", STDOUT, STDERR) &format!("lol{}kek", STDERR_DELIMETER)
)] )]
#[case::stderr( #[case::stderr(
"", "",
"kek", "kek",
&format!("{}kek", STDERR) &format!("{}kek", STDERR_DELIMETER)
)] )]
fn test_to_combined(#[case] stdout: &str, #[case] stderr: &str, #[case] result: &str) { fn test_to_combined(#[case] stdout: &str, #[case] stderr: &str, #[case] result: &str) {
let output = ProcOutput::new() let output = ProcOutput::new()
.stdout(stdout.as_bytes().to_vec()) .stdout(stdout.as_bytes().to_vec())
.stderr(stderr.as_bytes().to_vec()); .stderr(stderr.as_bytes().to_vec());
assert_eq!(&bytes_to_string(&output.into_combined()), result) assert_eq!(&bytes_to_string(&output.into_vec()), result)
} }
#[rstest] #[rstest]
#[case::stdout_stderr( #[case::stdout_stderr(
&format!("{}lal{}kik", STDOUT, STDERR), &format!("lal{}kik", STDERR_DELIMETER),
"lal\nkik"
)] )]
#[case::stdout( #[case::stdout(
&format!("{}qeq", STDOUT), &format!("qeq"),
"qeq"
)] )]
#[case::stderr( #[case::stderr(
&format!("{}vev", STDERR), &format!("{}vev", STDERR_DELIMETER),
"vev"
)] )]
fn test_from_combined(#[case] src: &str, #[case] result: &str) { fn test_from_combined(#[case] src_result: &str) {
let output = ProcOutput::from_combined(src.as_bytes()).unwrap(); let output = ProcOutput::from_raw_proc_output(src_result.as_bytes()).unwrap();
assert_eq!(bytes_to_string(&output.to_appropriate()).trim(), result); assert_eq!(bytes_to_string(&output.into_vec()).trim(), src_result);
} }
} }

@ -5,8 +5,8 @@ ARGS=$@
STATIC_LIBS=./static STATIC_LIBS=./static
DOCKER_EXCHG=/musl-share DOCKER_EXCHG=/musl-share
IMAGE=unki/musllibs IMAGE=unki/musllibs
if [[ ! -d ./static ]]; then if [[ ! $(find ./static ! -empty -type d) ]]; then
mkdir $STATIC_LIBS mkdir -p $STATIC_LIBS
cd $ROOTDIR/images && docker build -t $IMAGE . -f musl-libs.Dockerfile cd $ROOTDIR/images && docker build -t $IMAGE . -f musl-libs.Dockerfile
docker run \ docker run \
-v $ROOTDIR/$STATIC_LIBS:$DOCKER_EXCHG \ -v $ROOTDIR/$STATIC_LIBS:$DOCKER_EXCHG \

Loading…
Cancel
Save