frontend: Add linting for ts code

This commit is contained in:
Tom 2023-07-13 13:51:18 +02:00
parent ddb4bb3546
commit 16fa721048
18 changed files with 2299 additions and 469 deletions

View file

@ -61,6 +61,9 @@ func (am ActionMap) Handle(conn httpx.WebSocketConnection) (name string, err err
} else {
result.Success = false
result.Message = err.Error()
if result.Message == "" {
result.Message = "unspecified error"
}
}
// encode the result message to json!

View file

@ -1,7 +1,7 @@
import { Parcel } from "@parcel/core"
import { mkdir, rm, writeFile, readFile, unlink, rmdir, } from "fs/promises"
import { join } from "path"
import { parse as parseHTML } from 'node-html-parser';
import { Parcel } from '@parcel/core'
import { mkdir, rm, writeFile, readFile, unlink } from 'fs/promises'
import { join } from 'path'
import { parse as parseHTML } from 'node-html-parser'
import { spawnSync } from 'child_process'
//
@ -15,14 +15,14 @@ const PUBLIC_DIR = '/⛰/' // mountain's don't move, and neither do static files
const DEST_PACKAGE = process.env.GOPACKAGE ?? 'static'
const DEST_DISCLAIMER = (() => {
const source = (process.env.GOFILE ?? 'assets.go')
const base = source.substring(0, source.length - '.go'.length)
return base + '_disclaimer.txt'
const source = (process.env.GOFILE ?? 'assets.go')
const base = source.substring(0, source.length - '.go'.length)
return base + '_disclaimer.txt'
})()
const DEST_FILE = (() => {
const source = (process.env.GOFILE ?? 'assets.go')
const base = source.substring(0, source.length - '.go'.length)
return base + '_dist.go'
const source = (process.env.GOFILE ?? 'assets.go')
const base = source.substring(0, source.length - '.go'.length)
return base + '_dist.go'
})()
//
@ -31,57 +31,54 @@ const DEST_FILE = (() => {
process.stdout.write('Preparing directories ...')
await Promise.all([
mkdir(ENTRY_DIR, { recursive: true }),
rm(DIST_DIR, { recursive: true, force: true })
mkdir(ENTRY_DIR, { recursive: true }),
rm(DIST_DIR, { recursive: true, force: true })
])
console.log(' Done.')
//
// Write the disclaimer
//
process.stdout.write('Generating legal disclaimer ...')
const disclaimer = await new Promise((r, e) => {
var child = spawnSync("yarn", ["licenses", "generate-disclaimer"], { encoding : 'utf8' });
if (child.error) {
e(child.stderr)
return
}
const disclaimer = await new Promise((resolve, reject) => {
const child = spawnSync('yarn', ['licenses', 'generate-disclaimer'], { encoding: 'utf8' })
if (child.error) {
reject(child.stderr)
return
}
r(child.stdout)
});
resolve(child.stdout)
})
console.log(' Done.')
process.stdout.write(`Writing ${DEST_DISCLAIMER} ...`)
await writeFile(DEST_DISCLAIMER, disclaimer)
console.log(' Done.')
//
// WRITE ENTRY POINTS
//
process.stdout.write('Collecting entry points ')
const entries = await Promise.all(ENTRYPOINTS.map(async (name) => {
const entry = {
'name': name,
'bundleName': name + '.html',
'src': join(ENTRY_DIR, name + '.html'),
}
const entry = {
name,
bundleName: name + '.html',
src: join(ENTRY_DIR, name + '.html')
}
const content = `
const content = `
<script type='module' src='../src/base/index.ts'></script>
<script type='module' src='../src/entry/${name}/index.ts'></script>
<link rel='stylesheet' href='../src/entry/${name}/index.css'>
`;
await writeFile(entry.src, content)
`
await writeFile(entry.src, content)
process.stdout.write('.')
return entry;
process.stdout.write('.')
return entry
}))
console.log(' Done.')
@ -91,21 +88,21 @@ console.log(' Done.')
process.stdout.write('Bundleing assets ...')
const bundler = new Parcel({
entries: entries.map(e => e.src),
defaultConfig: '@parcel/config-default',
shouldDisableCache: true,
shouldContentHash: true,
defaultTargetOptions: {
shouldOptimize: true,
shouldScopeHoist: true,
sourceMaps: false,
distDir: DIST_DIR,
publicUrl: PUBLIC_DIR,
engines: {
browsers: "defaults",
}
entries: entries.map(e => e.src),
defaultConfig: '@parcel/config-default',
shouldDisableCache: true,
shouldContentHash: true,
defaultTargetOptions: {
shouldOptimize: true,
shouldScopeHoist: true,
sourceMaps: false,
distDir: DIST_DIR,
publicUrl: PUBLIC_DIR,
engines: {
browsers: 'defaults'
}
});
}
})
const { bundleGraph } = await bundler.run()
console.log(' Done.')
@ -116,19 +113,19 @@ console.log(' Done.')
process.stdout.write('Find Assets in Output ')
const bundles = bundleGraph.getBundles()
const assets = await Promise.all(entries.map(async (entry) => {
const mainBundle = bundles.find(b => b.name === entry.bundleName)
if (mainBundle === undefined) throw new Error('Unable to find bundle for ' + entry.name)
const mainBundle = bundles.find(b => b.name === entry.bundleName)
if (mainBundle === undefined) throw new Error('Unable to find bundle for ' + entry.name)
// read, then delete the generated output file
const { filePath } = mainBundle
const html = parseHTML(await readFile(filePath))
await unlink(filePath)
// read, then delete the generated output file
const { filePath } = mainBundle
const html = parseHTML(await readFile(filePath))
await unlink(filePath)
const scripts = html.querySelectorAll('script').map(script => script.outerHTML).join('')
const links = html.querySelectorAll('link').map(link => link.outerHTML).join('')
const scripts = html.querySelectorAll('script').map(script => script.outerHTML).join('')
const links = html.querySelectorAll('link').map(link => link.outerHTML).join('')
process.stdout.write('.')
return { ...entry, scripts, links }
process.stdout.write('.')
return { ...entry, scripts, links }
}))
console.log(' Done.')
@ -138,7 +135,7 @@ console.log(' Done.')
process.stdout.write(`Writing ${DEST_FILE} ...`)
const goAssets = assets.map(({ name, scripts, links }) => {
return `
return `
// Assets${name} contains assets for the '${name}' entrypoint.
var Assets${name} = Assets{
\tScripts: \`${scripts}\`,
@ -158,7 +155,7 @@ var Disclaimer string
const Public = ${JSON.stringify(PUBLIC_DIR)}
${goAssets}
`;
`
await writeFile(DEST_FILE, goSource)
console.log(' Done.')
console.log(' Done.')

View file

@ -11,5 +11,17 @@
"node-html-parser": "^6.1.1",
"parcel": "^2.7.0",
"purecss": "^2.1.0"
},
"devDependencies": {
"ts-standard": "^12.0.2",
"typescript": "^5.1.6"
},
"ts-standard": {
"ignore": [
"dist",
"node_modules",
".entry-cache",
".parcel-cache"
]
}
}
}

View file

@ -1,4 +1,4 @@
import "purecss/build/pure.css"
import "purecss/build/grids-responsive.css"
import 'purecss/build/pure.css'
import 'purecss/build/grids-responsive.css'
import "./index.css"
import './index.css'

View file

@ -1,15 +1,15 @@
// setup highlighting
import "~/src/lib/highlight"
import '~/src/lib/highlight'
// setup remote actions
import setup from "~/src/lib/remote"
setup();
import setup from '~/src/lib/remote'
// include the user styles!
import "../User/index.ts"
import "../User/index.css"
import '../User/index.ts'
import '../User/index.css'
// highlight everything
import "highlight.js/styles/default.css"
import highlightJs from "highlight.js"
highlightJs.highlightAll();
import 'highlight.js/styles/default.css'
import highlightJs from 'highlight.js'
setup()
highlightJs.highlightAll()

View file

@ -1,24 +1,23 @@
import "../Admin/index.ts"
import "../Admin/index.css"
import '../Admin/index.ts'
import '../Admin/index.css'
import { Provision } from "~/src/lib/remote/api"
import { Provision } from '~/src/lib/remote/api'
const provision = document.getElementById("provision") as HTMLFormElement;
const slug = document.getElementById("slug") as HTMLInputElement;
const php = document.getElementById("php") as HTMLSelectElement;
const opcacheDevelopment = document.getElementById("opcacheDevelopment") as HTMLInputElement;
const provision = document.getElementById('provision') as HTMLFormElement
const slug = document.getElementById('slug') as HTMLInputElement
const php = document.getElementById('php') as HTMLSelectElement
const opcacheDevelopment = document.getElementById('opcacheDevelopment') as HTMLInputElement
// add an event handler to open the modal form!
provision.addEventListener('submit', (evt) => {
evt.preventDefault();
evt.preventDefault()
Provision({ Slug: slug.value, System: { PHP: php.value, OpCacheDevelopment: opcacheDevelopment.checked } })
.then(slug => {
location.href = "/admin/instance/" + slug;
})
.catch((e) => {console.error(e); location.reload()});
Provision({ Slug: slug.value, System: { PHP: php.value, OpCacheDevelopment: opcacheDevelopment.checked } })
.then(slug => {
location.href = '/admin/instance/' + slug
})
.catch((e) => { console.error(e); location.reload() })
})
// enable the form!
provision.querySelector('fieldset')?.removeAttribute('disabled');
provision.querySelector('fieldset')?.removeAttribute('disabled')

View file

@ -1,24 +1,23 @@
import "../Admin/index.ts"
import "../Admin/index.css"
import '../Admin/index.ts'
import '../Admin/index.css'
import { Rebuild } from "~/src/lib/remote/api"
import { Rebuild } from '~/src/lib/remote/api'
const slug = document.getElementById("slug") as HTMLInputElement
const provision = document.getElementById("provision") as HTMLFormElement;
const php = document.getElementById("php") as HTMLSelectElement;
const opcacheDevelopment = document.getElementById("opcacheDevelopment") as HTMLInputElement;
const slug = document.getElementById('slug') as HTMLInputElement
const provision = document.getElementById('provision') as HTMLFormElement
const php = document.getElementById('php') as HTMLSelectElement
const opcacheDevelopment = document.getElementById('opcacheDevelopment') as HTMLInputElement
// add an event handler to open the modal form!
provision.addEventListener('submit', (evt) => {
evt.preventDefault();
evt.preventDefault()
Rebuild(slug.value, { PHP: php.value, OpCacheDevelopment: opcacheDevelopment.checked })
.then(slug => {
location.href = "/admin/instance/" + slug;
})
.catch((e) => {console.error(e); location.reload()});
Rebuild(slug.value, { PHP: php.value, OpCacheDevelopment: opcacheDevelopment.checked })
.then(slug => {
location.href = '/admin/instance/' + slug
})
.catch((e) => { console.error(e); location.reload() })
})
// enable the form!
provision.querySelector('fieldset')?.removeAttribute('disabled');
provision.querySelector('fieldset')?.removeAttribute('disabled')

View file

@ -1,2 +1,2 @@
import "~/src/lib/copy"
import "~/src/lib/reveal"
import '~/src/lib/copy'
import '~/src/lib/reveal'

View file

@ -1,21 +1,21 @@
import "./index.css"
import './index.css'
/** Adapted from http://blog.parkermoore.de/2014/08/01/header-anchor-links-in-vanilla-javascript-for-github-pages-and-jekyll/ */
const anchorForId = (id: string) => {
const anchor = document.createElement("a")
anchor.className = "header-link"
anchor.href = "#" + id
anchor.innerHTML = "#"
return anchor
const anchorForId = (id: string): HTMLAnchorElement => {
const anchor = document.createElement('a')
anchor.className = 'header-link'
anchor.href = '#' + id
anchor.innerHTML = '#'
return anchor
}
const linkifyAnchors = (level: number) => {
const headers = document.getElementsByTagName("h" + level);
Array.from(headers).forEach((header) => {
if (typeof header.id === "undefined" || header.id === "") return
header.appendChild(anchorForId(header.id))
})
const linkifyAnchors = (level: number): void => {
const headers = document.getElementsByTagName('h' + level.toString())
Array.from(headers).forEach((header) => {
if (typeof header.id === 'undefined' || header.id === '') return
header.appendChild(anchorForId(header.id))
})
}
// linkify all the anchors from 1 ... 6
(new Array(6)).fill(0).forEach((_, i) => linkifyAnchors(i + 1))
(new Array(6)).fill(0).forEach((_, i) => linkifyAnchors(i + 1))

View file

@ -1,8 +1,13 @@
import "./index.css"
import './index.css'
import { discard } from '~/src/lib/discard'
document.querySelectorAll('.copy').forEach((elem: Element) => {
elem.addEventListener('click', () => {
if (!navigator.clipboard) return;
navigator.clipboard.writeText((elem as HTMLElement).innerText);
})
})
elem.addEventListener('click', () => {
// Check if the clipboard api is supported
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (!navigator.clipboard) return
discard(navigator.clipboard.writeText((elem as HTMLElement).innerText))
})
})

View file

@ -0,0 +1,13 @@
import { Mutex, MutexInterface } from 'async-mutex'
const error = console.error.bind(console)
/** discard discards the result of a promise, or logs an error if it occurs */
export function discard<T> (p: Promise<T>): void {
p.then((): void => {}).catch(error)
}
/** runs worker exclusively on m, and discards the resulting promise */
export function runMutexExclusive<T> (m: Mutex, worker: MutexInterface.Worker<T>): void {
discard(m.runExclusive(worker))
}

View file

@ -1,61 +1,61 @@
import dayjs from "dayjs"
import dayjs from 'dayjs'
const types: Record<string, (element: HTMLElement) => HTMLElement | string> = {
"date": (element) => {
const value = dayjs(element.innerText);
const text = value.format('YYYY-MM-DD HH:mm:ss ([UTC]Z)')
date: (element) => {
const value = dayjs(element.innerText)
const text = value.format('YYYY-MM-DD HH:mm:ss ([UTC]Z)')
// if the date is the zero date, then it is assumed to be invalid
if (value.unix() === 0) {
const code = document.createElement('code')
code.style.color = 'gray'
code.append(text)
return code
}
return text
},
"path": (element) => {
const text = element.innerText.split("/");
return text[text.length - 1];
},
"pathbuilder": (element) => {
// create a link and get the blob
const filename = (element.getAttribute('data-name') ?? 'pathbuilder') + ".xml"
const [link, blob] = make_download_link(filename, element.innerText, "application/xml")
link.className = "pure-button"
const title = filename + ' (' + blob.size + ' Bytes)';
link.append(title)
return link
// if the date is the zero date, then it is assumed to be invalid
if (value.unix() === 0) {
const code = document.createElement('code')
code.style.color = 'gray'
code.append(text)
return code
}
return text
},
path: (element) => {
const text = element.innerText.split('/')
return text[text.length - 1]
},
pathbuilder: (element) => {
// create a link and get the blob
const filename = (element.getAttribute('data-name') ?? 'pathbuilder') + '.xml'
const [link, blob] = makeDownloadLink(filename, element.innerText, 'application/xml')
link.className = 'pure-button'
const title = filename + ' (' + blob.size.toString() + ' Bytes)'
link.append(title)
return link
}
}
const make_download_link = (filename: string, content: string, type: string): [HTMLAnchorElement, Blob] => {
const blob = new Blob(
[content],
{
type: type ?? "text/plain"
}
);
const makeDownloadLink = (filename: string, content: string, type: string): [HTMLAnchorElement, Blob] => {
const blob = new Blob(
[content],
{
type: type ?? 'text/plain'
}
)
const link = document.createElement("a")
link.target = "_blank"
link.download = filename
link.href = URL.createObjectURL(blob)
const link = document.createElement('a')
link.target = '_blank'
link.download = filename
link.href = URL.createObjectURL(blob)
return [link, blob]
return [link, blob]
}
Object.keys(types).forEach(key => {
const f = types[key];
const elements = document.querySelectorAll("code." + key) as NodeListOf<HTMLElement>
elements.forEach(element => {
const newElement = f(element)
if (typeof newElement === 'string') {
element.innerHTML = ""
element.appendChild(document.createTextNode(newElement))
return
}
const f = types[key]
const elements = document.querySelectorAll<HTMLElement>('code.' + key)
elements.forEach(element => {
const newElement = f(element)
if (typeof newElement === 'string') {
element.innerHTML = ''
element.appendChild(document.createTextNode(newElement))
return
}
element.parentNode!.replaceChild(newElement, element)
})
})
element.parentNode?.replaceChild(newElement, element)
})
})

View file

@ -1,50 +1,50 @@
import { createModal } from "~/src/lib/remote"
import { createModal } from '~/src/lib/remote'
/**
* Flags to provision a new system.
* Should mirror "provision".Flags.
*/
interface ProvisionFlags {
Slug: string
System: System
Slug: string
System: System
}
interface System {
PHP: string;
OpCacheDevelopment: boolean
PHP: string
OpCacheDevelopment: boolean
}
/** Rebuild the specified instance */
export async function Rebuild(slug: string, system: System): Promise<string> {
return new Promise((rs, rj) => {
createModal("rebuild", [slug, JSON.stringify(system)], {
bufferSize: 0,
onClose: (success: boolean, message?: string) => {
if (!success) {
rj(new Error(message ?? "unspecified error"))
return;
}
rs(slug);
},
})
});
export async function Rebuild (slug: string, system: System): Promise<string> {
return await new Promise((resolve, reject) => {
createModal('rebuild', [slug, JSON.stringify(system)], {
bufferSize: 0,
onClose: (success: boolean, message?: string) => {
if (!success) {
reject(new Error(message ?? 'unspecified error'))
return
}
resolve(slug)
}
})
})
}
/** Provision provisions a new instance */
export async function Provision(flags: ProvisionFlags): Promise<string> {
// open a modal to provision a new instance
return new Promise((rs, rj) => {
createModal("provision", [JSON.stringify(flags)], {
bufferSize: 0,
onClose: (success: boolean, message?: string) => {
if (!success) {
rj(new Error(message ?? "unspecified error"))
return;
}
rs(flags.Slug);
},
})
});
}
export async function Provision (flags: ProvisionFlags): Promise<string> {
// open a modal to provision a new instance
return await new Promise((resolve, reject) => {
createModal('provision', [JSON.stringify(flags)], {
bufferSize: 0,
onClose: (success: boolean, message?: string) => {
if (!success) {
reject(new Error(message ?? 'unspecified error'))
return
}
resolve(flags.Slug)
}
})
})
}

View file

@ -1,196 +1,215 @@
import "./index.css"
import './index.css'
import callServerAction, { ResultMessage } from './proto'
type Println = ((line: string, flush?: boolean) => void) & {
paintedFrames: number;
missedFrames: number;
paintedFrames: number
missedFrames: number
}
/**
* makeTextBuffer returns a println() function that efficiently writes text into target, and keeps at most size elements in the traceback.
* scrollContainer is used to scroll on every painted update.
*/
function makeTextBuffer(target: HTMLElement, scrollContainer: HTMLElement, size: number): Println {
let lastAnimationFrame: number | null = null; // last scheduled animation frame
function makeTextBuffer (target: HTMLElement, scrollContainer: HTMLElement, size: number): Println {
let lastAnimationFrame: number | null = null // last scheduled animation frame
const buffer: Array<string> = []; // the internal buffer of lines
const paint = () => {
println.paintedFrames++
target.innerText = buffer.join("\n")
scrollContainer.scrollTop = scrollContainer.scrollHeight
lastAnimationFrame = null
const buffer: string[] = [] // the internal buffer of lines
const paint = (): void => {
println.paintedFrames++
target.innerText = buffer.join('\n')
scrollContainer.scrollTop = scrollContainer.scrollHeight
lastAnimationFrame = null
}
const println = (line: string, flush?: boolean): void => {
// add the line
buffer.push(line)
if (size !== 0 && buffer.length > size) {
buffer.splice(0, buffer.length - size)
}
const println = (line: string, flush?: boolean) => {
// add the line
buffer.push(line)
if (size !== 0 && buffer.length > size) {
buffer.splice(0, buffer.length - size)
}
// and update the browser in the next animation frame
if (lastAnimationFrame !== null) {
println.missedFrames++
window.cancelAnimationFrame(lastAnimationFrame)
}
// force a repaint!
if(flush) return paint();
// schedule an animation frame
lastAnimationFrame = window.requestAnimationFrame(paint);
// and update the browser in the next animation frame
if (lastAnimationFrame !== null) {
println.missedFrames++
window.cancelAnimationFrame(lastAnimationFrame)
}
println.paintedFrames = 0;
println.missedFrames = 0;
return println;
// force a repaint!
if (flush === true) return paint()
// schedule an animation frame
lastAnimationFrame = window.requestAnimationFrame(paint)
}
println.paintedFrames = 0
println.missedFrames = 0
return println
}
export default function setup() {
const remote_action = document.getElementsByClassName('remote-action')
Array.from(remote_action).forEach((element) => {
const action = element.getAttribute('data-action') as string;
const reload = element.getAttribute('data-force-reload');
const param = element.getAttribute('data-param') as string | undefined;
const confirmElementName = element.getAttribute('data-confirm-param');
const confirmElement = (confirmElementName ? document.querySelector(confirmElementName) : null) as HTMLInputElement | null;
const bufferSize = (function () {
const number = parseInt(element.getAttribute('data-buffer') ?? "", 10) ?? 0;
return (isFinite(number) && number > 0) ? number : 0;
})()
const validate = function() {
if (!confirmElement) return true
return confirmElement.value === param;
}
if (confirmElement) {
const runValidation = () => {
if (validate()) {
element.removeAttribute('disabled')
} else {
element.setAttribute('disabled', 'disabled')
}
}
confirmElement.addEventListener('change', runValidation)
runValidation()
}
export default function setup (): void {
const remoteAction = document.getElementsByClassName('remote-action')
Array.from(remoteAction).forEach((element) => {
const action = element.getAttribute('data-action') as string
const reload = element.getAttribute('data-force-reload')
const param = element.getAttribute('data-param') as string | undefined
let onClose: (success: boolean) => void | null;
if (typeof reload === 'string') {
onClose = () => {
if (reload === '') location.reload();
else location.href = reload;
}
}
element.addEventListener('click', function (ev) {
ev.preventDefault();
// do nothing if the validation fails
if (!validate()) return;
const confirmElementName = element.getAttribute('data-confirm-param')
const confirmElement = typeof confirmElementName === 'string' ? document.querySelector(confirmElementName) : null
// create a modal dialog
const params = (typeof param === 'string') ? [param] : [];
createModal(action, params, {
onClose: onClose,
bufferSize: bufferSize,
});
});
})
}
const getConfirmValue = (): string | null => {
if (confirmElement === null) {
console.warn('data-confirm-param was not found')
return null
}
if (!('value' in confirmElement)) {
return null
}
const value = confirmElement.value
if (value === null || (typeof value !== 'string')) {
return null
}
return value
}
type ModalOptions = {
bufferSize: number;
onClose: (success: boolean, message?: string) => void
}
export function createModal(action: string, params: string[], opts: Partial<ModalOptions>) {
// create a modal dialog and append it to the body
const modal = document.createElement("div")
modal.className = "modal-terminal"
document.body.append(modal)
const bufferSize = (function () {
const number = parseInt(element.getAttribute('data-buffer') ?? '', 10) ?? 0
return (isFinite(number) && number > 0) ? number : 0
})()
// create a <pre> to write stuff into
const target = document.createElement("pre")
const println = makeTextBuffer(target, modal, opts.bufferSize ?? 1000)
modal.append(target)
// create a button to eventually close everything
const finishButton = document.createElement("button")
finishButton.className = "pure-button pure-button-success"
finishButton.append(typeof opts?.onClose === 'function' ? "Close & Finish" : "Close")
const validate = function (): boolean {
const confirmValue = getConfirmValue()
if (confirmValue === null) return true
return confirmValue === param
}
let result: ResultMessage = {success: false};
finishButton.addEventListener('click', (event) => {
event.preventDefault();
if (typeof opts?.onClose === 'function') {
finishButton.setAttribute('disabled', 'disabled')
target.innerHTML = 'Finishing up ...'
opts.onClose(result.success, result.message)
return;
}
modal.parentNode?.removeChild(modal);
})
const cancelButton = document.createElement("button")
cancelButton.className = "pure-button pure-button-danger"
cancelButton.setAttribute("disabled", "disabled")
cancelButton.append("Cancel")
modal.append(cancelButton)
const onbeforeunload = window.onbeforeunload;
window.onbeforeunload = () => "A remote session is in progress. Are you sure you want to leave?";
// when closing, add a button to the modal!
const close = (message: ResultMessage) => {
result = message
if (result.success) {
println('Process completed successfully. ', true);
if (confirmElement !== null) {
const runValidation = (): void => {
if (validate()) {
element.removeAttribute('disabled')
} else {
println('Process reported error: ' + result.message, true);
element.setAttribute('disabled', 'disabled')
}
window.onbeforeunload = onbeforeunload;
modal.removeChild(cancelButton)
modal.append(finishButton)
const quota = (println.paintedFrames / (println.missedFrames + println.paintedFrames)) * 100
console.debug(`Terminal: painted=${println.paintedFrames} missed=${println.missedFrames} (${quota}%)`, true)
}
confirmElement.addEventListener('change', runValidation)
runValidation()
}
println("Connecting ...", true)
let onClose: ((success: boolean) => void) | undefined
if (typeof reload === 'string') {
onClose = () => {
if (reload === '') location.reload()
else location.href = reload
}
}
element.addEventListener('click', function (ev) {
ev.preventDefault()
// connect to the socket and send the action
callServerAction(
location.href.replace('http', 'ws'),
{
'name': action,
'params': params,
},
(
send: (text: string) => void,
cancel: () => void,
) => {
cancelButton.removeAttribute("disabled")
cancelButton.addEventListener("click", (event) => {
event.preventDefault()
// do nothing if the validation fails
if (!validate()) return
println("Cancelling", true)
cancel()
})
println("Connected", true)
},
println
).then(close)
.catch(() => {
close({ 'success': false, 'message': "connection closed unexpectedly" })
// create a modal dialog
const params = (typeof param === 'string') ? [param] : []
createModal(action, params, {
onClose,
bufferSize
})
})
}
})
}
interface ModalOptions {
bufferSize: number
onClose: ((success: true) => void) & ((success: false, message: string) => void)
}
export function createModal (action: string, params: string[], opts: Partial<ModalOptions>): void {
// create a modal dialog and append it to the body
const modal = document.createElement('div')
modal.className = 'modal-terminal'
document.body.append(modal)
// create a <pre> to write stuff into
const target = document.createElement('pre')
const println = makeTextBuffer(target, modal, opts.bufferSize ?? 1000)
modal.append(target)
// create a button to eventually close everything
const finishButton = document.createElement('button')
finishButton.className = 'pure-button pure-button-success'
finishButton.append(typeof opts?.onClose === 'function' ? 'Close & Finish' : 'Close')
let result: ResultMessage = { success: false, message: 'Nothing happened' }
finishButton.addEventListener('click', (event) => {
event.preventDefault()
if (typeof opts?.onClose === 'function') {
finishButton.setAttribute('disabled', 'disabled')
target.innerHTML = 'Finishing up ...'
if (result.success) {
opts.onClose(result.success)
} else {
opts.onClose(result.success, result.message)
}
return
}
modal.parentNode?.removeChild(modal)
})
const cancelButton = document.createElement('button')
cancelButton.className = 'pure-button pure-button-danger'
cancelButton.setAttribute('disabled', 'disabled')
cancelButton.append('Cancel')
modal.append(cancelButton)
const onbeforeunload = window.onbeforeunload
window.onbeforeunload = () => 'A remote session is in progress. Are you sure you want to leave?'
// when closing, add a button to the modal!
const close = (message: ResultMessage): void => {
result = message
if (result.success) {
println('Process completed successfully. ', true)
} else {
println('Process reported error: ' + result.message, true)
}
window.onbeforeunload = onbeforeunload
modal.removeChild(cancelButton)
modal.append(finishButton)
const quota = (println.paintedFrames / (println.missedFrames + println.paintedFrames)) * 100
console.debug(`Terminal: painted=${println.paintedFrames} missed=${println.missedFrames} (${quota}%)`, true)
}
println('Connecting ...', true)
// connect to the socket and send the action
callServerAction(
location.href.replace('http', 'ws'),
{
name: action,
params
},
(
send: (text: string) => void,
cancel: () => void
) => {
cancelButton.removeAttribute('disabled')
cancelButton.addEventListener('click', (event) => {
event.preventDefault()
println('Cancelling', true)
cancel()
})
println('Connected', true)
},
println
).then(close)
.catch(() => {
close({ success: false, message: 'connection closed unexpectedly' })
})
}

View file

@ -1,12 +1,17 @@
import { Mutex } from "async-mutex";
import { Mutex } from 'async-mutex'
export type CallMessage = { name: string; params?: string[] | null }
export type ResultMessage = { success: boolean; message?: string; }
export type SignalMessage = { signal: string; }
function isResultMessage(value: any): value is ResultMessage {
return typeof value === 'object' &&
Object.prototype.hasOwnProperty.call(value, 'success') && typeof value['success'] === 'boolean' &&
(!Object.prototype.hasOwnProperty.call(value, 'message') || (value['message'] === undefined || typeof value['message'] === 'string'));
import { runMutexExclusive } from '~/src/lib/discard'
export interface CallMessage { name: string, params?: string[] | null }
export type ResultMessage = { success: true } | { success: false, message: string }
export interface SignalMessage { signal: string }
function isResultMessage (value: any): value is ResultMessage {
return typeof value === 'object' &&
Object.prototype.hasOwnProperty.call(value, 'success') &&
(
(value.success === true) ||
(value.success === false && Object.prototype.hasOwnProperty.call(value, 'message') && (typeof value.message === 'string'))
)
}
/**
@ -17,58 +22,62 @@ function isResultMessage(value: any): value is ResultMessage {
* @param onText called when the connection receives some text
* @returns a promise that is resolved once the conneciton is closed. Rejected if the connection errors.
*/
export default async function callServerAction(
endpoint: string,
call: CallMessage,
onOpen: (send: (text: string) => void, cancel: () => void) => void,
onText: (text: string) => void,
export default async function callServerAction (
endpoint: string,
call: CallMessage,
onOpen: (send: (text: string) => void, cancel: () => void) => void,
onText: (text: string) => void
): Promise<ResultMessage> {
return new Promise((rs, rj) => {
const mutex = new Mutex();
return await new Promise((resolve, reject) => {
const mutex = new Mutex()
const socket = new WebSocket(endpoint);
const socket = new WebSocket(endpoint)
let result: ResultMessage;
socket.onmessage = (msg) => {
mutex.runExclusive(async () => {
if (typeof msg.data === 'string') {
onText(msg.data)
return
}
if (msg.data instanceof Blob) {
const object = JSON.parse(await msg.data.text())
if (isResultMessage(object)) {
result = {'success': object['success'], 'message': object['message']}
return
}
}
console.warn('Unknown message', msg)
})
let result: ResultMessage
socket.onmessage = (msg) => {
runMutexExclusive(mutex, async () => {
if (typeof msg.data === 'string') {
onText(msg.data)
return
}
socket.onclose = () => {
mutex.runExclusive(() => rs(result));
};
socket.onerror = rj; // if an error occurs, close the socket
socket.onopen = () => {
const blob = new Blob([JSON.stringify(call)]);
socket.send(blob)
onOpen(
(text: string) => {
if (typeof text !== 'string') {
console.warn('Ignoring send() call with unknown type', text)
return
}
socket.send(text)
},
() => {
const blob = new Blob([JSON.stringify({'signal': 'cancel'})]);
socket.send(blob)
}
)
if (msg.data instanceof Blob) {
const object = JSON.parse(await msg.data.text())
if (isResultMessage(object)) {
if (object.success) {
result = { success: true }
} else {
result = { success: false, message: object.message }
}
return
}
}
})
}
console.warn('Unknown message', msg)
})
}
socket.onclose = () => {
mutex.runExclusive(() => resolve(result)).then(() => {}).catch(console.error.bind(console))
}
socket.onerror = reject // if an error occurs, close the socket
socket.onopen = () => {
const blob = new Blob([JSON.stringify(call)])
socket.send(blob)
onOpen(
(text: string) => {
if (typeof text !== 'string') {
console.warn('Ignoring send() call with unknown type', text)
return
}
socket.send(text)
},
() => {
const blob = new Blob([JSON.stringify({ signal: 'cancel' })])
socket.send(blob)
}
)
}
})
}

View file

@ -1,43 +1,45 @@
document.querySelectorAll('span').forEach((elem: Element) => {
if (!elem.hasAttribute('data-reveal')) return
if (!elem.hasAttribute('data-reveal')) return
addReveal(elem as HTMLSpanElement, 10000);
addReveal(elem as HTMLSpanElement, 10000)
})
export function addReveal(span: HTMLSpanElement, hideDelay: number) {
const content = span.getAttribute("data-reveal") ?? '(no content)'
export function addReveal (span: HTMLSpanElement, hideDelay: number): void {
const content = span.getAttribute('data-reveal') ?? '(no content)'
let isHidden = true
let isHidden = true
// handler to hide the element
const hide = () => {
isHidden = true
span.innerText = "(click to reveal)"
}
hide()
const reveal = () => {
isHidden = false
const code = document.createElement('code')
code.append(content)
code.addEventListener('click', (evt) => {
evt.preventDefault()
if (!navigator.clipboard) return
navigator.clipboard.writeText(content)
})
code.style.userSelect = "all";
// handler to hide the element
const hide = (): void => {
isHidden = true
span.innerText = '(click to reveal)'
}
hide()
span.innerHTML = ""
span.append(code)
}
const reveal = (): void => {
isHidden = false
const code = document.createElement('code')
code.append(content)
code.addEventListener('click', (evt) => {
evt.preventDefault()
span.addEventListener("click", (evt) => {
evt.preventDefault()
// Check if the clipboard api is supported
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (!navigator.clipboard) return
if (!isHidden) return
reveal()
setTimeout(hide, hideDelay) // hide again after 1 second
navigator.clipboard.writeText(content).then(() => {}).catch(console.error.bind(console))
})
}
code.style.userSelect = 'all'
span.innerHTML = ''
span.append(code)
}
span.addEventListener('click', (evt) => {
evt.preventDefault()
if (!isHidden) return
reveal()
setTimeout(hide, hideDelay) // hide again after 1 second
})
}

File diff suppressed because it is too large Load diff