Simplify dashboard

This commit is contained in:
Asher 2020-03-16 12:43:32 -05:00
parent d832f61d5b
commit d192726e80
No known key found for this signature in database
GPG Key ID: D63C1EF81242354A
14 changed files with 205 additions and 304 deletions

View File

@ -6,8 +6,11 @@
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
/> />
<meta http-equiv="Content-Security-Policy" content="style-src 'self'; manifest-src 'self'; img-src 'self' data:;" /> <meta
<title>code-server — {{APP_NAME}}</title> http-equiv="Content-Security-Policy"
content="style-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;"
/>
<title>code-server</title>
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" /> <link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link <link
rel="manifest" rel="manifest"
@ -20,6 +23,6 @@
</head> </head>
<body> <body>
<script src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script> <script src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script>
<script src="{{BASE}}/static/{{COMMIT}}/dist/app.js"></script> <script src="{{BASE}}/static/{{COMMIT}}/dist/pages/app.js"></script>
</body> </body>
</html> </html>

View File

@ -1,4 +1,5 @@
import { getOptions } from "../../common/util" import { getOptions, normalize } from "../../common/util"
import { ApiEndpoint } from "../../common/http"
import "./app.css" import "./app.css"
import "./error.css" import "./error.css"
@ -9,4 +10,29 @@ import "./update.css"
const options = getOptions() const options = getOptions()
console.log(options) const isInput = (el: Element): el is HTMLInputElement => {
return !!(el as HTMLInputElement).name
}
document.querySelectorAll("form").forEach((form) => {
if (!form.classList.contains("-x11")) {
return
}
form.addEventListener("submit", (event) => {
event.preventDefault()
const values: { [key: string]: string } = {}
Array.from(form.elements).forEach((element) => {
if (isInput(element)) {
values[element.name] = element.value
}
})
fetch(normalize(`${options.base}/api/${ApiEndpoint.process}`), {
method: "POST",
body: JSON.stringify(values),
})
})
})
// TEMP: Until we can get the real ready event.
const event = new CustomEvent("ide-ready")
window.dispatchEvent(event)

View File

@ -71,6 +71,15 @@ button {
padding: 40px; padding: 40px;
} }
.card-box > .content > .none {
margin: 2px 0;
}
.card-box + .card-box { .card-box + .card-box {
margin-top: 26px; margin-top: 26px;
} }
canvas {
top: 0;
left: 0;
}

View File

@ -7,8 +7,11 @@
margin: 2px 0; margin: 2px 0;
} }
.block-row > .item.-row { .block-row > button.item {
display: flex; background: none;
border: none;
cursor: pointer;
text-align: left;
} }
.block-row > .item > .sub { .block-row > .item > .sub {
@ -34,6 +37,7 @@
.block-row > .item > .icon.-missing { .block-row > .item > .icon.-missing {
background-color: rgba(87, 114, 245, 0.2); background-color: rgba(87, 114, 245, 0.2);
display: inline-block;
text-align: center; text-align: center;
} }

View File

@ -6,7 +6,7 @@
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
/> />
<meta http-equiv="Content-Security-Policy" content="style-src 'self'; manifest-src 'self'; img-src 'self' data:;" /> <meta http-equiv="Content-Security-Policy" content="style-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;" />
<title>code-server</title> <title>code-server</title>
<link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" /> <link rel="icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link <link
@ -20,26 +20,6 @@
</head> </head>
<body> <body>
<div class="center-container"> <div class="center-container">
<div class="card-box">
<div class="header">
<h2 class="main">Running</h2>
<div class="sub">Currently running applications.</div>
</div>
<div class="content">
{{APP_LIST:RUNNING}}
</div>
</div>
<div class="card-box">
<div class="header">
<h2 class="main">Recent</h2>
<div class="sub">Choose a recent directory or workspace to launch below.</div>
</div>
<div class="content">
{{APP_LIST:RECENT_PROJECTS}}
</div>
</div>
<div class="card-box"> <div class="card-box">
<div class="header"> <div class="header">
<h2 class="main">Editors</h2> <h2 class="main">Editors</h2>
@ -50,15 +30,15 @@
</div> </div>
</div> </div>
<!-- <div class="card-box"> --> <div class="card-box">
<!-- <div class="header"> --> <div class="header">
<!-- <h2 class="main">Other</h2> --> <h2 class="main">Other</h2>
<!-- <div class="sub">Choose an application to launch below.</div> --> <div class="sub">Choose an application to launch below.</div>
<!-- </div> --> </div>
<!-- <div class="content"> --> <div class="content">
<!-- {{APP_LIST:OTHER}} --> {{APP_LIST:OTHER}}
<!-- </div> --> </div>
<!-- </div> --> </div>
<div class="card-box"> <div class="card-box">
<div class="header"> <div class="header">
@ -71,5 +51,6 @@
</div> </div>
</div> </div>
<script src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script> <script src="{{BASE}}/static/{{COMMIT}}/dist/register.js"></script>
<script src="{{BASE}}/static/{{COMMIT}}/dist/pages/app.js"></script>
</body> </body>
</html> </html>

View File

@ -7,8 +7,14 @@ export interface Application {
readonly icon?: string readonly icon?: string
readonly installed?: boolean readonly installed?: boolean
readonly name: string readonly name: string
/**
* Path if this is a browser app (like VS Code).
*/
readonly path?: string readonly path?: string
readonly sessionId?: string /**
* PID if this is a process.
*/
readonly pid?: number
readonly version?: string readonly version?: string
} }
@ -17,19 +23,18 @@ export interface ApplicationsResponse {
} }
export enum SessionError { export enum SessionError {
NotFound = 4000, FailedToStart = 4000,
FailedToStart, Starting = 4001,
Starting, InvalidState = 4002,
InvalidState, Unknown = 4003,
Unknown,
} }
export interface SessionResponse { export interface SessionResponse {
/** /**
* Whether the session was created or an existing one was returned. * Whether the process was spawned or an existing one was returned.
*/ */
created: boolean created: boolean
sessionId: string pid: number
} }
export interface RecentResponse { export interface RecentResponse {
@ -37,10 +42,6 @@ export interface RecentResponse {
readonly workspaces: string[] readonly workspaces: string[]
} }
export interface RunningResponse {
readonly applications: ReadonlyArray<Application>
}
export interface HealthRequest { export interface HealthRequest {
readonly event: "health" readonly event: "health"
} }

View File

@ -17,9 +17,8 @@ export class HttpError extends Error {
export enum ApiEndpoint { export enum ApiEndpoint {
applications = "/applications", applications = "/applications",
process = "/process",
recent = "/recent", recent = "/recent",
run = "/run", run = "/run",
running = "/running",
session = "/session",
status = "/status", status = "/status",
} }

View File

@ -1,10 +1,10 @@
import { logger } from "@coder/logger" import { logger, field } from "@coder/logger"
export interface Options { export interface Options {
base: string base: string
commit: string commit: string
logLevel: number logLevel: number
sessionId?: string pid?: number
} }
/** /**
@ -34,14 +34,12 @@ export const normalize = (url: string, keepTrailing = false): string => {
} }
/** /**
* Get options embedded in the HTML from the server. * Get options embedded in the HTML or query params.
*/ */
export const getOptions = <T extends Options>(): T => { export const getOptions = <T extends Options>(): T => {
if (typeof document === "undefined") { let options: T
return {} as T
}
const el = document.getElementById("coder-options")
try { try {
const el = document.getElementById("coder-options")
if (!el) { if (!el) {
throw new Error("no options element") throw new Error("no options element")
} }
@ -49,19 +47,31 @@ export const getOptions = <T extends Options>(): T => {
if (!value) { if (!value) {
throw new Error("no options value") throw new Error("no options value")
} }
const options = JSON.parse(value) options = JSON.parse(value)
if (typeof options.logLevel !== "undefined") {
logger.level = options.logLevel
}
const parts = window.location.pathname.replace(/^\//g, "").split("/")
parts[parts.length - 1] = options.base
const url = new URL(window.location.origin + "/" + parts.join("/"))
return {
...options,
base: normalize(url.pathname, true),
}
} catch (error) { } catch (error) {
logger.warn(error.message) options = {} as T
return {} as T
} }
const params = new URLSearchParams(location.search)
const queryOpts = params.get("options")
if (queryOpts) {
options = {
...options,
...JSON.parse(queryOpts),
}
}
if (typeof options.logLevel !== "undefined") {
logger.level = options.logLevel
}
if (options.base) {
const parts = location.pathname.replace(/^\//g, "").split("/")
parts[parts.length - 1] = options.base
const url = new URL(location.origin + "/" + parts.join("/"))
options.base = normalize(url.pathname, true)
}
logger.debug("got options", field("options", options))
return options
} }

View File

@ -11,22 +11,15 @@ import {
ApplicationsResponse, ApplicationsResponse,
ClientMessage, ClientMessage,
RecentResponse, RecentResponse,
RunningResponse,
ServerMessage, ServerMessage,
SessionError, SessionError,
SessionResponse, SessionResponse,
} from "../../common/api" } from "../../common/api"
import { ApiEndpoint, HttpCode, HttpError } from "../../common/http" import { ApiEndpoint, HttpCode, HttpError } from "../../common/http"
import { normalize } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, Route } from "../http" import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, Route } from "../http"
import { findApplications, findWhitelistedApplications, Vscode } from "./bin" import { findApplications, findWhitelistedApplications, Vscode } from "./bin"
import { VscodeHttpProvider } from "./vscode" import { VscodeHttpProvider } from "./vscode"
interface ServerSession {
process?: cp.ChildProcess
readonly app: Application
}
interface VsRecents { interface VsRecents {
[key: string]: (string | { configURIPath: string })[] [key: string]: (string | { configURIPath: string })[]
} }
@ -38,7 +31,6 @@ type VsSettings = [string, string][]
*/ */
export class ApiHttpProvider extends HttpProvider { export class ApiHttpProvider extends HttpProvider {
private readonly ws = new WebSocket.Server({ noServer: true }) private readonly ws = new WebSocket.Server({ noServer: true })
private readonly sessions = new Map<string, ServerSession>()
public constructor( public constructor(
options: HttpProviderOptions, options: HttpProviderOptions,
@ -49,14 +41,6 @@ export class ApiHttpProvider extends HttpProvider {
super(options) 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> { public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
this.ensureAuthenticated(request) this.ensureAuthenticated(request)
if (route.requestPath !== "/index.html") { if (route.requestPath !== "/index.html") {
@ -67,22 +51,19 @@ export class ApiHttpProvider extends HttpProvider {
case ApiEndpoint.applications: case ApiEndpoint.applications:
this.ensureMethod(request) this.ensureMethod(request)
return { return {
mime: "application/json",
content: { content: {
applications: await this.applications(), applications: await this.applications(),
}, },
} as HttpResponse<ApplicationsResponse> } as HttpResponse<ApplicationsResponse>
case ApiEndpoint.session: case ApiEndpoint.process:
return this.session(request) return this.process(request)
case ApiEndpoint.recent: case ApiEndpoint.recent:
this.ensureMethod(request) this.ensureMethod(request)
return { return {
mime: "application/json",
content: await this.recent(), content: await this.recent(),
} as HttpResponse<RecentResponse> } as HttpResponse<RecentResponse>
case ApiEndpoint.running:
this.ensureMethod(request)
return {
content: await this.running(),
} as HttpResponse<RunningResponse>
} }
throw new HttpError("Not found", HttpCode.NotFound) 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( private async handleRunSocket(
route: Route, _route: Route,
request: http.IncomingMessage, request: http.IncomingMessage,
socket: net.Socket, socket: net.Socket,
head: Buffer, head: Buffer,
): Promise<void> { ): Promise<void> {
const sessionId = route.requestPath.replace(/^\//, "") logger.debug("connecting to process")
logger.debug("connecting session", field("sessionId", sessionId))
const ws = await new Promise<WebSocket>((resolve, reject) => { const ws = await new Promise<WebSocket>((resolve, reject) => {
this.ws.handleUpgrade(request, socket, head, (socket) => { this.ws.handleUpgrade(request, socket, head, (socket) => {
socket.binaryType = "arraybuffer" 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.on("error", (error) => {
socket.close(SessionError.FailedToStart) socket.close(SessionError.FailedToStart)
logger.error("got error while connecting socket", field("error", error)) logger.error("got error while connecting socket", field("error", error))
reject(error) reject(error)
}) })
resolve(socket as WebSocket)
}) })
}) })
logger.debug("connected to process")
// Send ready message. // Send ready message.
ws.send( ws.send(
Buffer.from( Buffer.from(
@ -192,61 +168,40 @@ export class ApiHttpProvider extends HttpProvider {
} }
/** /**
* Get a running application. * Handle /process endpoint.
*/ */
public getRunningApplication(sessionIdOrPath?: string): Application | undefined { private async process(request: http.IncomingMessage): Promise<HttpResponse> {
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> {
this.ensureMethod(request, ["DELETE", "POST"]) this.ensureMethod(request, ["DELETE", "POST"])
const data = await this.getData(request) const data = await this.getData(request)
if (!data) { 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) { switch (request.method) {
case "DELETE": case "DELETE":
return this.deleteSession(JSON.parse(data).sessionId) if (parsed.pid) {
case "POST": { await this.killProcess(parsed.pid)
// Prevent spawning the same app multiple times. } else if (parsed.path) {
const parsed: Application = JSON.parse(data) await this.killProcess(parsed.path)
const app = this.getRunningApplication(parsed.sessionId || parsed.path) } else {
if (app) { throw new Error("No pid or path was provided")
return {
content: {
created: false,
sessionId: app.sessionId,
},
} as HttpResponse<SessionResponse>
} }
return { return {
mime: "application/json",
code: HttpCode.Ok,
}
case "POST": {
if (!parsed.exec) {
throw new Error("No exec was provided")
}
return {
mime: "application/json",
content: { content: {
created: true, created: true,
sessionId: await this.createSession(parsed), pid: await this.spawnProcess(parsed.exec),
}, },
} as HttpResponse<SessionResponse> } 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> { public async killProcess(pid: number | string): Promise<void> {
logger.debug("deleting session", field("sessionId", sessionId)) if (typeof pid === "string") {
switch (sessionId) { switch (pid) {
case "vscode": case Vscode.path:
await this.vscode.dispose() await this.vscode.dispose()
return { code: HttpCode.Ok } break
default: { default:
const session = this.sessions.get(sessionId) throw new Error(`Process "${pid}" does not exist`)
if (!session) {
throw new Error("session does not exist")
}
if (session.process) {
session.process.kill()
}
this.sessions.delete(sessionId)
return { code: HttpCode.Ok }
} }
} 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> { public async spawnProcess(exec: string): Promise<number> {
const sessionId = Math.floor(Math.random() * 10000).toString() const proc = cp.spawn(exec, {
if (this.sessions.has(sessionId)) { shell: process.env.SHELL || true,
throw new Error("conflicting session id") env: {
} ...process.env,
if (!app.exec) {
throw new Error("cannot execute application with no `exec`")
}
const appSession: ServerSession = {
app: {
...app,
sessionId,
}, },
} })
this.sessions.set(sessionId, appSession)
try { proc.on("error", (error) => logger.error("process errored", field("pid", proc.pid), field("error", error)))
throw new Error("TODO") proc.on("exit", () => logger.debug("process exited", field("pid", proc.pid)))
} catch (error) {
this.sessions.delete(sessionId) logger.debug("started process", field("pid", proc.pid))
throw error
} 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 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") const setting = Array.isArray(state) && state.find((item) => item[0] === "recently.opened")
if (!setting) { if (!setting) {
throw new Error("settings appear malformed") return { paths: [], workspaces: [] }
} }
const pathPromises: { [key: string]: Promise<string> } = {} const pathPromises: { [key: string]: Promise<string> } = {}
@ -360,34 +299,13 @@ export class ApiHttpProvider extends HttpProvider {
return { paths: [], workspaces: [] } 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 * For these, just return the error message since they'll be requested as
* JSON. * JSON.
*/ */
public async getErrorRoot(_route: Route, _title: string, _header: string, error: string): Promise<HttpResponse> { public async getErrorRoot(_route: Route, _title: string, _header: string, error: string): Promise<HttpResponse> {
return { return {
mime: "application/json",
content: JSON.stringify({ error }), content: JSON.stringify({ error }),
} }
} }

View File

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

View File

@ -1,10 +1,10 @@
import * as http from "http" import * as http from "http"
import * as querystring from "querystring" import * as querystring from "querystring"
import { Application, RecentResponse } from "../../common/api" import { Application } from "../../common/api"
import { HttpCode, HttpError } from "../../common/http" import { HttpCode, HttpError } from "../../common/http"
import { normalize } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
import { ApiHttpProvider } from "./api" import { ApiHttpProvider } from "./api"
import { Vscode } from "./bin"
import { UpdateHttpProvider } from "./update" import { UpdateHttpProvider } from "./update"
/** /**
@ -25,21 +25,27 @@ export class DashboardHttpProvider extends HttpProvider {
} }
switch (route.base) { switch (route.base) {
case "/delete": { case "/spawn": {
this.ensureAuthenticated(request) this.ensureAuthenticated(request)
this.ensureMethod(request, "POST") this.ensureMethod(request, "POST")
const data = await this.getData(request) const data = await this.getData(request)
const p = data ? querystring.parse(data) : {} const app = data ? querystring.parse(data) : {}
this.api.deleteSession(p.sessionId as string) 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 } return { redirect: this.options.base }
} }
case "/app":
case "/": { case "/": {
this.ensureMethod(request) this.ensureMethod(request)
if (!this.authenticated(request)) { if (!this.authenticated(request)) {
return { redirect: "/login", query: { to: this.options.base } } 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") const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/home.html")
response.content = response.content response.content = response.content
.replace(/{{UPDATE:NAME}}/, await this.getUpdate(base)) .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( .replace(
/{{APP_LIST:EDITORS}}/, /{{APP_LIST:EDITORS}}/,
this.getAppRows( this.getAppRows(
@ -71,46 +75,32 @@ export class DashboardHttpProvider extends HttpProvider {
return this.replaceTemplates(route, response) return this.replaceTemplates(route, response)
} }
private getRecentProjectRows(base: string, recents: RecentResponse): string { public async getAppRoot(route: Route): Promise<HttpResponse> {
return recents.paths.length > 0 || recents.workspaces.length > 0 const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/app.html")
? recents.paths.map((recent) => this.getRecentProjectRow(base, recent)).join("\n") + return this.replaceTemplates(route, response)
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>`
} }
private getAppRows(base: string, apps: ReadonlyArray<Application>): string { private getAppRows(base: string, apps: ReadonlyArray<Application>): string {
return apps.length > 0 return apps.length > 0
? apps.map((app) => this.getAppRow(base, app)).join("\n") ? 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 { private getAppRow(base: string, app: Application): string {
return `<div class="block-row"> return `<form class="block-row${app.exec ? " -x11" : ""}" method="post" action="${normalize(
<a class="item -row -link" href="${base}${app.path}"> `${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 app.icon
? `<img class="icon" src="data:image/png;base64,${app.icon}"></img>` ? `<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> <span class="name">${app.name}</span>
</a> </button>
${ </form>`
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>`
} }
private async getUpdate(base: string): Promise<string> { private async getUpdate(base: string): Promise<string> {

View File

@ -140,7 +140,7 @@ export class UpdateHttpProvider extends HttpProvider {
update = { checked: now, version: data.name } update = { checked: now, version: data.name }
await this.settings.write({ update }) 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 return update
} catch (error) { } catch (error) {
logger.error("Failed to get latest version", field("error", error.message)) 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 { public isLatestVersion(latest: Update): boolean {
const version = this.currentVersion 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 { try {
return latest.version === version || semver.lt(latest.version, version) return latest.version === version || semver.lt(latest.version, version)
} catch (error) { } catch (error) {

View File

@ -3,7 +3,6 @@ import * as cp from "child_process"
import * as path from "path" import * as path from "path"
import { CliMessage } from "../../lib/vscode/src/vs/server/ipc" import { CliMessage } from "../../lib/vscode/src/vs/server/ipc"
import { ApiHttpProvider } from "./app/api" import { ApiHttpProvider } from "./app/api"
import { AppHttpProvider } from "./app/app"
import { DashboardHttpProvider } from "./app/dashboard" import { DashboardHttpProvider } from "./app/dashboard"
import { LoginHttpProvider } from "./app/login" import { LoginHttpProvider } from "./app/login"
import { StaticHttpProvider } from "./app/static" import { StaticHttpProvider } from "./app/static"
@ -49,7 +48,6 @@ const main = async (args: Args): Promise<void> => {
const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args) const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args)
const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer, vscode, args["user-data-dir"]) const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer, vscode, args["user-data-dir"])
const update = httpServer.registerHttpProvider("/update", UpdateHttpProvider, !args["disable-updates"]) const update = httpServer.registerHttpProvider("/update", UpdateHttpProvider, !args["disable-updates"])
httpServer.registerHttpProvider("/app", AppHttpProvider, api)
httpServer.registerHttpProvider("/login", LoginHttpProvider) httpServer.registerHttpProvider("/login", LoginHttpProvider)
httpServer.registerHttpProvider("/static", StaticHttpProvider) httpServer.registerHttpProvider("/static", StaticHttpProvider)
httpServer.registerHttpProvider("/dashboard", DashboardHttpProvider, api, update) httpServer.registerHttpProvider("/dashboard", DashboardHttpProvider, api, update)

View File

@ -514,8 +514,7 @@ export class HttpServer {
private onRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise<void> => { private onRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise<void> => {
this.heart.beat() this.heart.beat()
const route = this.parseUrl(request) const route = this.parseUrl(request)
try { const write = (payload: HttpResponse): void => {
const payload = this.maybeRedirect(request, route) || (await route.provider.handleRequest(route, request))
response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, { response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, {
"Content-Type": payload.mime || getMediaMime(payload.filePath), "Content-Type": payload.mime || getMediaMime(payload.filePath),
...(payload.redirect ? { Location: this.constructRedirect(request, route, payload as RedirectResponse) } : {}), ...(payload.redirect ? { Location: this.constructRedirect(request, route, payload as RedirectResponse) } : {}),
@ -547,6 +546,13 @@ export class HttpServer {
} else { } else {
response.end() response.end()
} }
}
try {
const payload = this.maybeRedirect(request, route) || (await route.provider.handleRequest(route, request))
if (!payload) {
throw new HttpError("Not found", HttpCode.NotFound)
}
write(payload)
} catch (error) { } catch (error) {
let e = error let e = error
if (error.code === "ENOENT" || error.code === "EISDIR") { if (error.code === "ENOENT" || error.code === "EISDIR") {
@ -555,9 +561,11 @@ export class HttpServer {
logger.debug("Request error", field("url", request.url)) logger.debug("Request error", field("url", request.url))
logger.debug(error.stack) logger.debug(error.stack)
const code = typeof e.code === "number" ? e.code : HttpCode.ServerError const code = typeof e.code === "number" ? e.code : HttpCode.ServerError
const content = (await route.provider.getErrorRoot(route, code, code, e.message)).content const payload = await route.provider.getErrorRoot(route, code, code, e.message)
response.writeHead(code) write({
response.end(content) code,
...payload,
})
} }
} }
@ -595,7 +603,7 @@ export class HttpServer {
(this.options.cert && !secure ? `${this.protocol}://${request.headers.host}/` : "") + (this.options.cert && !secure ? `${this.protocol}://${request.headers.host}/` : "") +
normalize(`${route.provider.base(route)}/${payload.redirect}`, true) + normalize(`${route.provider.base(route)}/${payload.redirect}`, true) +
(Object.keys(query).length > 0 ? `?${querystring.stringify(query)}` : "") (Object.keys(query).length > 0 ? `?${querystring.stringify(query)}` : "")
logger.debug("Redirecting", field("secure", !!secure), field("from", request.url), field("to", redirect)) logger.debug("redirecting", field("secure", !!secure), field("from", request.url), field("to", redirect))
return redirect return redirect
} }