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

@ -1,4 +1,4 @@
.PHONY: clean all deps live .PHONY: clean all deps live tslint
live: live:
sudo CGO_ENABLED=0 go run ./cmd/wdcli $(ARGS) sudo CGO_ENABLED=0 go run ./cmd/wdcli $(ARGS)
@ -9,6 +9,9 @@ wdcli:
go generate ./internal/dis/component/control/static/ go generate ./internal/dis/component/control/static/
CGO_ENABLED=0 go build -o ./wdcli ./cmd/wdcli CGO_ENABLED=0 go build -o ./wdcli ./cmd/wdcli
tslint:
cd internal/dis/component/server/assets/ && yarn ts-standard
deps: internal/dis/component/server/assets/node_modules deps: internal/dis/component/server/assets/node_modules
internal/dis/component/server/assets/node_modules: internal/dis/component/server/assets/node_modules:

View file

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

View file

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

View file

@ -11,5 +11,17 @@
"node-html-parser": "^6.1.1", "node-html-parser": "^6.1.1",
"parcel": "^2.7.0", "parcel": "^2.7.0",
"purecss": "^2.1.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/pure.css'
import "purecss/build/grids-responsive.css" import 'purecss/build/grids-responsive.css'
import "./index.css" import './index.css'

View file

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

View file

@ -1,24 +1,23 @@
import "../Admin/index.ts" import '../Admin/index.ts'
import "../Admin/index.css" 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 provision = document.getElementById('provision') as HTMLFormElement
const slug = document.getElementById("slug") as HTMLInputElement; const slug = document.getElementById('slug') as HTMLInputElement
const php = document.getElementById("php") as HTMLSelectElement; const php = document.getElementById('php') as HTMLSelectElement
const opcacheDevelopment = document.getElementById("opcacheDevelopment") as HTMLInputElement; const opcacheDevelopment = document.getElementById('opcacheDevelopment') as HTMLInputElement
// add an event handler to open the modal form! // add an event handler to open the modal form!
provision.addEventListener('submit', (evt) => { provision.addEventListener('submit', (evt) => {
evt.preventDefault(); evt.preventDefault()
Provision({ Slug: slug.value, System: { PHP: php.value, OpCacheDevelopment: opcacheDevelopment.checked } }) Provision({ Slug: slug.value, System: { PHP: php.value, OpCacheDevelopment: opcacheDevelopment.checked } })
.then(slug => { .then(slug => {
location.href = "/admin/instance/" + slug; location.href = '/admin/instance/' + slug
}) })
.catch((e) => {console.error(e); location.reload()}); .catch((e) => { console.error(e); location.reload() })
}) })
// enable the form! // 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.ts'
import "../Admin/index.css" 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 slug = document.getElementById('slug') as HTMLInputElement
const provision = document.getElementById("provision") as HTMLFormElement; const provision = document.getElementById('provision') as HTMLFormElement
const php = document.getElementById("php") as HTMLSelectElement; const php = document.getElementById('php') as HTMLSelectElement
const opcacheDevelopment = document.getElementById("opcacheDevelopment") as HTMLInputElement; const opcacheDevelopment = document.getElementById('opcacheDevelopment') as HTMLInputElement
// add an event handler to open the modal form! // add an event handler to open the modal form!
provision.addEventListener('submit', (evt) => { provision.addEventListener('submit', (evt) => {
evt.preventDefault(); evt.preventDefault()
Rebuild(slug.value, { PHP: php.value, OpCacheDevelopment: opcacheDevelopment.checked }) Rebuild(slug.value, { PHP: php.value, OpCacheDevelopment: opcacheDevelopment.checked })
.then(slug => { .then(slug => {
location.href = "/admin/instance/" + slug; location.href = '/admin/instance/' + slug
}) })
.catch((e) => {console.error(e); location.reload()}); .catch((e) => { console.error(e); location.reload() })
}) })
// enable the form! // 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/copy'
import "~/src/lib/reveal" 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/ */ /** 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 anchorForId = (id: string): HTMLAnchorElement => {
const anchor = document.createElement("a") const anchor = document.createElement('a')
anchor.className = "header-link" anchor.className = 'header-link'
anchor.href = "#" + id anchor.href = '#' + id
anchor.innerHTML = "#" anchor.innerHTML = '#'
return anchor return anchor
} }
const linkifyAnchors = (level: number) => { const linkifyAnchors = (level: number): void => {
const headers = document.getElementsByTagName("h" + level); const headers = document.getElementsByTagName('h' + level.toString())
Array.from(headers).forEach((header) => { Array.from(headers).forEach((header) => {
if (typeof header.id === "undefined" || header.id === "") return if (typeof header.id === 'undefined' || header.id === '') return
header.appendChild(anchorForId(header.id)) header.appendChild(anchorForId(header.id))
}) })
} }
// linkify all the anchors from 1 ... 6 // 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) => { document.querySelectorAll('.copy').forEach((elem: Element) => {
elem.addEventListener('click', () => { elem.addEventListener('click', () => {
if (!navigator.clipboard) return; // Check if the clipboard api is supported
navigator.clipboard.writeText((elem as HTMLElement).innerText); // 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> = { const types: Record<string, (element: HTMLElement) => HTMLElement | string> = {
"date": (element) => { date: (element) => {
const value = dayjs(element.innerText); const value = dayjs(element.innerText)
const text = value.format('YYYY-MM-DD HH:mm:ss ([UTC]Z)') 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 the date is the zero date, then it is assumed to be invalid
if (value.unix() === 0) { if (value.unix() === 0) {
const code = document.createElement('code') const code = document.createElement('code')
code.style.color = 'gray' code.style.color = 'gray'
code.append(text) code.append(text)
return code 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
} }
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 makeDownloadLink = (filename: string, content: string, type: string): [HTMLAnchorElement, Blob] => {
const blob = new Blob( const blob = new Blob(
[content], [content],
{ {
type: type ?? "text/plain" type: type ?? 'text/plain'
} }
); )
const link = document.createElement("a") const link = document.createElement('a')
link.target = "_blank" link.target = '_blank'
link.download = filename link.download = filename
link.href = URL.createObjectURL(blob) link.href = URL.createObjectURL(blob)
return [link, blob] return [link, blob]
} }
Object.keys(types).forEach(key => { Object.keys(types).forEach(key => {
const f = types[key]; const f = types[key]
const elements = document.querySelectorAll("code." + key) as NodeListOf<HTMLElement> const elements = document.querySelectorAll<HTMLElement>('code.' + key)
elements.forEach(element => { elements.forEach(element => {
const newElement = f(element) const newElement = f(element)
if (typeof newElement === 'string') { if (typeof newElement === 'string') {
element.innerHTML = "" element.innerHTML = ''
element.appendChild(document.createTextNode(newElement)) element.appendChild(document.createTextNode(newElement))
return 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. * Flags to provision a new system.
* Should mirror "provision".Flags. * Should mirror "provision".Flags.
*/ */
interface ProvisionFlags { interface ProvisionFlags {
Slug: string Slug: string
System: System System: System
} }
interface System { interface System {
PHP: string; PHP: string
OpCacheDevelopment: boolean OpCacheDevelopment: boolean
} }
/** Rebuild the specified instance */ /** Rebuild the specified instance */
export async function Rebuild(slug: string, system: System): Promise<string> { export async function Rebuild (slug: string, system: System): Promise<string> {
return new Promise((rs, rj) => { return await new Promise((resolve, reject) => {
createModal("rebuild", [slug, JSON.stringify(system)], { createModal('rebuild', [slug, JSON.stringify(system)], {
bufferSize: 0, bufferSize: 0,
onClose: (success: boolean, message?: string) => { onClose: (success: boolean, message?: string) => {
if (!success) { if (!success) {
rj(new Error(message ?? "unspecified error")) reject(new Error(message ?? 'unspecified error'))
return; return
} }
rs(slug); resolve(slug)
}, }
}) })
}); })
} }
/** Provision provisions a new instance */ /** Provision provisions a new instance */
export async function Provision(flags: ProvisionFlags): Promise<string> { export async function Provision (flags: ProvisionFlags): Promise<string> {
// open a modal to provision a new instance // open a modal to provision a new instance
return new Promise((rs, rj) => { return await new Promise((resolve, reject) => {
createModal("provision", [JSON.stringify(flags)], { createModal('provision', [JSON.stringify(flags)], {
bufferSize: 0, bufferSize: 0,
onClose: (success: boolean, message?: string) => { onClose: (success: boolean, message?: string) => {
if (!success) { if (!success) {
rj(new Error(message ?? "unspecified error")) reject(new Error(message ?? 'unspecified error'))
return; return
} }
rs(flags.Slug); resolve(flags.Slug)
}, }
}) })
}); })
} }

View file

@ -1,196 +1,215 @@
import "./index.css" import './index.css'
import callServerAction, { ResultMessage } from './proto' import callServerAction, { ResultMessage } from './proto'
type Println = ((line: string, flush?: boolean) => void) & { type Println = ((line: string, flush?: boolean) => void) & {
paintedFrames: number; paintedFrames: number
missedFrames: number; missedFrames: number
} }
/** /**
* makeTextBuffer returns a println() function that efficiently writes text into target, and keeps at most size elements in the traceback. * 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. * scrollContainer is used to scroll on every painted update.
*/ */
function makeTextBuffer(target: HTMLElement, scrollContainer: HTMLElement, size: number): Println { function makeTextBuffer (target: HTMLElement, scrollContainer: HTMLElement, size: number): Println {
let lastAnimationFrame: number | null = null; // last scheduled animation frame let lastAnimationFrame: number | null = null // last scheduled animation frame
const buffer: Array<string> = []; // the internal buffer of lines const buffer: string[] = [] // the internal buffer of lines
const paint = () => { const paint = (): void => {
println.paintedFrames++ println.paintedFrames++
target.innerText = buffer.join("\n") target.innerText = buffer.join('\n')
scrollContainer.scrollTop = scrollContainer.scrollHeight scrollContainer.scrollTop = scrollContainer.scrollHeight
lastAnimationFrame = null 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) => { // and update the browser in the next animation frame
// add the line if (lastAnimationFrame !== null) {
buffer.push(line) println.missedFrames++
if (size !== 0 && buffer.length > size) { window.cancelAnimationFrame(lastAnimationFrame)
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);
} }
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() { export default function setup (): void {
const remote_action = document.getElementsByClassName('remote-action') const remoteAction = document.getElementsByClassName('remote-action')
Array.from(remote_action).forEach((element) => { Array.from(remoteAction).forEach((element) => {
const action = element.getAttribute('data-action') as string; const action = element.getAttribute('data-action') as string
const reload = element.getAttribute('data-force-reload'); const reload = element.getAttribute('data-force-reload')
const param = element.getAttribute('data-param') as string | undefined; 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()
}
let onClose: (success: boolean) => void | null; const confirmElementName = element.getAttribute('data-confirm-param')
if (typeof reload === 'string') { const confirmElement = typeof confirmElementName === 'string' ? document.querySelector(confirmElementName) : null
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;
// create a modal dialog const getConfirmValue = (): string | null => {
const params = (typeof param === 'string') ? [param] : []; if (confirmElement === null) {
createModal(action, params, { console.warn('data-confirm-param was not found')
onClose: onClose, return null
bufferSize: bufferSize, }
}); if (!('value' in confirmElement)) {
}); return null
}) }
} const value = confirmElement.value
if (value === null || (typeof value !== 'string')) {
return null
}
return value
}
type ModalOptions = { const bufferSize = (function () {
bufferSize: number; const number = parseInt(element.getAttribute('data-buffer') ?? '', 10) ?? 0
onClose: (success: boolean, message?: string) => void return (isFinite(number) && number > 0) ? number : 0
} })()
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)
// create a <pre> to write stuff into const validate = function (): boolean {
const target = document.createElement("pre") const confirmValue = getConfirmValue()
const println = makeTextBuffer(target, modal, opts.bufferSize ?? 1000) if (confirmValue === null) return true
modal.append(target) return confirmValue === param
}
// 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}; if (confirmElement !== null) {
finishButton.addEventListener('click', (event) => { const runValidation = (): void => {
event.preventDefault(); if (validate()) {
element.removeAttribute('disabled')
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);
} else { } else {
println('Process reported error: ' + result.message, true); element.setAttribute('disabled', 'disabled')
} }
}
window.onbeforeunload = onbeforeunload; confirmElement.addEventListener('change', runValidation)
runValidation()
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) 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 // do nothing if the validation fails
callServerAction( if (!validate()) return
location.href.replace('http', 'ws'),
{
'name': action,
'params': params,
},
(
send: (text: string) => void,
cancel: () => void,
) => {
cancelButton.removeAttribute("disabled")
cancelButton.addEventListener("click", (event) => {
event.preventDefault()
println("Cancelling", true) // create a modal dialog
cancel() const params = (typeof param === 'string') ? [param] : []
}) createModal(action, params, {
println("Connected", true) onClose,
}, bufferSize
println })
).then(close)
.catch(() => {
close({ 'success': false, 'message': "connection closed unexpectedly" })
}) })
} })
}
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 } import { runMutexExclusive } from '~/src/lib/discard'
export type ResultMessage = { success: boolean; message?: string; }
export type SignalMessage = { signal: string; } export interface CallMessage { name: string, params?: string[] | null }
function isResultMessage(value: any): value is ResultMessage { export type ResultMessage = { success: true } | { success: false, message: string }
return typeof value === 'object' && export interface SignalMessage { signal: string }
Object.prototype.hasOwnProperty.call(value, 'success') && typeof value['success'] === 'boolean' && function isResultMessage (value: any): value is ResultMessage {
(!Object.prototype.hasOwnProperty.call(value, 'message') || (value['message'] === undefined || typeof value['message'] === 'string')); 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 * @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. * @returns a promise that is resolved once the conneciton is closed. Rejected if the connection errors.
*/ */
export default async function callServerAction( export default async function callServerAction (
endpoint: string, endpoint: string,
call: CallMessage, call: CallMessage,
onOpen: (send: (text: string) => void, cancel: () => void) => void, onOpen: (send: (text: string) => void, cancel: () => void) => void,
onText: (text: string) => void, onText: (text: string) => void
): Promise<ResultMessage> { ): Promise<ResultMessage> {
return new Promise((rs, rj) => { return await new Promise((resolve, reject) => {
const mutex = new Mutex(); const mutex = new Mutex()
const socket = new WebSocket(endpoint); const socket = new WebSocket(endpoint)
let result: ResultMessage; let result: ResultMessage
socket.onmessage = (msg) => { socket.onmessage = (msg) => {
mutex.runExclusive(async () => { runMutexExclusive(mutex, async () => {
if (typeof msg.data === 'string') { if (typeof msg.data === 'string') {
onText(msg.data) onText(msg.data)
return 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)
})
} }
socket.onclose = () => {
mutex.runExclusive(() => rs(result));
};
socket.onerror = rj; // if an error occurs, close the socket
socket.onopen = () => { if (msg.data instanceof Blob) {
const blob = new Blob([JSON.stringify(call)]); const object = JSON.parse(await msg.data.text())
socket.send(blob) if (isResultMessage(object)) {
if (object.success) {
onOpen( result = { success: true }
(text: string) => { } else {
if (typeof text !== 'string') { result = { success: false, message: object.message }
console.warn('Ignoring send() call with unknown type', text) }
return return
} }
socket.send(text)
},
() => {
const blob = new Blob([JSON.stringify({'signal': 'cancel'})]);
socket.send(blob)
}
)
} }
})
} 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) => { 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) { export function addReveal (span: HTMLSpanElement, hideDelay: number): void {
const content = span.getAttribute("data-reveal") ?? '(no content)' const content = span.getAttribute('data-reveal') ?? '(no content)'
let isHidden = true let isHidden = true
// handler to hide the element // handler to hide the element
const hide = () => { const hide = (): void => {
isHidden = true isHidden = true
span.innerText = "(click to reveal)" span.innerText = '(click to reveal)'
} }
hide() 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";
span.innerHTML = "" const reveal = (): void => {
span.append(code) isHidden = false
} const code = document.createElement('code')
code.append(content)
code.addEventListener('click', (evt) => {
evt.preventDefault()
span.addEventListener("click", (evt) => { // Check if the clipboard api is supported
evt.preventDefault() // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (!navigator.clipboard) return
navigator.clipboard.writeText(content).then(() => {}).catch(console.error.bind(console))
if (!isHidden) return
reveal()
setTimeout(hide, hideDelay) // hide again after 1 second
}) })
} 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