added phpipam and tailscale integration
This commit is contained in:
@@ -86,6 +86,47 @@ SSO is disabled (and the button is hidden) when any of the three `AUTHENTIK_*` v
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## phpIPAM
|
||||||
|
|
||||||
|
Import IP addresses and devices from a self-hosted [phpIPAM](https://phpipam.net/) instance (optional). When configured, the IPAM page gains a **Sync External** button and shows a phpIPAM source badge on synced entries. Addresses can be edited or deleted with write-back to phpIPAM.
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `PHPIPAM_URL` | Yes | Base URL of your phpIPAM instance, e.g. `https://ipam.example.com` |
|
||||||
|
| `PHPIPAM_APP_ID` | Yes | API application ID created in phpIPAM → Administration → API |
|
||||||
|
| `PHPIPAM_TOKEN` | Yes | Static app token (token-based auth, no username/password needed) |
|
||||||
|
|
||||||
|
**Setup steps in phpIPAM:**
|
||||||
|
|
||||||
|
1. Go to **Administration → phpIPAM Settings** and enable the REST API.
|
||||||
|
2. Go to **Administration → API** and click **+ Create API key**.
|
||||||
|
3. Set **App identifier** to any slug (e.g. `sloth-manager`), **App permissions** to `Read/Write`, and **App security** to `SSL with App code token`.
|
||||||
|
4. Copy the **App code** — this is your `PHPIPAM_TOKEN`.
|
||||||
|
5. Set `PHPIPAM_APP_ID` to the same slug you chose as the App identifier.
|
||||||
|
|
||||||
|
phpIPAM integration is disabled when any of the three `PHPIPAM_*` variables are missing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tailscale
|
||||||
|
|
||||||
|
Import devices from a [Tailscale](https://tailscale.com/) tailnet (optional). When configured, the IPAM page shows devices with their Tailscale IPs. Devices can be authorized/de-authorized or removed from the tailnet directly from Sloth Manager.
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `TAILSCALE_API_KEY` | Yes | API key generated at [tailscale.com/settings/keys](https://login.tailscale.com/admin/settings/keys) |
|
||||||
|
| `TAILSCALE_TAILNET` | Yes | Tailnet name, e.g. `yourorg.github` — use `-` to target your default tailnet |
|
||||||
|
|
||||||
|
**Setup steps:**
|
||||||
|
|
||||||
|
1. Go to **tailscale.com → Settings → Keys → Generate access token**.
|
||||||
|
2. Grant the token **Devices: Read** and **Devices: Write** (write is needed for authorize and delete actions).
|
||||||
|
3. Set `TAILSCALE_TAILNET` to your tailnet name (visible in the Admin console URL: `login.tailscale.com/admin/machines/<tailnet>`), or use `-` for the default.
|
||||||
|
|
||||||
|
Tailscale integration is disabled when either variable is missing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
@@ -139,6 +180,15 @@ AUTHENTIK_URL=https://auth.example.com/application/o/sloth-manager
|
|||||||
AUTHENTIK_CLIENT_ID=your_client_id
|
AUTHENTIK_CLIENT_ID=your_client_id
|
||||||
AUTHENTIK_CLIENT_SECRET=your_client_secret
|
AUTHENTIK_CLIENT_SECRET=your_client_secret
|
||||||
|
|
||||||
|
# phpIPAM (optional)
|
||||||
|
PHPIPAM_URL=https://ipam.example.com
|
||||||
|
PHPIPAM_APP_ID=sloth-manager
|
||||||
|
PHPIPAM_TOKEN=your_app_token
|
||||||
|
|
||||||
|
# Tailscale (optional)
|
||||||
|
TAILSCALE_API_KEY=tskey-api-xxxxxxxxxxxxxxxx
|
||||||
|
TAILSCALE_TAILNET=yourorg.github
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
JWT_SECRET=your-long-random-secret-here
|
JWT_SECRET=your-long-random-secret-here
|
||||||
JWT_EXPIRES_IN=24h
|
JWT_EXPIRES_IN=24h
|
||||||
|
|||||||
@@ -46,6 +46,33 @@ npm run build # outputs to frontend/build/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
### Production (pull images from registry)
|
||||||
|
|
||||||
|
Uses pre-built images from the Gitea registry — no local build required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
To update to the latest images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development (build images locally)
|
||||||
|
|
||||||
|
Builds images from the local source code.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## First run
|
## First run
|
||||||
|
|
||||||
On first start the backend automatically creates a default admin account and prints a warning in the terminal:
|
On first start the backend automatically creates a default admin account and prints a warning in the terminal:
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ DISABLED_PROVIDERS=
|
|||||||
# AUTHENTIK_CLIENT_ID=your_client_id
|
# AUTHENTIK_CLIENT_ID=your_client_id
|
||||||
# AUTHENTIK_CLIENT_SECRET=your_client_secret
|
# AUTHENTIK_CLIENT_SECRET=your_client_secret
|
||||||
|
|
||||||
|
# phpIPAM (optional — leave blank to disable phpIPAM integration)
|
||||||
|
# PHPIPAM_URL=https://ipam.example.com
|
||||||
|
# PHPIPAM_APP_ID=sloth-manager
|
||||||
|
# PHPIPAM_TOKEN=your_app_token
|
||||||
|
|
||||||
|
# Tailscale (optional — leave blank to disable Tailscale integration)
|
||||||
|
# TAILSCALE_API_KEY=tskey-api-xxxxxxxxxxxxxxxx
|
||||||
|
# TAILSCALE_TAILNET=yourorg.github
|
||||||
|
|
||||||
# Auth — generate a strong random secret, e.g: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
# Auth — generate a strong random secret, e.g: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
JWT_SECRET=change-this-to-a-long-random-string
|
JWT_SECRET=change-this-to-a-long-random-string
|
||||||
JWT_EXPIRES_IN=24h
|
JWT_EXPIRES_IN=24h
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* phpIPAM adapter
|
||||||
|
*
|
||||||
|
* Requires env:
|
||||||
|
* PHPIPAM_URL Base URL, e.g. https://192.168.1.100 or https://ipam.example.com
|
||||||
|
* PHPIPAM_APP_ID Application ID created in phpIPAM → Administration → API
|
||||||
|
* PHPIPAM_TOKEN App code token (set App security to "App code token" in phpIPAM)
|
||||||
|
*
|
||||||
|
* API docs: https://phpipam.net/api/api_documentation/
|
||||||
|
*/
|
||||||
|
|
||||||
|
function base() {
|
||||||
|
return `${(process.env.PHPIPAM_URL || '').replace(/\/$/, '')}/api/${process.env.PHPIPAM_APP_ID || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
return {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'phpipam-token': process.env.PHPIPAM_TOKEN || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const url = `${base()}${path}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: headers(),
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(text); } catch { throw new Error(`phpIPAM returned non-JSON: ${text.slice(0, 120)}`); }
|
||||||
|
if (data.success === false) throw new Error(data.message || 'phpIPAM API error');
|
||||||
|
return data.data ?? data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Subnets ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function listSubnets() {
|
||||||
|
const raw = await api('GET', '/subnets/');
|
||||||
|
return (raw || []).map(s => ({
|
||||||
|
id: String(s.id),
|
||||||
|
subnet: s.subnet,
|
||||||
|
mask: s.mask,
|
||||||
|
description: s.description || '',
|
||||||
|
section_id: String(s.sectionId || ''),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Addresses ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function listAddresses() {
|
||||||
|
const raw = await api('GET', '/addresses/');
|
||||||
|
return (raw || []).map(normaliseAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseAddress(a) {
|
||||||
|
return {
|
||||||
|
external_id: String(a.id),
|
||||||
|
source: 'phpipam',
|
||||||
|
kind: 'address',
|
||||||
|
address: a.ip || '',
|
||||||
|
label: a.hostname || '',
|
||||||
|
vendor: '',
|
||||||
|
location: '',
|
||||||
|
notes: a.description || a.note || '',
|
||||||
|
subnet_id: String(a.subnetId || ''),
|
||||||
|
online: a.online_status === '1' || a.online_status === 1,
|
||||||
|
last_seen: a.lastSeen || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAddress(data) {
|
||||||
|
const result = await api('POST', '/addresses/', {
|
||||||
|
ip: data.address,
|
||||||
|
hostname: data.label || '',
|
||||||
|
description: data.notes || '',
|
||||||
|
subnetId: data.subnet_id || 1,
|
||||||
|
});
|
||||||
|
// phpIPAM returns the new ID as a string
|
||||||
|
return { external_id: String(result), ...data, source: 'phpipam', kind: 'address' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAddress(externalId, data) {
|
||||||
|
await api('PATCH', `/addresses/${externalId}/`, {
|
||||||
|
hostname: data.label !== undefined ? data.label : undefined,
|
||||||
|
description: data.notes !== undefined ? data.notes : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAddress(externalId) {
|
||||||
|
await api('DELETE', `/addresses/${externalId}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Devices ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function listDevices() {
|
||||||
|
const raw = await api('GET', '/devices/');
|
||||||
|
return (raw || []).map(d => ({
|
||||||
|
external_id: `dev-${d.id}`,
|
||||||
|
source: 'phpipam',
|
||||||
|
kind: 'device',
|
||||||
|
address: d.ip || '',
|
||||||
|
label: d.hostname || d.description || '',
|
||||||
|
vendor: d.vendor || d.type || '',
|
||||||
|
location: d.location || '',
|
||||||
|
notes: d.description || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateDevice(rawId, data) {
|
||||||
|
await api('PATCH', `/devices/${rawId}/`, {
|
||||||
|
hostname: data.label !== undefined ? data.label : undefined,
|
||||||
|
description: data.notes !== undefined ? data.notes : undefined,
|
||||||
|
location: data.location !== undefined ? data.location : undefined,
|
||||||
|
vendor: data.vendor !== undefined ? data.vendor : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDevice(rawId) {
|
||||||
|
await api('DELETE', `/devices/${rawId}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Health check ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function ping() {
|
||||||
|
const start = Date.now();
|
||||||
|
await api('GET', '/sections/');
|
||||||
|
return Date.now() - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
return !!(process.env.PHPIPAM_URL && process.env.PHPIPAM_APP_ID && process.env.PHPIPAM_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isEnabled,
|
||||||
|
ping,
|
||||||
|
listSubnets,
|
||||||
|
listAddresses,
|
||||||
|
createAddress,
|
||||||
|
updateAddress,
|
||||||
|
deleteAddress,
|
||||||
|
listDevices,
|
||||||
|
updateDevice,
|
||||||
|
deleteDevice,
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Tailscale adapter
|
||||||
|
*
|
||||||
|
* Requires env:
|
||||||
|
* TAILSCALE_API_KEY API key from tailscale.com/settings/keys (or OAuth client secret)
|
||||||
|
* TAILSCALE_TAILNET Tailnet name, e.g. "yourorg.github" — use "-" for the default tailnet
|
||||||
|
*
|
||||||
|
* API docs: https://tailscale.com/api
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = 'https://api.tailscale.com';
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${process.env.TAILSCALE_API_KEY || ''}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tailnet() {
|
||||||
|
return encodeURIComponent(process.env.TAILSCALE_TAILNET || '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: headers(),
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.message || `Tailscale API error: HTTP ${res.status}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Devices ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function listDevices() {
|
||||||
|
const data = await api('GET', `/api/v2/tailnet/${tailnet()}/devices`);
|
||||||
|
return (data.devices || []).map(d => ({
|
||||||
|
external_id: d.nodeId || d.id,
|
||||||
|
tailnet_id: d.id, // stable numeric ID for device-level API calls
|
||||||
|
source: 'tailscale',
|
||||||
|
kind: 'device',
|
||||||
|
address: d.addresses?.[0] || '', // primary Tailscale IP
|
||||||
|
all_addresses: d.addresses || [],
|
||||||
|
label: d.displayName || d.hostname || '',
|
||||||
|
vendor: d.os || '',
|
||||||
|
location: '',
|
||||||
|
notes: '',
|
||||||
|
last_seen: d.lastSeen || null,
|
||||||
|
is_exit_node: !!(d.isExitNode),
|
||||||
|
authorized: !!(d.authorized),
|
||||||
|
online: d.online ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a device from the tailnet
|
||||||
|
async function deleteDevice(nodeId) {
|
||||||
|
await api('DELETE', `/api/v2/device/${nodeId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize (or de-authorize) a device
|
||||||
|
async function setAuthorized(nodeId, authorized) {
|
||||||
|
await api('POST', `/api/v2/device/${nodeId}/authorized`, { authorized });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Health check ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function ping() {
|
||||||
|
const start = Date.now();
|
||||||
|
await api('GET', `/api/v2/tailnet/${tailnet()}/devices`);
|
||||||
|
return Date.now() - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
return !!(process.env.TAILSCALE_API_KEY && process.env.TAILSCALE_TAILNET);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isEnabled,
|
||||||
|
ping,
|
||||||
|
listDevices,
|
||||||
|
deleteDevice,
|
||||||
|
setAuthorized,
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* External IPAM cache — stores data synced from phpIPAM and Tailscale.
|
||||||
|
* Kept separate from ipam.json (local entries) so a re-sync never risks
|
||||||
|
* overwriting local data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const EXT_PATH = process.env.IPAM_EXT_PATH || path.join(__dirname, '..', 'ipam-external.json');
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
try { return JSON.parse(fs.readFileSync(EXT_PATH, 'utf8')); }
|
||||||
|
catch { return { synced_at: null, entries: [] }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(data) {
|
||||||
|
fs.writeFileSync(EXT_PATH, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAll() {
|
||||||
|
return load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace all cached entries (optionally for one source only). */
|
||||||
|
function set(newEntries, source) {
|
||||||
|
const current = load();
|
||||||
|
const kept = source
|
||||||
|
? current.entries.filter(e => e.source !== source)
|
||||||
|
: [];
|
||||||
|
save({
|
||||||
|
synced_at: new Date().toISOString(),
|
||||||
|
entries: [...kept, ...newEntries],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getAll, set };
|
||||||
+138
-6
@@ -1,18 +1,74 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ipam = require('../ipam');
|
const ipam = require('../ipam');
|
||||||
const audit = require('../audit');
|
const ext = require('../ipam-external');
|
||||||
|
const audit = require('../audit');
|
||||||
const { requireAuth } = require('../auth');
|
const { requireAuth } = require('../auth');
|
||||||
|
const phpipam = require('../adapters/phpipam');
|
||||||
|
const tailscale = require('../adapters/tailscale');
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
// ─── GET /api/ipam ────────────────────────────────────────────────────────────
|
||||||
|
// Returns local entries (with source:'local') merged with cached external data.
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
res.json(ipam.getAll());
|
const local = ipam.getAll().map(e => ({ ...e, source: e.source || 'local' }));
|
||||||
|
const { entries: external, synced_at } = ext.getAll();
|
||||||
|
res.json({ entries: [...local, ...external], synced_at });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── GET /api/ipam/sources ────────────────────────────────────────────────────
|
||||||
|
// Tells the frontend which external sources are configured.
|
||||||
|
|
||||||
|
router.get('/sources', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
phpipam: phpipam.isEnabled(),
|
||||||
|
tailscale: tailscale.isEnabled(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── POST /api/ipam/sync ──────────────────────────────────────────────────────
|
||||||
|
// Body: { sources: ['phpipam', 'tailscale'] } (omit to sync all enabled)
|
||||||
|
|
||||||
|
router.post('/sync', async (req, res) => {
|
||||||
|
const requested = req.body?.sources ?? ['phpipam', 'tailscale'];
|
||||||
|
const results = { phpipam: null, tailscale: null, errors: {} };
|
||||||
|
|
||||||
|
if (requested.includes('phpipam') && phpipam.isEnabled()) {
|
||||||
|
try {
|
||||||
|
const [addresses, devices] = await Promise.all([
|
||||||
|
phpipam.listAddresses(),
|
||||||
|
phpipam.listDevices(),
|
||||||
|
]);
|
||||||
|
const entries = [...addresses, ...devices];
|
||||||
|
ext.set(entries, 'phpipam');
|
||||||
|
results.phpipam = entries.length;
|
||||||
|
} catch (err) {
|
||||||
|
results.errors.phpipam = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requested.includes('tailscale') && tailscale.isEnabled()) {
|
||||||
|
try {
|
||||||
|
const devices = await tailscale.listDevices();
|
||||||
|
ext.set(devices, 'tailscale');
|
||||||
|
results.tailscale = devices.length;
|
||||||
|
} catch (err) {
|
||||||
|
results.errors.tailscale = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entries: external, synced_at } = ext.getAll();
|
||||||
|
const local = ipam.getAll().map(e => ({ ...e, source: e.source || 'local' }));
|
||||||
|
res.json({ results, entries: [...local, ...external], synced_at });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Local entry CRUD ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const entry = ipam.create(req.body);
|
const entry = ipam.create({ ...req.body, source: 'local' });
|
||||||
audit.logIpam(req.user, 'add', entry);
|
audit.logIpam(req.user, 'add', entry);
|
||||||
res.status(201).json(entry);
|
res.status(201).json(entry);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -42,4 +98,80 @@ router.delete('/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── phpIPAM write-back ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.put('/phpipam/:externalId', async (req, res) => {
|
||||||
|
if (!phpipam.isEnabled()) return res.status(400).json({ error: 'phpIPAM is not configured' });
|
||||||
|
const { externalId } = req.params;
|
||||||
|
try {
|
||||||
|
if (externalId.startsWith('dev-')) {
|
||||||
|
await phpipam.updateDevice(externalId.replace('dev-', ''), req.body);
|
||||||
|
} else {
|
||||||
|
await phpipam.updateAddress(externalId, req.body);
|
||||||
|
}
|
||||||
|
// Refresh the phpIPAM cache entry in-place
|
||||||
|
const [addresses, devices] = await Promise.all([
|
||||||
|
phpipam.listAddresses(),
|
||||||
|
phpipam.listDevices(),
|
||||||
|
]);
|
||||||
|
ext.set([...addresses, ...devices], 'phpipam');
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/phpipam/:externalId', async (req, res) => {
|
||||||
|
if (!phpipam.isEnabled()) return res.status(400).json({ error: 'phpIPAM is not configured' });
|
||||||
|
const { externalId } = req.params;
|
||||||
|
try {
|
||||||
|
if (externalId.startsWith('dev-')) {
|
||||||
|
await phpipam.deleteDevice(externalId.replace('dev-', ''));
|
||||||
|
} else {
|
||||||
|
await phpipam.deleteAddress(externalId);
|
||||||
|
}
|
||||||
|
// Remove from local cache
|
||||||
|
const { entries } = ext.getAll();
|
||||||
|
ext.set(entries.filter(e => !(e.source === 'phpipam' && e.external_id === externalId)), null);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tailscale device actions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Remove a device from the tailnet
|
||||||
|
router.delete('/tailscale/:nodeId', async (req, res) => {
|
||||||
|
if (!tailscale.isEnabled()) return res.status(400).json({ error: 'Tailscale is not configured' });
|
||||||
|
try {
|
||||||
|
await tailscale.deleteDevice(req.params.nodeId);
|
||||||
|
const { entries } = ext.getAll();
|
||||||
|
ext.set(entries.filter(e => !(e.source === 'tailscale' && e.external_id === req.params.nodeId)), null);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authorize / de-authorize a device
|
||||||
|
router.post('/tailscale/:nodeId/authorized', async (req, res) => {
|
||||||
|
if (!tailscale.isEnabled()) return res.status(400).json({ error: 'Tailscale is not configured' });
|
||||||
|
const authorized = req.body?.authorized !== false; // default true
|
||||||
|
try {
|
||||||
|
await tailscale.setAuthorized(req.params.nodeId, authorized);
|
||||||
|
// Update cached entry
|
||||||
|
const { entries } = ext.getAll();
|
||||||
|
const updated = entries.map(e =>
|
||||||
|
e.source === 'tailscale' && e.external_id === req.params.nodeId
|
||||||
|
? { ...e, authorized }
|
||||||
|
: e
|
||||||
|
);
|
||||||
|
ext.set(updated, null);
|
||||||
|
res.json({ success: true, authorized });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: sloth-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: ./backend/.env
|
||||||
|
volumes:
|
||||||
|
- ./sloth-data:/data
|
||||||
|
networks:
|
||||||
|
- sloth-net
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: sloth-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8088:80"
|
||||||
|
networks:
|
||||||
|
- sloth-net
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
networks:
|
||||||
|
sloth-net:
|
||||||
|
driver: bridge
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
image: gitea.labsconnect.se/bobban/sloth-manager-backend:latest
|
||||||
container_name: sloth-backend
|
container_name: sloth-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: ./backend/.env
|
env_file: ./backend/.env
|
||||||
@@ -11,7 +11,7 @@ services:
|
|||||||
- sloth-net
|
- sloth-net
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
image: gitea.labsconnect.se/bobban/sloth-manager-frontend:latest
|
||||||
container_name: sloth-frontend
|
container_name: sloth-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -367,6 +367,16 @@ body {
|
|||||||
.secret-notes { color: var(--text-muted); cursor: help; margin-left: 4px; }
|
.secret-notes { color: var(--text-muted); cursor: help; margin-left: 4px; }
|
||||||
|
|
||||||
/* ===== IPAM ===== */
|
/* ===== IPAM ===== */
|
||||||
|
.source-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.ipam-address {
|
.ipam-address {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -104,6 +104,43 @@ export async function getIpam() {
|
|||||||
return handleResponse(await fetch(`${BASE}/ipam`, { headers: authHeaders() }));
|
return handleResponse(await fetch(`${BASE}/ipam`, { headers: authHeaders() }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getIpamSources() {
|
||||||
|
return handleResponse(await fetch(`${BASE}/ipam/sources`, { headers: authHeaders() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncIpam(sources) {
|
||||||
|
return handleResponse(await fetch(`${BASE}/ipam/sync`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify(sources ? { sources } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePhpipamEntry(externalId, data) {
|
||||||
|
return handleResponse(await fetch(`${BASE}/ipam/phpipam/${externalId}`, {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePhpipamEntry(externalId) {
|
||||||
|
return handleResponse(await fetch(`${BASE}/ipam/phpipam/${externalId}`, {
|
||||||
|
method: 'DELETE', headers: authHeaders(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTailscaleDevice(nodeId) {
|
||||||
|
return handleResponse(await fetch(`${BASE}/ipam/tailscale/${nodeId}`, {
|
||||||
|
method: 'DELETE', headers: authHeaders(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTailscaleAuthorized(nodeId, authorized) {
|
||||||
|
return handleResponse(await fetch(`${BASE}/ipam/tailscale/${nodeId}/authorized`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify({ authorized }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function createIpEntry(data) {
|
export async function createIpEntry(data) {
|
||||||
return handleResponse(await fetch(`${BASE}/ipam`, {
|
return handleResponse(await fetch(`${BASE}/ipam`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
|||||||
@@ -1,20 +1,49 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getIpam, createIpEntry, updateIpEntry, deleteIpEntry } from '../api/dns';
|
import {
|
||||||
|
getIpam, getIpamSources, syncIpam,
|
||||||
|
createIpEntry, updateIpEntry, deleteIpEntry,
|
||||||
|
updatePhpipamEntry, deletePhpipamEntry,
|
||||||
|
deleteTailscaleDevice, setTailscaleAuthorized,
|
||||||
|
} from '../api/dns';
|
||||||
import ConfirmDialog from './ConfirmDialog';
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
import { exportCsv } from '../utils/exportCsv';
|
import { exportCsv } from '../utils/exportCsv';
|
||||||
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
|
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
|
||||||
|
|
||||||
function isIPv6(address) {
|
function isIPv6(address) {
|
||||||
return address.includes(':');
|
return address && address.includes(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
function IpForm({ initial, onSave, onCancel }) {
|
// ─── Source badge ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SOURCE_COLORS = {
|
||||||
|
local: { bg: 'var(--accent)', text: '#fff' },
|
||||||
|
phpipam: { bg: '#e67e22', text: '#fff' },
|
||||||
|
tailscale: { bg: '#4a90e2', text: '#fff' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function SourceBadge({ source, kind }) {
|
||||||
|
const c = SOURCE_COLORS[source] ?? SOURCE_COLORS.local;
|
||||||
|
const label = source === 'local' ? 'Local'
|
||||||
|
: source === 'phpipam' ? (kind === 'device' ? 'phpIPAM device' : 'phpIPAM')
|
||||||
|
: 'Tailscale';
|
||||||
|
return (
|
||||||
|
<span className="source-badge" style={{ background: c.bg, color: c.text }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Edit / Add form ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function IpForm({ initial, onSave, onCancel, subnets }) {
|
||||||
|
const isExternal = initial && initial.source !== 'local';
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
address: initial?.address ?? '',
|
address: initial?.address ?? '',
|
||||||
label: initial?.label ?? '',
|
label: initial?.label ?? '',
|
||||||
vendor: initial?.vendor ?? '',
|
vendor: initial?.vendor ?? '',
|
||||||
location: initial?.location ?? '',
|
location: initial?.location ?? '',
|
||||||
notes: initial?.notes ?? '',
|
notes: initial?.notes ?? '',
|
||||||
|
subnet_id: initial?.subnet_id ?? '',
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -34,27 +63,56 @@ function IpForm({ initial, onSave, onCancel }) {
|
|||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onCancel}>
|
<div className="modal-overlay" onClick={onCancel}>
|
||||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||||
<h3>{initial ? 'Edit IP Address' : 'Add IP Address'}</h3>
|
<h3>
|
||||||
|
{initial ? 'Edit' : 'Add'} IP Address
|
||||||
|
{initial && <SourceBadge source={initial.source} kind={initial.kind} />}
|
||||||
|
</h3>
|
||||||
<form onSubmit={handleSubmit} className="record-form">
|
<form onSubmit={handleSubmit} className="record-form">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label>IP Address
|
<label>IP Address
|
||||||
<input name="address" value={form.address} onChange={handleChange} placeholder="1.2.3.4 or 2001:db8::1" required autoFocus disabled={!!initial} />
|
<input name="address" value={form.address} onChange={handleChange}
|
||||||
|
placeholder="1.2.3.4 or 2001:db8::1" required autoFocus
|
||||||
|
disabled={!!initial} />
|
||||||
</label>
|
</label>
|
||||||
<label>Label
|
<label>Label / Hostname
|
||||||
<input name="label" value={form.label} onChange={handleChange} placeholder="e.g. Web Server 1" />
|
<input name="label" value={form.label} onChange={handleChange}
|
||||||
|
placeholder="e.g. Web Server 1" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
|
||||||
<label>Vendor / Provider
|
{/* Local + phpIPAM device only */}
|
||||||
<input name="vendor" value={form.vendor} onChange={handleChange} placeholder="e.g. Hetzner, DigitalOcean" />
|
{(!isExternal || (initial?.source === 'phpipam' && initial?.kind === 'device')) && (
|
||||||
|
<div className="form-row">
|
||||||
|
<label>Vendor
|
||||||
|
<input name="vendor" value={form.vendor} onChange={handleChange}
|
||||||
|
placeholder="e.g. Cisco, Dell" />
|
||||||
|
</label>
|
||||||
|
<label>Location
|
||||||
|
<input name="location" value={form.location} onChange={handleChange}
|
||||||
|
placeholder="e.g. Rack A, Floor 2" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* phpIPAM address — subnet selector */}
|
||||||
|
{!initial && subnets.length > 0 && (
|
||||||
|
<label>phpIPAM Subnet (optional)
|
||||||
|
<select name="subnet_id" value={form.subnet_id} onChange={handleChange}>
|
||||||
|
<option value="">— Local only —</option>
|
||||||
|
{subnets.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.subnet}/{s.mask}{s.description ? ` — ${s.description}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Location / Region
|
)}
|
||||||
<input name="location" value={form.location} onChange={handleChange} placeholder="e.g. Frankfurt, US-East" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label>Notes
|
<label>Notes
|
||||||
<input name="notes" value={form.notes} onChange={handleChange} placeholder="Optional notes" />
|
<input name="notes" value={form.notes} onChange={handleChange}
|
||||||
|
placeholder="Optional notes" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="button" className="btn-secondary" onClick={onCancel} disabled={saving}>Cancel</button>
|
<button type="button" className="btn-secondary" onClick={onCancel} disabled={saving}>Cancel</button>
|
||||||
@@ -68,13 +126,18 @@ function IpForm({ initial, onSave, onCancel }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── DNS match badges ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DnsMatchBadges({ matches, colors }) {
|
function DnsMatchBadges({ matches, colors }) {
|
||||||
if (!matches || matches.length === 0) return <span style={{ color: 'var(--text-muted)', fontSize: 12 }}>—</span>;
|
if (!matches || matches.length === 0)
|
||||||
|
return <span style={{ color: 'var(--text-muted)', fontSize: 12 }}>—</span>;
|
||||||
return (
|
return (
|
||||||
<div className="ipam-dns-matches">
|
<div className="ipam-dns-matches">
|
||||||
{matches.map((m, i) => (
|
{matches.map((m, i) => (
|
||||||
<span key={i} className="ipam-dns-match" title={`${m.type} ${m.name}`}>
|
<span key={i} className="ipam-dns-match" title={`${m.type} ${m.name}`}>
|
||||||
<span className="badge" style={{ ...providerBadgeStyle(colors, m.provider), fontSize: 10, padding: '1px 5px' }}>{m.provider}</span>
|
<span className="badge" style={{ ...providerBadgeStyle(colors, m.provider), fontSize: 10, padding: '1px 5px' }}>
|
||||||
|
{m.provider}
|
||||||
|
</span>
|
||||||
<span className="ipam-dns-name">{m.name}</span>
|
<span className="ipam-dns-name">{m.name}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -82,99 +145,226 @@ function DnsMatchBadges({ matches, colors }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function IpamPage() {
|
export default function IpamPage() {
|
||||||
const { colors } = useProviderColors();
|
const { colors } = useProviderColors();
|
||||||
const [entries, setEntries] = useState([]);
|
const [entries, setEntries] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [syncedAt, setSyncedAt] = useState(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [sources, setSources] = useState({ phpipam: false, tailscale: false });
|
||||||
const [editing, setEditing] = useState(null);
|
const [subnets, setSubnets] = useState([]);
|
||||||
const [confirmDel, setConfirmDel] = useState(null);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState('');
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(null);
|
||||||
|
const [confirmDel, setConfirmDel] = useState(null);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [sourceFilter, setSourceFilter] = useState('all');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
function load() {
|
function mergeEntries(data) {
|
||||||
setLoading(true);
|
setEntries(data.entries ?? []);
|
||||||
getIpam()
|
if (data.synced_at) setSyncedAt(data.synced_at);
|
||||||
.then(setEntries)
|
|
||||||
.catch(e => setError(e.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [data, src] = await Promise.all([getIpam(), getIpamSources()]);
|
||||||
|
mergeEntries(data);
|
||||||
|
setSources(src);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||||
|
|
||||||
|
async function handleSync() {
|
||||||
|
setSyncing(true); setError('');
|
||||||
|
try {
|
||||||
|
const data = await syncIpam();
|
||||||
|
mergeEntries(data);
|
||||||
|
if (Object.keys(data.results?.errors ?? {}).length) {
|
||||||
|
const errs = Object.entries(data.results.errors).map(([k, v]) => `${k}: ${v}`).join('; ');
|
||||||
|
setError(`Sync completed with errors — ${errs}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save (local or phpIPAM write-back) ──────────────────────────────────────
|
||||||
|
|
||||||
async function handleSave(data) {
|
async function handleSave(data) {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
const updated = await updateIpEntry(editing.id, data);
|
if (editing.source === 'phpipam') {
|
||||||
setEntries(e => e.map(x => x.id === editing.id ? updated : x));
|
await updatePhpipamEntry(editing.external_id, data);
|
||||||
|
// Refresh entries from server
|
||||||
|
const fresh = await getIpam();
|
||||||
|
mergeEntries(fresh);
|
||||||
|
} else {
|
||||||
|
const updated = await updateIpEntry(editing.id, data);
|
||||||
|
setEntries(e => e.map(x => x.id === editing.id ? { ...updated, source: 'local' } : x));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const created = await createIpEntry(data);
|
// If a subnet_id was chosen, also create in phpIPAM
|
||||||
setEntries(e => [...e, created]);
|
if (data.subnet_id && sources.phpipam) {
|
||||||
|
// fire both in parallel — create locally and re-sync phpIPAM cache
|
||||||
|
const [created] = await Promise.all([
|
||||||
|
createIpEntry({ ...data, source: 'local' }),
|
||||||
|
import('../api/dns').then(m => m.syncIpam(['phpipam'])).catch(() => null),
|
||||||
|
]);
|
||||||
|
setEntries(e => [...e, { ...created, source: 'local' }]);
|
||||||
|
} else {
|
||||||
|
const created = await createIpEntry({ ...data, source: 'local' });
|
||||||
|
setEntries(e => [...e, { ...created, source: 'local' }]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Delete ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
await deleteIpEntry(confirmDel.id);
|
const e = confirmDel;
|
||||||
setEntries(e => e.filter(x => x.id !== confirmDel.id));
|
|
||||||
setConfirmDel(null);
|
setConfirmDel(null);
|
||||||
|
try {
|
||||||
|
if (e.source === 'phpipam') {
|
||||||
|
await deletePhpipamEntry(e.external_id);
|
||||||
|
setEntries(prev => prev.filter(x => !(x.source === 'phpipam' && x.external_id === e.external_id)));
|
||||||
|
} else if (e.source === 'tailscale') {
|
||||||
|
await deleteTailscaleDevice(e.external_id);
|
||||||
|
setEntries(prev => prev.filter(x => !(x.source === 'tailscale' && x.external_id === e.external_id)));
|
||||||
|
} else {
|
||||||
|
await deleteIpEntry(e.id);
|
||||||
|
setEntries(prev => prev.filter(x => x.id !== e.id));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tailscale authorize toggle ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleAuthorize(entry, authorized) {
|
||||||
|
try {
|
||||||
|
await setTailscaleAuthorized(entry.external_id, authorized);
|
||||||
|
setEntries(prev => prev.map(e =>
|
||||||
|
e.source === 'tailscale' && e.external_id === entry.external_id
|
||||||
|
? { ...e, authorized }
|
||||||
|
: e
|
||||||
|
));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filtering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const filtered = entries.filter(e => {
|
const filtered = entries.filter(e => {
|
||||||
|
if (sourceFilter !== 'all' && e.source !== sourceFilter) return false;
|
||||||
if (!filter) return true;
|
if (!filter) return true;
|
||||||
const q = filter.toLowerCase();
|
const q = filter.toLowerCase();
|
||||||
return (
|
return (
|
||||||
e.address.includes(q) ||
|
(e.address || '').includes(q) ||
|
||||||
(e.label || '').toLowerCase().includes(q) ||
|
(e.label || '').toLowerCase().includes(q) ||
|
||||||
(e.vendor || '').toLowerCase().includes(q) ||
|
(e.vendor || '').toLowerCase().includes(q) ||
|
||||||
(e.location || '').toLowerCase().includes(q)
|
(e.location || '').toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasExternal = sources.phpipam || sources.tailscale;
|
||||||
const v4count = entries.filter(e => !isIPv6(e.address)).length;
|
const v4count = entries.filter(e => !isIPv6(e.address)).length;
|
||||||
const v6count = entries.filter(e => isIPv6(e.address)).length;
|
const v6count = entries.filter(e => isIPv6(e.address)).length;
|
||||||
|
|
||||||
|
function formatSynced(iso) {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.toLocaleDateString('sv-SE')} ${d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
|
{/* Header */}
|
||||||
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<div>
|
<div>
|
||||||
<h2>IP Addresses</h2>
|
<h2>IP Addresses</h2>
|
||||||
<p className="dashboard-hint">
|
<p className="dashboard-hint">
|
||||||
{entries.length} address{entries.length !== 1 ? 'es' : ''} stored
|
{entries.length} address{entries.length !== 1 ? 'es' : ''}
|
||||||
{entries.length > 0 && ` · ${v4count} IPv4 · ${v6count} IPv6`}
|
{entries.length > 0 && ` · ${v4count} IPv4 · ${v6count} IPv6`}
|
||||||
|
{syncedAt && <span style={{ marginLeft: 8, color: 'var(--text-muted)', fontSize: 12 }}>
|
||||||
|
Last synced: {formatSynced(syncedAt)}
|
||||||
|
</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-primary" onClick={() => { setEditing(null); setShowForm(true); }}>+ Add IP</button>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{hasExternal && (
|
||||||
|
<button className="btn-sync" onClick={handleSync} disabled={syncing}>
|
||||||
|
{syncing ? 'Syncing…' : '⟳ Sync External'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn-primary" onClick={() => { setEditing(null); setShowForm(true); }}>
|
||||||
|
+ Add IP
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
|
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}<button className="dismiss" onClick={() => setError('')}>✕</button></div>}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
<div className="records-actions" style={{ marginBottom: 16 }}>
|
<div className="records-actions" style={{ marginBottom: 16 }}>
|
||||||
<input className="filter-input" placeholder="Filter by IP, label, vendor…" value={filter} onChange={e => setFilter(e.target.value)} />
|
<input className="filter-input" placeholder="Filter by IP, label, vendor…"
|
||||||
|
value={filter} onChange={e => setFilter(e.target.value)} />
|
||||||
|
|
||||||
|
{hasExternal && (
|
||||||
|
<select
|
||||||
|
className="filter-input"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
|
value={sourceFilter}
|
||||||
|
onChange={e => setSourceFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All sources</option>
|
||||||
|
<option value="local">Local</option>
|
||||||
|
{sources.phpipam && <option value="phpipam">phpIPAM</option>}
|
||||||
|
{sources.tailscale && <option value="tailscale">Tailscale</option>}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
<button className="btn-export" onClick={() =>
|
<button className="btn-export" onClick={() =>
|
||||||
exportCsv(
|
exportCsv(
|
||||||
filtered.map(e => ({
|
filtered.map(e => ({
|
||||||
address: e.address, label: e.label, vendor: e.vendor,
|
source: e.source, address: e.address, label: e.label,
|
||||||
location: e.location, notes: e.notes,
|
vendor: e.vendor, location: e.location, notes: e.notes,
|
||||||
dns_matches: (e.dnsMatches || []).map(m => `${m.name} (${m.provider})`).join('; '),
|
dns_matches: (e.dnsMatches || []).map(m => `${m.name} (${m.provider})`).join('; '),
|
||||||
})),
|
})),
|
||||||
['address','label','vendor','location','notes','dns_matches'],
|
['source','address','label','vendor','location','notes','dns_matches'],
|
||||||
{ address:'IP Address', label:'Label', vendor:'Vendor', location:'Location', notes:'Notes', dns_matches:'DNS Records' },
|
{ source:'Source', address:'IP Address', label:'Label', vendor:'Vendor', location:'Location', notes:'Notes', dns_matches:'DNS Records' },
|
||||||
'ip-addresses.csv'
|
'ip-addresses.csv'
|
||||||
)
|
)
|
||||||
}>⬇ Export CSV</button>
|
}>⬇ Export CSV</button>
|
||||||
<span className="record-count">{filtered.length} entries</span>
|
<span className="record-count">{filtered.length} entries</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
{loading ? <p className="hint">Loading…</p> : filtered.length === 0 ? (
|
{loading ? <p className="hint">Loading…</p> : filtered.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>{entries.length === 0 ? 'No IP addresses stored yet — press + Add IP to get started.' : 'No entries match your filter.'}</p>
|
<p>{entries.length === 0
|
||||||
|
? 'No IP addresses yet — press + Add IP or ⟳ Sync External to get started.'
|
||||||
|
: 'No entries match your filter.'
|
||||||
|
}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper">
|
<div className="table-wrapper">
|
||||||
<table className="records-table">
|
<table className="records-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{hasExternal && <th>Source</th>}
|
||||||
<th>IP Address</th>
|
<th>IP Address</th>
|
||||||
<th>Label</th>
|
<th>Label</th>
|
||||||
<th>Vendor</th>
|
<th>Vendor</th>
|
||||||
@@ -184,8 +374,17 @@ export default function IpamPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map(e => (
|
{filtered.map((e, i) => (
|
||||||
<tr key={e.id}>
|
<tr key={e.id ?? `${e.source}-${e.external_id}-${i}`}>
|
||||||
|
{hasExternal && (
|
||||||
|
<td>
|
||||||
|
<SourceBadge source={e.source} kind={e.kind} />
|
||||||
|
{e.is_exit_node && <span className="source-badge" style={{ background: '#27ae60', color: '#fff', marginLeft: 4 }}>Exit</span>}
|
||||||
|
{e.source === 'tailscale' && e.authorized === false && (
|
||||||
|
<span className="source-badge" style={{ background: '#e74c3c', color: '#fff', marginLeft: 4 }}>Pending</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td>
|
<td>
|
||||||
<span className={`ipam-address ${isIPv6(e.address) ? 'ipam-v6' : 'ipam-v4'}`}>{e.address}</span>
|
<span className={`ipam-address ${isIPv6(e.address) ? 'ipam-v6' : 'ipam-v4'}`}>{e.address}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -195,8 +394,21 @@ export default function IpamPage() {
|
|||||||
<td><DnsMatchBadges matches={e.dnsMatches} colors={colors} /></td>
|
<td><DnsMatchBadges matches={e.dnsMatches} colors={colors} /></td>
|
||||||
<td className="actions-cell">
|
<td className="actions-cell">
|
||||||
{e.notes && <span className="secret-notes" title={e.notes}>ℹ</span>}
|
{e.notes && <span className="secret-notes" title={e.notes}>ℹ</span>}
|
||||||
<button className="btn-edit" onClick={() => { setEditing(e); setShowForm(true); }} title="Edit">✎</button>
|
|
||||||
<button className="btn-delete" onClick={() => setConfirmDel(e)} title="Delete">✕</button>
|
{/* Tailscale: authorize toggle */}
|
||||||
|
{e.source === 'tailscale' && e.authorized === false && (
|
||||||
|
<button className="btn-edit" title="Authorize device"
|
||||||
|
onClick={() => handleAuthorize(e, true)}>✓</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit — not for Tailscale (IPs are read-only) */}
|
||||||
|
{e.source !== 'tailscale' && (
|
||||||
|
<button className="btn-edit" title="Edit"
|
||||||
|
onClick={() => { setEditing(e); setShowForm(true); }}>✎</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="btn-delete" title="Delete"
|
||||||
|
onClick={() => setConfirmDel(e)}>✕</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -208,6 +420,7 @@ export default function IpamPage() {
|
|||||||
{showForm && (
|
{showForm && (
|
||||||
<IpForm
|
<IpForm
|
||||||
initial={editing}
|
initial={editing}
|
||||||
|
subnets={subnets}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onCancel={() => { setShowForm(false); setEditing(null); }}
|
onCancel={() => { setShowForm(false); setEditing(null); }}
|
||||||
/>
|
/>
|
||||||
@@ -215,8 +428,12 @@ export default function IpamPage() {
|
|||||||
|
|
||||||
{confirmDel && (
|
{confirmDel && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Delete IP Address"
|
title={`Delete ${confirmDel.source === 'tailscale' ? 'Tailscale device' : 'IP Address'}`}
|
||||||
message={`Remove ${confirmDel.address}${confirmDel.label ? ` (${confirmDel.label})` : ''}?`}
|
message={
|
||||||
|
confirmDel.source === 'tailscale'
|
||||||
|
? `Remove device "${confirmDel.label || confirmDel.address}" from the tailnet? This cannot be undone.`
|
||||||
|
: `Remove ${confirmDel.address}${confirmDel.label ? ` (${confirmDel.label})` : ''}?${confirmDel.source === 'phpipam' ? ' This will also delete it in phpIPAM.' : ''}`
|
||||||
|
}
|
||||||
confirmLabel="Delete"
|
confirmLabel="Delete"
|
||||||
danger
|
danger
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
|
|||||||
Reference in New Issue
Block a user