Merge pull request #1 from SamuelSJames/claude/wonderful-faraday-qxym5t
Claude/wonderful faraday qxym5t
This commit is contained in:
commit
106a7d9911
49 changed files with 5486 additions and 405 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
.github
|
||||
pics
|
||||
*.md
|
||||
39
.github/workflows/deploy.yml
vendored
Normal file
39
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: Deploy to racknerd1
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
env:
|
||||
DEPLOY_PATH: /opt/archnest
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Copy repo to racknerd1
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.RACKNERD_HOST }}
|
||||
username: ${{ secrets.RACKNERD_USER }}
|
||||
key: ${{ secrets.RACKNERD_SSH_KEY }}
|
||||
port: ${{ secrets.RACKNERD_PORT || 22 }}
|
||||
source: "."
|
||||
target: ${{ env.DEPLOY_PATH }}
|
||||
rm: false
|
||||
|
||||
- name: Build and restart container
|
||||
uses: appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.RACKNERD_HOST }}
|
||||
username: ${{ secrets.RACKNERD_USER }}
|
||||
key: ${{ secrets.RACKNERD_SSH_KEY }}
|
||||
port: ${{ secrets.RACKNERD_PORT || 22 }}
|
||||
script: |
|
||||
cd ${{ env.DEPLOY_PATH }}
|
||||
docker compose up -d --build
|
||||
docker image prune -f
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -12,6 +12,15 @@ dist
|
|||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Backend data/secrets
|
||||
backend/data
|
||||
backend/.env
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
*.tsbuildinfo
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
|
|
|||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 8080
|
||||
90
README.md
90
README.md
|
|
@ -1,73 +1,39 @@
|
|||
# React + TypeScript + Vite
|
||||
# ArchNest
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
A self-hosted ops dashboard — infrastructure monitoring, a bookmark hub for your homelab/cloud links, an embedded terminal, and system settings, all in one place.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
Built with React 19 + TypeScript + Vite, styled with Tailwind CSS v4, charts via Recharts, icons via Lucide React.
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
## Pages
|
||||
|
||||
## React Compiler
|
||||
| Page | Route | Status |
|
||||
|------|-------|--------|
|
||||
| Glance | `/` | Done — main ops dashboard (system status, resource overview, alerts, network traffic) |
|
||||
| Infrastructure | `/infrastructure` | Done — resource distribution, node status grid, cost/trend breakdown. "Network" sub-tab planned as a future addition. |
|
||||
| BookNest | `/booknest` | Done — categorized bookmark hub with quick access, favorites, link health, and category breakdown |
|
||||
| Terminal | `/terminal` | Pending — will be based on a fork of the (archived) Termix project, not yet merged in |
|
||||
| Settings | `/settings` | Done — Profile (incl. avatar upload), Appearance, Integrations, Notifications, Data & Backup, About |
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
See `archnest-blueprint.md` for the original per-page design spec and `design-decisions.md` for the visual/UX conventions and lessons learned while building each page — read that file before making layout changes, it documents *why* things are built the way they are (hero banner layering, card blend techniques, icon library gotchas, etc.).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
## Development
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
Type-check with `npx tsc --noEmit` before committing — Vite/the browser surface some runtime errors (e.g. missing icon exports) that the type-checker won't catch, so also smoke-test pages in a browser.
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
## Tech Stack
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
- React 19 + Vite + TypeScript
|
||||
- React Router for routing
|
||||
- Tailwind CSS v4
|
||||
- Recharts (donuts, line/area charts)
|
||||
- Lucide React (icons)
|
||||
- Deploy target: Docker on racknerd1 → NPM proxy at archnest.snsnetlabs.com
|
||||
|
||||
## Deployment
|
||||
|
||||
This project is deployed via Docker on `racknerd1`, proxied through Nginx Proxy Manager at `archnest.snsnetlabs.com`.
|
||||
|
|
|
|||
5
backend/.env.example
Normal file
5
backend/.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
PORT=4000
|
||||
ARCHNEST_DB_PATH=./data/archnest.db
|
||||
ARCHNEST_JWT_SECRET=change-me-to-a-long-random-string
|
||||
ARCHNEST_SECRET_KEY=change-me-to-another-long-random-string
|
||||
ARCHNEST_CORS_ORIGIN=http://localhost:5173
|
||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev=false
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=build /app/dist ./dist
|
||||
EXPOSE 4000
|
||||
CMD ["node", "dist/server.js"]
|
||||
1865
backend/package-lock.json
generated
Normal file
1865
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
backend/package.json
Normal file
27
backend/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "archnest-backend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -b",
|
||||
"start": "node dist/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/jwt": "^9.0.4",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"fastify": "^5.2.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/node": "^22.10.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
25
backend/src/db/crypto.ts
Normal file
25
backend/src/db/crypto.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'
|
||||
|
||||
const rawKey = process.env.ARCHNEST_SECRET_KEY
|
||||
if (!rawKey) {
|
||||
throw new Error('ARCHNEST_SECRET_KEY env var is required to encrypt integration secrets')
|
||||
}
|
||||
const key = createHash('sha256').update(rawKey).digest()
|
||||
|
||||
export function encryptSecret(plaintext: string): string {
|
||||
const iv = randomBytes(12)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const authTag = cipher.getAuthTag()
|
||||
return Buffer.concat([iv, authTag, encrypted]).toString('base64')
|
||||
}
|
||||
|
||||
export function decryptSecret(payload: string): string {
|
||||
const buf = Buffer.from(payload, 'base64')
|
||||
const iv = buf.subarray(0, 12)
|
||||
const authTag = buf.subarray(12, 28)
|
||||
const encrypted = buf.subarray(28)
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAuthTag(authTag)
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
|
||||
}
|
||||
72
backend/src/db/index.ts
Normal file
72
backend/src/db/index.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import Database from 'better-sqlite3'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
const DB_PATH = process.env.ARCHNEST_DB_PATH ?? './data/archnest.db'
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true })
|
||||
|
||||
export const db = new Database(DB_PATH)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
email TEXT,
|
||||
avatar_data_url TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
last_checked_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
integration_id INTEGER NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value_encrypted TEXT NOT NULL,
|
||||
UNIQUE(integration_id, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bookmark_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER REFERENCES bookmark_categories(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
last_checked_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
source TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
|
||||
export function logEvent(type: string, title: string, source?: string | null) {
|
||||
db.prepare('INSERT INTO events (type, title, source) VALUES (?, ?, ?)').run(type, title, source ?? null)
|
||||
}
|
||||
28
backend/src/integrations/docker.ts
Normal file
28
backend/src/integrations/docker.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { IntegrationAdapter, Resource } from './types.js'
|
||||
|
||||
export const docker: IntegrationAdapter = {
|
||||
async testConnection(config) {
|
||||
const baseUrl = config.baseUrl?.replace(/\/$/, '')
|
||||
if (!baseUrl) return { ok: false, message: 'Missing baseUrl' }
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/version`)
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
|
||||
return { ok: true, message: 'Connected' }
|
||||
} catch (err) {
|
||||
return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' }
|
||||
}
|
||||
},
|
||||
|
||||
async listResources(config): Promise<Resource[]> {
|
||||
const baseUrl = config.baseUrl?.replace(/\/$/, '')
|
||||
if (!baseUrl) return []
|
||||
const res = await fetch(`${baseUrl}/containers/json?all=true`)
|
||||
if (!res.ok) return []
|
||||
const containers = (await res.json()) as { Names: string[]; State: string }[]
|
||||
return containers.map((c) => ({
|
||||
name: c.Names[0]?.replace(/^\//, '') ?? 'unknown',
|
||||
status: c.State === 'running' ? 'healthy' : c.State === 'restarting' ? 'warning' : 'critical',
|
||||
detail: c.State,
|
||||
}))
|
||||
},
|
||||
}
|
||||
19
backend/src/integrations/registry.ts
Normal file
19
backend/src/integrations/registry.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { IntegrationAdapter, IntegrationType } from './types.js'
|
||||
import { uptimeKuma } from './uptimeKuma.js'
|
||||
import { docker } from './docker.js'
|
||||
|
||||
const notImplemented: IntegrationAdapter = {
|
||||
async testConnection() {
|
||||
return { ok: false, message: 'Test connection not yet implemented for this integration type' }
|
||||
},
|
||||
}
|
||||
|
||||
export const adapterRegistry: Record<IntegrationType, IntegrationAdapter> = {
|
||||
uptime_kuma: uptimeKuma,
|
||||
docker,
|
||||
proxmox: notImplemented,
|
||||
netbird: notImplemented,
|
||||
cloudflare: notImplemented,
|
||||
aws: notImplemented,
|
||||
weather: notImplemented,
|
||||
}
|
||||
28
backend/src/integrations/types.ts
Normal file
28
backend/src/integrations/types.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export type IntegrationType =
|
||||
| 'proxmox'
|
||||
| 'docker'
|
||||
| 'netbird'
|
||||
| 'cloudflare'
|
||||
| 'aws'
|
||||
| 'uptime_kuma'
|
||||
| 'weather'
|
||||
|
||||
export interface IntegrationConfig {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
ok: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
name: string
|
||||
status: 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface IntegrationAdapter {
|
||||
testConnection(config: IntegrationConfig, secrets: Record<string, string>): Promise<TestResult>
|
||||
listResources?(config: IntegrationConfig, secrets: Record<string, string>): Promise<Resource[]>
|
||||
}
|
||||
15
backend/src/integrations/uptimeKuma.ts
Normal file
15
backend/src/integrations/uptimeKuma.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { IntegrationAdapter } from './types.js'
|
||||
|
||||
export const uptimeKuma: IntegrationAdapter = {
|
||||
async testConnection(config) {
|
||||
const baseUrl = config.baseUrl?.replace(/\/$/, '')
|
||||
if (!baseUrl) return { ok: false, message: 'Missing baseUrl' }
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/status-page/heartbeat/default`)
|
||||
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
|
||||
return { ok: true, message: 'Connected' }
|
||||
} catch (err) {
|
||||
return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' }
|
||||
}
|
||||
},
|
||||
}
|
||||
60
backend/src/routes/auth.ts
Normal file
60
backend/src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import { db, logEvent } from '../db/index.js'
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
username: z.string().min(3).max(64),
|
||||
password: z.string().min(8).max(256),
|
||||
})
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.get('/api/system/setup-status', async () => {
|
||||
const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }
|
||||
return { needsSetup: row.count === 0 }
|
||||
})
|
||||
|
||||
app.post('/api/setup', async (req, reply) => {
|
||||
const existing = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }
|
||||
if (existing.count > 0) {
|
||||
return reply.code(409).send({ error: 'Setup already completed' })
|
||||
}
|
||||
const parsed = credentialsSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
|
||||
}
|
||||
const { username, password } = parsed.data
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
const result = db
|
||||
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
|
||||
.run(username, passwordHash)
|
||||
const token = app.jwt.sign({ sub: Number(result.lastInsertRowid), username })
|
||||
logEvent('account_created', `Account created for ${username}`)
|
||||
return { token }
|
||||
})
|
||||
|
||||
app.post('/api/auth/login', async (req, reply) => {
|
||||
const parsed = credentialsSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'Invalid input' })
|
||||
}
|
||||
const { username, password } = parsed.data
|
||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
|
||||
| { id: number; username: string; password_hash: string }
|
||||
| undefined
|
||||
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||
return reply.code(401).send({ error: 'Invalid username or password' })
|
||||
}
|
||||
const token = app.jwt.sign({ sub: user.id, username: user.username })
|
||||
logEvent('user_login', `${user.username} logged in`)
|
||||
return { token }
|
||||
})
|
||||
|
||||
app.get('/api/auth/me', { onRequest: [app.authenticate] }, async (req) => {
|
||||
const payload = req.user as { sub: number; username: string }
|
||||
const user = db
|
||||
.prepare('SELECT id, username, display_name, email, avatar_data_url FROM users WHERE id = ?')
|
||||
.get(payload.sub)
|
||||
return { user }
|
||||
})
|
||||
}
|
||||
81
backend/src/routes/bookmarks.ts
Normal file
81
backend/src/routes/bookmarks.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { db, logEvent } from '../db/index.js'
|
||||
|
||||
const bookmarkSchema = z.object({
|
||||
categoryId: z.number().int().nullable().optional(),
|
||||
title: z.string().min(1).max(128),
|
||||
url: z.string().url(),
|
||||
icon: z.string().optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const categorySchema = z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
icon: z.string().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
})
|
||||
|
||||
export async function bookmarkRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate)
|
||||
|
||||
app.get('/api/bookmarks/categories', async () => {
|
||||
const categories = db.prepare('SELECT * FROM bookmark_categories ORDER BY sort_order').all()
|
||||
return { categories }
|
||||
})
|
||||
|
||||
app.post('/api/bookmarks/categories', async (req, reply) => {
|
||||
const parsed = categorySchema.safeParse(req.body)
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' })
|
||||
const { name, icon, sortOrder } = parsed.data
|
||||
const result = db
|
||||
.prepare('INSERT INTO bookmark_categories (name, icon, sort_order) VALUES (?, ?, ?)')
|
||||
.run(name, icon ?? null, sortOrder ?? 0)
|
||||
return reply.code(201).send({ id: result.lastInsertRowid })
|
||||
})
|
||||
|
||||
app.get('/api/bookmarks', async () => {
|
||||
const bookmarks = db.prepare('SELECT * FROM bookmarks ORDER BY created_at DESC').all()
|
||||
return { bookmarks }
|
||||
})
|
||||
|
||||
app.post('/api/bookmarks', async (req, reply) => {
|
||||
const parsed = bookmarkSchema.safeParse(req.body)
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' })
|
||||
const { categoryId, title, url, icon, favorite } = parsed.data
|
||||
const result = db
|
||||
.prepare(
|
||||
'INSERT INTO bookmarks (category_id, title, url, icon, favorite) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(categoryId ?? null, title, url, icon ?? null, favorite ? 1 : 0)
|
||||
logEvent('bookmark_created', `Bookmark added: ${title}`)
|
||||
return reply.code(201).send({ id: result.lastInsertRowid })
|
||||
})
|
||||
|
||||
app.put('/api/bookmarks/:id', async (req, reply) => {
|
||||
const id = Number((req.params as { id: string }).id)
|
||||
const parsed = bookmarkSchema.partial().safeParse(req.body)
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' })
|
||||
const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id) as
|
||||
| { category_id: number | null; title: string; url: string; icon: string | null; favorite: number }
|
||||
| undefined
|
||||
if (!existing) return reply.code(404).send({ error: 'Not found' })
|
||||
const categoryId = parsed.data.categoryId ?? existing.category_id
|
||||
const title = parsed.data.title ?? existing.title
|
||||
const url = parsed.data.url ?? existing.url
|
||||
const icon = parsed.data.icon ?? existing.icon
|
||||
const favorite = parsed.data.favorite ?? !!existing.favorite
|
||||
db.prepare(
|
||||
'UPDATE bookmarks SET category_id = ?, title = ?, url = ?, icon = ?, favorite = ? WHERE id = ?'
|
||||
).run(categoryId, title, url, icon, favorite ? 1 : 0, id)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.delete('/api/bookmarks/:id', async (req, reply) => {
|
||||
const id = Number((req.params as { id: string }).id)
|
||||
const existing = db.prepare('SELECT title FROM bookmarks WHERE id = ?').get(id) as { title: string } | undefined
|
||||
db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id)
|
||||
if (existing) logEvent('bookmark_deleted', `Bookmark removed: ${existing.title}`)
|
||||
return reply.code(204).send()
|
||||
})
|
||||
}
|
||||
13
backend/src/routes/events.ts
Normal file
13
backend/src/routes/events.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { FastifyInstance } from 'fastify'
|
||||
import { db } from '../db/index.js'
|
||||
|
||||
export async function eventRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate)
|
||||
|
||||
app.get('/api/events', async (req) => {
|
||||
const query = req.query as { limit?: string }
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const events = db.prepare('SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ?').all(limit)
|
||||
return { events }
|
||||
})
|
||||
}
|
||||
163
backend/src/routes/integrations.ts
Normal file
163
backend/src/routes/integrations.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { db, logEvent } from '../db/index.js'
|
||||
import { encryptSecret, decryptSecret } from '../db/crypto.js'
|
||||
import { adapterRegistry } from '../integrations/registry.js'
|
||||
import type { IntegrationType, Resource } from '../integrations/types.js'
|
||||
|
||||
const integrationTypes = [
|
||||
'proxmox',
|
||||
'docker',
|
||||
'netbird',
|
||||
'cloudflare',
|
||||
'aws',
|
||||
'uptime_kuma',
|
||||
'weather',
|
||||
] as const
|
||||
|
||||
const createSchema = z.object({
|
||||
type: z.enum(integrationTypes),
|
||||
name: z.string().min(1).max(128),
|
||||
config: z.record(z.string(), z.string()).default({}),
|
||||
secrets: z.record(z.string(), z.string()).default({}),
|
||||
})
|
||||
|
||||
interface IntegrationRow {
|
||||
id: number
|
||||
type: string
|
||||
name: string
|
||||
enabled: number
|
||||
status: string
|
||||
config_json: string
|
||||
last_checked_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function serialize(row: IntegrationRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
enabled: !!row.enabled,
|
||||
status: row.status,
|
||||
config: JSON.parse(row.config_json),
|
||||
lastCheckedAt: row.last_checked_at,
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
function loadSecrets(integrationId: number): Record<string, string> {
|
||||
const rows = db
|
||||
.prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?')
|
||||
.all(integrationId) as { key: string; value_encrypted: string }[]
|
||||
const out: Record<string, string> = {}
|
||||
for (const row of rows) out[row.key] = decryptSecret(row.value_encrypted)
|
||||
return out
|
||||
}
|
||||
|
||||
export async function integrationRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate)
|
||||
|
||||
app.get('/api/integrations', async () => {
|
||||
const rows = db.prepare('SELECT * FROM integrations ORDER BY created_at').all() as IntegrationRow[]
|
||||
return { integrations: rows.map(serialize) }
|
||||
})
|
||||
|
||||
app.post('/api/integrations', async (req, reply) => {
|
||||
const parsed = createSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
|
||||
}
|
||||
const { type, name, config, secrets } = parsed.data
|
||||
const result = db
|
||||
.prepare('INSERT INTO integrations (type, name, config_json) VALUES (?, ?, ?)')
|
||||
.run(type, name, JSON.stringify(config))
|
||||
const integrationId = Number(result.lastInsertRowid)
|
||||
const insertSecret = db.prepare(
|
||||
'INSERT INTO secrets (integration_id, key, value_encrypted) VALUES (?, ?, ?)'
|
||||
)
|
||||
for (const [key, value] of Object.entries(secrets)) {
|
||||
insertSecret.run(integrationId, key, encryptSecret(value))
|
||||
}
|
||||
const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(integrationId) as IntegrationRow
|
||||
logEvent('integration_created', `${name} integration added`, type)
|
||||
return reply.code(201).send({ integration: serialize(row) })
|
||||
})
|
||||
|
||||
app.put('/api/integrations/:id', async (req, reply) => {
|
||||
const id = Number((req.params as { id: string }).id)
|
||||
const parsed = createSchema.partial().safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
|
||||
}
|
||||
const existing = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as
|
||||
| IntegrationRow
|
||||
| undefined
|
||||
if (!existing) return reply.code(404).send({ error: 'Not found' })
|
||||
|
||||
const name = parsed.data.name ?? existing.name
|
||||
const config = parsed.data.config ?? JSON.parse(existing.config_json)
|
||||
db.prepare('UPDATE integrations SET name = ?, config_json = ? WHERE id = ?').run(
|
||||
name,
|
||||
JSON.stringify(config),
|
||||
id
|
||||
)
|
||||
if (parsed.data.secrets) {
|
||||
const upsert = db.prepare(
|
||||
`INSERT INTO secrets (integration_id, key, value_encrypted) VALUES (?, ?, ?)
|
||||
ON CONFLICT(integration_id, key) DO UPDATE SET value_encrypted = excluded.value_encrypted`
|
||||
)
|
||||
for (const [key, value] of Object.entries(parsed.data.secrets)) {
|
||||
upsert.run(id, key, encryptSecret(value))
|
||||
}
|
||||
}
|
||||
const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as IntegrationRow
|
||||
return { integration: serialize(row) }
|
||||
})
|
||||
|
||||
app.delete('/api/integrations/:id', async (req, reply) => {
|
||||
const id = Number((req.params as { id: string }).id)
|
||||
const existing = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as IntegrationRow | undefined
|
||||
db.prepare('DELETE FROM integrations WHERE id = ?').run(id)
|
||||
if (existing) logEvent('integration_deleted', `${existing.name} integration removed`, existing.type)
|
||||
return reply.code(204).send()
|
||||
})
|
||||
|
||||
app.post('/api/integrations/:id/test', async (req, reply) => {
|
||||
const id = Number((req.params as { id: string }).id)
|
||||
const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as
|
||||
| IntegrationRow
|
||||
| undefined
|
||||
if (!row) return reply.code(404).send({ error: 'Not found' })
|
||||
|
||||
const adapter = adapterRegistry[row.type as IntegrationType]
|
||||
const config = JSON.parse(row.config_json)
|
||||
const secrets = loadSecrets(id)
|
||||
const result = await adapter.testConnection(config, secrets)
|
||||
const status = result.ok ? 'connected' : 'error'
|
||||
db.prepare("UPDATE integrations SET status = ?, last_checked_at = datetime('now') WHERE id = ?").run(
|
||||
status,
|
||||
id
|
||||
)
|
||||
logEvent('integration_tested', `${row.name} test ${result.ok ? 'succeeded' : 'failed'}`, row.type)
|
||||
return result
|
||||
})
|
||||
|
||||
app.get('/api/integrations/resources', async () => {
|
||||
const rows = db.prepare("SELECT * FROM integrations WHERE enabled = 1 AND status = 'connected'").all() as IntegrationRow[]
|
||||
const resources: (Resource & { integration: string })[] = []
|
||||
for (const row of rows) {
|
||||
const adapter = adapterRegistry[row.type as IntegrationType]
|
||||
if (!adapter.listResources) continue
|
||||
const config = JSON.parse(row.config_json)
|
||||
const secrets = loadSecrets(row.id)
|
||||
try {
|
||||
const found = await adapter.listResources(config, secrets)
|
||||
for (const r of found) resources.push({ ...r, integration: row.name })
|
||||
} catch {
|
||||
// adapter unreachable — skip, connection test already surfaces this
|
||||
}
|
||||
}
|
||||
return { resources }
|
||||
})
|
||||
}
|
||||
39
backend/src/server.ts
Normal file
39
backend/src/server.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import 'dotenv/config'
|
||||
import Fastify from 'fastify'
|
||||
import cors from '@fastify/cors'
|
||||
import jwt from '@fastify/jwt'
|
||||
import { authRoutes } from './routes/auth.js'
|
||||
import { integrationRoutes } from './routes/integrations.js'
|
||||
import { bookmarkRoutes } from './routes/bookmarks.js'
|
||||
import { eventRoutes } from './routes/events.js'
|
||||
|
||||
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
|
||||
if (!JWT_SECRET) {
|
||||
throw new Error('ARCHNEST_JWT_SECRET env var is required')
|
||||
}
|
||||
|
||||
const app = Fastify({ logger: true })
|
||||
|
||||
await app.register(cors, { origin: process.env.ARCHNEST_CORS_ORIGIN ?? true })
|
||||
await app.register(jwt, { secret: JWT_SECRET })
|
||||
|
||||
app.decorate('authenticate', async function (req, reply) {
|
||||
try {
|
||||
await req.jwtVerify()
|
||||
} catch {
|
||||
reply.code(401).send({ error: 'Unauthorized' })
|
||||
}
|
||||
})
|
||||
|
||||
await app.register(authRoutes)
|
||||
await app.register(integrationRoutes)
|
||||
await app.register(bookmarkRoutes)
|
||||
await app.register(eventRoutes)
|
||||
|
||||
app.get('/api/health', async () => ({ ok: true }))
|
||||
|
||||
const port = Number(process.env.PORT ?? 4000)
|
||||
app.listen({ port, host: '0.0.0.0' }).catch((err) => {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
14
backend/src/types.d.ts
vendored
Normal file
14
backend/src/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import '@fastify/jwt'
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
authenticate: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: { sub: number; username: string }
|
||||
user: { sub: number; username: string }
|
||||
}
|
||||
}
|
||||
15
backend/tsconfig.json
Normal file
15
backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -8,8 +8,8 @@
|
|||
## Global Rules (Apply to Every Page)
|
||||
|
||||
### Sidebar
|
||||
- **Expanded width**: 100px (not 80px — needs room for labels)
|
||||
- **Collapsed width**: 60px (icon only)
|
||||
- **Expanded width**: 200px (matches mockup proportions — needs room for labels)
|
||||
- **Collapsed width**: 64px (icon only)
|
||||
- **User can manually collapse/expand** via toggle button (not just responsive)
|
||||
- **Main content margin-left** must match sidebar width exactly
|
||||
|
||||
|
|
@ -80,3 +80,146 @@
|
|||
- Bottom row: 2 columns (65/35)
|
||||
- Network Traffic card has its own background image at low opacity
|
||||
- User avatar dropdown has: Profile, Appearance, Security, Help & Support, Sign Out
|
||||
|
||||
### Infrastructure Page
|
||||
- Hero banner rendered at the **App.tsx layout level** (not per-page), so it can extend
|
||||
behind the sticky TopBar — conditional via `showHero = location.pathname === '/infrastructure'`.
|
||||
TopBar and the search input are transparent on hero routes so the banner shows through.
|
||||
- Hero image: `object-position: center 5%` to reveal the full arch + sky; faded out via
|
||||
`linear-gradient` mask (vertical) + a `radial-gradient(ellipse 70% 100% at center, ...)`
|
||||
overlay (sides/corners) — borderless, blends into page background.
|
||||
- Sub-tabs trimmed to **Overview only** (Compute → Tags are future work, not built yet).
|
||||
- Status cards: `rgba(10,10,12,0.5)` background (more transparent than other pages),
|
||||
content centered (`justify-content/alignItems: center`) with a fixed row height (110px)
|
||||
so there's breathing room instead of empty space below left-aligned content.
|
||||
- Middle row (`grid-cols-[1fr_1.6fr]`): **Resource Distribution** (donut) and **Node Status**
|
||||
(server tile grid). Both use the `/blank-kpi-bg.png` background art with a `cardDim`
|
||||
(semi-transparent dark overlay) + `cardVignette` (radial-gradient `closest-side` blend)
|
||||
combo — keeps the background pattern visible but subdued, with borders blended rather
|
||||
than hard-edged. Card titles are rendered as our own text, NOT baked into the image
|
||||
(baked-in labels got covered by the dim overlay).
|
||||
- **Node Status** card: originally a world-map-style region dot plot, replaced with a
|
||||
4-column tile grid (one tile per server, colored status dot + name) — a world map
|
||||
didn't make sense for a small/single-site infra. Reuse this "small-scale" reasoning
|
||||
for any future map-like cards.
|
||||
- Bottom row (`grid-cols-[1.4fr_1fr_1fr]`): Resource Trend / Cost Breakdown / Recent
|
||||
Activity — **left plain/regular**, no dim/vignette blending (explicit user preference,
|
||||
only the middle row gets the hero-style blend). Resource Trend uses the
|
||||
`/archnest-network-traffic-bg.png` background (plain, no dim/vignette) with 4 trend
|
||||
lines: blue `#3B82F6` (compute), orange `#E67E22` (storage), green `#2ECC71` (database),
|
||||
brown `#8B5E3C` (network).
|
||||
- `cardVignette` radial-gradient must use the `closest-side` keyword (not a fixed `%`)
|
||||
— otherwise straight edges of the card don't reach full opacity and a hard border line
|
||||
remains visible (only corners fade correctly with a fixed percentage).
|
||||
|
||||
### BookNest Page
|
||||
- Hero banner reused at the App.tsx layout level (`showHero` now includes `/booknest`),
|
||||
with page-specific tuning via small lookup maps in `App.tsx` keyed on `location.pathname`:
|
||||
`heroPaddingTop` (how far content sits below the hero top) and `heroObjectPosition`
|
||||
(horizontal/vertical crop of the arch image) — `70px` / `54% 8%` for BookNest vs.
|
||||
`72px` / `center 5%` for Infrastructure. Extend these maps rather than hardcoding a
|
||||
single value when a future page needs different hero framing.
|
||||
- **Large hero title + subtitle**: unlike other pages, BookNest's TopBar title is NOT the
|
||||
small 18px uppercase label — it's rendered at 28px with a subtitle line ("Your Digital
|
||||
Library") underneath, driven by a new `pageSubtitles` map in `TopBar.tsx`. When a page
|
||||
has a subtitle, the header height grows from 56px → 72px (`TopBar.tsx`), and `App.tsx`'s
|
||||
`topBarHeight` lookup keeps the content section's `calc(100vh - Npx)` in sync — both
|
||||
must be updated together or the layout will clip/gap.
|
||||
- **Stats row lives directly under the hero subtitle** (Links / Categories / Favorites),
|
||||
not in its own separate bar — matches the blueprint's hero-header block grouping.
|
||||
- **"Quick Access" section label** added above the 5 quick-access category cards (gold,
|
||||
same `sectionTitle` style) — the row is intentionally pulled up via a small negative
|
||||
hero-padding tune so it slightly overlaps the bottom edge of the hero, like the blueprint.
|
||||
- **"+ Add Bookmark" button**: same gold-fill button style as Infrastructure's "+ Add
|
||||
Resource", placed inline next to the "Quick Access" label rather than the page-stats row.
|
||||
- **Right sidebar spans both grid rows** (`gridRow: '1 / span 2'` in a `gridTemplateRows:
|
||||
'auto 1fr'` grid) so the Favorites card can rise up near the hero while the main column's
|
||||
page-stats row stays in row 1 of column 1 only — keeps the two from overlapping/clipping.
|
||||
Negative margins were tried first and discarded: content pushed above the scroll
|
||||
container's natural top edge gets clipped by `overflow-y-auto`, so prefer reshaping the
|
||||
grid/flow over negative-margin hacks when something needs to "reach upward."
|
||||
- **Sidebar cards stretch to match the main column's full height** so the last card's
|
||||
(Category Breakdown) bottom border lines up with the bottom of the bookmark groups grid:
|
||||
sidebar wrapper is `display:flex; flex-direction:column; height:100%` (grid's default
|
||||
`align-items: stretch` already gives it the matching height), and each card uses
|
||||
`flex: 1 0 auto` (Favorites gets `flex: 1.4 0 auto` to read as visibly taller, per
|
||||
explicit request) so they share the leftover vertical space instead of all packing tight
|
||||
at content height.
|
||||
- lucide-react gotcha (see Global Rules candidate): the installed version does **not**
|
||||
export brand/wordmark icons (`Github`, `Gitlab`, `Linkedin`, `Youtube`) even though
|
||||
TypeScript's type declarations list them — `tsc --noEmit` stays clean while the page
|
||||
renders blank with a runtime `SyntaxError` only visible in the Vite dev log. Verify
|
||||
icon names against `Object.keys(require('lucide-react'))` before importing anything
|
||||
brand-flavored; substitutes used here: `GitBranch`/`GitFork` (GitHub/GitLab/Gitea),
|
||||
`SquarePlay` (YouTube), `Briefcase` (LinkedIn Learning).
|
||||
|
||||
### Settings Page
|
||||
- No mockup image existed for this page — built directly from the blueprint's Page 6
|
||||
spec rather than iterating against a screenshot.
|
||||
- Layout: fixed-width (200px) left nav listing the 6 sections (Profile, Appearance,
|
||||
Integrations, Notifications, Data & Backup, About) + a scrollable content panel on
|
||||
the right showing the active section. No hero banner (not in the blueprint spec for
|
||||
this page, and a settings page doesn't need one).
|
||||
- Active section is local component state (a string id) mapped through a
|
||||
`sectionComponents` record to the corresponding section-renderer function — simplest
|
||||
approach for a page with no routing/deep-linking requirement.
|
||||
- Shared style helpers (`cardBase`, `sectionTitle`, `labelStyle`, `inputStyle`) plus two
|
||||
small reusable components defined in the same file: `Toggle` (on/off pill switch) and
|
||||
`GoldButton` (gold-filled primary / danger-outline variant) — kept local to
|
||||
`Settings.tsx` rather than extracted, since no other page needs them yet.
|
||||
- **Integrations** cards mask secret fields (API keys/tokens) behind dots with an eye
|
||||
icon to reveal/hide, plus a "Test Connection" button per card — matches the blueprint's
|
||||
explicit "masked secrets with eye toggle" instruction.
|
||||
- **Avatar upload** (Profile section): the avatar circle is clickable and opens a hidden
|
||||
`<input type="file" accept="image/*">` via a `useRef` + `.click()` call rather than a
|
||||
visible file input — keeps the round avatar as the only visible control. On change,
|
||||
`FileReader.readAsDataURL` converts the selected image to a base64 data URL stored in
|
||||
component state, which becomes the circle's `backgroundImage` (cover-fit), replacing
|
||||
the "AO" initials fallback. A hover-only camera-icon overlay (Tailwind `group` /
|
||||
`group-hover:opacity-100`) signals the circle is clickable without cluttering the
|
||||
default state. This is a frontend-only preview (no backend upload endpoint exists yet).
|
||||
|
||||
---
|
||||
|
||||
## Backend (added once frontend reached "good enough" state)
|
||||
|
||||
- New `backend/` package (separate `package.json`/`tsconfig.json`, own `node_modules`,
|
||||
own Dockerfile) — Fastify + TypeScript, `better-sqlite3` for storage, deployed as a
|
||||
second container alongside the existing frontend container (`docker-compose.yml` now
|
||||
has `archnest` + `archnest-backend`, with a named volume for the SQLite file).
|
||||
- Auth: single-user, JWT-based (`@fastify/jwt`). `POST /api/setup` creates the one user
|
||||
and only succeeds while the `users` table is empty — this is what powers the
|
||||
first-run enrollment page. `GET /api/system/setup-status` tells the frontend whether
|
||||
to show enrollment/login or the normal app.
|
||||
- Integration credentials are split across two tables: `integrations` (type, name,
|
||||
status, non-secret `config_json`) and `secrets` (per-key AES-256-GCM-encrypted
|
||||
values, key derived from the `ARCHNEST_SECRET_KEY` env var) — keeps secrets out of any
|
||||
generic "list integrations" query/response by construction, not by remembering to
|
||||
redact a field.
|
||||
- Each integration type has an adapter module under `backend/src/integrations/`
|
||||
exporting `testConnection(config, secrets)`; `registry.ts` maps `IntegrationType` →
|
||||
adapter. Only `uptime_kuma` and `docker` are real so far (simple HTTP health checks);
|
||||
the rest return a "not yet implemented" result until built out — this lets the
|
||||
Integrations UI and `POST /api/integrations/:id/test` endpoint work end-to-end for
|
||||
every type without blocking on every adapter being finished.
|
||||
- Vite dev server proxies `/api` → `http://localhost:4000` (`vite.config.ts`) so the
|
||||
frontend can call relative `/api/...` paths in both dev and prod (prod routes `/api`
|
||||
to the backend container via NPM).
|
||||
- Next steps (not yet done): build the enrollment/login frontend pages, strip the mock
|
||||
arrays out of Glance/Infrastructure/BookNest/Settings and replace with calls to this
|
||||
API, add bookmark category seeding.
|
||||
|
||||
## Future Integration Notes
|
||||
|
||||
### Live Provider Data (AWS, Linode, etc.)
|
||||
- All KPI/status card data (resource counts, health, pricing, budgets, cost breakdowns,
|
||||
utilization, regions/map data) is currently mocked/static.
|
||||
- The Infrastructure page (and likely Glance) should eventually integrate with real
|
||||
cloud provider APIs — AWS, Linode, or any other VPC/cloud provider — via user-supplied
|
||||
API keys, to pull live data such as:
|
||||
- Resource inventory/counts and health status
|
||||
- Pricing and budget/cost data (replacing the static Cost Breakdown numbers)
|
||||
- Resource utilization metrics
|
||||
- Region/datacenter info for the Infrastructure Map
|
||||
- Design the data layer so it's provider-agnostic (a common interface/adapter per
|
||||
provider) since users may connect more than one provider's API key.
|
||||
|
|
|
|||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
services:
|
||||
archnest:
|
||||
build: .
|
||||
image: archnest:latest
|
||||
container_name: archnest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- archnest-backend
|
||||
|
||||
archnest-backend:
|
||||
build: ./backend
|
||||
image: archnest-backend:latest
|
||||
container_name: archnest-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=4000
|
||||
- ARCHNEST_DB_PATH=/data/archnest.db
|
||||
- ARCHNEST_JWT_SECRET=${ARCHNEST_JWT_SECRET}
|
||||
- ARCHNEST_SECRET_KEY=${ARCHNEST_SECRET_KEY}
|
||||
- ARCHNEST_CORS_ORIGIN=${ARCHNEST_CORS_ORIGIN:-https://archnest.snsnetlabs.com}
|
||||
volumes:
|
||||
- archnest-data:/data
|
||||
ports:
|
||||
- "4000:4000"
|
||||
|
||||
volumes:
|
||||
archnest-data:
|
||||
10
nginx.conf
Normal file
10
nginx.conf
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
133
package-lock.json
generated
133
package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"lucide-react": "^1.17.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwindcss": "^4.3.0"
|
||||
},
|
||||
|
|
@ -61,7 +62,6 @@
|
|||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
|
|
@ -271,10 +271,31 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -793,6 +814,37 @@
|
|||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
|
|
@ -1200,7 +1252,6 @@
|
|||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
|
|
@ -1211,7 +1262,6 @@
|
|||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -1277,7 +1327,6 @@
|
|||
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
|
|
@ -1508,7 +1557,6 @@
|
|||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -1599,7 +1647,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
|
|
@ -1651,6 +1698,19 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -1893,7 +1953,6 @@
|
|||
"integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
|
|
@ -2826,7 +2885,6 @@
|
|||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -2887,7 +2945,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -2897,7 +2954,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
|
|
@ -2917,7 +2973,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
|
|
@ -2936,6 +2991,44 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
|
||||
"integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz",
|
||||
"integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||
|
|
@ -2970,8 +3063,7 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -3037,6 +3129,12 @@
|
|||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
|
@ -3149,7 +3247,6 @@
|
|||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -3266,7 +3363,6 @@
|
|||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
|
|
@ -3391,7 +3487,6 @@
|
|||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"lucide-react": "^1.17.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwindcss": "^4.3.0"
|
||||
},
|
||||
|
|
|
|||
BIN
public/archnest-logo-clean.png
Normal file
BIN
public/archnest-logo-clean.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
BIN
public/blank-kpi-bg.png
Normal file
BIN
public/blank-kpi-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/infra-map-kpi.png
Normal file
BIN
public/infra-map-kpi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/network-kpi-bg.png
Normal file
BIN
public/network-kpi-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2 MiB |
BIN
public/resource-distrabution-bg.png
Normal file
BIN
public/resource-distrabution-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
public/resource-utilization.png
Normal file
BIN
public/resource-utilization.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
107
src/App.tsx
107
src/App.tsx
|
|
@ -1,13 +1,39 @@
|
|||
import { useState } from 'react'
|
||||
import { Routes, Route, useLocation } from 'react-router-dom'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import TopBar from './components/TopBar'
|
||||
import StatusCards from './components/StatusCards'
|
||||
import MiddleRow from './components/MiddleRow'
|
||||
import BottomRow from './components/BottomRow'
|
||||
import Glance from './pages/Glance'
|
||||
import Infrastructure from './pages/Infrastructure'
|
||||
import BookNest from './pages/BookNest'
|
||||
import Settings from './pages/Settings'
|
||||
import Login from './pages/Login'
|
||||
import Enrollment from './pages/Enrollment'
|
||||
import { useAuth } from './lib/AuthContext'
|
||||
|
||||
function App() {
|
||||
const { status } = useAuth()
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-page">
|
||||
<p style={{ color: '#7A7D85', fontSize: '13px' }}>Loading…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'needs-setup' || status === 'enrolling') return <Enrollment />
|
||||
if (status === 'logged-out') return <Login />
|
||||
|
||||
return <Dashboard />
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const sidebarWidth = sidebarCollapsed ? 60 : 140
|
||||
const sidebarWidth = sidebarCollapsed ? 64 : 200
|
||||
const location = useLocation()
|
||||
const showHero = location.pathname === '/infrastructure' || location.pathname === '/booknest'
|
||||
const heroPaddingTop = location.pathname === '/booknest' ? '70px' : '72px'
|
||||
const heroObjectPosition = location.pathname === '/booknest' ? '54% 8%' : 'center 5%'
|
||||
const topBarHeight = location.pathname === '/booknest' ? 72 : 56
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-screen overflow-hidden bg-page">
|
||||
|
|
@ -17,50 +43,45 @@ function App() {
|
|||
/>
|
||||
|
||||
<main
|
||||
className="h-screen overflow-hidden"
|
||||
className="relative h-screen overflow-hidden"
|
||||
style={{ marginLeft: `${sidebarWidth}px`, width: `calc(100vw - ${sidebarWidth}px)` }}
|
||||
>
|
||||
<TopBar />
|
||||
{showHero && (
|
||||
<div className="pointer-events-none absolute left-0 right-0 top-0" style={{ height: '300px', zIndex: 0 }}>
|
||||
<img
|
||||
src="/archnest-hero-banner.png"
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full"
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
objectPosition: heroObjectPosition,
|
||||
maskImage: 'linear-gradient(to bottom, black 0%, black 55%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 55%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 70% 100% at center, transparent 40%, var(--color-page) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative" style={{ zIndex: 1 }}>
|
||||
<TopBar />
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="w-full overflow-y-auto"
|
||||
style={{ height: 'calc(100vh - 56px)', scrollbarWidth: 'none', padding: '16px 24px 32px 24px' }}
|
||||
className="relative flex w-full flex-col overflow-hidden"
|
||||
style={{ height: `calc(100vh - ${topBarHeight}px)`, scrollbarWidth: 'none', padding: showHero ? `${heroPaddingTop} 24px 24px 24px` : '16px 24px 24px 24px', gap: '20px', zIndex: 1 }}
|
||||
>
|
||||
<div className="flex w-full max-w-none flex-col gap-0">
|
||||
{/* Hero + KPI overlap — KPI bottom aligns with banner bottom */}
|
||||
<div className="relative">
|
||||
<div className="w-full overflow-hidden" style={{ borderRadius: '12px 12px 0 0' }}>
|
||||
<img
|
||||
src="/archnest-hero-banner.png"
|
||||
alt="ArchNest Banner"
|
||||
className="w-full"
|
||||
style={{ display: 'block' }}
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget
|
||||
target.style.display = 'none'
|
||||
target.parentElement!.classList.add('bg-card')
|
||||
target.parentElement!.style.height = '260px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* KPI cards positioned so their bottom edge aligns with banner bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 px-4">
|
||||
<StatusCards />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 24px breathing room between KPI row and middle row */}
|
||||
<div style={{ height: '24px' }} />
|
||||
|
||||
{/* Middle Row */}
|
||||
<MiddleRow />
|
||||
|
||||
{/* Gap */}
|
||||
<div style={{ height: '24px' }} />
|
||||
|
||||
{/* Bottom Row */}
|
||||
<BottomRow />
|
||||
</div>
|
||||
<Routes>
|
||||
<Route path="/" element={<Glance />} />
|
||||
<Route path="/infrastructure" element={<Infrastructure />} />
|
||||
<Route path="/booknest" element={<BookNest />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
import { AreaChart, Area, ResponsiveContainer } from 'recharts'
|
||||
import { ServerCog, DatabaseBackup, Rocket, FileText } from 'lucide-react'
|
||||
|
||||
const trafficData = Array.from({ length: 48 }, (_, i) => ({
|
||||
time: i,
|
||||
incoming: 800 + Math.sin(i / 6) * 300 + Math.random() * 150,
|
||||
outgoing: 700 + Math.cos(i / 8) * 250 + Math.random() * 100,
|
||||
}))
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plug, ServerCog, BookMarked, Settings as SettingsIcon } from 'lucide-react'
|
||||
import { api, type Integration } from '../lib/api'
|
||||
|
||||
const shortcuts = [
|
||||
{ icon: ServerCog, label: 'Add Server' },
|
||||
{ icon: DatabaseBackup, label: 'Create Backup' },
|
||||
{ icon: Rocket, label: 'Deploy App' },
|
||||
{ icon: FileText, label: 'View Logs' },
|
||||
{ icon: ServerCog, label: 'Add Integration', to: '/settings' },
|
||||
{ icon: BookMarked, label: 'Add Bookmark', to: '/booknest' },
|
||||
{ icon: Plug, label: 'Infrastructure', to: '/infrastructure' },
|
||||
{ icon: SettingsIcon, label: 'Settings', to: '/settings' },
|
||||
]
|
||||
|
||||
const cardBase: React.CSSProperties = {
|
||||
|
|
@ -25,56 +21,48 @@ const cardBase: React.CSSProperties = {
|
|||
overflow: 'hidden',
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
connected: '#2ECC71',
|
||||
error: '#E74C3C',
|
||||
unknown: '#7A7D85',
|
||||
}
|
||||
|
||||
export default function BottomRow() {
|
||||
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-[1.8fr_1fr] gap-6">
|
||||
{/* Network Traffic */}
|
||||
{/* Connected Integrations */}
|
||||
<div style={cardBase} className="hover:!border-gold/15">
|
||||
{/* Background image at very low opacity */}
|
||||
<div style={{ position: 'absolute', inset: 0, opacity: 0.12, backgroundImage: 'url(/archnest-network-traffic-bg.png)', backgroundSize: 'cover', backgroundPosition: 'center', pointerEvents: 'none' }} />
|
||||
{/* Gold top edge */}
|
||||
<div style={{ position: 'absolute', top: 0, left: '5%', right: '5%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(200,164,52,0.15), transparent)', pointerEvents: 'none' }} />
|
||||
|
||||
<div className="relative z-10">
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
|
||||
Network Traffic
|
||||
Connected Integrations
|
||||
</h3>
|
||||
<div className="flex items-end gap-6">
|
||||
<div style={{ flex: 1, height: '100px' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={trafficData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="trafficGold" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#C8A434" stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor="#C8A434" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
<linearGradient id="trafficAmber" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#E67E22" stopOpacity={0.15} />
|
||||
<stop offset="100%" stopColor="#E67E22" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area type="monotone" dataKey="incoming" stroke="#C8A434" strokeWidth={1.5} fill="url(#trafficGold)" dot={false} isAnimationActive={true} animationDuration={1200} />
|
||||
<Area type="monotone" dataKey="outgoing" stroke="rgba(230,126,34,0.6)" strokeWidth={1} fill="url(#trafficAmber)" dot={false} isAnimationActive={true} animationDuration={1200} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
{integrations === null ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading…</p>
|
||||
) : integrations.length === 0 ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No integrations added yet — add one in Settings.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{integrations.map((i) => (
|
||||
<div key={i.id} className="flex items-center gap-2.5" style={{ padding: '8px 10px', borderRadius: '8px', backgroundColor: 'rgba(255,255,255,0.02)' }}>
|
||||
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: statusColor[i.status] ?? '#7A7D85', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0', flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{i.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 flex-shrink-0">
|
||||
<div>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85' }}>Incoming</p>
|
||||
<p style={{ fontSize: '18px', fontWeight: 700, color: '#E8E6E0' }}>1.23 Gbps</p>
|
||||
<p style={{ fontSize: '11px', color: '#E74C3C' }}>↓ 12.4%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85' }}>Outgoing</p>
|
||||
<p style={{ fontSize: '18px', fontWeight: 700, color: '#E8E6E0' }}>1.08 Gbps</p>
|
||||
<p style={{ fontSize: '11px', color: '#2ECC71' }}>↑ 8.7%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shortcuts — miniature control panels */}
|
||||
{/* Shortcuts */}
|
||||
<div style={cardBase} className="hover:!border-gold/15">
|
||||
<div style={{ position: 'absolute', inset: 0, opacity: 0.02, backgroundImage: 'radial-gradient(circle, #C8A434 1px, transparent 1px)', backgroundSize: '24px 24px', pointerEvents: 'none' }} />
|
||||
|
||||
|
|
@ -82,19 +70,20 @@ export default function BottomRow() {
|
|||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
|
||||
Shortcuts
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{shortcuts.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => navigate(item.to)}
|
||||
className="flex flex-col items-center gap-2 cursor-pointer bg-transparent transition-all duration-200 group/btn"
|
||||
style={{ padding: '16px 12px', borderRadius: '10px', border: '1px solid rgba(200,164,52,0.08)', boxShadow: '0 0 12px rgba(200,164,52,0.02)' }}
|
||||
style={{ padding: '16px 8px', borderRadius: '10px', border: '1px solid rgba(200,164,52,0.08)', boxShadow: '0 0 12px rgba(200,164,52,0.02)' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'rgba(200,164,52,0.2)'; e.currentTarget.style.boxShadow = '0 0 16px rgba(200,164,52,0.06)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'rgba(200,164,52,0.08)'; e.currentTarget.style.boxShadow = '0 0 12px rgba(200,164,52,0.02)' }}
|
||||
>
|
||||
<Icon size={20} style={{ color: '#7A7D85', transition: 'color 0.2s' }} />
|
||||
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{item.label}</span>
|
||||
<Icon size={20} style={{ color: '#C8A434', transition: 'color 0.2s' }} />
|
||||
<span style={{ fontSize: '11px', color: '#C9CCD1', whiteSpace: 'nowrap' }}>{item.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,6 @@
|
|||
import { X, CircleCheck, Shield, Play, Settings, User } from 'lucide-react'
|
||||
|
||||
const resources = [
|
||||
{ label: 'Compute', current: 18, max: 24, unit: '' },
|
||||
{ label: 'Storage', current: 12.4, max: 20, unit: ' TB' },
|
||||
{ label: 'Database', current: 8, max: 12, unit: '' },
|
||||
{ label: 'Network', current: 98.7, max: 100, unit: '%' },
|
||||
{ label: 'Containers', current: 32, max: 40, unit: '' },
|
||||
]
|
||||
|
||||
const activities = [
|
||||
{ icon: CircleCheck, title: 'Backup completed', source: 'Database Cluster 01', time: '2m ago' },
|
||||
{ icon: Shield, title: 'Security scan completed', source: 'Web Frontend', time: '8m ago' },
|
||||
{ icon: Play, title: 'Instance launched', source: 'App Server 03', time: '15m ago' },
|
||||
{ icon: Settings, title: 'Configuration updated', source: 'Load Balancer', time: '22m ago' },
|
||||
{ icon: User, title: 'User login detected', source: 'admin@archnest.io', time: '35m ago' },
|
||||
]
|
||||
|
||||
const alerts = [
|
||||
{ severity: 'high', title: 'High CPU Usage', source: 'App Server 02', time: '2m ago' },
|
||||
{ severity: 'medium', title: 'Disk Space Low', source: 'Database Cluster 01', time: '15m ago' },
|
||||
{ severity: 'medium', title: 'Unauthorized Login Attempt', source: 'Web Frontend', time: '32m ago' },
|
||||
{ severity: 'medium', title: 'SSL Certificate Expiring', source: 'api.archnest.io', time: '1h ago' },
|
||||
]
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CircleCheck, AlertTriangle, Plug, Bookmark as BookmarkIcon, LogIn } from 'lucide-react'
|
||||
import { api, type Event, type Resource, type Integration } from '../lib/api'
|
||||
|
||||
const cardBase: React.CSSProperties = {
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
||||
|
|
@ -32,111 +11,148 @@ const cardBase: React.CSSProperties = {
|
|||
transition: 'border-color 0.2s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
|
||||
function getBarColor(percentage: number) {
|
||||
if (percentage >= 90) return '#E74C3C'
|
||||
if (percentage >= 70) return '#E67E22'
|
||||
return '#C8A434'
|
||||
const statusColor: Record<string, string> = {
|
||||
healthy: '#C8A434',
|
||||
warning: '#E67E22',
|
||||
critical: '#E74C3C',
|
||||
unknown: '#7A7D85',
|
||||
}
|
||||
|
||||
const eventIcons: Record<string, typeof CircleCheck> = {
|
||||
integration_created: Plug,
|
||||
integration_tested: CircleCheck,
|
||||
integration_deleted: Plug,
|
||||
bookmark_created: BookmarkIcon,
|
||||
bookmark_deleted: BookmarkIcon,
|
||||
user_login: LogIn,
|
||||
account_created: LogIn,
|
||||
}
|
||||
|
||||
function timeAgo(iso: string) {
|
||||
const diffMs = Date.now() - new Date(iso.replace(' ', 'T') + 'Z').getTime()
|
||||
const mins = Math.floor(diffMs / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
}
|
||||
|
||||
export default function MiddleRow() {
|
||||
const [resources, setResources] = useState<Resource[] | null>(null)
|
||||
const [events, setEvents] = useState<Event[] | null>(null)
|
||||
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.listResources().then(({ resources }) => setResources(resources))
|
||||
api.listEvents(5).then(({ events }) => setEvents(events))
|
||||
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
||||
}, [])
|
||||
|
||||
const erroredIntegrations = integrations?.filter((i) => i.status === 'error') ?? []
|
||||
const problemResources = resources?.filter((r) => r.status === 'warning' || r.status === 'critical') ?? []
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-[1fr_1.4fr_1fr] gap-6">
|
||||
<div className="grid h-full w-full grid-cols-[1fr_1.4fr_1fr] gap-6">
|
||||
{/* Resource Overview */}
|
||||
<div style={cardBase} className="hover:!border-gold/15 group">
|
||||
{/* Subtle hex pattern overlay */}
|
||||
<div style={{ position: 'absolute', inset: 0, opacity: 0.03, backgroundImage: 'radial-gradient(circle, #C8A434 1px, transparent 1px)', backgroundSize: '20px 20px', pointerEvents: 'none' }} />
|
||||
{/* Gold top edge lighting */}
|
||||
<div style={{ position: 'absolute', top: 0, left: '10%', right: '10%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(200,164,52,0.15), transparent)', pointerEvents: 'none' }} />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500 }}>
|
||||
Resource Overview
|
||||
</h3>
|
||||
<button className="bg-transparent border-none cursor-pointer p-1" style={{ color: '#7A7D85' }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{resources.map((res) => {
|
||||
const percentage = res.unit === '%' ? res.current : (res.current / res.max) * 100
|
||||
const displayValue = res.unit === '%' ? `${res.current}%` : `${res.current} / ${res.max}${res.unit}`
|
||||
return (
|
||||
<div key={res.label} className="flex items-center gap-3">
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0', width: '90px' }}>{res.label}</span>
|
||||
<div style={{ flex: 1, height: '6px', backgroundColor: 'rgba(30,32,37,0.8)', borderRadius: '3px', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${percentage}%`, backgroundColor: getBarColor(percentage), borderRadius: '3px', transition: 'width 0.8s ease' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: '12px', color: '#7A7D85', width: '80px', textAlign: 'right' }}>{displayValue}</span>
|
||||
<div className="relative z-10 flex flex-1 flex-col">
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
|
||||
Resource Overview
|
||||
</h3>
|
||||
{resources === null ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading…</p>
|
||||
) : resources.length === 0 ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Connect an integration in Settings to see live resources here.</p>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col justify-around gap-3" style={{ overflowY: 'auto' }}>
|
||||
{resources.slice(0, 6).map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-2.5">
|
||||
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: statusColor[r.status], flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0', flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name}</span>
|
||||
<span style={{ fontSize: '10px', color: '#7A7D85', flexShrink: 0 }}>{r.integration}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity — visually dominant */}
|
||||
{/* Recent Activity */}
|
||||
<div style={cardBase} className="hover:!border-gold/15 group">
|
||||
{/* City grid texture */}
|
||||
<div style={{ position: 'absolute', inset: 0, opacity: 0.02, backgroundImage: 'linear-gradient(rgba(200,164,52,0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(200,164,52,0.3) 1px, transparent 1px)', backgroundSize: '40px 40px', pointerEvents: 'none' }} />
|
||||
{/* Gold top edge */}
|
||||
<div style={{ position: 'absolute', top: 0, left: '5%', right: '5%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(200,164,52,0.2), transparent)', pointerEvents: 'none' }} />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500 }}>
|
||||
Recent Activity
|
||||
</h3>
|
||||
<button className="bg-transparent border-none cursor-pointer p-1" style={{ color: '#7A7D85' }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{activities.map((item, i) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div style={{ width: '28px', height: '28px', borderRadius: '6px', backgroundColor: 'rgba(200,164,52,0.06)', border: '1px solid rgba(200,164,52,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, boxShadow: '0 0 8px rgba(200,164,52,0.04)' }}>
|
||||
<Icon size={13} style={{ color: '#C8A434' }} />
|
||||
<div className="relative z-10 flex flex-1 flex-col">
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
|
||||
Recent Activity
|
||||
</h3>
|
||||
{events === null ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading…</p>
|
||||
) : events.length === 0 ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No activity yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col justify-around gap-3">
|
||||
{events.map((item) => {
|
||||
const Icon = eventIcons[item.type] ?? CircleCheck
|
||||
return (
|
||||
<div key={item.id} className="flex items-start gap-3">
|
||||
<div style={{ width: '28px', height: '28px', borderRadius: '6px', backgroundColor: 'rgba(200,164,52,0.06)', border: '1px solid rgba(200,164,52,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, boxShadow: '0 0 8px rgba(200,164,52,0.04)' }}>
|
||||
<Icon size={13} style={{ color: '#C8A434' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</p>
|
||||
{item.source && <p style={{ fontSize: '11px', color: '#7A7D85', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.source}</p>}
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: '#7A7D85', flexShrink: 0 }}>{timeAgo(item.created_at)}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</p>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.source}</p>
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: '#7A7D85', flexShrink: 0 }}>{item.time}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Alerts */}
|
||||
<div style={cardBase} className="hover:!border-gold/15 group">
|
||||
{/* Amber edge lighting */}
|
||||
<div style={{ position: 'absolute', top: 0, left: '10%', right: '10%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(231,126,34,0.15), transparent)', pointerEvents: 'none' }} />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500 }}>
|
||||
Top Alerts
|
||||
</h3>
|
||||
<a href="#" style={{ fontSize: '11px', color: '#C8A434', textDecoration: 'none' }}>View all</a>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{alerts.map((alert, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', flexShrink: 0, marginTop: '5px', backgroundColor: alert.severity === 'high' ? '#E74C3C' : '#E67E22', boxShadow: alert.severity === 'high' ? '0 0 6px rgba(231,76,60,0.3)' : '0 0 6px rgba(230,126,34,0.2)' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{alert.title}</p>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{alert.source}</p>
|
||||
<div className="relative z-10 flex flex-1 flex-col">
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
|
||||
Top Alerts
|
||||
</h3>
|
||||
{erroredIntegrations.length === 0 && problemResources.length === 0 ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No alerts — everything connected is healthy.</p>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col justify-around gap-3">
|
||||
{erroredIntegrations.map((i) => (
|
||||
<div key={`int-${i.id}`} className="flex items-start gap-3">
|
||||
<AlertTriangle size={14} style={{ color: '#E74C3C', flexShrink: 0, marginTop: '2px' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500 }}>Connection failing</p>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85' }}>{i.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: '#7A7D85', flexShrink: 0 }}>{alert.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{problemResources.map((r, idx) => (
|
||||
<div key={`res-${idx}`} className="flex items-start gap-3">
|
||||
<AlertTriangle size={14} style={{ color: r.status === 'critical' ? '#E74C3C' : '#E67E22', flexShrink: 0, marginTop: '2px' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500 }}>{r.name}</p>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85' }}>{r.integration} · {r.detail ?? r.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useLocation, Link } from 'react-router-dom'
|
||||
import {
|
||||
LayoutGrid,
|
||||
Server,
|
||||
Globe,
|
||||
Bookmark,
|
||||
Terminal,
|
||||
Settings,
|
||||
|
|
@ -15,81 +15,96 @@ interface SidebarProps {
|
|||
}
|
||||
|
||||
const navItems = [
|
||||
{ icon: LayoutGrid, label: 'Glance', route: '/', active: true },
|
||||
{ icon: Server, label: 'Infrastructure', route: '/infrastructure', active: false },
|
||||
{ icon: Globe, label: 'Network', route: '/network', active: false },
|
||||
{ icon: Bookmark, label: 'BookNest', route: '/booknest', active: false },
|
||||
{ icon: Terminal, label: 'Terminal', route: '/terminal', active: false },
|
||||
{ icon: Settings, label: 'Settings', route: '/settings', active: false },
|
||||
{ icon: LayoutGrid, label: 'Glance', route: '/' },
|
||||
{ icon: Server, label: 'Infrastructure', route: '/infrastructure' },
|
||||
{ icon: Bookmark, label: 'BookNest', route: '/booknest' },
|
||||
{ icon: Terminal, label: 'Terminal', route: '/terminal' },
|
||||
{ icon: Settings, label: 'Settings', route: '/settings' },
|
||||
]
|
||||
|
||||
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
const width = collapsed ? 60 : 140
|
||||
const width = collapsed ? 64 : 200
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="fixed left-0 top-0 z-50 h-screen overflow-hidden flex flex-col items-center py-5"
|
||||
className="fixed left-0 top-0 z-50 h-screen overflow-hidden flex flex-col py-6"
|
||||
style={{ width: `${width}px`, backgroundColor: '#0A0B0D' }}
|
||||
>
|
||||
{/* Logo — larger, aligned with top bar */}
|
||||
<div className="flex flex-col items-center mb-6" style={{ paddingTop: '8px' }}>
|
||||
<img
|
||||
src="/archnest-logo.png"
|
||||
alt="ArchNest"
|
||||
className="mb-1.5"
|
||||
style={{
|
||||
width: collapsed ? '28px' : '44px',
|
||||
height: collapsed ? '28px' : '44px',
|
||||
filter: 'drop-shadow(0 0 8px rgba(200,164,52,0.5))',
|
||||
}}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span style={{ fontSize: '9px', fontWeight: 700, letterSpacing: '2.5px', color: '#C8A434', textTransform: 'uppercase' }}>
|
||||
ArchNest
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav Items */}
|
||||
<nav className="flex-1 flex flex-col justify-center gap-6 w-full">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<a
|
||||
key={item.label}
|
||||
href={item.route}
|
||||
className="relative flex w-full flex-col items-center justify-center gap-1.5 px-2 py-2 text-center no-underline transition-all duration-200"
|
||||
style={{ color: item.active ? '#C8A434' : '#7A7D85' }}
|
||||
title={collapsed ? item.label : undefined}
|
||||
onMouseEnter={(e) => { if (!item.active) e.currentTarget.style.backgroundColor = 'rgba(200,164,52,0.05)'; e.currentTarget.style.color = '#C8A434' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; if (!item.active) e.currentTarget.style.color = '#7A7D85' }}
|
||||
>
|
||||
{item.active && (
|
||||
<div
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 rounded-r"
|
||||
style={{ width: '3px', height: '26px', backgroundColor: '#C8A434', boxShadow: '0 0 6px rgba(200,164,52,0.5)' }}
|
||||
/>
|
||||
)}
|
||||
<Icon className="h-5 w-5 shrink-0" strokeWidth={item.active ? 2 : 1.5} />
|
||||
{!collapsed && (
|
||||
<span className="max-w-[90px] truncate leading-tight font-medium" style={{ fontSize: '10px' }}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Collapse Toggle */}
|
||||
{/* Collapse Toggle — floating on the sidebar/content edge, vertically centered */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-1.5 rounded cursor-pointer bg-transparent transition-colors"
|
||||
style={{ border: '1px solid #1E2025', color: '#7A7D85', marginBottom: '12px' }}
|
||||
className="fixed top-1/2 z-50 flex items-center justify-center rounded-full cursor-pointer transition-colors"
|
||||
style={{
|
||||
left: `${width - 11}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
border: '1px solid rgba(200,164,52,0.25)',
|
||||
backgroundColor: '#15161A',
|
||||
color: '#7A7D85',
|
||||
boxShadow: '0 0 8px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#C8A434'; e.currentTarget.style.borderColor = 'rgba(200,164,52,0.5)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#7A7D85'; e.currentTarget.style.borderColor = 'rgba(200,164,52,0.25)' }}
|
||||
>
|
||||
{collapsed ? <ChevronRight size={12} /> : <ChevronLeft size={12} />}
|
||||
</button>
|
||||
|
||||
{/* Logo — prominent, centered at top. Blend mode hides the baked-in
|
||||
dark background of the source PNG so only the gold arc shows. */}
|
||||
<div className="flex flex-col items-center mb-10" style={{ paddingTop: '4px' }}>
|
||||
<img
|
||||
src="/archnest-logo-clean.png"
|
||||
alt="ArchNest"
|
||||
style={{
|
||||
width: collapsed ? '56px' : '168px',
|
||||
height: 'auto',
|
||||
filter: 'drop-shadow(0 0 10px rgba(200,164,52,0.3))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Nav Items */}
|
||||
<nav className="flex-1 flex flex-col gap-2 w-full" style={{ padding: collapsed ? '0 8px' : '0 12px' }}>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = location.pathname === item.route
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
to={item.route}
|
||||
className={`relative flex items-center no-underline transition-all duration-200 ${collapsed ? 'justify-center' : ''}`}
|
||||
style={{
|
||||
color: active ? '#C8A434' : '#7A7D85',
|
||||
gap: collapsed ? '0' : '12px',
|
||||
padding: collapsed ? '12px 0' : '12px 14px',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: active ? 'rgba(200,164,52,0.1)' : 'transparent',
|
||||
border: active ? '1px solid rgba(200,164,52,0.18)' : '1px solid transparent',
|
||||
boxShadow: active ? '0 0 14px rgba(200,164,52,0.06)' : 'none',
|
||||
}}
|
||||
title={collapsed ? item.label : undefined}
|
||||
onMouseEnter={(e) => { if (!active) { e.currentTarget.style.backgroundColor = 'rgba(200,164,52,0.05)'; e.currentTarget.style.color = '#C8A434' } }}
|
||||
onMouseLeave={(e) => { if (!active) { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = '#7A7D85' } }}
|
||||
>
|
||||
{active && (
|
||||
<div
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2"
|
||||
style={{ width: '3px', height: '22px', backgroundColor: '#C8A434', borderRadius: '0 3px 3px 0', boxShadow: '0 0 6px rgba(200,164,52,0.5)' }}
|
||||
/>
|
||||
)}
|
||||
<Icon className="h-5 w-5 shrink-0" strokeWidth={active ? 2 : 1.5} />
|
||||
{!collapsed && (
|
||||
<span className="truncate leading-tight font-medium" style={{ fontSize: '13px' }}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* System Status — rounded block, fits inside nav with breathing room */}
|
||||
<div style={{ width: '100%', padding: '0 12px 16px 12px' }}>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,103 +1,108 @@
|
|||
import { Server, Shield, Network } from 'lucide-react'
|
||||
import SparklineChart from './SparklineChart'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Server, Plug, BookMarked } from 'lucide-react'
|
||||
import ProgressRing from './ProgressRing'
|
||||
import { api, type Integration, type Resource, type Bookmark } from '../lib/api'
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
border: '1px solid rgba(200, 164, 52, 0.08)',
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.55)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(200, 164, 52, 0.1)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)',
|
||||
transition: 'border-color 0.2s ease',
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1.5px',
|
||||
color: '#7A7D85',
|
||||
fontWeight: 500,
|
||||
marginBottom: '8px',
|
||||
}
|
||||
|
||||
export default function StatusCards() {
|
||||
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
||||
const [resources, setResources] = useState<Resource[] | null>(null)
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
||||
api.listResources().then(({ resources }) => setResources(resources))
|
||||
api.listBookmarks().then(({ bookmarks }) => setBookmarks(bookmarks))
|
||||
}, [])
|
||||
|
||||
const connected = integrations?.filter((i) => i.status === 'connected').length ?? 0
|
||||
const errored = integrations?.filter((i) => i.status === 'error').length ?? 0
|
||||
const total = integrations?.length ?? 0
|
||||
|
||||
const healthy = resources?.filter((r) => r.status === 'healthy').length ?? 0
|
||||
const warning = resources?.filter((r) => r.status === 'warning').length ?? 0
|
||||
const critical = resources?.filter((r) => r.status === 'critical').length ?? 0
|
||||
const resourceTotal = resources?.length ?? 0
|
||||
|
||||
const favorites = bookmarks?.filter((b) => b.favorite).length ?? 0
|
||||
|
||||
const systemLabel = errored > 0 ? 'Issues Detected' : total === 0 ? 'Not Configured' : 'All Systems Operational'
|
||||
const systemPercent = total === 0 ? 0 : Math.round((connected / total) * 100)
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-4 gap-5">
|
||||
{/* System Status */}
|
||||
<div style={cardStyle} className="hover:!border-gold/20">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '8px' }}>
|
||||
System Status
|
||||
</h3>
|
||||
<p style={{ fontSize: '13px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1.2 }}>All Systems</p>
|
||||
<p style={{ fontSize: '13px', fontWeight: 700, color: '#C8A434', fontStyle: 'italic', lineHeight: 1.2 }}>Operational</p>
|
||||
<h3 style={labelStyle}>System Status</h3>
|
||||
<p style={{ fontSize: '16px', fontWeight: 700, color: errored > 0 ? '#E74C3C' : '#2ECC71', lineHeight: 1.3 }}>{systemLabel}</p>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>{connected} of {total} integrations connected</p>
|
||||
</div>
|
||||
<ProgressRing percentage={100} size={44} strokeWidth={3} />
|
||||
</div>
|
||||
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(200,164,52,0.06)' }}>
|
||||
<SparklineChart data={[100, 100, 99, 100, 100, 100, 98, 100, 100, 100, 100, 100]} color="#C8A434" height={20} />
|
||||
<p style={{ fontSize: '9px', color: '#7A7D85', marginTop: '4px' }}>Last checked: 2m ago</p>
|
||||
<ProgressRing percentage={systemPercent} size={44} strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Infrastructure */}
|
||||
<div style={cardStyle} className="hover:!border-gold/20">
|
||||
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Infrastructure
|
||||
</h3>
|
||||
<h3 style={labelStyle}>Infrastructure</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server size={16} style={{ color: '#C8A434' }} />
|
||||
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>24</span>
|
||||
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>{resourceTotal}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Total Resources</p>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Resources from connected integrations</p>
|
||||
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(200,164,52,0.06)', fontSize: '9px' }} className="flex items-center gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#2ECC71' }} />
|
||||
<span style={{ color: '#7A7D85' }}>24 Running</span>
|
||||
<span style={{ color: '#7A7D85' }}>{healthy} Healthy</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E67E22' }} />
|
||||
<span style={{ color: '#7A7D85' }}>0 Warning</span>
|
||||
<span style={{ color: '#7A7D85' }}>{warning} Warning</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E74C3C' }} />
|
||||
<span style={{ color: '#7A7D85' }}>0 Critical</span>
|
||||
<span style={{ color: '#7A7D85' }}>{critical} Critical</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security */}
|
||||
{/* Integrations */}
|
||||
<div style={cardStyle} className="hover:!border-gold/20">
|
||||
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Security
|
||||
</h3>
|
||||
<h3 style={labelStyle}>Integrations</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={16} style={{ color: '#C8A434' }} />
|
||||
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>2</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Active Alerts</p>
|
||||
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(200,164,52,0.06)', fontSize: '9px' }} className="flex items-center gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#2ECC71' }} />
|
||||
<span style={{ color: '#7A7D85' }}>2 Low</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E67E22' }} />
|
||||
<span style={{ color: '#7A7D85' }}>0 Medium</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E74C3C' }} />
|
||||
<span style={{ color: '#7A7D85' }}>0 High</span>
|
||||
</span>
|
||||
<Plug size={16} style={{ color: '#C8A434' }} />
|
||||
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>{connected}/{total}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Connected services</p>
|
||||
</div>
|
||||
|
||||
{/* Network */}
|
||||
{/* Bookmarks */}
|
||||
<div style={cardStyle} className="hover:!border-gold/20">
|
||||
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Network
|
||||
</h3>
|
||||
<h3 style={labelStyle}>Bookmarks</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network size={16} style={{ color: '#C8A434' }} />
|
||||
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>98.7%</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Network Uptime</p>
|
||||
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(200,164,52,0.06)' }}>
|
||||
<SparklineChart data={[99, 98, 99, 100, 99, 98, 99, 100, 99, 99, 98, 99, 100, 99, 98, 99, 100, 99, 99, 98, 99, 100, 99, 98]} color="#C8A434" height={24} filled />
|
||||
<BookMarked size={16} style={{ color: '#C8A434' }} />
|
||||
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>{bookmarks?.length ?? 0}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>{favorites} favorited</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,27 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Search, Bell, ChevronDown, User, Palette, LogOut, Shield, HelpCircle } from 'lucide-react'
|
||||
import { useAuth } from '../lib/AuthContext'
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
'/': 'Glance',
|
||||
'/infrastructure': 'Infrastructure',
|
||||
'/booknest': 'BookNest',
|
||||
'/terminal': 'Terminal',
|
||||
'/settings': 'Settings',
|
||||
}
|
||||
|
||||
const pageSubtitles: Record<string, string> = {
|
||||
'/booknest': 'Your Digital Library',
|
||||
}
|
||||
|
||||
export default function TopBar() {
|
||||
const { logout } = useAuth()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const location = useLocation()
|
||||
const title = pageTitles[location.pathname] ?? 'Glance'
|
||||
const subtitle = pageSubtitles[location.pathname]
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
|
|
@ -16,11 +34,21 @@ export default function TopBar() {
|
|||
}, [])
|
||||
|
||||
return (
|
||||
<header className="h-14 flex items-center px-6 bg-page sticky top-0 z-40">
|
||||
<header className="flex items-center px-6 sticky top-0 z-40" style={{ height: subtitle ? '72px' : '56px' }}>
|
||||
{/* Page Title — pushed away from sidebar edge */}
|
||||
<h1 className="text-[18px] font-bold uppercase tracking-wide" style={{ color: '#C8A434', marginLeft: '20px' }}>
|
||||
Glance
|
||||
</h1>
|
||||
<div style={{ marginLeft: '20px' }}>
|
||||
<h1
|
||||
className="font-bold uppercase tracking-wide"
|
||||
style={{ color: '#C8A434', fontSize: subtitle ? '28px' : '18px' }}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-[13px]" style={{ color: '#A8A6A0', marginTop: '2px' }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center section — Search bar */}
|
||||
<div className="flex-1 flex justify-center">
|
||||
|
|
@ -29,8 +57,8 @@ export default function TopBar() {
|
|||
<input
|
||||
type="text"
|
||||
placeholder="Search resources..."
|
||||
className="w-[300px] h-8 rounded-full bg-card border border-border text-[12px] text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-gold transition-colors"
|
||||
style={{ paddingLeft: '36px', paddingRight: '16px' }}
|
||||
className="w-[300px] h-8 rounded-full border border-border text-[12px] text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-gold transition-colors"
|
||||
style={{ paddingLeft: '36px', paddingRight: '16px', backgroundColor: 'rgba(255,255,255,0.04)', backdropFilter: 'blur(6px)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -87,10 +115,13 @@ export default function TopBar() {
|
|||
</a>
|
||||
</div>
|
||||
<div className="border-t border-border py-1">
|
||||
<a href="#" className="flex items-center gap-2.5 px-3 py-2 text-[12px] text-danger hover:bg-page transition-colors no-underline">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-[12px] text-danger hover:bg-page transition-colors cursor-pointer border-none bg-transparent text-left"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
<span>Sign Out</span>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
83
src/lib/AuthContext.tsx
Normal file
83
src/lib/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { api, getToken, setToken } from './api'
|
||||
|
||||
type AuthStatus = 'loading' | 'needs-setup' | 'enrolling' | 'logged-out' | 'logged-in'
|
||||
|
||||
interface AuthUser {
|
||||
id: number
|
||||
username: string
|
||||
display_name: string | null
|
||||
email: string | null
|
||||
avatar_data_url: string | null
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus
|
||||
user: AuthUser | null
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
completeSetup: (username: string, password: string) => Promise<void>
|
||||
finishEnrollment: () => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>('loading')
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
|
||||
async function refresh() {
|
||||
if (getToken()) {
|
||||
try {
|
||||
const { user } = await api.me()
|
||||
setUser(user)
|
||||
setStatus('logged-in')
|
||||
return
|
||||
} catch {
|
||||
setToken(null)
|
||||
}
|
||||
}
|
||||
const { needsSetup } = await api.getSetupStatus()
|
||||
setStatus(needsSetup ? 'needs-setup' : 'logged-out')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [])
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const { token } = await api.login(username, password)
|
||||
setToken(token)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function completeSetup(username: string, password: string) {
|
||||
const { token } = await api.setup(username, password)
|
||||
setToken(token)
|
||||
const { user } = await api.me()
|
||||
setUser(user)
|
||||
setStatus('enrolling')
|
||||
}
|
||||
|
||||
async function finishEnrollment() {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function logout() {
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
setStatus('logged-out')
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ status, user, login, completeSetup, finishEnrollment, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
118
src/lib/api.ts
Normal file
118
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
const TOKEN_KEY = 'archnest_token'
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken()
|
||||
const headers: Record<string, string> = {
|
||||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
|
||||
const res = await fetch(`/api${path}`, { ...options, headers })
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const body = await res.json()
|
||||
message = body.error ?? message
|
||||
} catch {
|
||||
// ignore non-JSON error bodies
|
||||
}
|
||||
throw new ApiError(res.status, message)
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getSetupStatus: () => apiFetch<{ needsSetup: boolean }>('/system/setup-status'),
|
||||
setup: (username: string, password: string) =>
|
||||
apiFetch<{ token: string }>('/setup', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
login: (username: string, password: string) =>
|
||||
apiFetch<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
me: () => apiFetch<{ user: { id: number; username: string; display_name: string | null; email: string | null; avatar_data_url: string | null } }>('/auth/me'),
|
||||
|
||||
listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'),
|
||||
createIntegration: (data: { type: string; name: string; config?: Record<string, string>; secrets?: Record<string, string> }) =>
|
||||
apiFetch<{ integration: Integration }>('/integrations', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateIntegration: (id: number, data: Partial<{ name: string; config: Record<string, string>; secrets: Record<string, string> }>) =>
|
||||
apiFetch<{ integration: Integration }>(`/integrations/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteIntegration: (id: number) => apiFetch<void>(`/integrations/${id}`, { method: 'DELETE' }),
|
||||
testIntegration: (id: number) => apiFetch<{ ok: boolean; message: string }>(`/integrations/${id}/test`, { method: 'POST' }),
|
||||
|
||||
listBookmarks: () => apiFetch<{ bookmarks: Bookmark[] }>('/bookmarks'),
|
||||
listBookmarkCategories: () => apiFetch<{ categories: BookmarkCategory[] }>('/bookmarks/categories'),
|
||||
createBookmarkCategory: (data: { name: string; icon?: string; sortOrder?: number }) =>
|
||||
apiFetch<{ id: number }>('/bookmarks/categories', { method: 'POST', body: JSON.stringify(data) }),
|
||||
createBookmark: (data: { categoryId?: number | null; title: string; url: string; icon?: string; favorite?: boolean }) =>
|
||||
apiFetch<{ id: number }>('/bookmarks', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateBookmark: (id: number, data: Partial<{ categoryId: number | null; title: string; url: string; icon: string; favorite: boolean }>) =>
|
||||
apiFetch<{ ok: boolean }>(`/bookmarks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`),
|
||||
listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'),
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
id: number
|
||||
type: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
status: string
|
||||
config: Record<string, string>
|
||||
lastCheckedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Bookmark {
|
||||
id: number
|
||||
category_id: number | null
|
||||
title: string
|
||||
url: string
|
||||
icon: string | null
|
||||
favorite: number
|
||||
status: string
|
||||
last_checked_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface BookmarkCategory {
|
||||
id: number
|
||||
name: string
|
||||
icon: string | null
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
id: number
|
||||
type: string
|
||||
title: string
|
||||
source: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
name: string
|
||||
status: 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
detail?: string
|
||||
integration: string
|
||||
}
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { AuthProvider } from './lib/AuthContext'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
552
src/pages/BookNest.tsx
Normal file
552
src/pages/BookNest.tsx
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
|
||||
import {
|
||||
Link2,
|
||||
FolderOpen,
|
||||
Star,
|
||||
Plus,
|
||||
Server,
|
||||
Bot,
|
||||
Cloud,
|
||||
Network,
|
||||
GitBranch,
|
||||
GitFork,
|
||||
Box,
|
||||
Terminal as TerminalIcon,
|
||||
Database,
|
||||
Shield,
|
||||
Workflow,
|
||||
FileCode,
|
||||
Router,
|
||||
Wifi,
|
||||
BookOpen,
|
||||
GraduationCap,
|
||||
SquarePlay,
|
||||
Briefcase,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
PiggyBank,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
Mail,
|
||||
Image,
|
||||
HardDrive,
|
||||
FileText,
|
||||
Plane,
|
||||
Sparkles,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
Globe2,
|
||||
Container,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { api, ApiError, type Bookmark, type BookmarkCategory } from '../lib/api'
|
||||
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
server: Server,
|
||||
bot: Bot,
|
||||
cloud: Cloud,
|
||||
network: Network,
|
||||
gitbranch: GitBranch,
|
||||
gitfork: GitFork,
|
||||
box: Box,
|
||||
terminal: TerminalIcon,
|
||||
database: Database,
|
||||
shield: Shield,
|
||||
workflow: Workflow,
|
||||
filecode: FileCode,
|
||||
router: Router,
|
||||
wifi: Wifi,
|
||||
bookopen: BookOpen,
|
||||
graduationcap: GraduationCap,
|
||||
squareplay: SquarePlay,
|
||||
briefcase: Briefcase,
|
||||
wallet: Wallet,
|
||||
creditcard: CreditCard,
|
||||
piggybank: PiggyBank,
|
||||
trendingup: TrendingUp,
|
||||
calendar: Calendar,
|
||||
mail: Mail,
|
||||
image: Image,
|
||||
harddrive: HardDrive,
|
||||
filetext: FileText,
|
||||
plane: Plane,
|
||||
sparkles: Sparkles,
|
||||
messagesquare: MessageSquare,
|
||||
zap: Zap,
|
||||
globe2: Globe2,
|
||||
container: Container,
|
||||
link2: Link2,
|
||||
}
|
||||
|
||||
function resolveIcon(name: string | null | undefined): LucideIcon {
|
||||
if (!name) return Link2
|
||||
return ICONS[name.toLowerCase()] ?? Link2
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
online: '#2ECC71',
|
||||
warning: '#E67E22',
|
||||
offline: '#E74C3C',
|
||||
unknown: '#7A7D85',
|
||||
}
|
||||
|
||||
const categoryPalette = ['#C8A434', '#3B82F6', '#2ECC71', '#E67E22', '#7A7D85', '#8B5E3C', '#9B59B6', '#E74C3C']
|
||||
|
||||
const cardBase: React.CSSProperties = {
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
||||
border: '1px solid rgba(200, 164, 52, 0.08)',
|
||||
borderRadius: '12px',
|
||||
padding: '18px',
|
||||
boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)',
|
||||
transition: 'border-color 0.2s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
|
||||
const sectionTitle: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1.5px',
|
||||
color: '#7A7D85',
|
||||
fontWeight: 500,
|
||||
marginBottom: '14px',
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
border: '1px solid rgba(200,164,52,0.12)',
|
||||
borderRadius: '8px',
|
||||
padding: '9px 12px',
|
||||
fontSize: '13px',
|
||||
color: '#E8E6E0',
|
||||
width: '100%',
|
||||
outline: 'none',
|
||||
}
|
||||
|
||||
function Donut({ data, centerLabel }: { data: { name: string; value: number; color: string }[]; centerLabel?: string }) {
|
||||
const hasData = data.some((d) => d.value > 0)
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative" style={{ width: '88px', height: '88px', flexShrink: 0 }}>
|
||||
{hasData && (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey="value" innerRadius={28} outerRadius={42} paddingAngle={2} isAnimationActive animationDuration={1000}>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} stroke="none" />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
{centerLabel && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span style={{ fontSize: '14px', fontWeight: 700, color: '#E8E6E0' }}>{centerLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{data.map((entry) => (
|
||||
<div key={entry.name} className="flex items-center gap-1.5">
|
||||
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: entry.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '11px', color: '#E8E6E0' }}>{entry.name}</span>
|
||||
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkRow({ bookmark, onToggleFavorite }: { bookmark: Bookmark; onToggleFavorite: () => void }) {
|
||||
const Icon = resolveIcon(bookmark.icon)
|
||||
return (
|
||||
<div className="flex items-center justify-between group" style={{ padding: '6px 0' }}>
|
||||
<a
|
||||
href={bookmark.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2.5 cursor-pointer"
|
||||
style={{ minWidth: 0, textDecoration: 'none' }}
|
||||
>
|
||||
<Icon size={16} style={{ color: '#C8A434', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{bookmark.title}</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={onToggleFavorite}
|
||||
className="cursor-pointer bg-transparent border-none p-0 flex items-center"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Star size={14} fill={bookmark.favorite ? '#C8A434' : 'none'} style={{ color: bookmark.favorite ? '#C8A434' : '#4A4D55' }} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddBookmarkModal({
|
||||
categories,
|
||||
onClose,
|
||||
onCreated,
|
||||
onCategoryCreated,
|
||||
}: {
|
||||
categories: BookmarkCategory[]
|
||||
onClose: () => void
|
||||
onCreated: (bookmark: Bookmark) => void
|
||||
onCategoryCreated: (category: BookmarkCategory) => void
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [icon, setIcon] = useState('link2')
|
||||
const [categoryId, setCategoryId] = useState<number | 'new' | ''>('')
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (!title.trim() || !url.trim()) {
|
||||
setError('Title and URL are required')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
let resolvedCategoryId: number | null = null
|
||||
if (categoryId === 'new') {
|
||||
if (!newCategoryName.trim()) {
|
||||
setError('Enter a name for the new category')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
const { id } = await api.createBookmarkCategory({ name: newCategoryName.trim() })
|
||||
resolvedCategoryId = id
|
||||
onCategoryCreated({ id, name: newCategoryName.trim(), icon: null, sort_order: 0 })
|
||||
} else if (categoryId !== '') {
|
||||
resolvedCategoryId = categoryId
|
||||
}
|
||||
const { id } = await api.createBookmark({
|
||||
title: title.trim(),
|
||||
url: url.trim(),
|
||||
icon,
|
||||
categoryId: resolvedCategoryId,
|
||||
})
|
||||
onCreated({
|
||||
id,
|
||||
category_id: resolvedCategoryId,
|
||||
title: title.trim(),
|
||||
url: url.trim(),
|
||||
icon,
|
||||
favorite: 0,
|
||||
status: 'unknown',
|
||||
last_checked_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Failed to create bookmark')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 50 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<form
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSubmit={handleSubmit}
|
||||
style={{ ...cardBase, width: '420px', padding: '24px', gap: '14px' }}
|
||||
>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: '4px' }}>
|
||||
<h3 style={{ fontSize: '14px', color: '#E8E6E0', fontWeight: 600 }}>Add Bookmark</h3>
|
||||
<button type="button" onClick={onClose} className="cursor-pointer bg-transparent border-none p-0">
|
||||
<X size={16} style={{ color: '#7A7D85' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label style={{ fontSize: '11px', color: '#7A7D85' }}>Title</label>
|
||||
<input style={inputStyle} value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Proxmox" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label style={{ fontSize: '11px', color: '#7A7D85' }}>URL</label>
|
||||
<input style={inputStyle} value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label style={{ fontSize: '11px', color: '#7A7D85' }}>Icon</label>
|
||||
<select style={inputStyle} value={icon} onChange={(e) => setIcon(e.target.value)}>
|
||||
{Object.keys(ICONS).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label style={{ fontSize: '11px', color: '#7A7D85' }}>Category</label>
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value === 'new' ? 'new' : e.target.value === '' ? '' : Number(e.target.value))}
|
||||
>
|
||||
<option value="">Uncategorized</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="new">+ New category</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{categoryId === 'new' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label style={{ fontSize: '11px', color: '#7A7D85' }}>New category name</label>
|
||||
<input style={inputStyle} value={newCategoryName} onChange={(e) => setNewCategoryName(e.target.value)} placeholder="e.g. Monitoring" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p style={{ fontSize: '12px', color: '#E74C3C' }}>{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="cursor-pointer transition-colors"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#0A0B0D',
|
||||
backgroundColor: '#C8A434',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 14px',
|
||||
opacity: busy ? 0.6 : 1,
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{busy ? 'Saving…' : 'Add Bookmark'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BookNest() {
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[] | null>(null)
|
||||
const [categories, setCategories] = useState<BookmarkCategory[]>([])
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.listBookmarks(), api.listBookmarkCategories()]).then(([b, c]) => {
|
||||
setBookmarks(b.bookmarks)
|
||||
setCategories(c.categories)
|
||||
})
|
||||
}, [])
|
||||
|
||||
async function toggleFavorite(bookmark: Bookmark) {
|
||||
const next = bookmark.favorite ? false : true
|
||||
setBookmarks((prev) => prev?.map((b) => (b.id === bookmark.id ? { ...b, favorite: next ? 1 : 0 } : b)) ?? prev)
|
||||
try {
|
||||
await api.updateBookmark(bookmark.id, { favorite: next })
|
||||
} catch {
|
||||
setBookmarks((prev) => prev?.map((b) => (b.id === bookmark.id ? { ...b, favorite: bookmark.favorite } : b)) ?? prev)
|
||||
}
|
||||
}
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!bookmarks) return []
|
||||
const byCategory = new Map<number | null, Bookmark[]>()
|
||||
for (const b of bookmarks) {
|
||||
const key = b.category_id
|
||||
if (!byCategory.has(key)) byCategory.set(key, [])
|
||||
byCategory.get(key)!.push(b)
|
||||
}
|
||||
const named = categories
|
||||
.map((c) => ({ title: c.name, links: byCategory.get(c.id) ?? [] }))
|
||||
.filter((g) => g.links.length > 0)
|
||||
const uncategorized = byCategory.get(null) ?? []
|
||||
return uncategorized.length > 0 ? [...named, { title: 'Uncategorized', links: uncategorized }] : named
|
||||
}, [bookmarks, categories])
|
||||
|
||||
const favorites = useMemo(() => (bookmarks ?? []).filter((b) => b.favorite), [bookmarks])
|
||||
|
||||
const recentlyAdded = useMemo(
|
||||
() => [...(bookmarks ?? [])].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)).slice(0, 5),
|
||||
[bookmarks]
|
||||
)
|
||||
|
||||
const quickAccess = useMemo(
|
||||
() =>
|
||||
[...groups]
|
||||
.sort((a, b) => b.links.length - a.links.length)
|
||||
.slice(0, 5)
|
||||
.map((g) => ({ label: g.title, icons: g.links.slice(0, 5).map((l) => resolveIcon(l.icon)), count: g.links.length })),
|
||||
[groups]
|
||||
)
|
||||
|
||||
const linkHealthData = useMemo(() => {
|
||||
const counts: Record<string, number> = { online: 0, warning: 0, offline: 0, unknown: 0 }
|
||||
for (const b of bookmarks ?? []) counts[b.status in counts ? b.status : 'unknown']++
|
||||
return Object.entries(counts)
|
||||
.filter(([, value]) => value > 0)
|
||||
.map(([name, value]) => ({ name: name.charAt(0).toUpperCase() + name.slice(1), value, color: statusColors[name] }))
|
||||
}, [bookmarks])
|
||||
|
||||
const categoryBreakdownData = useMemo(
|
||||
() => groups.map((g, i) => ({ name: g.title, value: g.links.length, color: categoryPalette[i % categoryPalette.length] })),
|
||||
[groups]
|
||||
)
|
||||
|
||||
if (!bookmarks) {
|
||||
return (
|
||||
<div style={cardBase}>
|
||||
<p style={{ fontSize: '13px', color: '#7A7D85' }}>Loading bookmarks…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto" style={{ scrollbarWidth: 'none' }}>
|
||||
{showAddModal && (
|
||||
<AddBookmarkModal
|
||||
categories={categories}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onCreated={(b) => setBookmarks((prev) => [b, ...(prev ?? [])])}
|
||||
onCategoryCreated={(c) => setCategories((prev) => [...prev, c])}
|
||||
/>
|
||||
)}
|
||||
<div className="grid w-full gap-5" style={{ gridTemplateColumns: '3fr 1fr', gridTemplateRows: 'auto 1fr' }}>
|
||||
{/* Page stats — sits directly under the hero title/subtitle, like the blueprint */}
|
||||
<div className="flex items-center shrink-0" style={{ gridColumn: 1, gridRow: 1, marginTop: '8px' }}>
|
||||
<div className="flex items-center gap-5" style={{ fontSize: '12px', color: '#7A7D85' }}>
|
||||
<span className="flex items-center gap-1.5"><Link2 size={13} style={{ color: '#C8A434' }} /> <strong style={{ color: '#E8E6E0' }}>{bookmarks.length}</strong> Links</span>
|
||||
<span className="flex items-center gap-1.5"><FolderOpen size={13} style={{ color: '#C8A434' }} /> <strong style={{ color: '#E8E6E0' }}>{categories.length}</strong> Categories</span>
|
||||
<span className="flex items-center gap-1.5"><Star size={13} style={{ color: '#C8A434' }} /> <strong style={{ color: '#E8E6E0' }}>{favorites.length}</strong> Favorites</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main column */}
|
||||
<div className="flex flex-col gap-5" style={{ gridColumn: 1, gridRow: 2, marginTop: '14px' }}>
|
||||
{/* Quick Access */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 style={{ ...sectionTitle, marginBottom: 0, color: '#C8A434' }}>Quick Access</h3>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#0A0B0D',
|
||||
backgroundColor: '#C8A434',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 14px',
|
||||
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Bookmark
|
||||
</button>
|
||||
</div>
|
||||
{quickAccess.length > 0 ? (
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{quickAccess.map((qa) => (
|
||||
<div key={qa.label} style={{ ...cardBase, padding: '14px' }} className="hover:!border-gold/20">
|
||||
<span style={{ fontSize: '11px', color: '#E8E6E0', fontWeight: 600, marginBottom: '10px' }}>{qa.label}</span>
|
||||
<div className="flex items-center gap-1.5" style={{ marginBottom: '8px' }}>
|
||||
{qa.icons.map((Icon, i) => (
|
||||
<div key={i} style={{ width: '22px', height: '22px', borderRadius: '6px', backgroundColor: 'rgba(200,164,52,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon size={12} style={{ color: '#C8A434' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{qa.count} links</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={cardBase}>
|
||||
<p style={{ fontSize: '13px', color: '#7A7D85' }}>No bookmarks yet — add your first one to get started.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bookmark groups grid */}
|
||||
{groups.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.title} style={cardBase} className="hover:!border-gold/15">
|
||||
<h3 style={sectionTitle}>{group.title}</h3>
|
||||
<div className="flex flex-col" style={{ gap: '2px' }}>
|
||||
{group.links.map((link) => (
|
||||
<LinkRow key={link.id} bookmark={link} onToggleFavorite={() => toggleFavorite(link)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right sidebar — spans both rows so Favorites reaches up near the hero, and stretches to match the main column's full height */}
|
||||
<div className="flex h-full flex-col gap-5" style={{ gridColumn: 2, gridRow: '1 / span 2' }}>
|
||||
<div style={{ ...cardBase, padding: '22px', flex: '1.4 0 auto' }}>
|
||||
<h3 style={{ ...sectionTitle, fontSize: '12px', marginBottom: '18px' }}>Favorites</h3>
|
||||
<div className="flex flex-col" style={{ gap: '4px' }}>
|
||||
{favorites.length > 0 ? (
|
||||
favorites.map((f) => {
|
||||
const Icon = resolveIcon(f.icon)
|
||||
return (
|
||||
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" className="flex items-center gap-3" style={{ padding: '8px 0', textDecoration: 'none' }}>
|
||||
<Icon size={17} style={{ color: '#C8A434' }} />
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>{f.title}</span>
|
||||
</a>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No favorites yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...cardBase, flex: '1 0 auto' }}>
|
||||
<h3 style={sectionTitle}>Recently Added</h3>
|
||||
<div className="flex flex-col" style={{ gap: '8px' }}>
|
||||
{recentlyAdded.length > 0 ? (
|
||||
recentlyAdded.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between">
|
||||
<span style={{ fontSize: '12px', color: '#E8E6E0' }}>{r.title}</span>
|
||||
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{new Date(r.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Nothing yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...cardBase, flex: '1 0 auto' }}>
|
||||
<h3 style={sectionTitle}>Link Health</h3>
|
||||
<Donut data={linkHealthData} centerLabel={String(bookmarks.length)} />
|
||||
</div>
|
||||
|
||||
<div className="justify-center" style={{ ...cardBase, flex: '1 0 auto', display: 'flex' }}>
|
||||
<div className="flex flex-col" style={{ width: '100%' }}>
|
||||
<h3 style={sectionTitle}>Category Breakdown</h3>
|
||||
<Donut data={categoryBreakdownData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
275
src/pages/Enrollment.tsx
Normal file
275
src/pages/Enrollment.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { useState } from 'react'
|
||||
import { Server, Container, Network, Cloud, CloudCog, Activity, CloudSun, Check, ArrowRight } from 'lucide-react'
|
||||
import { useAuth } from '../lib/AuthContext'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
|
||||
const cardBase: React.CSSProperties = {
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
||||
border: '1px solid rgba(200, 164, 52, 0.12)',
|
||||
borderRadius: '14px',
|
||||
padding: '32px',
|
||||
}
|
||||
|
||||
const fieldLabel: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
color: '#7A7D85',
|
||||
marginBottom: '6px',
|
||||
display: 'block',
|
||||
}
|
||||
|
||||
const fieldInput: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(200,164,52,0.12)',
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
color: '#E8E6E0',
|
||||
fontSize: '13px',
|
||||
padding: '0 12px',
|
||||
outline: 'none',
|
||||
}
|
||||
|
||||
const goldButton: React.CSSProperties = {
|
||||
height: '38px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: '#0A0B0D',
|
||||
backgroundColor: '#C8A434',
|
||||
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
|
||||
padding: '0 20px',
|
||||
}
|
||||
|
||||
const integrationOptions = [
|
||||
{ type: 'proxmox', name: 'Proxmox', icon: Server },
|
||||
{ type: 'docker', name: 'Docker', icon: Container },
|
||||
{ type: 'netbird', name: 'NetBird', icon: Network },
|
||||
{ type: 'cloudflare', name: 'Cloudflare', icon: Cloud },
|
||||
{ type: 'aws', name: 'AWS', icon: CloudCog },
|
||||
{ type: 'uptime_kuma', name: 'Uptime Kuma', icon: Activity },
|
||||
{ type: 'weather', name: 'Weather API', icon: CloudSun },
|
||||
] as const
|
||||
|
||||
export default function Enrollment() {
|
||||
const { status, completeSetup, finishEnrollment } = useAuth()
|
||||
const step = status === 'enrolling' ? 'connect' : 'account'
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-page">
|
||||
<div style={{ width: step === 'account' ? '380px' : '640px' }}>
|
||||
{step === 'account' ? (
|
||||
<AccountStep completeSetup={completeSetup} />
|
||||
) : (
|
||||
<ConnectStep onFinish={finishEnrollment} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountStep({
|
||||
completeSetup,
|
||||
}: {
|
||||
completeSetup: (username: string, password: string) => Promise<void>
|
||||
}) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
if (password !== confirm) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await completeSetup(username, password)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Setup failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={cardBase}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '1px',
|
||||
textTransform: 'uppercase',
|
||||
color: '#C8A434',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Welcome to ArchNest
|
||||
</h1>
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>
|
||||
Create your admin account to get started
|
||||
</p>
|
||||
|
||||
<label style={fieldLabel}>Username</label>
|
||||
<input style={fieldInput} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus required />
|
||||
|
||||
<label style={{ ...fieldLabel, marginTop: '14px' }}>Password</label>
|
||||
<input style={fieldInput} type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
|
||||
<label style={{ ...fieldLabel, marginTop: '14px' }}>Confirm Password</label>
|
||||
<input style={fieldInput} type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
|
||||
|
||||
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
|
||||
|
||||
<button type="submit" disabled={submitting} style={{ ...goldButton, width: '100%', marginTop: '22px', opacity: submitting ? 0.6 : 1 }}>
|
||||
{submitting ? 'Creating account…' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectStep({ onFinish }: { onFinish: () => Promise<void> }) {
|
||||
const [connected, setConnected] = useState<Set<string>>(new Set())
|
||||
const [active, setActive] = useState<(typeof integrationOptions)[number] | null>(null)
|
||||
|
||||
return (
|
||||
<div style={cardBase}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '1px',
|
||||
textTransform: 'uppercase',
|
||||
color: '#C8A434',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Connect Your Services
|
||||
</h1>
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>
|
||||
Add the integrations you want ArchNest to monitor. You can also do this later from Settings.
|
||||
</p>
|
||||
|
||||
{active ? (
|
||||
<ConnectForm
|
||||
option={active}
|
||||
onConnected={() => {
|
||||
setConnected((prev) => new Set(prev).add(active.type))
|
||||
setActive(null)
|
||||
}}
|
||||
onCancel={() => setActive(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{integrationOptions.map((opt) => {
|
||||
const Icon = opt.icon
|
||||
const isDone = connected.has(opt.type)
|
||||
return (
|
||||
<button
|
||||
key={opt.type}
|
||||
onClick={() => setActive(opt)}
|
||||
className="flex flex-col items-center gap-2 cursor-pointer transition-colors"
|
||||
style={{
|
||||
border: `1px solid ${isDone ? 'rgba(46,204,113,0.4)' : 'rgba(200,164,52,0.12)'}`,
|
||||
borderRadius: '10px',
|
||||
padding: '18px 8px',
|
||||
backgroundColor: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
{isDone ? <Check size={20} color="#2ECC71" /> : <Icon size={20} color="#C8A434" />}
|
||||
<span style={{ fontSize: '11px', color: '#E8E6E0' }}>{opt.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!active && (
|
||||
<div className="flex justify-end" style={{ marginTop: '24px' }}>
|
||||
<button
|
||||
onClick={() => onFinish()}
|
||||
className="cursor-pointer"
|
||||
style={{ ...goldButton, display: 'inline-flex', alignItems: 'center', gap: '8px' }}
|
||||
>
|
||||
{connected.size > 0 ? 'Finish' : 'Skip for now'} <ArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectForm({
|
||||
option,
|
||||
onConnected,
|
||||
onCancel,
|
||||
}: {
|
||||
option: (typeof integrationOptions)[number]
|
||||
onConnected: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [testResult, setTestResult] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setTestResult(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const { integration } = await api.createIntegration({
|
||||
type: option.type,
|
||||
name: option.name,
|
||||
config: baseUrl ? { baseUrl } : {},
|
||||
secrets: apiKey ? { apiKey } : {},
|
||||
})
|
||||
const result = await api.testIntegration(integration.id)
|
||||
setTestResult(result.message)
|
||||
if (result.ok) onConnected()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Failed to add integration')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<p style={{ fontSize: '13px', color: '#E8E6E0', marginBottom: '16px', fontWeight: 600 }}>{option.name}</p>
|
||||
|
||||
<label style={fieldLabel}>Base URL</label>
|
||||
<input style={fieldInput} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://..." />
|
||||
|
||||
<label style={{ ...fieldLabel, marginTop: '14px' }}>API Key (optional)</label>
|
||||
<input style={fieldInput} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
|
||||
|
||||
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
|
||||
{testResult && <p style={{ fontSize: '12px', color: '#7A7D85', marginTop: '12px' }}>{testResult}</p>}
|
||||
|
||||
<div className="flex gap-3" style={{ marginTop: '20px' }}>
|
||||
<button type="submit" disabled={submitting} style={{ ...goldButton, opacity: submitting ? 0.6 : 1 }}>
|
||||
{submitting ? 'Connecting…' : 'Connect'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="cursor-pointer"
|
||||
style={{ height: '38px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.12)', background: 'transparent', color: '#7A7D85', fontSize: '13px', padding: '0 16px' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
51
src/pages/Glance.tsx
Normal file
51
src/pages/Glance.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import StatusCards from '../components/StatusCards'
|
||||
import MiddleRow from '../components/MiddleRow'
|
||||
import BottomRow from '../components/BottomRow'
|
||||
|
||||
export default function Glance() {
|
||||
return (
|
||||
<>
|
||||
{/* Hero + KPI overlap — KPI bottom aligns with banner bottom */}
|
||||
<div className="relative w-full shrink-0 overflow-hidden" style={{ height: '240px' }}>
|
||||
<img
|
||||
src="/archnest-hero-banner.png"
|
||||
alt="ArchNest Banner"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center 35%',
|
||||
maskImage: 'linear-gradient(to bottom, black 0%, black 45%, transparent 95%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 45%, transparent 95%)',
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget
|
||||
target.style.display = 'none'
|
||||
target.parentElement!.classList.add('bg-card')
|
||||
}}
|
||||
/>
|
||||
{/* Side vignette so the rectangular image blends into the page edges */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse 75% 100% at center, transparent 55%, var(--color-page) 100%)',
|
||||
}}
|
||||
/>
|
||||
{/* KPI cards positioned so their bottom edge aligns with banner bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 px-4">
|
||||
<StatusCards />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Row — stretches to fill available vertical space */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<MiddleRow />
|
||||
</div>
|
||||
|
||||
{/* Bottom Row — anchored to the bottom */}
|
||||
<div className="shrink-0">
|
||||
<BottomRow />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
350
src/pages/Infrastructure.tsx
Normal file
350
src/pages/Infrastructure.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
|
||||
import { Plus, Server, Activity, AlertTriangle, CircleCheck } from 'lucide-react'
|
||||
import { api, type Resource, type Integration, type Event } from '../lib/api'
|
||||
|
||||
const subTabs = ['Overview']
|
||||
const futureSubTabs = ['Network']
|
||||
|
||||
const cardBase: React.CSSProperties = {
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
||||
border: '1px solid rgba(200, 164, 52, 0.08)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)',
|
||||
transition: 'border-color 0.2s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
|
||||
const sectionTitle: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1.5px',
|
||||
color: '#7A7D85',
|
||||
fontWeight: 500,
|
||||
marginBottom: '16px',
|
||||
}
|
||||
|
||||
function framedCard(bgUrl: string): React.CSSProperties {
|
||||
return {
|
||||
backgroundImage: `url(${bgUrl})`,
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '20px 20px 64px 20px',
|
||||
}
|
||||
}
|
||||
|
||||
const cardVignette: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
background: 'radial-gradient(ellipse closest-side at center, transparent 70%, var(--color-page) 100%)',
|
||||
}
|
||||
|
||||
const cardDim: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: 'rgba(8, 8, 10, 0.45)',
|
||||
}
|
||||
|
||||
const nodeStatusColor: Record<string, string> = {
|
||||
healthy: '#2ECC71',
|
||||
warning: '#E67E22',
|
||||
critical: '#E74C3C',
|
||||
unknown: '#7A7D85',
|
||||
}
|
||||
|
||||
const integrationPalette = ['#C8A434', '#E67E22', '#2ECC71', '#7A7D85', '#3B82F6', '#8B5E3C']
|
||||
|
||||
function Donut({ data, centerLabel }: { data: { name: string; value: number; color: string }[]; centerLabel?: string }) {
|
||||
const hasData = data.some((d) => d.value > 0)
|
||||
return (
|
||||
<div className="flex flex-1 items-center gap-4">
|
||||
<div className="relative" style={{ width: '120px', height: '120px', flexShrink: 0 }}>
|
||||
{hasData && (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey="value" innerRadius={38} outerRadius={56} paddingAngle={2} isAnimationActive animationDuration={1000}>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} stroke="none" />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
{centerLabel && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span style={{ fontSize: '18px', fontWeight: 700, color: '#E8E6E0' }}>{centerLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.map((entry) => (
|
||||
<div key={entry.name} className="flex items-center gap-2">
|
||||
<span style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: entry.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '12px', color: '#E8E6E0', width: '90px' }}>{entry.name}</span>
|
||||
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Infrastructure() {
|
||||
const [activeTab, setActiveTab] = useState('Overview')
|
||||
const [resources, setResources] = useState<Resource[] | null>(null)
|
||||
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
||||
const [events, setEvents] = useState<Event[] | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
api.listResources().then(({ resources }) => setResources(resources))
|
||||
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
||||
api.listEvents(4).then(({ events }) => setEvents(events))
|
||||
}, [])
|
||||
|
||||
const healthy = resources?.filter((r) => r.status === 'healthy').length ?? 0
|
||||
const warning = resources?.filter((r) => r.status === 'warning').length ?? 0
|
||||
const critical = resources?.filter((r) => r.status === 'critical').length ?? 0
|
||||
const total = resources?.length ?? 0
|
||||
|
||||
const statusCards = [
|
||||
{ label: 'Total Resources', value: String(total), icon: Server, sub: `${integrations?.filter((i) => i.status === 'connected').length ?? 0} integrations connected` },
|
||||
{ label: 'Healthy', value: String(healthy), icon: Activity, sub: total ? `${Math.round((healthy / total) * 100)}%` : '—', color: '#2ECC71' },
|
||||
{ label: 'Warnings', value: String(warning), icon: AlertTriangle, sub: warning ? 'Needs attention' : 'None', color: '#E67E22' },
|
||||
{ label: 'Critical', value: String(critical), icon: AlertTriangle, sub: critical ? 'Action required' : 'None', color: '#E74C3C' },
|
||||
]
|
||||
|
||||
const distributionData = useMemo(() => {
|
||||
if (!resources) return []
|
||||
const byIntegration = new Map<string, number>()
|
||||
for (const r of resources) byIntegration.set(r.integration, (byIntegration.get(r.integration) ?? 0) + 1)
|
||||
return Array.from(byIntegration.entries()).map(([name, value], i) => ({ name, value, color: integrationPalette[i % integrationPalette.length] }))
|
||||
}, [resources])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sub-tabs + Add Resource — hero banner is rendered at the layout level behind this */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-1 overflow-x-auto" style={{ scrollbarWidth: 'none' }}>
|
||||
{subTabs.map((tab) => {
|
||||
const active = tab === activeTab
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className="cursor-pointer bg-transparent border-none transition-colors whitespace-nowrap"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
padding: '8px 14px',
|
||||
borderRadius: '8px',
|
||||
color: active ? '#C8A434' : '#7A7D85',
|
||||
backgroundColor: active ? 'rgba(200,164,52,0.1)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{futureSubTabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
disabled
|
||||
title="Coming soon"
|
||||
className="cursor-not-allowed bg-transparent border-none whitespace-nowrap"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
padding: '8px 14px',
|
||||
borderRadius: '8px',
|
||||
color: '#4A4D55',
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#0A0B0D',
|
||||
backgroundColor: '#C8A434',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '9px 16px',
|
||||
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Resource
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="grid w-full grid-cols-4 gap-5 shrink-0" style={{ marginTop: '8px', height: '110px' }}>
|
||||
{statusCards.map((card) => {
|
||||
const Icon = card.icon
|
||||
return (
|
||||
<div
|
||||
key={card.label}
|
||||
style={{ ...cardBase, backgroundColor: 'rgba(10, 10, 12, 0.5)', padding: '18px', justifyContent: 'center', alignItems: 'center', gap: '8px' }}
|
||||
className="hover:!border-gold/20"
|
||||
>
|
||||
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, textAlign: 'center' }}>
|
||||
{card.label}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={18} style={{ color: card.color ?? '#C8A434' }} />
|
||||
<span style={{ fontSize: '26px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>{card.value}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '10px', color: card.color ?? '#7A7D85', textAlign: 'center' }}>{card.sub}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Middle Row */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className="grid h-full w-full grid-cols-[1fr_1.6fr] gap-6">
|
||||
{/* Resource Distribution */}
|
||||
<div style={framedCard('/blank-kpi-bg.png')}>
|
||||
<div style={cardDim} />
|
||||
<div style={cardVignette} />
|
||||
<div className="relative z-10 flex flex-1 flex-col">
|
||||
<h3 style={sectionTitle}>Resource Distribution</h3>
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
{distributionData.length > 0 ? (
|
||||
<Donut data={distributionData} />
|
||||
) : (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85', textAlign: 'center' }}>Connect an integration in Settings to see resource distribution.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node Status — expanded */}
|
||||
<div style={framedCard('/blank-kpi-bg.png')}>
|
||||
<div style={cardDim} />
|
||||
<div style={cardVignette} />
|
||||
<div className="relative z-10 flex flex-1 flex-col">
|
||||
<h3 style={sectionTitle}>Node Status</h3>
|
||||
{resources && resources.length > 0 ? (
|
||||
<div className="grid flex-1 grid-cols-4 gap-3 content-center">
|
||||
{resources.map((node, i) => (
|
||||
<div
|
||||
key={i}
|
||||
title={`${node.name}: ${node.detail ?? node.status}`}
|
||||
style={{
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.55)',
|
||||
border: `1px solid ${nodeStatusColor[node.status]}33`,
|
||||
borderRadius: '8px',
|
||||
padding: '10px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: nodeStatusColor[node.status], boxShadow: `0 0 6px ${nodeStatusColor[node.status]}` }} />
|
||||
<Server size={12} style={{ color: '#7A7D85' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{node.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85', textAlign: 'center' }}>No resources reported yet. Connect Docker (or another supported integration) in Settings to populate this view.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row */}
|
||||
<div className="shrink-0">
|
||||
<div className="grid w-full grid-cols-2 gap-6">
|
||||
{/* Integration Health */}
|
||||
<div style={{ ...cardBase, height: 'auto' }} className="hover:!border-gold/15">
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<h3 style={sectionTitle}>Integration Health</h3>
|
||||
{integrations && integrations.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{integrations.map((i) => (
|
||||
<div key={i.id} className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2" style={{ fontSize: '12px', color: '#E8E6E0' }}>
|
||||
<CircleCheck size={13} style={{ color: i.status === 'connected' ? '#2ECC71' : i.status === 'error' ? '#E74C3C' : '#7A7D85' }} />
|
||||
{i.name}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{i.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No integrations added yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div style={{ ...cardBase, height: 'auto' }} className="hover:!border-gold/15">
|
||||
<div className="relative z-10">
|
||||
<h3 style={sectionTitle}>Recent Activity</h3>
|
||||
{events && events.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{events.map((item) => (
|
||||
<div key={item.id} className="flex items-start justify-between gap-3">
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: '12px', color: '#E8E6E0', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No activity yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer stats bar */}
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-center gap-3"
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: '#7A7D85',
|
||||
padding: '8px 0',
|
||||
borderTop: '1px solid rgba(200,164,52,0.08)',
|
||||
}}
|
||||
>
|
||||
<span>{total} Resources</span><span style={{ color: 'rgba(200,164,52,0.3)' }}>|</span>
|
||||
<span>{integrations?.filter((i) => i.status === 'connected').length ?? 0} Integrations Connected</span>
|
||||
{critical > 0 && (
|
||||
<>
|
||||
<span style={{ color: 'rgba(200,164,52,0.3)' }}>|</span>
|
||||
<span style={{ color: '#E74C3C' }}>{critical} Critical</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
115
src/pages/Login.tsx
Normal file
115
src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { useState } from 'react'
|
||||
import { useAuth } from '../lib/AuthContext'
|
||||
import { ApiError } from '../lib/api'
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Login failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-page">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
width: '380px',
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
||||
border: '1px solid rgba(200, 164, 52, 0.12)',
|
||||
borderRadius: '14px',
|
||||
padding: '32px',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '1px',
|
||||
textTransform: 'uppercase',
|
||||
color: '#C8A434',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
ArchNest
|
||||
</h1>
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>Sign in to your dashboard</p>
|
||||
|
||||
<label style={fieldLabel}>Username</label>
|
||||
<input
|
||||
style={fieldInput}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
|
||||
<label style={{ ...fieldLabel, marginTop: '14px' }}>Password</label>
|
||||
<input
|
||||
style={fieldInput}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="cursor-pointer transition-opacity"
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: '22px',
|
||||
height: '38px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: '#0A0B0D',
|
||||
backgroundColor: '#C8A434',
|
||||
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
|
||||
opacity: submitting ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Signing in…' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldLabel: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
color: '#7A7D85',
|
||||
marginBottom: '6px',
|
||||
display: 'block',
|
||||
}
|
||||
|
||||
const fieldInput: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(200,164,52,0.12)',
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
color: '#E8E6E0',
|
||||
fontSize: '13px',
|
||||
padding: '0 12px',
|
||||
outline: 'none',
|
||||
}
|
||||
653
src/pages/Settings.tsx
Normal file
653
src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, ApiError, type Integration } from '../lib/api'
|
||||
import {
|
||||
User,
|
||||
Palette,
|
||||
Plug,
|
||||
Bell,
|
||||
Database,
|
||||
Info,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Check,
|
||||
Download,
|
||||
Upload,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Camera,
|
||||
} from 'lucide-react'
|
||||
|
||||
const navSections = [
|
||||
{ id: 'profile', label: 'Profile', icon: User },
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette },
|
||||
{ id: 'integrations', label: 'Integrations', icon: Plug },
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
||||
{ id: 'data', label: 'Data & Backup', icon: Database },
|
||||
{ id: 'about', label: 'About', icon: Info },
|
||||
]
|
||||
|
||||
const accentColors = [
|
||||
{ name: 'Gold', color: '#C8A434' },
|
||||
{ name: 'Teal', color: '#2DD4BF' },
|
||||
{ name: 'Purple', color: '#A855F7' },
|
||||
{ name: 'Blue', color: '#3B82F6' },
|
||||
{ name: 'Green', color: '#2ECC71' },
|
||||
{ name: 'Red', color: '#E74C3C' },
|
||||
]
|
||||
|
||||
type FieldDef = { key: string; label: string; secret?: boolean }
|
||||
|
||||
const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[] = [
|
||||
{ type: 'proxmox', name: 'Proxmox', fields: [{ key: 'baseUrl', label: 'Host URL' }, { key: 'apiKey', label: 'API Token', secret: true }] },
|
||||
{ type: 'docker', name: 'Docker', fields: [{ key: 'baseUrl', label: 'Socket / Remote URL' }] },
|
||||
{ type: 'netbird', name: 'NetBird', fields: [{ key: 'apiKey', label: 'API Key', secret: true }] },
|
||||
{ type: 'cloudflare', name: 'Cloudflare', fields: [{ key: 'apiKey', label: 'API Token', secret: true }, { key: 'zoneId', label: 'Zone ID' }] },
|
||||
{ type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] },
|
||||
{ type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] },
|
||||
{ type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] },
|
||||
]
|
||||
|
||||
const cardBase: React.CSSProperties = {
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
||||
border: '1px solid rgba(200, 164, 52, 0.08)',
|
||||
borderRadius: '12px',
|
||||
padding: '22px',
|
||||
position: 'relative',
|
||||
}
|
||||
|
||||
const sectionTitle: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1.5px',
|
||||
color: '#7A7D85',
|
||||
fontWeight: 500,
|
||||
marginBottom: '16px',
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
color: '#7A7D85',
|
||||
marginBottom: '6px',
|
||||
display: 'block',
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '34px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(200,164,52,0.12)',
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
color: '#E8E6E0',
|
||||
fontSize: '12px',
|
||||
padding: '0 12px',
|
||||
outline: 'none',
|
||||
}
|
||||
|
||||
function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="cursor-pointer border-none"
|
||||
style={{
|
||||
width: '38px',
|
||||
height: '20px',
|
||||
borderRadius: '10px',
|
||||
backgroundColor: on ? '#C8A434' : 'rgba(255,255,255,0.08)',
|
||||
position: 'relative',
|
||||
transition: 'background-color 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '2px',
|
||||
left: on ? '20px' : '2px',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#0A0B0D',
|
||||
transition: 'left 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function GoldButton({ children, danger }: { children: React.ReactNode; danger?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: danger ? '#E74C3C' : '#0A0B0D',
|
||||
backgroundColor: danger ? 'transparent' : '#C8A434',
|
||||
border: danger ? '1px solid rgba(231,76,60,0.4)' : 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '9px 16px',
|
||||
boxShadow: danger ? 'none' : '0 0 14px rgba(200,164,52,0.2)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileSection() {
|
||||
const [avatar, setAvatar] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => setAvatar(reader.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={cardBase}>
|
||||
<h3 style={sectionTitle}>Profile</h3>
|
||||
<div className="flex items-center gap-4" style={{ marginBottom: '24px' }}>
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="relative rounded-full border-2 flex items-center justify-center font-bold cursor-pointer group"
|
||||
style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
borderColor: '#C8A434',
|
||||
color: '#C8A434',
|
||||
fontSize: '20px',
|
||||
backgroundColor: 'rgba(200,164,52,0.08)',
|
||||
backgroundImage: avatar ? `url(${avatar})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
title="Upload photo"
|
||||
>
|
||||
{!avatar && 'AO'}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
|
||||
>
|
||||
<Camera size={18} color="#E8E6E0" />
|
||||
</div>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleAvatarChange} className="hidden" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ fontSize: '15px', color: '#E8E6E0', fontWeight: 600 }}>ArchNest Ops</span>
|
||||
<span style={{ fontSize: '10px', color: '#C8A434', border: '1px solid rgba(200,164,52,0.3)', borderRadius: '6px', padding: '2px 8px' }}>
|
||||
Administrator
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '12px', color: '#7A7D85' }}>admin@archnest.io</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4" style={{ marginBottom: '20px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Display Name</label>
|
||||
<input style={inputStyle} defaultValue="ArchNest Ops" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Email</label>
|
||||
<input style={inputStyle} defaultValue="admin@archnest.io" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Role</label>
|
||||
<input style={inputStyle} defaultValue="Administrator" disabled />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Timezone</label>
|
||||
<input style={inputStyle} defaultValue="America/New_York (EST)" />
|
||||
</div>
|
||||
</div>
|
||||
<GoldButton>
|
||||
<Check size={14} />
|
||||
Save Changes
|
||||
</GoldButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppearanceSection() {
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark')
|
||||
const [accent, setAccent] = useState('Gold')
|
||||
const [fontSize, setFontSize] = useState(13)
|
||||
const [radius, setRadius] = useState(12)
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState(true)
|
||||
const [animations, setAnimations] = useState(true)
|
||||
|
||||
return (
|
||||
<div style={cardBase}>
|
||||
<h3 style={sectionTitle}>Appearance</h3>
|
||||
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: '20px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Theme</span>
|
||||
<div className="flex items-center gap-1" style={{ backgroundColor: 'rgba(255,255,255,0.03)', borderRadius: '8px', padding: '3px' }}>
|
||||
{(['dark', 'light'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTheme(t)}
|
||||
className="cursor-pointer border-none capitalize"
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '6px 14px',
|
||||
borderRadius: '6px',
|
||||
color: theme === t ? '#0A0B0D' : '#7A7D85',
|
||||
backgroundColor: theme === t ? '#C8A434' : 'transparent',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0', marginBottom: '10px', display: 'block' }}>Accent Color</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{accentColors.map((a) => (
|
||||
<button
|
||||
key={a.name}
|
||||
onClick={() => setAccent(a.name)}
|
||||
title={a.name}
|
||||
className="cursor-pointer border-none rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
backgroundColor: a.color,
|
||||
outline: accent === a.name ? `2px solid ${a.color}` : 'none',
|
||||
outlineOffset: '3px',
|
||||
}}
|
||||
>
|
||||
{accent === a.name && <Check size={14} color="#0A0B0D" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Font Size</span>
|
||||
<span style={{ fontSize: '11px', color: '#C8A434' }}>{fontSize}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={12}
|
||||
max={16}
|
||||
value={fontSize}
|
||||
onChange={(e) => setFontSize(Number(e.target.value))}
|
||||
className="w-full"
|
||||
style={{ accentColor: '#C8A434' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Card Border Radius</span>
|
||||
<span style={{ fontSize: '11px', color: '#C8A434' }}>{radius}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={4}
|
||||
max={16}
|
||||
value={radius}
|
||||
onChange={(e) => setRadius(Number(e.target.value))}
|
||||
className="w-full"
|
||||
style={{ accentColor: '#C8A434' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Sidebar Expanded by Default</span>
|
||||
<Toggle on={sidebarExpanded} onClick={() => setSidebarExpanded((v) => !v)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Animations</span>
|
||||
<Toggle on={animations} onClick={() => setAnimations((v) => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IntegrationsSection() {
|
||||
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
||||
const [revealed, setRevealed] = useState<Set<string>>(new Set())
|
||||
const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>({})
|
||||
const [statusMsg, setStatusMsg] = useState<Record<string, string>>({})
|
||||
const [busy, setBusy] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
||||
}, [])
|
||||
|
||||
function toggleReveal(key: string) {
|
||||
setRevealed((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function setBusyFlag(type: string, value: boolean) {
|
||||
setBusy((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (value) next.add(type)
|
||||
else next.delete(type)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function setDraftField(type: string, fieldKey: string, value: string) {
|
||||
setDrafts((prev) => ({ ...prev, [type]: { ...prev[type], [fieldKey]: value } }))
|
||||
}
|
||||
|
||||
async function handleSave(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) {
|
||||
setBusyFlag(def.type, true)
|
||||
setStatusMsg((prev) => ({ ...prev, [def.type]: '' }))
|
||||
try {
|
||||
const draft = drafts[def.type] ?? {}
|
||||
const config: Record<string, string> = {}
|
||||
const secrets: Record<string, string> = {}
|
||||
for (const f of def.fields) {
|
||||
const value = draft[f.key]
|
||||
if (value === undefined) continue
|
||||
if (f.secret) secrets[f.key] = value
|
||||
else config[f.key] = value
|
||||
}
|
||||
let integration: Integration
|
||||
if (existing) {
|
||||
;({ integration } = await api.updateIntegration(existing.id, { config, secrets }))
|
||||
} else {
|
||||
;({ integration } = await api.createIntegration({ type: def.type, name: def.name, config, secrets }))
|
||||
}
|
||||
setIntegrations((prev) => {
|
||||
const others = (prev ?? []).filter((i) => i.id !== integration.id)
|
||||
return [...others, integration]
|
||||
})
|
||||
setStatusMsg((prev) => ({ ...prev, [def.type]: 'Saved' }))
|
||||
} catch (err) {
|
||||
setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Save failed' }))
|
||||
} finally {
|
||||
setBusyFlag(def.type, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) {
|
||||
if (!existing) {
|
||||
setStatusMsg((prev) => ({ ...prev, [def.type]: 'Save the integration before testing' }))
|
||||
return
|
||||
}
|
||||
setBusyFlag(def.type, true)
|
||||
try {
|
||||
const result = await api.testIntegration(existing.id)
|
||||
setStatusMsg((prev) => ({ ...prev, [def.type]: result.message }))
|
||||
const { integrations } = await api.listIntegrations()
|
||||
setIntegrations(integrations)
|
||||
} catch (err) {
|
||||
setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Test failed' }))
|
||||
} finally {
|
||||
setBusyFlag(def.type, false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!integrations) {
|
||||
return (
|
||||
<div style={cardBase}>
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading integrations…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{integrationTypeDefs.map((def) => {
|
||||
const existing = integrations.find((i) => i.type === def.type)
|
||||
const online = existing?.status === 'connected'
|
||||
const draft = drafts[def.type] ?? {}
|
||||
|
||||
return (
|
||||
<div key={def.type} style={cardBase}>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: online ? '#2ECC71' : '#4A4D55',
|
||||
boxShadow: online ? '0 0 6px rgba(46,204,113,0.6)' : 'none',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>{def.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusMsg[def.type] && (
|
||||
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[def.type]}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleSave(def, existing)}
|
||||
disabled={busy.has(def.type)}
|
||||
className="cursor-pointer border-none"
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#0A0B0D',
|
||||
backgroundColor: '#C8A434',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
opacity: busy.has(def.type) ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTest(def, existing)}
|
||||
disabled={busy.has(def.type)}
|
||||
className="cursor-pointer border-none"
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#C8A434',
|
||||
backgroundColor: 'rgba(200,164,52,0.08)',
|
||||
border: '1px solid rgba(200,164,52,0.2)',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
opacity: busy.has(def.type) ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{def.fields.map((f) => {
|
||||
const key = `${def.type}-${f.key}`
|
||||
const isRevealed = revealed.has(key)
|
||||
const savedValue = f.secret ? '' : existing?.config[f.key] ?? ''
|
||||
const value = draft[f.key] ?? savedValue
|
||||
return (
|
||||
<div key={key}>
|
||||
<label style={labelStyle}>{f.label}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
style={inputStyle}
|
||||
type={f.secret && !isRevealed ? 'password' : 'text'}
|
||||
value={value}
|
||||
onChange={(e) => setDraftField(def.type, f.key, e.target.value)}
|
||||
placeholder={f.secret && existing ? '••••••••••••' : 'Not configured'}
|
||||
/>
|
||||
{f.secret && (
|
||||
<button
|
||||
onClick={() => toggleReveal(key)}
|
||||
className="absolute cursor-pointer border-none bg-transparent"
|
||||
style={{ right: '8px', top: '50%', transform: 'translateY(-50%)', color: '#7A7D85' }}
|
||||
>
|
||||
{isRevealed ? <EyeOff size={13} /> : <Eye size={13} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationsSection() {
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [email, setEmail] = useState(true)
|
||||
const [push, setPush] = useState(false)
|
||||
const [sound, setSound] = useState(true)
|
||||
|
||||
return (
|
||||
<div style={cardBase}>
|
||||
<h3 style={sectionTitle}>Notifications</h3>
|
||||
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: '20px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Enable Notifications</span>
|
||||
<Toggle on={enabled} onClick={() => setEnabled((v) => !v)} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={labelStyle}>Alert Threshold</label>
|
||||
<select style={{ ...inputStyle, width: '220px' }} defaultValue="all">
|
||||
<option value="all">All</option>
|
||||
<option value="critical">Critical Only</option>
|
||||
<option value="warning">Warning & Above</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Email Notifications</span>
|
||||
<Toggle on={email} onClick={() => setEmail((v) => !v)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Browser Push</span>
|
||||
<Toggle on={push} onClick={() => setPush((v) => !v)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Sound</span>
|
||||
<Toggle on={sound} onClick={() => setSound((v) => !v)} />
|
||||
</div>
|
||||
{sound && (
|
||||
<input type="range" min={0} max={100} defaultValue={70} className="w-full" style={{ accentColor: '#C8A434' }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DataBackupSection() {
|
||||
return (
|
||||
<div style={cardBase}>
|
||||
<h3 style={sectionTitle}>Data & Backup</h3>
|
||||
<div className="flex flex-col gap-3" style={{ maxWidth: '320px' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Export Bookmarks (JSON)</span>
|
||||
<GoldButton><Download size={13} /> Export</GoldButton>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Import Bookmarks (JSON)</span>
|
||||
<GoldButton><Upload size={13} /> Import</GoldButton>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Export Settings</span>
|
||||
<GoldButton><Download size={13} /> Export</GoldButton>
|
||||
</div>
|
||||
<div className="border-t" style={{ borderColor: 'rgba(231,76,60,0.15)', margin: '8px 0' }} />
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Clear Cache</span>
|
||||
<GoldButton danger><Trash2 size={13} /> Clear</GoldButton>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Reset to Defaults</span>
|
||||
<GoldButton danger><RotateCcw size={13} /> Reset</GoldButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AboutSection() {
|
||||
const rows: [string, string][] = [
|
||||
['App', 'ArchNest Dashboard v1.0.0'],
|
||||
['Author', 'Samuel James'],
|
||||
['Repo', 'github.com/SamuelSJames/archnest'],
|
||||
['Stack', 'React 19, Vite, TypeScript'],
|
||||
['License', 'MIT'],
|
||||
]
|
||||
return (
|
||||
<div style={cardBase}>
|
||||
<h3 style={sectionTitle}>About</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="flex items-center justify-between">
|
||||
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{label}</span>
|
||||
<span style={{ fontSize: '12px', color: '#E8E6E0' }}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sectionComponents: Record<string, () => JSX.Element> = {
|
||||
profile: ProfileSection,
|
||||
appearance: AppearanceSection,
|
||||
integrations: IntegrationsSection,
|
||||
notifications: NotificationsSection,
|
||||
data: DataBackupSection,
|
||||
about: AboutSection,
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const [active, setActive] = useState('profile')
|
||||
const ActiveSection = sectionComponents[active]
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full gap-5">
|
||||
{/* Settings nav */}
|
||||
<div className="flex flex-col gap-1 shrink-0" style={{ width: '200px' }}>
|
||||
{navSections.map((s) => {
|
||||
const Icon = s.icon
|
||||
const isActive = active === s.id
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setActive(s.id)}
|
||||
className="flex items-center gap-2.5 cursor-pointer border-none bg-transparent transition-colors"
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
padding: '10px 14px',
|
||||
borderRadius: '8px',
|
||||
color: isActive ? '#C8A434' : '#7A7D85',
|
||||
backgroundColor: isActive ? 'rgba(200,164,52,0.1)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{s.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'none' }}>
|
||||
<ActiveSection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,4 +4,12 @@ import tailwindcss from '@tailwindcss/vite'
|
|||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue