Rename Installation to Instance

Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.
This commit is contained in:
2026-08-31 10:12:01 +02:00
parent 271d4513eb
commit 6c8a101d24
101 changed files with 374 additions and 375 deletions
+3 -3
View File
@@ -2379,7 +2379,7 @@ export const PanelsConfigSchema = {
},
type: 'object',
title: 'PanelsConfig',
description: 'Every panel this installation knows about.'
description: 'Every panel this instance knows about.'
} as const;
export const PanelsPublicSchema = {
@@ -2405,7 +2405,7 @@ The address is the server's own, because the browser's origin is not a
reliable answer to it: an admin working through the portal is on the
portal's origin, and this one is for a screen on this network.
An installation enrolled with a portal has a second address, built by the
An instance enrolled with a portal has a second address, built by the
dialog from what \`\`/cloud/status\`\` reports rather than from here — a panel
is not the thing that knows whether remote access is on.`
} as const;
@@ -3803,7 +3803,7 @@ A colour widget picks what it publishes with \`\`format\`\`, because fixtures
differ and a change node per tile is not the answer:
- \`\`hsv\`\` (the default) — \`\`[h, s, v]\`\`, hue 0-360 degrees, saturation and
value 0-100 percent. What the Node-RED installation this ports feeds its
value 0-100 percent. What the Node-RED instance this ports feeds its
3CH/4CH DMX encoders, which divide by 360 and by 100.
- \`\`rgb\`\` — \`\`[r, g, b]\`\`, each 0-255. The conventional range; the
reference's own encoders produce it after converting.
+6 -6
View File
@@ -177,7 +177,7 @@ export class ArtifactsService {
export class CloudService {
/**
* Read Status
* Whether this installation is enrolled, and whether the link is up.
* Whether this instance is enrolled, and whether the link is up.
*
* Readable by any signed-in user: everyone here has a right to know whether
* the machine they are using can be reached from outside.
@@ -196,7 +196,7 @@ export class CloudService {
* Redeem a claim code and start dialling the portal.
*
* The account performing this is mapped to the portal account that owns the
* installation, so the owner's portal sessions arrive here as them. Widening
* instance, so the owner's portal sessions arrive here as them. Widening
* that to anyone else is a local decision made one person at a time, below —
* never something the portal can do from its side.
*
@@ -1710,7 +1710,7 @@ export class PanelsService {
* All this hands out is a code that means nothing until somebody with an
* account approves it, so the worst an unwelcome caller achieves is an entry
* that expires ten minutes later. Reachable from the internet when this
* installation is enrolled with a portal, which is what the cap and the
* instance is enrolled with a portal, which is what the cap and the
* portal's own per-address limits are between.
* @returns PairStarted Successful Response
* @throws ApiError
@@ -1781,8 +1781,8 @@ export class PanelsService {
*
* A credential minted here names the approver, so what the panel does stays
* attributable to a person rather than to nobody. One minted by the portal —
* for a device that reached this installation only through it — names the
* account this installation was enrolled with instead, since that is the one
* for a device that reached this instance only through it — names the
* account this instance was enrolled with instead, since that is the one
* every portal-borne request already acts as.
* @param data The data for the request.
* @param data.panelId
@@ -2228,7 +2228,7 @@ export class SearchService {
* Everything searchable, for the client to match against as it is typed.
*
* The whole index rather than a query: it is a few hundred short rows for an
* installation of any ordinary size, so one fetch when the panel opens beats a
* instance of any ordinary size, so one fetch when the panel opens beats a
* round trip per keystroke — and the client already has a matcher.
*
* Secrets are named only to a superuser, which is who ``/secrets`` answers to.
+3 -3
View File
@@ -852,7 +852,7 @@ export type PanelDef = {
};
/**
* Every panel this installation knows about.
* Every panel this instance knows about.
*/
export type PanelsConfig = {
panels?: Array<PanelDef>;
@@ -865,7 +865,7 @@ export type PanelsConfig = {
* reliable answer to it: an admin working through the portal is on the
* portal's origin, and this one is for a screen on this network.
*
* An installation enrolled with a portal has a second address, built by the
* An instance enrolled with a portal has a second address, built by the
* dialog from what ``/cloud/status`` reports rather than from here — a panel
* is not the thing that knows whether remote access is on.
*/
@@ -1286,7 +1286,7 @@ export type WebPushKey = {
* differ and a change node per tile is not the answer:
*
* - ``hsv`` (the default) — ``[h, s, v]``, hue 0-360 degrees, saturation and
* value 0-100 percent. What the Node-RED installation this ports feeds its
* value 0-100 percent. What the Node-RED instance this ports feeds its
* 3CH/4CH DMX encoders, which divide by 360 and by 100.
* - ``rgb`` — ``[r, g, b]``, each 0-255. The conventional range; the
* reference's own encoders produce it after converting.
@@ -9,7 +9,7 @@ import { isPortal } from "@/lib/portal"
const ID = "connection-offline"
/**
* Says when the installation cannot be reached, and when it was last heard
* Says when the instance cannot be reached, and when it was last heard
* from.
*
* Only ever meaningful under a portal: a local install cannot lose contact with
@@ -39,8 +39,8 @@ export function ConnectionNotice() {
}
notify(
connection.lastSeen
? `Installation offline — last seen ${ago(connection.lastSeen / 1000)}, reconnecting…`
: "Installation offline — reconnecting…",
? `Instance offline — last seen ${ago(connection.lastSeen / 1000)}, reconnecting…`
: "Instance offline — reconnecting…",
"warning",
{ id: ID, persistent: true },
)
@@ -19,8 +19,8 @@ const DEFAULT_COLUMNS = 12
* How many tiles are worth a request of their own.
*
* ponytail: the list endpoint carries no placements, so a footprint means
* reading that dashboard's document — cheap for the handful an installation
* has, and shared with the editor's own cache. The ceiling is an installation
* reading that dashboard's document — cheap for the handful an instance
* has, and shared with the editor's own cache. The ceiling is an instance
* with dozens: the tiles past this show their name and nothing else, and the
* fix would be a stored footprint on `DashboardSummary`.
*/
@@ -58,7 +58,7 @@ function hint(entry: SearchEntry): string {
}
/**
* Everything in this installation, by name, from anywhere.
* Everything in this instance, by name, from anywhere.
*
* The whole index arrives in one fetch and `cmdk` does the matching, so results
* narrow as they are typed without a round trip per keystroke.
@@ -190,7 +190,7 @@ export function GlobalSearch({
</>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
Start typing to search this installation.
Start typing to search this instance.
</p>
)}
</CommandList>
@@ -3,7 +3,7 @@
*
* A single stack at the top centre, over whatever is below it. Newest on top,
* each card going on its own after a few seconds unless it was raised as
* persistent — an offline notice stays until the installation answers again.
* persistent — an offline notice stays until the instance answers again.
*
* Raised from anywhere through `notify()` in `lib/notificationStore`, which is
* where the semantics live; this only draws them.
@@ -303,10 +303,10 @@ export function ConfirmDelete({
<DialogDescription>
{description ?? (
<>
{names.length === 1 ? "It goes" : "They go"} from the
installation at once. The store's git history keeps what was
there, but nothing in the app brings{" "}
{names.length === 1 ? "it" : "them"} back.
{names.length === 1 ? "It goes" : "They go"} from the instance
at once. The store's git history keeps what was there, but
nothing in the app brings {names.length === 1 ? "it" : "them"}{" "}
back.
</>
)}
</DialogDescription>
@@ -51,7 +51,7 @@ export function PanelsDialog() {
const { data: config } = useQuery(panelsQueryOptions())
const { data: dashboards } = useQuery(dashboardsQueryOptions())
const { user } = useAuth()
// Where a screen that cannot reach this installation pairs instead. The
// Where a screen that cannot reach this instance pairs instead. The
// issuer is the portal as a browser reaches it, which is not always the
// address this machine dialled — enrolment may have named a container.
const { data: cloud } = useQuery({
@@ -60,12 +60,12 @@ export function PanelsDialog() {
(await CloudService.readStatus()) as {
enrolled: boolean
issuer: string | null
installation_id: string | null
instance_id: string | null
},
})
const remoteHost =
cloud?.enrolled && cloud.issuer && cloud.installation_id
? `${cloud.issuer.replace(/\/$/, "")}/i/${cloud.installation_id}`
cloud?.enrolled && cloud.issuer && cloud.instance_id
? `${cloud.issuer.replace(/\/$/, "")}/i/${cloud.instance_id}`
: ""
const save = useSavePanels()
const { showErrorToast } = useCustomToast()
@@ -75,7 +75,7 @@ export function PanelsDialog() {
const panels = config?.panels ?? []
const known = dashboards?.data ?? []
// The installation's own address, not this browser's: administering through
// The instance's own address, not this browser's: administering through
// the portal puts the page on the portal's origin, and a screen cannot be
// sent there — it has no portal session and could not hold a panel
// credential if it had one.
@@ -98,7 +98,7 @@ export function PanelsDialog() {
<DialogTitle>Panels</DialogTitle>
<DialogDescription>
A panel is one screen and the dashboards it shows. Point the device at
a link and it asks for a code you enter here — this installation's own
a link and it asks for a code you enter here — this instance's own
address for a screen on your network, or the portal's for one hanging
where this machine is not reachable.
</DialogDescription>
@@ -187,9 +187,9 @@ function PanelRow({
onRemove,
}: {
panel: PanelDef
/** Where this installation answers, as it knows itself. */
/** Where this instance answers, as it knows itself. */
host: string
/** Where the portal serves this installation, when it is enrolled. */
/** Where the portal serves this instance, when it is enrolled. */
remoteHost: string
/**
* Whether this account may change anything here. Every control below saves
@@ -335,7 +335,7 @@ function PanelRow({
<Input
readOnly
value={link}
placeholder="This installation has no address set"
placeholder="This instance has no address set"
aria-label={`Link for ${panel.id}`}
className="text-muted-foreground"
onFocus={(event) => event.currentTarget.select()}
+1 -1
View File
@@ -854,7 +854,7 @@ function ParamsForm({
* How much of a machine this node takes, and how long it is expected to take.
*
* A named size is the usual answer: what "gpu-small" means is a property of the
* machines this installation has, and those change. The numbers are still there
* machines this instance has, and those change. The numbers are still there
* for a node that genuinely wants its own.
*/
function ResourcesSection({
+1 -1
View File
@@ -100,7 +100,7 @@ export function useParamSuggestions(
staleTime: SUGGEST_STALE,
})
// ponytail: reads every flow to collect them, sharing the editor's own cache
// entries; an aggregate endpoint if a big installation makes that hurt.
// entries; an aggregate endpoint if a big instance makes that hurt.
const details = useQueries({
queries: (type ? (flows?.data ?? []) : []).map((flow) => ({
...flowQueryOptions(flow.name),
@@ -32,7 +32,7 @@ type FlowEvent =
nodes: { id: string; status: string; error?: string | null }[]
paused?: string[]
logs?: LogLine[]
/** Emission counts per qualified node. Absent on older installations. */
/** Emission counts per qualified node. Absent on older instances. */
emits?: Record<string, number>
}
| {
@@ -194,7 +194,7 @@ function connect() {
liveStore.setStatuses(message.nodes ?? [])
liveStore.setPausedFlows(message.paused ?? [])
liveStore.setLogs(message.logs ?? [])
// Missing against an installation older than this bundle; the graph
// Missing against an instance older than this bundle; the graph
// then starts from zero the way it always did.
liveStore.setEmits(message.emits ?? {})
break
@@ -321,7 +321,7 @@ function connect() {
const payload = JSON.parse(event.data)
if (payload?.type === "batch") {
// A cascade publishes a dozen events at once and the engine coalesces
// them into one frame. An installation older than this bundle sends
// them into one frame. An instance older than this bundle sends
// them one at a time, which is the branch below.
for (const message of payload.events ?? []) handle(message)
} else {
@@ -347,7 +347,7 @@ function connect() {
return
}
}
// 1013 is the portal saying the installation is not attached — the one
// 1013 is the portal saying the instance is not attached — the one
// close code that means "offline" rather than "the socket dropped".
if (event.code === 1013) {
connectionStore.setOffline(null)
@@ -75,7 +75,7 @@ export function HealthOverview({
const queue = (summary?.queue ?? {}) as Record<string, number>
const degraded = summary?.status === "degraded"
const errors = (flows ?? []).reduce((total, row) => total + row.errors, 0)
// An installation nobody has run a batch flow on has no runs tile at all —
// An instance nobody has run a batch flow on has no runs tile at all —
// the same "nothing has been run yet" the Runs screen itself goes by.
const running = (runs ?? []).reduce((total, row) => total + row.running, 0)
const queued = (runs ?? []).reduce((total, row) => total + row.queued, 0)
@@ -23,7 +23,7 @@ const GRACE = 3000
* stop pulsing and the values stop changing, with nothing to say why. Nothing
* at all while the socket is up: a page that works needs no chip saying so.
*
* Quiet too while the offline notification is up, since an installation that cannot
* Quiet too while the offline notification is up, since an instance that cannot
* be reached has no socket either and one explanation of that is enough. Named
* in words rather than coloured, and deliberately not terracotta: the brain
* graph above it already owns that accent for a flow that cannot run.
@@ -33,7 +33,7 @@ import { type Item, Main } from "./Main"
/**
* Where the documentation is published.
*
* A constant rather than something derived: an installation reached over a LAN
* A constant rather than something derived: an instance reached over a LAN
* address, or at localhost, has no domain to build this from, and the docs are
* one site for all of them.
*/
@@ -70,13 +70,13 @@ export function AppSidebar() {
? [...baseItems, { icon: Users, title: "Admin", path: "/admin" }]
: baseItems
// Reached through a portal, the way out is back to the installations list —
// Reached through a portal, the way out is back to the instances list —
// and the session belongs to the portal, so logging out is its business.
const items: Item[] = portal
? [
{
icon: ArrowLeft,
title: "All installations",
title: "All instances",
onClick: () => window.location.assign(portal.portalUrl),
},
...withAdmin,
@@ -31,19 +31,19 @@ type CloudStatus = {
connected: boolean
portal_url: string | null
portal_account: string | null
installation_id: string | null
instance_id: string | null
last_error: string | null
connected_since: number | null
}
/**
* Connecting this installation to a Fluksio portal, or cutting it loose, and
* Connecting this instance to a Fluksio portal, or cutting it loose, and
* admitting other portal accounts to it.
*
* Deliberately blunt about what it grants: the account that enrolled is what
* the portal owner's sessions act as, and this screen says which one. Anyone
* else gets in only by being added here, as a local user of their own.
* Everything here is optional — an installation nobody enrolls never contacts
* Everything here is optional — an instance nobody enrolls never contacts
* anything.
*/
export function RemoteAccess() {
@@ -108,8 +108,8 @@ export function RemoteAccess() {
<CardHeader>
<CardTitle>Remote access</CardTitle>
<CardDescription>
Reach this installation from fluksio.com. Entirely optional — without
it, this installation talks to nothing outside your network.
Reach this instance from fluksio.com. Entirely optional — without it,
this instance talks to nothing outside your network.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
@@ -133,9 +133,9 @@ export function RemoteAccess() {
own user.
</span>
</Field>
<Field label="Installation">
<Field label="Instance">
<span className="font-mono text-xs">
{status.installation_id ?? "—"}
{status.instance_id ?? "—"}
</span>
</Field>
</dl>
@@ -156,8 +156,8 @@ export function RemoteAccess() {
<div>
<h3 className="font-medium">Remote users</h3>
<p className="text-sm text-muted-foreground">
Let someone else reach this installation through the portal.
They get a user of their own here — not yours, and never a
Let someone else reach this instance through the portal. They
get a user of their own here — not yours, and never a
superuser, so they cannot pass access on.
</p>
</div>
@@ -173,9 +173,9 @@ export function RemoteAccess() {
}
/>
<p className="text-xs text-muted-foreground">
They get a code at fluksio.com → Installations → Join an
installation. Added users appear under Admin → Users; deleting
them there ends their access.
They get a code at fluksio.com → Instances → Join an instance.
Added users appear under Admin → Users; deleting them there
ends their access.
</p>
</div>
<div>
@@ -210,7 +210,7 @@ export function RemoteAccess() {
onChange={(event) => setCode(event.target.value.toUpperCase())}
/>
<p className="text-xs text-muted-foreground">
Get a code at fluksio.com → Installations → Add installation.
Get a code at fluksio.com → Instances → Add instance.
</p>
</div>
<div>
@@ -235,8 +235,8 @@ export function RemoteAccess() {
<DialogTitle>Disconnect from the portal?</DialogTitle>
<DialogDescription>
Remote access ends immediately and the portal's credentials stop
working here. Nothing on this installation is changed or deleted,
and you can connect again with a new code.
working here. Nothing on this instance is changed or deleted, and
you can connect again with a new code.
</DialogDescription>
</DialogHeader>
<DialogFooter>
+5 -5
View File
@@ -1,5 +1,5 @@
/**
* Whether the installation this page talks to is reachable right now.
* Whether the instance this page talks to is reachable right now.
*
* Transport state, kept apart from `liveStore` on purpose: that one holds what
* the engine is doing, this one holds whether we can hear it at all. Only
@@ -11,7 +11,7 @@
type Connection = {
offline: boolean
/** When the portal last heard from the installation, epoch ms. */
/** When the portal last heard from the instance, epoch ms. */
lastSeen: number | null
}
@@ -38,7 +38,7 @@ function emit(next: Connection) {
export const connectionStore = {
setOffline(lastSeen: string | number | null) {
// The socket is up, so the installation is reachable: this was one bad
// The socket is up, so the instance is reachable: this was one bad
// answer rather than a lost tunnel, and the banner would have nothing to
// take it back down again.
if (socketOpen) return
@@ -56,7 +56,7 @@ export const connectionStore = {
/**
* The live socket opened or closed.
*
* An open socket is proof the installation can be heard, so it also clears
* An open socket is proof the instance can be heard, so it also clears
* the banner — and since the socket reconnects on its own, that is the one
* release the offline state can always count on.
*/
@@ -76,7 +76,7 @@ export const connectionStore = {
/**
* The proxy's offline answer: a 503 carrying `{offline, last_seen}`.
*
* Told apart from every other 503 by that body, so an installation that is
* Told apart from every other 503 by that body, so an instance that is
* merely busy is not reported as unreachable.
*/
export function offlineDetail(
+6 -6
View File
@@ -1,26 +1,26 @@
/**
* Runtime configuration injected when this app is served through a portal.
*
* A normal installation ships the same bundle and finds nothing here, so every
* A normal instance ships the same bundle and finds nothing here, so every
* portal-aware branch in the app collapses to its ordinary behaviour. When a
* portal serves the page it writes this object into the document first: the
* API lives under the installation's own path, the credential comes with the
* API lives under the instance's own path, the credential comes with the
* page rather than from storage, and there is somewhere to go "back" to.
*
* Deliberately not localStorage: two installations open in one browser share
* Deliberately not localStorage: two instances open in one browser share
* an origin, and a single `access_token` key would have them overwrite each
* other's session.
*/
export type PortalConfig = {
installationId: string
installationName: string
instanceId: string
instanceName: string
/** Router basepath, e.g. `/i/{id}`. */
basePath: string
/** Origin-relative API base; the SDK appends `/api/v1/...`. */
apiBase: string
/** Where "back to portal" goes. */
portalUrl: string
/** Short-lived token scoped to this installation. */
/** Short-lived token scoped to this instance. */
token: string
}
+4 -4
View File
@@ -1,13 +1,13 @@
/**
* Subscribing this browser to the installation's notifications.
* Subscribing this browser to the instance's notifications.
*
* A push arrives through the service worker, so it reaches a phone with no tab
* open — which is the whole point, and the reason this is not the in-app
* notification stack. The engine decides *what* is worth sending in its alert
* rules; this only says which browsers hear it.
*
* Everything here is per browser and per installation: the subscription is
* stored by the installation it was made against, so a phone that opens two
* Everything here is per browser and per instance: the subscription is
* stored by the instance it was made against, so a phone that opens two
* houses through the portal is two subscriptions and hears each separately.
*/
@@ -62,7 +62,7 @@ function keyBytes(key: string): Uint8Array<ArrayBuffer> {
}
/**
* Ask permission, subscribe, and tell the installation where to reach us.
* Ask permission, subscribe, and tell the instance where to reach us.
*
* Throws with something worth reading if the person says no — the caller puts
* it on screen, because a silently ignored button is worse than a refusal.
+2 -2
View File
@@ -51,7 +51,7 @@ const isPointlessToRetry = (error: unknown) =>
const handleApiError = (error: Error) => {
const offline = offlineDetail(error)
if (offline) {
// The installation is unreachable, not the session invalid: keep the user
// The instance is unreachable, not the session invalid: keep the user
// where they are and let the banner explain.
connectionStore.setOffline(offline.lastSeen)
return
@@ -67,7 +67,7 @@ const handleApiError = (error: Error) => {
if (portal) {
// The portal knows whether they are still signed in; it can mint a fresh
// handoff or send them to the login screen.
window.location.href = `${portal.portalUrl}?reauth=${portal.installationId}`
window.location.href = `${portal.portalUrl}?reauth=${portal.instanceId}`
return
}
safeStorage.remove("access_token")
+1 -1
View File
@@ -97,7 +97,7 @@ function showSetting(value: unknown): string {
/**
* The one channel whose setting is on the device rather than in the config:
* a browser has to ask its own permission, and what it hands back is stored
* against this installation.
* against this instance.
*/
function ThisBrowser() {
const { showSuccessToast, showErrorToast } = useCustomToast()
+1 -1
View File
@@ -33,7 +33,7 @@ const HEADER =
* it has been doing. The brain and the health screens compose in here rather
* than living at routes of their own.
*
* Top to bottom it is a widening lens: the whole installation as a graph, what
* Top to bottom it is a widening lens: the whole instance as a graph, what
* has been built on it, whether it is well, then flow by flow and finally
* moment by moment.
*/
+1 -1
View File
@@ -50,7 +50,7 @@ function UserSettings() {
<motion.div variants={slideUp}>
<Appearance />
</motion.div>
{/* Whether this whole installation can be reached from outside is not
{/* Whether this whole instance can be reached from outside is not
a personal preference, so it only exists for an operator. */}
{currentUser.is_superuser && (
<motion.div variants={slideUp}>
+3 -3
View File
@@ -11,8 +11,8 @@ import { apiToken, appPath, isPortal, openPortalSession } from "@/lib/portal"
/**
* The panel a credential in hand names, or "" when it names none.
*
* Two shapes say it, because two things mint one. This installation writes a
* `panel` claim; the hub, which knows nothing of this installation's users,
* Two shapes say it, because two things mint one. This instance writes a
* `panel` claim; the hub, which knows nothing of this instance's users,
* puts the panel in `sub` and says so with `scope`. A local token carries no
* `scope` at all, so the two cannot be confused — and without the second shape
* a screen adopted through the portal named no panel, and asked the household
@@ -53,7 +53,7 @@ function pairedPanel(): string {
* no session and there is nothing to put around it.
*
* Reached through the portal as well, for a screen hanging where this
* installation is not: the hub serves this one page without a session and
* instance is not: the hub serves this one page without a session and
* forwards these two calls down the tunnel, because a device with no
* credential is what they are for.
*/
+3 -3
View File
@@ -4,13 +4,13 @@ import { ago } from "./components/Health/queries"
import { offlineDetail } from "./lib/connectionStore"
function extractErrorMessage(err: ApiError): string {
// An unreachable installation is not a rejected action: say plainly that
// An unreachable instance is not a rejected action: say plainly that
// nothing was delivered, so nobody is left wondering whether it half-landed.
const offline = offlineDetail(err)
if (offline) {
return offline.lastSeen
? `Not delivered — the installation is offline (last seen ${ago(offline.lastSeen)})`
: "Not delivered — the installation is offline"
? `Not delivered — the instance is offline (last seen ${ago(offline.lastSeen)})`
: "Not delivered — the instance is offline"
}
if (err instanceof AxiosError) {