feat(mobile): env editing, domains, scaling, notifications (#61)

Companion app was read-mostly — quick fixes still needed a workstation.

- service/variables: full env editor — add/edit/delete rows, secret
  toggle, masked-secret preservation (untouched secrets resubmit the
  "********" placeholder so stored ciphertext survives the PUT).
- service/domains: list with default/TLS status, add, remove,
  set-default.
- service/settings: replicas stepper (0–16), container port, scale-to-
  zero toggle + idle minutes — saves via PUT /services/:id.
- (tabs)/notifications: alerts tab with unread badge on the tab bar,
  mark-read per item and mark-all.
- Service detail gains a Manage section linking all three screens.

State initializes at mount inside a child component rather than an
effect — post-save refetches no longer clobber in-flight edits, and
the react-hooks/set-state-in-effect lint stays clean.

Verified: tsc --noEmit, expo lint, expo export (android bundle,
1378 modules — router graph compiles).

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-10-09 22:33:06 +02:00
1 parent 3cc84553f4
commit 604319ee06
8 files changed
+758

No files matched your search

+93
View File
@@ -68,6 +68,36 @@ export interface Template {
icon?: string;
}
export interface Variable {
id?: string;
key: string;
value: string;
is_secret: boolean;
}
export interface ServiceDomain {
id: string;
domain: string;
is_default: boolean;
cert_type?: string;
cert_status?: string;
}
export interface AppNotification {
id: string;
kind: string;
title: string;
body?: string;
resource_type?: string;
resource_id?: string;
read_at?: string | null;
created_at?: string;
}
// Masked value the API returns for secret variables; echoing it back on
// update preserves the stored ciphertext.
export const MASKED_SECRET = '********';
export class ApiError extends Error {
constructor(
public status: number,
@@ -145,6 +175,69 @@ export class Api {
return this.req<EnvCheck>('GET', `/services/${id}/env-check`);
}
serviceVariables(id: string) {
return this.req<{ variables: Variable[] }>('GET', `/services/${id}/variables`).then(
(r) => r.variables ?? [],
);
}
updateVariables(id: string, variables: { key: string; value: string; is_secret: boolean }[]) {
return this.req<unknown>('PUT', `/services/${id}/variables`, { variables });
}
serviceDomains(id: string) {
return this.req<{ domains: ServiceDomain[] }>('GET', `/services/${id}/domains`).then(
(r) => r.domains ?? [],
);
}
addDomain(id: string, domain: string) {
return this.req<unknown>('POST', `/services/${id}/domains`, { domain });
}
deleteDomain(id: string, domainId: string) {
return this.req<unknown>('DELETE', `/services/${id}/domains/${domainId}`);
}
setDefaultDomain(id: string, domainId: string) {
return this.req<unknown>('POST', `/services/${id}/domains/${domainId}/default`);
}
updateService(
id: string,
patch: Partial<{
name: string;
command: string;
replicas: number;
port: number;
domain: string;
restart_policy: string;
healthcheck_path: string;
cpu: string;
memory: string;
sleep_enabled: boolean;
sleep_idle_minutes: number;
maintenance_mode: boolean;
}>,
) {
return this.req<unknown>('PUT', `/services/${id}`, patch);
}
notifications() {
return this.req<{ notifications: AppNotification[]; unread: number }>(
'GET',
'/notifications?limit=50',
);
}
markNotificationRead(id: string) {
return this.req<unknown>('POST', `/notifications/${id}/read`);
}
markAllNotificationsRead() {
return this.req<unknown>('POST', '/notifications/read-all');
}
serviceDeployments(id: string) {
return this.req<{ deployments: Deployment[] }>(
'GET',