mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Merge remote-tracking branch 'origin/main' into feat/env-doctor
# Conflicts: # ROADMAP2.md
This commit is contained in:
commit
d5960fdfff
31 files changed
+13013
No files matched your search
@@ -24,6 +24,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
code: ${{ steps.filter.outputs.code }}
|
code: ${{ steps.filter.outputs.code }}
|
||||||
|
mobile: ${{ steps.filter.outputs.mobile }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dorny/paths-filter@v3
|
- uses: dorny/paths-filter@v3
|
||||||
@@ -37,6 +38,8 @@ jobs:
|
|||||||
- 'install.sh'
|
- 'install.sh'
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/**'
|
||||||
- 'docs/api/**'
|
- 'docs/api/**'
|
||||||
|
mobile:
|
||||||
|
- 'app/mobile/**'
|
||||||
|
|
||||||
infra:
|
infra:
|
||||||
name: Infra (Compose + Scripts)
|
name: Infra (Compose + Scripts)
|
||||||
@@ -121,6 +124,34 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
mobile:
|
||||||
|
name: Mobile (Expo)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.mobile == 'true' || github.event_name != 'pull_request'
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: app/mobile
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: app/mobile/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npx expo lint
|
||||||
|
|
||||||
smoke:
|
smoke:
|
||||||
name: Compose boot smoke
|
name: Compose boot smoke
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+47
@@ -308,6 +308,7 @@ and wired refs, one click or one CLI call.
|
|||||||
audit logs, deployments, notifications tables.
|
audit logs, deployments, notifications tables.
|
||||||
- [ ] **Notification producers round-out** — backups, agent offline,
|
- [ ] **Notification producers round-out** — backups, agent offline,
|
||||||
upgrade available, security findings.
|
upgrade available, security findings.
|
||||||
|
<<<<<<< HEAD
|
||||||
- [ ] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
|
- [ ] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
|
||||||
`services.sleep_after_idle` (duration, empty=off). Reaper loop stops
|
`services.sleep_after_idle` (duration, empty=off). Reaper loop stops
|
||||||
containers past idle threshold (last-seen from Traefik access logs or
|
containers past idle threshold (last-seen from Traefik access logs or
|
||||||
@@ -328,6 +329,34 @@ and wired refs, one click or one CLI call.
|
|||||||
frontend build, sqlc-diff check), path-filtered jobs for speed,
|
frontend build, sqlc-diff check), path-filtered jobs for speed,
|
||||||
and a release pipeline: merge to main → tag → GoReleaser binaries
|
and a release pipeline: merge to main → tag → GoReleaser binaries
|
||||||
(CLI + MCP) → GHCR images (api, web, agent) → GitHub Release notes.
|
(CLI + MCP) → GHCR images (api, web, agent) → GitHub Release notes.
|
||||||
|
=======
|
||||||
|
- [x] **Sleep-on-idle / wake-on-traffic** (serverless-lite) —
|
||||||
|
`services.sleep_enabled` + `sleep_idle_minutes` (PR #29). Sweeper
|
||||||
|
polls container NetworkIO deltas; idle past threshold → workload
|
||||||
|
removed, status `sleeping`. Wake-on-traffic via per-service
|
||||||
|
placeholder container on the edge network carrying the same Traefik
|
||||||
|
host rule at higher priority — serves a waking page, calls
|
||||||
|
`/internal/wake/:id`, exits; Traefik falls back once the app is up.
|
||||||
|
Verified `running → sleeping → waking → running` end to end.
|
||||||
|
- [x] **Env doctor** — `GET /services/:id/env-check` surfaces
|
||||||
|
unresolved `${{…}}` refs, empty values, and unreadable
|
||||||
|
(rotated-key) secrets; template `plan` endpoint reports missing
|
||||||
|
required vars before deploy (PR #30). Frontend Variables section
|
||||||
|
shows the banner.
|
||||||
|
- [x] **Shared project variables** — `project_variables` table,
|
||||||
|
`GET/PUT /projects/:id/variables`, `${{shared.KEY}}` refs resolved
|
||||||
|
at deploy/reconcile, secrets `enc:v1:` at rest and masked (PR #32).
|
||||||
|
- [x] **One-click install** — `install.sh` (curl|sh): detects docker,
|
||||||
|
writes compose + .env with generated secrets incl. `SECRETS_KEY`,
|
||||||
|
pulls images, runs migrations, prints admin URL. GHCR images mean
|
||||||
|
installs never build from source.
|
||||||
|
- [x] **CI/CD v2** — required checks (Go test/vet, frontend
|
||||||
|
lint/build/test, compose boot smoke), `dorny/paths-filter` gate so
|
||||||
|
docs-only PRs skip heavyweight jobs, and release on main/tag:
|
||||||
|
conventional-commit version bump → tag → GHCR images (backend,
|
||||||
|
frontend) → cross-platform CLI + MCP binaries → SHA-256 checksums
|
||||||
|
→ GitHub Release → Trivy scans (PR #33).
|
||||||
|
>>>>>>> origin/main
|
||||||
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
|
- [ ] **Custom roles/RBAC** — defer until demanded; PAT scopes designed
|
||||||
forward-compatible now.
|
forward-compatible now.
|
||||||
|
|
||||||
@@ -347,6 +376,7 @@ and wired refs, one click or one CLI call.
|
|||||||
|
|
||||||
## 9b. Phase I — Companion surfaces
|
## 9b. Phase I — Companion surfaces
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
- [ ] **Phone app** — fully-featured companion, not a read-only viewer.
|
- [ ] **Phone app** — fully-featured companion, not a read-only viewer.
|
||||||
The target: a user on the go can see a problem, understand it, and
|
The target: a user on the go can see a problem, understand it, and
|
||||||
fix it without a laptop. PAT + profile auth (multi-instance like the
|
fix it without a laptop. PAT + profile auth (multi-instance like the
|
||||||
@@ -366,6 +396,23 @@ and wired refs, one click or one CLI call.
|
|||||||
- **Stack** — React Native (Expo) thin client over the agent-shaped
|
- **Stack** — React Native (Expo) thin client over the agent-shaped
|
||||||
API; dark UI mirroring the web design system; deploy status as the
|
API; dark UI mirroring the web design system; deploy status as the
|
||||||
first-class view. Ships with `app/` workspace in this repo.
|
first-class view. Ships with `app/` workspace in this repo.
|
||||||
|
=======
|
||||||
|
- [x] **Phone app (v1)** — `app/mobile`, Expo/React Native thin client
|
||||||
|
over the agent-shaped API; dark UI mirroring the design system. PAT
|
||||||
|
auth against any instance URL, stored in platform secure storage.
|
||||||
|
- **Dashboard** — service status counts (running/sleeping/failed/
|
||||||
|
stopped), per-project service health dots, recent deployments feed.
|
||||||
|
- **Operate** — start/stop/restart/redeploy, sleep/wake, rollback to
|
||||||
|
a prior deployment, database start/stop/restart/backup-now.
|
||||||
|
- **Observe** — polling log tail (300 lines, 5s refresh), per-service
|
||||||
|
deployment history, env-doctor issues surfaced on the service view.
|
||||||
|
- [ ] **env var edit + apply-redeploy, domain management, replica
|
||||||
|
scaling, cron execution history** — next iteration.
|
||||||
|
- [ ] **Push notifications** — zero-infra path = self-hosted
|
||||||
|
ntfy/Gotify relay (no Firebase dependency), FCM/APNs optional.
|
||||||
|
- [ ] **Offline resilience** — cached last-known state + unreachable
|
||||||
|
banner.
|
||||||
|
>>>>>>> origin/main
|
||||||
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
|
- [ ] **Status/offline resilience** — app + CLI degrade gracefully when
|
||||||
the instance is unreachable (cached last-known state, clear banner).
|
the instance is unreachable (cached last-known state, clear banner).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
This is an Expo/React Native mobile application — the Containr companion client. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
|
||||||
|
|
||||||
|
## Expo has changed — do not trust your training data
|
||||||
|
|
||||||
|
Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API:
|
||||||
|
|
||||||
|
1. Read the major version of the `expo` package in `package.json`.
|
||||||
|
2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v<major>.0.0/`
|
||||||
|
3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install <package> # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions
|
||||||
|
npx expo start # start the dev server
|
||||||
|
npx expo lint # lint
|
||||||
|
npx tsc --noEmit # typecheck
|
||||||
|
npx expo-doctor # diagnose dependency and config issues
|
||||||
|
npx expo install --fix # fix incompatible package versions
|
||||||
|
```
|
||||||
|
|
||||||
|
Run lint and typecheck before declaring any task done.
|
||||||
|
|
||||||
|
## Navigation & Routing
|
||||||
|
|
||||||
|
- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`.
|
||||||
|
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`.
|
||||||
|
- Docs: https://docs.expo.dev/router/introduction.md
|
||||||
|
|
||||||
|
## Building with EAS
|
||||||
|
|
||||||
|
Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `npx eas-cli@latest <command>`.
|
||||||
|
Docs: https://docs.expo.dev/eas/index.md
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins. They are gitignored.
|
||||||
|
- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`.
|
||||||
|
- Prefer recommended Expo modules over third-party libraries. Docs: https://docs.expo.dev/versions/latest/index.md
|
||||||
|
- The app speaks only to the Containr REST API (`/api/v1`) with a `cnp_` personal access token — same contract as the CLI and MCP server. Add endpoints there, never to the mobile client alone.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Containr",
|
||||||
|
"slug": "containr-mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"scheme": "containr",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "ai.containr.mobile"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "ai.containr.mobile",
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#0B0F14",
|
||||||
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"predictiveBackGestureEnabled": false
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-secure-store",
|
||||||
|
"expo-font"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const expoConfig = require("eslint-config-expo/flat");
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
{
|
||||||
|
ignores: ["dist/*"],
|
||||||
|
}
|
||||||
|
]);
|
||||||
Generated
+11420
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "containr-mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@tanstack/react-query": "^5.104.1",
|
||||||
|
"expo": "~57.0.27",
|
||||||
|
"expo-constants": "~57.0.21",
|
||||||
|
"expo-font": "~57.0.4",
|
||||||
|
"expo-linking": "~57.0.12",
|
||||||
|
"expo-router": "~57.0.25",
|
||||||
|
"expo-secure-store": "~57.0.4",
|
||||||
|
"expo-status-bar": "~57.0.1",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-native": "0.86.3",
|
||||||
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
|
"react-native-screens": "~4.26.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.2.2",
|
||||||
|
"eslint": "^9.39.5",
|
||||||
|
"eslint-config-expo": "~57.0.2",
|
||||||
|
"typescript": "~6.0.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "expo lint"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
image?: string;
|
||||||
|
environment?: string;
|
||||||
|
domain?: string;
|
||||||
|
public_url?: string;
|
||||||
|
port?: number;
|
||||||
|
replicas?: number;
|
||||||
|
cpu?: string;
|
||||||
|
memory?: string;
|
||||||
|
sleep_enabled?: boolean;
|
||||||
|
sleep_idle_minutes?: number;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Deployment {
|
||||||
|
id: string;
|
||||||
|
service_id: string;
|
||||||
|
service_name?: string;
|
||||||
|
project_name?: string;
|
||||||
|
status: string;
|
||||||
|
image_name?: string;
|
||||||
|
created_at?: string;
|
||||||
|
completed_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Database {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
version?: string;
|
||||||
|
plan?: string;
|
||||||
|
connection_url?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvCheck {
|
||||||
|
ok: boolean;
|
||||||
|
unresolved: string[];
|
||||||
|
empty: string[];
|
||||||
|
unreadable: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
message: string;
|
||||||
|
stream: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Template {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Api {
|
||||||
|
constructor(
|
||||||
|
private baseUrl: string,
|
||||||
|
private token: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const j = (await res.json()) as { error?: string };
|
||||||
|
if (j.error) msg = j.error;
|
||||||
|
} catch {
|
||||||
|
// non-JSON error body
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, msg);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
ping() {
|
||||||
|
return fetch(`${this.baseUrl}/health`).then((r) => r.ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
projects() {
|
||||||
|
return this.req<{ projects: Project[] }>('GET', '/projects?limit=100').then(
|
||||||
|
(r) => r.projects ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
projectServices(projectId: string) {
|
||||||
|
return this.req<{ services: Service[] }>(
|
||||||
|
'GET',
|
||||||
|
`/projects/${projectId}/services?limit=200`,
|
||||||
|
).then((r) => r.services ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
service(id: string) {
|
||||||
|
return this.req<{ service: Service }>('GET', `/services/${id}`).then((r) => r.service);
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceAction(id: string, action: 'start' | 'stop' | 'restart' | 'redeploy' | 'sleep' | 'wake') {
|
||||||
|
return this.req<{ status?: string; message?: string }>(
|
||||||
|
'POST',
|
||||||
|
`/services/${id}/${action}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceLogs(id: string, tail = 300) {
|
||||||
|
return this.req<{ logs: LogEntry[]; message?: string }>(
|
||||||
|
'GET',
|
||||||
|
`/services/${id}/logs?tail=${tail}`,
|
||||||
|
).then((r) => r.logs ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
envCheck(id: string) {
|
||||||
|
return this.req<EnvCheck>('GET', `/services/${id}/env-check`);
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceDeployments(id: string) {
|
||||||
|
return this.req<{ deployments: Deployment[] }>(
|
||||||
|
'GET',
|
||||||
|
`/services/${id}/deployments?limit=20`,
|
||||||
|
).then((r) => r.deployments ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
recentDeployments() {
|
||||||
|
return this.req<{ deployments: Deployment[] }>(
|
||||||
|
'GET',
|
||||||
|
'/deployments?limit=20',
|
||||||
|
).then((r) => r.deployments ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
deploymentAction(id: string, action: 'rollback' | 'cancel') {
|
||||||
|
return this.req<unknown>('POST', `/deployments/${id}/${action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
databases() {
|
||||||
|
return this.req<{ databases: Database[] }>('GET', '/databases').then(
|
||||||
|
(r) => r.databases ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseBackup(id: string) {
|
||||||
|
return this.req<unknown>('POST', `/databases/${id}/backup`);
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseAction(id: string, action: 'start' | 'stop' | 'restart') {
|
||||||
|
return this.req<unknown>('POST', `/databases/${id}/action`, { action });
|
||||||
|
}
|
||||||
|
|
||||||
|
templates() {
|
||||||
|
return this.req<{ templates: Template[] }>('GET', '/templates?limit=100').then(
|
||||||
|
(r) => r.templates ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Tabs } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
|
||||||
|
export default function TabsLayout() {
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: colors.bg },
|
||||||
|
headerTintColor: colors.text,
|
||||||
|
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border },
|
||||||
|
tabBarActiveTintColor: colors.accent,
|
||||||
|
tabBarInactiveTintColor: colors.textDim,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: 'Overview',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="grid-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="services"
|
||||||
|
options={{
|
||||||
|
title: 'Services',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="cube-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="databases"
|
||||||
|
options={{
|
||||||
|
title: 'Databases',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="server-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
title: 'Settings',
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="settings-outline" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Alert, FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { relTime } from '../../time';
|
||||||
|
import { ActionButton, Card, Empty, ErrorBox, Loading, StatusBadge } from '../../components';
|
||||||
|
|
||||||
|
export default function Databases() {
|
||||||
|
const api = useApi();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const dbs = useQuery({ queryKey: ['databases'], queryFn: () => api.databases() });
|
||||||
|
|
||||||
|
const action = useMutation({
|
||||||
|
mutationFn: ({ id, act }: { id: string; act: 'start' | 'stop' | 'restart' }) =>
|
||||||
|
api.databaseAction(id, act),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['databases'] }),
|
||||||
|
onError: (e) => Alert.alert('Action failed', e instanceof Error ? e.message : String(e)),
|
||||||
|
});
|
||||||
|
const backup = useMutation({
|
||||||
|
mutationFn: (id: string) => api.databaseBackup(id),
|
||||||
|
onSuccess: () => Alert.alert('Backup started', 'Snapshot queued for this database.'),
|
||||||
|
onError: (e) => Alert.alert('Backup failed', e instanceof Error ? e.message : String(e)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dbs.isLoading) return <Loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
data={dbs.data ?? []}
|
||||||
|
keyExtractor={(d) => d.id}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={dbs.isRefetching}
|
||||||
|
onRefresh={() => dbs.refetch()}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
dbs.error ? <ErrorBox error={dbs.error} /> : <Empty text="No databases" />
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Card>
|
||||||
|
<View style={styles.head}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.name}>{item.name}</Text>
|
||||||
|
<Text style={styles.meta}>
|
||||||
|
{item.type}
|
||||||
|
{item.version ? ` ${item.version}` : ''} · created {relTime(item.created_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{item.status === 'running' ? (
|
||||||
|
<ActionButton
|
||||||
|
label="Backup now"
|
||||||
|
kind="primary"
|
||||||
|
disabled={backup.isPending}
|
||||||
|
onPress={() => backup.mutate(item.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ActionButton
|
||||||
|
label="Start"
|
||||||
|
kind="primary"
|
||||||
|
disabled={action.isPending}
|
||||||
|
onPress={() => action.mutate({ id: item.id, act: 'start' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.status === 'running' ? (
|
||||||
|
<>
|
||||||
|
<ActionButton
|
||||||
|
label="Restart"
|
||||||
|
disabled={action.isPending}
|
||||||
|
onPress={() => action.mutate({ id: item.id, act: 'restart' })}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="Stop"
|
||||||
|
kind="danger"
|
||||||
|
disabled={action.isPending}
|
||||||
|
onPress={() => action.mutate({ id: item.id, act: 'stop' })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
head: { flexDirection: 'row', alignItems: 'center', marginBottom: 10 },
|
||||||
|
name: { color: colors.text, fontSize: 16, fontWeight: '600' },
|
||||||
|
meta: { color: colors.textDim, fontSize: 12, marginTop: 2 },
|
||||||
|
actions: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { relTime } from '../../time';
|
||||||
|
import { Card, Empty, ErrorBox, Row, SectionTitle, StatusBadge, StatusDot } from '../../components';
|
||||||
|
|
||||||
|
export default function Overview() {
|
||||||
|
const api = useApi();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const projects = useQuery({ queryKey: ['projects'], queryFn: () => api.projects() });
|
||||||
|
const deployments = useQuery({
|
||||||
|
queryKey: ['deployments'],
|
||||||
|
queryFn: () => api.recentDeployments(),
|
||||||
|
});
|
||||||
|
const servicesQueries = useQueries({
|
||||||
|
queries: (projects.data ?? []).map((p) => ({
|
||||||
|
queryKey: ['services', p.id],
|
||||||
|
queryFn: () => api.projectServices(p.id),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allServices = servicesQueries.flatMap((q) => q.data ?? []);
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const s of allServices) counts[s.status] = (counts[s.status] ?? 0) + 1;
|
||||||
|
|
||||||
|
const refetch = () => {
|
||||||
|
projects.refetch();
|
||||||
|
deployments.refetch();
|
||||||
|
servicesQueries.forEach((q) => q.refetch());
|
||||||
|
};
|
||||||
|
const refreshing = projects.isRefetching || deployments.isRefetching;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={refetch} tintColor={colors.accent} />
|
||||||
|
}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<>
|
||||||
|
{projects.error ? <ErrorBox error={projects.error} /> : null}
|
||||||
|
<View style={styles.statRow}>
|
||||||
|
<Stat label="Running" value={counts.running ?? 0} color={colors.ok} />
|
||||||
|
<Stat label="Sleeping" value={counts.sleeping ?? 0} color={colors.info} />
|
||||||
|
<Stat label="Failed" value={counts.failed ?? 0} color={colors.err} />
|
||||||
|
<Stat label="Stopped" value={counts.stopped ?? 0} color={colors.textDim} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SectionTitle>Projects</SectionTitle>
|
||||||
|
{projects.data?.length === 0 ? <Empty text="No projects yet" /> : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
data={projects.data ?? []}
|
||||||
|
keyExtractor={(p) => p.id}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const idx = (projects.data ?? []).findIndex((p) => p.id === item.id);
|
||||||
|
const svcs = servicesQueries[idx]?.data ?? [];
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Row
|
||||||
|
title={item.name}
|
||||||
|
subtitle={`${svcs.length} service${svcs.length === 1 ? '' : 's'}`}
|
||||||
|
onPress={() => router.push(`/project/${item.id}?name=${encodeURIComponent(item.name)}`)}
|
||||||
|
right={
|
||||||
|
<View style={styles.dots}>
|
||||||
|
{svcs.slice(0, 6).map((s) => (
|
||||||
|
<StatusDot key={s.id} status={s.status} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ListFooterComponent={
|
||||||
|
<>
|
||||||
|
<SectionTitle>Recent deployments</SectionTitle>
|
||||||
|
{deployments.data?.length === 0 ? <Empty text="No deployments" /> : null}
|
||||||
|
{(deployments.data ?? []).slice(0, 10).map((d) => (
|
||||||
|
<Row
|
||||||
|
key={d.id}
|
||||||
|
title={`${d.service_name ?? d.service_id}`}
|
||||||
|
subtitle={`${d.project_name ?? ''} · ${relTime(d.created_at)}`}
|
||||||
|
right={<StatusBadge status={d.status} />}
|
||||||
|
onPress={() => router.push(`/service/${d.service_id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.stat, { borderColor: color }]}>
|
||||||
|
<Text style={[styles.statValue, { color }]}>{value}</Text>
|
||||||
|
<Text style={styles.statLabel}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
statRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
|
||||||
|
stat: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statValue: { fontSize: 20, fontWeight: '800' },
|
||||||
|
statLabel: { color: colors.textDim, fontSize: 11, marginTop: 2 },
|
||||||
|
dots: { flexDirection: 'row', gap: 4 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FlatList, RefreshControl, StyleSheet } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { Empty, ErrorBox, Loading, Row, StatusBadge } from '../../components';
|
||||||
|
|
||||||
|
export default function Services() {
|
||||||
|
const api = useApi();
|
||||||
|
const router = useRouter();
|
||||||
|
const projects = useQuery({ queryKey: ['projects'], queryFn: () => api.projects() });
|
||||||
|
const servicesQueries = useQueries({
|
||||||
|
queries: (projects.data ?? []).map((p) => ({
|
||||||
|
queryKey: ['services', p.id],
|
||||||
|
queryFn: () => api.projectServices(p.id),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = servicesQueries.flatMap((q, i) =>
|
||||||
|
(q.data ?? []).map((s) => ({ ...s, _project: projects.data?.[i]?.name ?? '' })),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (projects.isLoading) return <Loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
data={rows}
|
||||||
|
keyExtractor={(s) => s.id}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={servicesQueries.some((q) => q.isRefetching)}
|
||||||
|
onRefresh={() => servicesQueries.forEach((q) => q.refetch())}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
projects.error ? <ErrorBox error={projects.error} /> : <Empty text="No services" />
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Row
|
||||||
|
title={item.name}
|
||||||
|
subtitle={`${item._project} · ${item.type}${item.domain ? ` · ${item.domain}` : ''}`}
|
||||||
|
right={<StatusBadge status={item.status} />}
|
||||||
|
onPress={() => router.push(`/service/${item.id}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.bg } });
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Alert, ScrollView, StyleSheet, Text } from 'react-native';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useSession } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { ActionButton, Card, SectionTitle } from '../../components';
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const { serverUrl, signOut } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.root} contentContainerStyle={{ padding: 16 }}>
|
||||||
|
<SectionTitle>Connection</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
<Text style={styles.label}>Server</Text>
|
||||||
|
<Text style={styles.value}>{serverUrl}</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<SectionTitle>App</SectionTitle>
|
||||||
|
<Card>
|
||||||
|
<Text style={styles.label}>Version</Text>
|
||||||
|
<Text style={styles.value}>
|
||||||
|
{Constants.expoConfig?.version ?? 'dev'} · Expo SDK{' '}
|
||||||
|
{Constants.expoConfig?.sdkVersion ?? '?'}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
label="Disconnect"
|
||||||
|
kind="danger"
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert('Disconnect', 'Remove the stored server and token?', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Disconnect',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await signOut();
|
||||||
|
router.replace('/login');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
label: { color: colors.textDim, fontSize: 12, marginBottom: 4 },
|
||||||
|
value: { color: colors.text, fontSize: 15 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { SessionProvider, useSession } from '../session';
|
||||||
|
import { colors } from '../theme';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { staleTime: 15_000, retry: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function AuthGate() {
|
||||||
|
const { ready, api } = useSession();
|
||||||
|
const segments = useSegments();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
const inLogin = segments[0] === 'login';
|
||||||
|
if (!api && !inLogin) router.replace('/login');
|
||||||
|
else if (api && inLogin) router.replace('/');
|
||||||
|
}, [ready, api, segments, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: colors.bg },
|
||||||
|
headerTintColor: colors.text,
|
||||||
|
contentStyle: { backgroundColor: colors.bg },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="project/[id]" options={{ title: 'Project' }} />
|
||||||
|
<Stack.Screen name="service/[id]" options={{ title: 'Service' }} />
|
||||||
|
<Stack.Screen name="service/logs" options={{ title: 'Logs' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SessionProvider>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<AuthGate />
|
||||||
|
</SessionProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Redirect } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return <Redirect href="/(tabs)" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSession, normalizeServerUrl } from '../session';
|
||||||
|
import { colors } from '../theme';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { signIn } = useSession();
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const normalized = normalizeServerUrl(url);
|
||||||
|
const res = await fetch(`${normalized}/api/v1/projects?limit=1`, {
|
||||||
|
headers: { Authorization: `Bearer ${token.trim()}` },
|
||||||
|
});
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
throw new Error('Token rejected. Use a personal access token (cnp_…).');
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`Server responded ${res.status}`);
|
||||||
|
await signIn(normalized, token);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Connection failed');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.root}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<Text style={styles.logo}>Containr</Text>
|
||||||
|
<Text style={styles.sub}>Self-hosted platform console</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Server URL</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="https://containr.example.com"
|
||||||
|
placeholderTextColor={colors.textDim}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
value={url}
|
||||||
|
onChangeText={setUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Personal access token</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="cnp_…"
|
||||||
|
placeholderTextColor={colors.textDim}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
secureTextEntry
|
||||||
|
value={token}
|
||||||
|
onChangeText={setToken}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <Text style={styles.err}>{error}</Text> : null}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.btn, (!url.trim() || !token.trim() || busy) && { opacity: 0.5 }]}
|
||||||
|
onPress={connect}
|
||||||
|
disabled={!url.trim() || !token.trim() || busy}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator color={colors.text} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.btnText}>Connect</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
Create a token in the web UI under Settings → Access Tokens.
|
||||||
|
</Text>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg, padding: 24, justifyContent: 'center' },
|
||||||
|
logo: { color: colors.text, fontSize: 34, fontWeight: '800', letterSpacing: -0.5 },
|
||||||
|
sub: { color: colors.textDim, fontSize: 14, marginBottom: 32, marginTop: 4 },
|
||||||
|
label: {
|
||||||
|
color: colors.textDim,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 10,
|
||||||
|
color: colors.text,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 15,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
err: { color: colors.err, marginBottom: 12 },
|
||||||
|
btn: {
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||||
|
hint: { color: colors.textDim, fontSize: 12, marginTop: 20, textAlign: 'center' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { FlatList, RefreshControl, StyleSheet } from 'react-native';
|
||||||
|
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { Empty, ErrorBox, Loading, Row, StatusBadge } from '../../components';
|
||||||
|
|
||||||
|
export default function ProjectDetail() {
|
||||||
|
const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
|
||||||
|
const api = useApi();
|
||||||
|
const router = useRouter();
|
||||||
|
const nav = useNavigation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (name) nav.setOptions({ title: name });
|
||||||
|
}, [name, nav]);
|
||||||
|
|
||||||
|
const services = useQuery({
|
||||||
|
queryKey: ['services', id],
|
||||||
|
queryFn: () => api.projectServices(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (services.isLoading) return <Loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
data={services.data ?? []}
|
||||||
|
keyExtractor={(s) => s.id}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={services.isRefetching}
|
||||||
|
onRefresh={() => services.refetch()}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
services.error ? <ErrorBox error={services.error} /> : <Empty text="No services" />
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Row
|
||||||
|
title={item.name}
|
||||||
|
subtitle={`${item.type} · ${item.environment ?? 'production'}${
|
||||||
|
item.domain ? ` · ${item.domain}` : ''
|
||||||
|
}`}
|
||||||
|
right={<StatusBadge status={item.status} />}
|
||||||
|
onPress={() => router.push(`/service/${item.id}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.bg } });
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Alert, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { relTime } from '../../time';
|
||||||
|
import {
|
||||||
|
ActionButton,
|
||||||
|
Card,
|
||||||
|
Empty,
|
||||||
|
ErrorBox,
|
||||||
|
Loading,
|
||||||
|
SectionTitle,
|
||||||
|
StatusBadge,
|
||||||
|
} from '../../components';
|
||||||
|
|
||||||
|
export default function ServiceDetail() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const api = useApi();
|
||||||
|
const router = useRouter();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const service = useQuery({
|
||||||
|
queryKey: ['service', id],
|
||||||
|
queryFn: () => api.service(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
const envCheck = useQuery({
|
||||||
|
queryKey: ['env-check', id],
|
||||||
|
queryFn: () => api.envCheck(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const deployments = useQuery({
|
||||||
|
queryKey: ['service-deployments', id],
|
||||||
|
queryFn: () => api.serviceDeployments(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['service', id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['services'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['service-deployments', id] });
|
||||||
|
};
|
||||||
|
const onErr = (e: unknown) =>
|
||||||
|
Alert.alert('Action failed', e instanceof Error ? e.message : String(e));
|
||||||
|
|
||||||
|
const action = useMutation({
|
||||||
|
mutationFn: (act: 'start' | 'stop' | 'restart' | 'redeploy' | 'sleep' | 'wake') =>
|
||||||
|
api.serviceAction(id!, act),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const rollback = useMutation({
|
||||||
|
mutationFn: (depId: string) => api.deploymentAction(depId, 'rollback'),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
|
||||||
|
const s = service.data;
|
||||||
|
if (service.isLoading) return <Loading />;
|
||||||
|
if (!s) return <ErrorBox error={service.error ?? new Error('Not found')} />;
|
||||||
|
|
||||||
|
const busy = action.isPending;
|
||||||
|
const canStart = s.status === 'stopped' || s.status === 'failed';
|
||||||
|
const canStop = s.status === 'running';
|
||||||
|
const canWake = s.status === 'sleeping';
|
||||||
|
const lastDeploy = deployments.data?.[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={service.isRefetching}
|
||||||
|
onRefresh={() => service.refetch()}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={styles.head}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.name}>{s.name}</Text>
|
||||||
|
<Text style={styles.meta}>
|
||||||
|
{s.type} · {s.environment ?? 'production'}
|
||||||
|
{s.image ? `\n${s.image}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{envCheck.data && !envCheck.data.ok ? (
|
||||||
|
<Card style={{ borderColor: colors.warn }}>
|
||||||
|
<Text style={styles.warnTitle}>Environment issues</Text>
|
||||||
|
{envCheck.data.unresolved.map((v) => (
|
||||||
|
<Text key={v} style={styles.warnItem}>
|
||||||
|
Unresolved reference: {v}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{envCheck.data.empty.map((v) => (
|
||||||
|
<Text key={v} style={styles.warnItem}>
|
||||||
|
Empty value: {v}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{envCheck.data.unreadable.map((v) => (
|
||||||
|
<Text key={v} style={styles.warnItem}>
|
||||||
|
Unreadable secret (rotate): {v}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SectionTitle>Actions</SectionTitle>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{canStart ? (
|
||||||
|
<ActionButton label="Start" kind="primary" disabled={busy} onPress={() => action.mutate('start')} />
|
||||||
|
) : null}
|
||||||
|
{canStop ? (
|
||||||
|
<ActionButton label="Stop" kind="danger" disabled={busy} onPress={() => action.mutate('stop')} />
|
||||||
|
) : null}
|
||||||
|
{canWake ? (
|
||||||
|
<ActionButton label="Wake" kind="primary" disabled={busy} onPress={() => action.mutate('wake')} />
|
||||||
|
) : null}
|
||||||
|
{s.status === 'running' ? (
|
||||||
|
<>
|
||||||
|
<ActionButton label="Restart" disabled={busy} onPress={() => action.mutate('restart')} />
|
||||||
|
<ActionButton label="Redeploy" disabled={busy} onPress={() => action.mutate('redeploy')} />
|
||||||
|
{s.sleep_enabled ? (
|
||||||
|
<ActionButton label="Sleep" disabled={busy} onPress={() => action.mutate('sleep')} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<ActionButton
|
||||||
|
label="View logs"
|
||||||
|
onPress={() => router.push(`/service/logs?id=${s.id}&name=${encodeURIComponent(s.name)}`)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{s.sleep_enabled ? (
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
Sleep enabled — scales to zero after {s.sleep_idle_minutes}m idle, wakes on traffic.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SectionTitle>Deployments</SectionTitle>
|
||||||
|
{deployments.data?.length === 0 ? <Empty text="No deployments" /> : null}
|
||||||
|
{(deployments.data ?? []).slice(0, 8).map((d) => (
|
||||||
|
<Card key={d.id}>
|
||||||
|
<View style={styles.depRow}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.depTitle}>{d.image_name ?? d.id.slice(0, 8)}</Text>
|
||||||
|
<Text style={styles.meta}>{relTime(d.created_at)}</Text>
|
||||||
|
</View>
|
||||||
|
<StatusBadge status={d.status} />
|
||||||
|
</View>
|
||||||
|
{d.id !== lastDeploy?.id && d.status === 'success' ? (
|
||||||
|
<ActionButton
|
||||||
|
label="Rollback to this"
|
||||||
|
disabled={rollback.isPending}
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert('Rollback', 'Roll back to this deployment?', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Rollback', onPress: () => rollback.mutate(d.id) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
head: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
|
||||||
|
name: { color: colors.text, fontSize: 24, fontWeight: '800' },
|
||||||
|
meta: { color: colors.textDim, fontSize: 12, marginTop: 4 },
|
||||||
|
warnTitle: { color: colors.warn, fontWeight: '700', marginBottom: 6 },
|
||||||
|
warnItem: { color: colors.text, fontSize: 13, marginTop: 2 },
|
||||||
|
actions: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||||
|
hint: { color: colors.textDim, fontSize: 12, marginTop: 4 },
|
||||||
|
depRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
|
||||||
|
depTitle: { color: colors.text, fontSize: 14, fontWeight: '600' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { FlatList, RefreshControl, StyleSheet, Text } from 'react-native';
|
||||||
|
import { useLocalSearchParams, useNavigation } from 'expo-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../../session';
|
||||||
|
import { colors } from '../../theme';
|
||||||
|
import { Empty, ErrorBox, Loading } from '../../components';
|
||||||
|
|
||||||
|
export default function ServiceLogs() {
|
||||||
|
const { id, name } = useLocalSearchParams<{ id: string; name?: string }>();
|
||||||
|
const api = useApi();
|
||||||
|
const nav = useNavigation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (name) nav.setOptions({ title: `${name} logs` });
|
||||||
|
}, [name, nav]);
|
||||||
|
|
||||||
|
const logs = useQuery({
|
||||||
|
queryKey: ['logs', id],
|
||||||
|
queryFn: () => api.serviceLogs(id!, 300),
|
||||||
|
enabled: !!id,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (logs.isLoading) return <Loading />;
|
||||||
|
const entries = logs.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
style={styles.root}
|
||||||
|
contentContainerStyle={{ padding: 12 }}
|
||||||
|
data={[...entries].reverse()}
|
||||||
|
inverted
|
||||||
|
keyExtractor={(_, i) => String(i)}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={logs.isRefetching}
|
||||||
|
onRefresh={() => logs.refetch()}
|
||||||
|
tintColor={colors.accent}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
logs.error ? (
|
||||||
|
<ErrorBox error={logs.error} />
|
||||||
|
) : (
|
||||||
|
<Empty text="No log output yet" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.line,
|
||||||
|
item.stream === 'stderr' && { color: colors.err },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
line: {
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
paddingVertical: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { colors, statusColor } from './theme';
|
||||||
|
|
||||||
|
export function StatusDot({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.dot, { backgroundColor: statusColor[status] ?? colors.textDim }]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
const c = statusColor[status] ?? colors.textDim;
|
||||||
|
return (
|
||||||
|
<View style={[styles.badge, { borderColor: c }]}>
|
||||||
|
<View style={[styles.dot, { backgroundColor: c }]} />
|
||||||
|
<Text style={[styles.badgeText, { color: c }]}>{status}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, style }: { children: React.ReactNode; style?: object }) {
|
||||||
|
return <View style={[styles.card, style]}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
|
return <Text style={styles.section}>{children}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Empty({ text }: { text: string }) {
|
||||||
|
return <Text style={styles.empty}>{text}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBox({ error }: { error: unknown }) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
return <Text style={styles.err}>{msg}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionButton({
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
kind = 'default',
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
kind?: 'default' | 'primary' | 'danger';
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const bg =
|
||||||
|
kind === 'primary' ? colors.accent : kind === 'danger' ? colors.err : colors.surfaceAlt;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.btn, { backgroundColor: bg, opacity: disabled ? 0.4 : 1 }]}
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text style={styles.btnText}>{label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Row({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
right,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.row} onPress={onPress} disabled={!onPress}>
|
||||||
|
<View style={{ flex: 1, marginRight: 8 }}>
|
||||||
|
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text style={styles.rowSub} numberOfLines={1}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{right}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Loading() {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<ActivityIndicator color={colors.accent} size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
dot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
|
badge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
badgeText: { fontSize: 11, fontWeight: '600', textTransform: 'uppercase' },
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
color: colors.textDim,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginTop: 14,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
empty: { color: colors.textDim, fontSize: 14, paddingVertical: 12 },
|
||||||
|
err: { color: colors.err, fontSize: 13, paddingVertical: 8 },
|
||||||
|
btn: {
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
btnText: { color: colors.text, fontWeight: '600', fontSize: 13 },
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
rowTitle: { color: colors.text, fontSize: 15, fontWeight: '500' },
|
||||||
|
rowSub: { color: colors.textDim, fontSize: 12, marginTop: 2 },
|
||||||
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { Api } from './api';
|
||||||
|
|
||||||
|
const KEY_URL = 'containr.server_url';
|
||||||
|
const KEY_TOKEN = 'containr.token';
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
ready: boolean;
|
||||||
|
api: Api | null;
|
||||||
|
serverUrl: string | null;
|
||||||
|
signIn: (serverUrl: string, token: string) => Promise<void>;
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<Session>({
|
||||||
|
ready: false,
|
||||||
|
api: null,
|
||||||
|
serverUrl: null,
|
||||||
|
signIn: async () => {},
|
||||||
|
signOut: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function normalizeServerUrl(raw: string): string {
|
||||||
|
let u = raw.trim();
|
||||||
|
if (!/^https?:\/\//i.test(u)) u = `https://${u}`;
|
||||||
|
return u.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [serverUrl, setServerUrl] = useState<string | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [u, t] = await Promise.all([
|
||||||
|
SecureStore.getItemAsync(KEY_URL),
|
||||||
|
SecureStore.getItemAsync(KEY_TOKEN),
|
||||||
|
]);
|
||||||
|
setServerUrl(u);
|
||||||
|
setToken(t);
|
||||||
|
} finally {
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<Session>(
|
||||||
|
() => ({
|
||||||
|
ready,
|
||||||
|
serverUrl,
|
||||||
|
api: serverUrl && token ? new Api(serverUrl, token) : null,
|
||||||
|
signIn: async (url, tok) => {
|
||||||
|
const normalized = normalizeServerUrl(url);
|
||||||
|
await SecureStore.setItemAsync(KEY_URL, normalized);
|
||||||
|
await SecureStore.setItemAsync(KEY_TOKEN, tok.trim());
|
||||||
|
setServerUrl(normalized);
|
||||||
|
setToken(tok.trim());
|
||||||
|
},
|
||||||
|
signOut: async () => {
|
||||||
|
await SecureStore.deleteItemAsync(KEY_URL);
|
||||||
|
await SecureStore.deleteItemAsync(KEY_TOKEN);
|
||||||
|
setServerUrl(null);
|
||||||
|
setToken(null);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[ready, serverUrl, token],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
return useContext(Ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApi(): Api {
|
||||||
|
const { api } = useSession();
|
||||||
|
if (!api) throw new Error('not signed in');
|
||||||
|
return api;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export const colors = {
|
||||||
|
bg: '#0B0F14',
|
||||||
|
surface: '#131A22',
|
||||||
|
surfaceAlt: '#1A232E',
|
||||||
|
border: '#26313D',
|
||||||
|
text: '#E8EEF4',
|
||||||
|
textDim: '#8A97A5',
|
||||||
|
accent: '#4F8FF7',
|
||||||
|
accentDim: '#2A4A7F',
|
||||||
|
ok: '#3FB68B',
|
||||||
|
warn: '#E0A93E',
|
||||||
|
err: '#E05C5C',
|
||||||
|
info: '#4FB7D9',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusColor: Record<string, string> = {
|
||||||
|
running: colors.ok,
|
||||||
|
deployed: colors.ok,
|
||||||
|
success: colors.ok,
|
||||||
|
building: colors.warn,
|
||||||
|
deploying: colors.warn,
|
||||||
|
pending: colors.warn,
|
||||||
|
waking: colors.warn,
|
||||||
|
sleeping: colors.info,
|
||||||
|
stopped: colors.textDim,
|
||||||
|
queued: colors.textDim,
|
||||||
|
failed: colors.err,
|
||||||
|
error: colors.err,
|
||||||
|
cancelled: colors.textDim,
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function relTime(iso?: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(t)) return '—';
|
||||||
|
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
|
||||||
|
if (s < 60) return `${s}s ago`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
if (d < 30) return `${d}d ago`;
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user