Files
technitium-console/src/api/apps.ts
T
DenozordecandClaude Opus 5.5 6acd2d656d Hub stage 2: install, update and uninstall apps on several servers
Writes use only <ID>_ADMIN_TOKEN and need the apps.write capability.
Store URLs are resolved from each server's own listing. The config is saved
to history before an uninstall. Zip uploads are fanned out to the servers.
Every target's result is audited. The console gets a row menu on the
matrix, a target picker, a results dialog with Retry failed, the store and
install from file in All servers mode, and single-server actions for writers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:11:40 +07:00

264 lines
8.8 KiB
TypeScript

import { apiRequest, type ApiOutcome } from './client'
/*
The nine endpoints of the `apps` family (upstream/master:www/js/apps.js).
Shapes verified with curl against a real instance (v15.x) on 2026-08-25, not
deduced from the code:
1. `updateVersion`, `updateUrl` and `updateAvailable` are OPTIONAL on an
installed app. The server only writes them if the app appears in the store
catalog AND there is a version there compatible with this server
(WebServiceAppsApi.cs:60-99). An app installed from your own zip never
brings them. On top of that, `apps/list` queries the catalog with a 5 s
timeout and, if it fails, returns the list WITHOUT those three fields on any
app.
2. `installedApp` / `updatedApp` (what install, update, downloadAndInstall and
downloadAndUpdate return) never bring them either: that `WriteAppAsJson` is
called without the catalog. Upstream redraws the row with that response, so
after installing, the "Store Update" button disappears until the next reload
of the list. Here the list is reloaded and that is that.
3. `config/get` can return `config: null`, not only a string. It happens as
soon as someone saves an empty config: `config/set` with an empty string
stores it as null (WebServiceAppsApi.cs:494-496).
4. `apps/uninstall` of an app that does not exist answers `ok`, not an error.
*/
export interface DnsAppDetail {
classPath: string
description: string
isAppRecordRequestHandler: boolean
recordDataTemplate: string | null
isRequestController: boolean
isAuthoritativeRequestHandler: boolean
isRequestBlockingHandler: boolean
isQueryLogger: boolean
isQueryLogs: boolean
isPostProcessor: boolean
}
export interface InstalledApp {
name: string
description: string | null
version: string
updateVersion?: string
updateUrl?: string
updateAvailable?: boolean
dnsApps: DnsAppDetail[]
}
export interface StoreApp {
name: string
description: string
version: string
url: string
size: string
installed: boolean
/** Only when `installed` is true. */
installedVersion?: string
/** Only when `installed` is true. */
updateAvailable?: boolean
/** Only behind a hub, in the All servers listing: the servers that have it installed. */
installedOn?: string[]
}
/*
The capability labels of each of the app's classes, in the exact order of
`getAppRowHtml` (apps.js:80-104). If there is none, upstream puts "Generic".
*/
export function dnsAppLabels(d: DnsAppDetail): string[] {
const labels: string[] = []
if (d.isAppRecordRequestHandler) labels.push('APP Record')
if (d.isRequestController) labels.push('Access Control')
if (d.isAuthoritativeRequestHandler) labels.push('Authoritative')
if (d.isRequestBlockingHandler) labels.push('Blocking')
if (d.isQueryLogger) labels.push('Query Logger')
if (d.isQueryLogs) labels.push('Query Logs')
if (d.isPostProcessor) labels.push('Post Processor')
return labels.length > 0 ? labels : ['Generic']
}
export function listApps(
token: string | null,
node = '',
): Promise<ApiOutcome<{ response: { apps: InstalledApp[] } }>> {
return apiRequest('apps/list', { token, body: { node } })
}
export function listStoreApps(
token: string | null,
node = '',
): Promise<ApiOutcome<{ response: { storeApps: StoreApp[] } }>> {
return apiRequest('apps/listStoreApps', { token, node })
}
/* Behind a hub, `node` is the one server the call is for; elsewhere it stays empty and is not sent. */
export function downloadAndInstall(
token: string | null,
name: string,
url: string,
node = '',
): Promise<ApiOutcome<{ response: { installedApp: InstalledApp } }>> {
return apiRequest('apps/downloadAndInstall', { token, body: { name, url }, node })
}
export function downloadAndUpdate(
token: string | null,
name: string,
url: string,
node = '',
): Promise<ApiOutcome<{ response: { updatedApp: InstalledApp } }>> {
return apiRequest('apps/downloadAndUpdate', { token, body: { name, url }, node })
}
export function uninstallApp(token: string | null, name: string, node = ''): Promise<ApiOutcome> {
return apiRequest('apps/uninstall', { token, body: { name }, node })
}
/*
`node` is the name of the cluster's PRIMARY node: upstream always reads from it
so as not to show a config that has not propagated yet (apps.js:460). Without a
cluster it sends the empty string and the server ignores it
(DnsWebService.cs:2367-2370). The primary's name comes from
`sessionData.info.clusterNodes`, which this console does not expose yet.
*/
export function getAppConfig(
token: string | null,
name: string,
node = '',
): Promise<ApiOutcome<{ response: { config: string | null } }>> {
return apiRequest('apps/config/get', { token, body: { name, node } })
}
/*
POST, because a config can be long and does not fit in a query. The server reads
both parameters with `QueryOrForm`, so both go in the body. An empty config is
stored as `null`: that is the server's doing, not ours.
*/
export function setAppConfig(
token: string | null,
name: string,
config: string,
): Promise<ApiOutcome> {
return apiRequest('apps/config/set', { token, method: 'POST', body: { name, config } })
}
/*
--- Zip upload: pending multipart support in the client ---
`apps/install` and `apps/update` are the ONLY TWO calls of this family that are
not an ordinary request: they are POST `multipart/form-data` with the zip
(apps.js:348 and apps.js:392). The server requires `HasFormContentType` and takes
`Form.Files[0]` (WebServiceAppsApi.cs:350-357 and 401-408), so:
- the field NAME of the file does not matter (verified with curl: uploading the
zip as `anyNameAtAll` installs just the same). `fileAppZip` is sent here only
out of fidelity to upstream;
- `name` may go in the query, and that is what upstream does;
- they are POST-only: by GET the server answers 404 (DnsWebService.cs:2183-2184).
`apiRequest` only knows how to send `x-www-form-urlencoded` and is not touched
from here. `buildUpload` leaves the request built for the day the client accepts
a `FormData`; that day `installApp`/`updateApp` become one line each.
*/
export interface UploadRequest {
path: string
form: FormData
}
export function buildUpload(path: string, name: string, file: File): UploadRequest {
const form = new FormData()
form.append('fileAppZip', file)
return { path: `${path}?${new URLSearchParams({ name }).toString()}`, form }
}
export function installApp(
token: string | null,
name: string,
file: File,
node = '',
): Promise<ApiOutcome<{ response: { installedApp: InstalledApp } }>> {
const { path, form } = buildUpload('apps/install', name, file)
return apiRequest(path, { token, form, node })
}
export function updateApp(
token: string | null,
name: string,
file: File,
node = '',
): Promise<ApiOutcome<{ response: { updatedApp: InstalledApp } }>> {
const { path, form } = buildUpload('apps/update', name, file)
return apiRequest(path, { token, form, node })
}
/*
The hub's matrix of apps over servers (`hub/apps/matrix`, hub/apps-matrix.ts). Only on a
hub. A config is never in it: just a hash per server and the comparison.
*/
export interface MatrixCell {
installed: boolean
version?: string
updateAvailable?: boolean
updateVersion?: string
configStatus: 'none' | 'read' | 'unreadable'
configHash?: string
configError?: string
}
export interface MatrixApp {
name: string
description: string
servers: Record<string, MatrixCell>
configs: 'same' | 'different' | 'single' | 'unknown'
diffCount?: number
versions: 'same' | 'different' | 'single'
}
export interface AppsMatrixData {
servers: { id: string; ok: boolean; writable: boolean; error?: string }[]
apps: MatrixApp[]
}
export function getAppsMatrix(token: string | null): Promise<ApiOutcome<{ response: AppsMatrixData }>> {
return apiRequest('hub/apps/matrix', { token })
}
/*
App writes across servers (hub/apps-write.ts). The hub resolves a store URL from each
server's own store; the client never sends one.
*/
export type HubRunAction = 'install-store' | 'update-store' | 'uninstall'
export interface TargetResult {
server: string
ok: boolean
message?: string
}
export type RunOutcome = ApiOutcome<{ response: { results: TargetResult[] } }>
export function runHubApps(token: string | null, action: HubRunAction, name: string, targets: string[]): Promise<RunOutcome> {
return apiRequest('hub/apps/run', { token, method: 'POST', body: { action, name, targets: targets.join(',') } })
}
/* The same zip to several servers: multipart, as the hub's `hub/apps/upload` expects. */
export function uploadHubApp(
token: string | null,
action: 'install' | 'update',
name: string,
file: File,
targets: string[],
): Promise<RunOutcome> {
const form = new FormData()
form.append('action', action)
form.append('name', name)
form.append('targets', targets.join(','))
form.append('fileAppZip', file)
return apiRequest('hub/apps/upload', { token, form })
}