Simplify dashboard
This commit is contained in:
@ -11,22 +11,15 @@ import {
|
||||
ApplicationsResponse,
|
||||
ClientMessage,
|
||||
RecentResponse,
|
||||
RunningResponse,
|
||||
ServerMessage,
|
||||
SessionError,
|
||||
SessionResponse,
|
||||
} from "../../common/api"
|
||||
import { ApiEndpoint, HttpCode, HttpError } from "../../common/http"
|
||||
import { normalize } from "../../common/util"
|
||||
import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, Route } from "../http"
|
||||
import { findApplications, findWhitelistedApplications, Vscode } from "./bin"
|
||||
import { VscodeHttpProvider } from "./vscode"
|
||||
|
||||
interface ServerSession {
|
||||
process?: cp.ChildProcess
|
||||
readonly app: Application
|
||||
}
|
||||
|
||||
interface VsRecents {
|
||||
[key: string]: (string | { configURIPath: string })[]
|
||||
}
|
||||
@ -38,7 +31,6 @@ type VsSettings = [string, string][]
|
||||
*/
|
||||
export class ApiHttpProvider extends HttpProvider {
|
||||
private readonly ws = new WebSocket.Server({ noServer: true })
|
||||
private readonly sessions = new Map<string, ServerSession>()
|
||||
|
||||
public constructor(
|
||||
options: HttpProviderOptions,
|
||||
@ -49,14 +41,6 @@ export class ApiHttpProvider extends HttpProvider {
|
||||
super(options)
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.sessions.forEach((s) => {
|
||||
if (s.process) {
|
||||
s.process.kill()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
||||
this.ensureAuthenticated(request)
|
||||
if (route.requestPath !== "/index.html") {
|
||||
@ -67,22 +51,19 @@ export class ApiHttpProvider extends HttpProvider {
|
||||
case ApiEndpoint.applications:
|
||||
this.ensureMethod(request)
|
||||
return {
|
||||
mime: "application/json",
|
||||
content: {
|
||||
applications: await this.applications(),
|
||||
},
|
||||
} as HttpResponse<ApplicationsResponse>
|
||||
case ApiEndpoint.session:
|
||||
return this.session(request)
|
||||
case ApiEndpoint.process:
|
||||
return this.process(request)
|
||||
case ApiEndpoint.recent:
|
||||
this.ensureMethod(request)
|
||||
return {
|
||||
mime: "application/json",
|
||||
content: await this.recent(),
|
||||
} as HttpResponse<RecentResponse>
|
||||
case ApiEndpoint.running:
|
||||
this.ensureMethod(request)
|
||||
return {
|
||||
content: await this.running(),
|
||||
} as HttpResponse<RunningResponse>
|
||||
}
|
||||
|
||||
throw new HttpError("Not found", HttpCode.NotFound)
|
||||
@ -137,36 +118,31 @@ export class ApiHttpProvider extends HttpProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* A socket that connects to a session.
|
||||
* A socket that connects to the process.
|
||||
*/
|
||||
private async handleRunSocket(
|
||||
route: Route,
|
||||
_route: Route,
|
||||
request: http.IncomingMessage,
|
||||
socket: net.Socket,
|
||||
head: Buffer,
|
||||
): Promise<void> {
|
||||
const sessionId = route.requestPath.replace(/^\//, "")
|
||||
logger.debug("connecting session", field("sessionId", sessionId))
|
||||
logger.debug("connecting to process")
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
this.ws.handleUpgrade(request, socket, head, (socket) => {
|
||||
socket.binaryType = "arraybuffer"
|
||||
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
socket.close(SessionError.NotFound)
|
||||
return reject(new Error("session not found"))
|
||||
}
|
||||
|
||||
resolve(socket as WebSocket)
|
||||
|
||||
socket.on("error", (error) => {
|
||||
socket.close(SessionError.FailedToStart)
|
||||
logger.error("got error while connecting socket", field("error", error))
|
||||
reject(error)
|
||||
})
|
||||
|
||||
resolve(socket as WebSocket)
|
||||
})
|
||||
})
|
||||
|
||||
logger.debug("connected to process")
|
||||
|
||||
// Send ready message.
|
||||
ws.send(
|
||||
Buffer.from(
|
||||
@ -192,61 +168,40 @@ export class ApiHttpProvider extends HttpProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a running application.
|
||||
* Handle /process endpoint.
|
||||
*/
|
||||
public getRunningApplication(sessionIdOrPath?: string): Application | undefined {
|
||||
if (!sessionIdOrPath) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sessionId = sessionIdOrPath.replace(/\//g, "")
|
||||
let session = this.sessions.get(sessionId)
|
||||
if (session) {
|
||||
logger.debug("found application by session id", field("id", sessionId))
|
||||
return session.app
|
||||
}
|
||||
|
||||
const base = normalize("/" + sessionIdOrPath)
|
||||
session = Array.from(this.sessions.values()).find((s) => s.app.path === base)
|
||||
if (session) {
|
||||
logger.debug("found application by path", field("path", base))
|
||||
return session.app
|
||||
}
|
||||
|
||||
logger.debug("no application found matching route", field("route", sessionIdOrPath))
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /session endpoint.
|
||||
*/
|
||||
private async session(request: http.IncomingMessage): Promise<HttpResponse> {
|
||||
private async process(request: http.IncomingMessage): Promise<HttpResponse> {
|
||||
this.ensureMethod(request, ["DELETE", "POST"])
|
||||
|
||||
const data = await this.getData(request)
|
||||
if (!data) {
|
||||
throw new HttpError("Not found", HttpCode.NotFound)
|
||||
throw new HttpError("No data was provided", HttpCode.BadRequest)
|
||||
}
|
||||
|
||||
const parsed: Application = JSON.parse(data)
|
||||
|
||||
switch (request.method) {
|
||||
case "DELETE":
|
||||
return this.deleteSession(JSON.parse(data).sessionId)
|
||||
case "POST": {
|
||||
// Prevent spawning the same app multiple times.
|
||||
const parsed: Application = JSON.parse(data)
|
||||
const app = this.getRunningApplication(parsed.sessionId || parsed.path)
|
||||
if (app) {
|
||||
return {
|
||||
content: {
|
||||
created: false,
|
||||
sessionId: app.sessionId,
|
||||
},
|
||||
} as HttpResponse<SessionResponse>
|
||||
if (parsed.pid) {
|
||||
await this.killProcess(parsed.pid)
|
||||
} else if (parsed.path) {
|
||||
await this.killProcess(parsed.path)
|
||||
} else {
|
||||
throw new Error("No pid or path was provided")
|
||||
}
|
||||
return {
|
||||
mime: "application/json",
|
||||
code: HttpCode.Ok,
|
||||
}
|
||||
case "POST": {
|
||||
if (!parsed.exec) {
|
||||
throw new Error("No exec was provided")
|
||||
}
|
||||
return {
|
||||
mime: "application/json",
|
||||
content: {
|
||||
created: true,
|
||||
sessionId: await this.createSession(parsed),
|
||||
pid: await this.spawnProcess(parsed.exec),
|
||||
},
|
||||
} as HttpResponse<SessionResponse>
|
||||
}
|
||||
@ -256,55 +211,39 @@ export class ApiHttpProvider extends HttpProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a session identified by `app.sessionId`.
|
||||
* Kill a process identified by pid or path if a web app.
|
||||
*/
|
||||
public async deleteSession(sessionId: string): Promise<HttpResponse> {
|
||||
logger.debug("deleting session", field("sessionId", sessionId))
|
||||
switch (sessionId) {
|
||||
case "vscode":
|
||||
await this.vscode.dispose()
|
||||
return { code: HttpCode.Ok }
|
||||
default: {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("session does not exist")
|
||||
}
|
||||
if (session.process) {
|
||||
session.process.kill()
|
||||
}
|
||||
this.sessions.delete(sessionId)
|
||||
return { code: HttpCode.Ok }
|
||||
public async killProcess(pid: number | string): Promise<void> {
|
||||
if (typeof pid === "string") {
|
||||
switch (pid) {
|
||||
case Vscode.path:
|
||||
await this.vscode.dispose()
|
||||
break
|
||||
default:
|
||||
throw new Error(`Process "${pid}" does not exist`)
|
||||
}
|
||||
} else {
|
||||
process.kill(pid)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session and return the session ID.
|
||||
* Spawn a process and return the pid.
|
||||
*/
|
||||
public async createSession(app: Application): Promise<string> {
|
||||
const sessionId = Math.floor(Math.random() * 10000).toString()
|
||||
if (this.sessions.has(sessionId)) {
|
||||
throw new Error("conflicting session id")
|
||||
}
|
||||
|
||||
if (!app.exec) {
|
||||
throw new Error("cannot execute application with no `exec`")
|
||||
}
|
||||
|
||||
const appSession: ServerSession = {
|
||||
app: {
|
||||
...app,
|
||||
sessionId,
|
||||
public async spawnProcess(exec: string): Promise<number> {
|
||||
const proc = cp.spawn(exec, {
|
||||
shell: process.env.SHELL || true,
|
||||
env: {
|
||||
...process.env,
|
||||
},
|
||||
}
|
||||
this.sessions.set(sessionId, appSession)
|
||||
})
|
||||
|
||||
try {
|
||||
throw new Error("TODO")
|
||||
} catch (error) {
|
||||
this.sessions.delete(sessionId)
|
||||
throw error
|
||||
}
|
||||
proc.on("error", (error) => logger.error("process errored", field("pid", proc.pid), field("error", error)))
|
||||
proc.on("exit", () => logger.debug("process exited", field("pid", proc.pid)))
|
||||
|
||||
logger.debug("started process", field("pid", proc.pid))
|
||||
|
||||
return proc.pid
|
||||
}
|
||||
|
||||
/**
|
||||
@ -319,7 +258,7 @@ export class ApiHttpProvider extends HttpProvider {
|
||||
const state: VsSettings = JSON.parse(await fs.readFile(path.join(this.dataDir, "User/state/global.json"), "utf8"))
|
||||
const setting = Array.isArray(state) && state.find((item) => item[0] === "recently.opened")
|
||||
if (!setting) {
|
||||
throw new Error("settings appear malformed")
|
||||
return { paths: [], workspaces: [] }
|
||||
}
|
||||
|
||||
const pathPromises: { [key: string]: Promise<string> } = {}
|
||||
@ -360,34 +299,13 @@ export class ApiHttpProvider extends HttpProvider {
|
||||
return { paths: [], workspaces: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return running sessions.
|
||||
*/
|
||||
public async running(): Promise<RunningResponse> {
|
||||
return {
|
||||
applications: (this.vscode.running
|
||||
? [
|
||||
{
|
||||
...Vscode,
|
||||
sessionId: "vscode",
|
||||
},
|
||||
]
|
||||
: []
|
||||
).concat(
|
||||
Array.from(this.sessions).map(([sessionId, session]) => ({
|
||||
...session.app,
|
||||
sessionId,
|
||||
})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For these, just return the error message since they'll be requested as
|
||||
* JSON.
|
||||
*/
|
||||
public async getErrorRoot(_route: Route, _title: string, _header: string, error: string): Promise<HttpResponse> {
|
||||
return {
|
||||
mime: "application/json",
|
||||
content: JSON.stringify({ error }),
|
||||
}
|
||||
}
|
||||
|
@ -1,46 +0,0 @@
|
||||
import * as http from "http"
|
||||
import { HttpCode, HttpError } from "../../common/http"
|
||||
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
|
||||
import { ApiHttpProvider } from "./api"
|
||||
|
||||
/**
|
||||
* App/fallback HTTP provider.
|
||||
*/
|
||||
export class AppHttpProvider extends HttpProvider {
|
||||
public constructor(options: HttpProviderOptions, private readonly api: ApiHttpProvider) {
|
||||
super(options)
|
||||
}
|
||||
|
||||
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
||||
if (!this.authenticated(request)) {
|
||||
return { redirect: "/login", query: { to: route.fullPath } }
|
||||
}
|
||||
|
||||
this.ensureMethod(request)
|
||||
if (route.requestPath !== "/index.html") {
|
||||
throw new HttpError("Not found", HttpCode.NotFound)
|
||||
}
|
||||
|
||||
// Run an existing app, but if it doesn't exist go ahead and start it.
|
||||
let app = this.api.getRunningApplication(route.base)
|
||||
let sessionId = app && app.sessionId
|
||||
if (!app) {
|
||||
app = (await this.api.installedApplications()).find((a) => a.path === route.base)
|
||||
if (app && app.exec) {
|
||||
sessionId = await this.api.createSession(app)
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
return this.getAppRoot(route, (app && app.name) || "", sessionId)
|
||||
}
|
||||
|
||||
throw new HttpError("Application not found", HttpCode.NotFound)
|
||||
}
|
||||
|
||||
public async getAppRoot(route: Route, name: string, sessionId: string): Promise<HttpResponse> {
|
||||
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/app.html")
|
||||
response.content = response.content.replace(/{{APP_NAME}}/, name)
|
||||
return this.replaceTemplates(route, response, sessionId)
|
||||
}
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
import * as http from "http"
|
||||
import * as querystring from "querystring"
|
||||
import { Application, RecentResponse } from "../../common/api"
|
||||
import { Application } from "../../common/api"
|
||||
import { HttpCode, HttpError } from "../../common/http"
|
||||
import { normalize } from "../../common/util"
|
||||
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
|
||||
import { ApiHttpProvider } from "./api"
|
||||
import { Vscode } from "./bin"
|
||||
import { UpdateHttpProvider } from "./update"
|
||||
|
||||
/**
|
||||
@ -25,21 +25,27 @@ export class DashboardHttpProvider extends HttpProvider {
|
||||
}
|
||||
|
||||
switch (route.base) {
|
||||
case "/delete": {
|
||||
case "/spawn": {
|
||||
this.ensureAuthenticated(request)
|
||||
this.ensureMethod(request, "POST")
|
||||
const data = await this.getData(request)
|
||||
const p = data ? querystring.parse(data) : {}
|
||||
this.api.deleteSession(p.sessionId as string)
|
||||
const app = data ? querystring.parse(data) : {}
|
||||
if (app.path) {
|
||||
return { redirect: Array.isArray(app.path) ? app.path[0] : app.path }
|
||||
}
|
||||
if (!app.exec) {
|
||||
throw new Error("No exec was provided")
|
||||
}
|
||||
this.api.spawnProcess(Array.isArray(app.exec) ? app.exec[0] : app.exec)
|
||||
return { redirect: this.options.base }
|
||||
}
|
||||
|
||||
case "/app":
|
||||
case "/": {
|
||||
this.ensureMethod(request)
|
||||
if (!this.authenticated(request)) {
|
||||
return { redirect: "/login", query: { to: this.options.base } }
|
||||
}
|
||||
return this.getRoot(route)
|
||||
return route.base === "/" ? this.getRoot(route) : this.getAppRoot(route)
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,8 +58,6 @@ export class DashboardHttpProvider extends HttpProvider {
|
||||
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/home.html")
|
||||
response.content = response.content
|
||||
.replace(/{{UPDATE:NAME}}/, await this.getUpdate(base))
|
||||
.replace(/{{APP_LIST:RUNNING}}/, this.getAppRows(base, (await this.api.running()).applications))
|
||||
.replace(/{{APP_LIST:RECENT_PROJECTS}}/, this.getRecentProjectRows(base, await this.api.recent()))
|
||||
.replace(
|
||||
/{{APP_LIST:EDITORS}}/,
|
||||
this.getAppRows(
|
||||
@ -71,46 +75,32 @@ export class DashboardHttpProvider extends HttpProvider {
|
||||
return this.replaceTemplates(route, response)
|
||||
}
|
||||
|
||||
private getRecentProjectRows(base: string, recents: RecentResponse): string {
|
||||
return recents.paths.length > 0 || recents.workspaces.length > 0
|
||||
? recents.paths.map((recent) => this.getRecentProjectRow(base, recent)).join("\n") +
|
||||
recents.workspaces.map((recent) => this.getRecentProjectRow(base, recent, true)).join("\n")
|
||||
: `<div class="none">No recent directories or workspaces.</div>`
|
||||
}
|
||||
|
||||
private getRecentProjectRow(base: string, recent: string, workspace?: boolean): string {
|
||||
return `<div class="block-row">
|
||||
<a class="item -row -link" href="${base}${Vscode.path}?${workspace ? "workspace" : "folder"}=${recent}">
|
||||
<div class="name">${recent}${workspace ? " (workspace)" : ""}</div>
|
||||
</a>
|
||||
</div>`
|
||||
public async getAppRoot(route: Route): Promise<HttpResponse> {
|
||||
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/app.html")
|
||||
return this.replaceTemplates(route, response)
|
||||
}
|
||||
|
||||
private getAppRows(base: string, apps: ReadonlyArray<Application>): string {
|
||||
return apps.length > 0
|
||||
? apps.map((app) => this.getAppRow(base, app)).join("\n")
|
||||
: `<div class="none">No applications are currently running.</div>`
|
||||
: `<div class="none">No applications found.</div>`
|
||||
}
|
||||
|
||||
private getAppRow(base: string, app: Application): string {
|
||||
return `<div class="block-row">
|
||||
<a class="item -row -link" href="${base}${app.path}">
|
||||
return `<form class="block-row${app.exec ? " -x11" : ""}" method="post" action="${normalize(
|
||||
`${base}${this.options.base}/spawn`,
|
||||
)}">
|
||||
<button class="item -row -link">
|
||||
<input type="hidden" name="path" value="${app.path || ""}">
|
||||
<input type="hidden" name="exec" value="${app.exec || ""}">
|
||||
${
|
||||
app.icon
|
||||
? `<img class="icon" src="data:image/png;base64,${app.icon}"></img>`
|
||||
: `<div class="icon -missing"></div>`
|
||||
: `<span class="icon -missing"></span>`
|
||||
}
|
||||
<div class="name">${app.name}</div>
|
||||
</a>
|
||||
${
|
||||
app.sessionId
|
||||
? `<form class="kill-form" action="${base}${this.options.base}/delete" method="POST">
|
||||
<input type="hidden" name="sessionId" value="${app.sessionId}">
|
||||
<button class="kill -button" type="submit">Kill</button>
|
||||
</form>`
|
||||
: ""
|
||||
}
|
||||
</div>`
|
||||
<span class="name">${app.name}</span>
|
||||
</button>
|
||||
</form>`
|
||||
}
|
||||
|
||||
private async getUpdate(base: string): Promise<string> {
|
||||
|
@ -140,7 +140,7 @@ export class UpdateHttpProvider extends HttpProvider {
|
||||
update = { checked: now, version: data.name }
|
||||
await this.settings.write({ update })
|
||||
}
|
||||
logger.debug("Got latest version", field("latest", update.version))
|
||||
logger.debug("got latest version", field("latest", update.version))
|
||||
return update
|
||||
} catch (error) {
|
||||
logger.error("Failed to get latest version", field("error", error.message))
|
||||
@ -160,7 +160,7 @@ export class UpdateHttpProvider extends HttpProvider {
|
||||
*/
|
||||
public isLatestVersion(latest: Update): boolean {
|
||||
const version = this.currentVersion
|
||||
logger.debug("Comparing versions", field("current", version), field("latest", latest.version))
|
||||
logger.debug("comparing versions", field("current", version), field("latest", latest.version))
|
||||
try {
|
||||
return latest.version === version || semver.lt(latest.version, version)
|
||||
} catch (error) {
|
||||
|
Reference in New Issue
Block a user