feat: add company sidebar filters, internal phone, mail icon fix, and update script
This commit is contained in:
@@ -78,11 +78,20 @@ async function initializeTables() {
|
||||
email TEXT,
|
||||
building TEXT,
|
||||
floor TEXT,
|
||||
internal_phone TEXT,
|
||||
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Dynamic schema migration: add internal_phone column if it does not exist
|
||||
const columns = await all('PRAGMA table_info(employees)');
|
||||
const hasInternalPhone = columns.some((col) => col.name === 'internal_phone');
|
||||
if (!hasInternalPhone) {
|
||||
console.log('Migrating database: adding internal_phone column to employees table...');
|
||||
await run('ALTER TABLE employees ADD COLUMN internal_phone TEXT');
|
||||
}
|
||||
|
||||
// Seed data if database is fresh
|
||||
const deptCount = await get('SELECT COUNT(*) as count FROM departments');
|
||||
if (deptCount.count === 0) {
|
||||
|
||||
+28
-11
@@ -190,7 +190,7 @@ app.put('/api/companies/:id', authenticateToken, async (req, res) => {
|
||||
|
||||
// 4. Employees Endpoints (Search & List)
|
||||
app.get('/api/employees', async (req, res) => {
|
||||
const { search, departmentId } = req.query;
|
||||
const { search, departmentId, companyId } = req.query;
|
||||
|
||||
let sql = `
|
||||
SELECT e.*, d.name as department_name, c.name as company_name
|
||||
@@ -206,6 +206,11 @@ app.get('/api/employees', async (req, res) => {
|
||||
params.push(departmentId);
|
||||
}
|
||||
|
||||
if (companyId) {
|
||||
conditions.push('e.company_id = ?');
|
||||
params.push(companyId);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
@@ -237,6 +242,7 @@ app.get('/api/employees', async (req, res) => {
|
||||
const compMatch = e.company_name && e.company_name.toLowerCase().includes(cleanSearch);
|
||||
const buildMatch = e.building && e.building.toLowerCase().includes(cleanSearch);
|
||||
const floorMatch = e.floor && e.floor.toLowerCase().includes(cleanSearch);
|
||||
const internalPhoneMatch = e.internal_phone && e.internal_phone.includes(cleanSearch);
|
||||
|
||||
let phoneMatch = false;
|
||||
if (searchDigits.length > 0 && e.phone) {
|
||||
@@ -246,7 +252,7 @@ app.get('/api/employees', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
return nameMatch || jobMatch || emailMatch || deptMatch || compMatch || buildMatch || floorMatch || phoneMatch;
|
||||
return nameMatch || jobMatch || emailMatch || deptMatch || compMatch || buildMatch || floorMatch || phoneMatch || internalPhoneMatch;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,16 +264,20 @@ app.get('/api/employees', async (req, res) => {
|
||||
|
||||
// Create Employee
|
||||
app.post('/api/employees', authenticateToken, async (req, res) => {
|
||||
const { name, company_id, job_title, department_id, phone, email, building, floor } = req.body;
|
||||
const { name, company_id, job_title, department_id, phone, email, building, floor, internal_phone } = req.body;
|
||||
|
||||
if (!name || !job_title) {
|
||||
return res.status(400).json({ error: 'Name and Job Title are required fields.' });
|
||||
}
|
||||
|
||||
if (internal_phone && !/^\d{3}$/.test(internal_phone.trim())) {
|
||||
return res.status(400).json({ error: 'Внутренний телефон должен состоять ровно из 3 цифр.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.run(`
|
||||
INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor, internal_phone)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
name.trim(),
|
||||
company_id || null,
|
||||
@@ -276,7 +286,8 @@ app.post('/api/employees', authenticateToken, async (req, res) => {
|
||||
phone ? phone.trim() : '',
|
||||
email ? email.trim() : '',
|
||||
building ? building.trim() : '',
|
||||
floor ? floor.trim() : ''
|
||||
floor ? floor.trim() : '',
|
||||
internal_phone ? internal_phone.trim() : ''
|
||||
]);
|
||||
|
||||
const newEmp = await db.get(`
|
||||
@@ -296,16 +307,20 @@ app.post('/api/employees', authenticateToken, async (req, res) => {
|
||||
// Update Employee
|
||||
app.put('/api/employees/:id', authenticateToken, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name, company_id, job_title, department_id, phone, email, building, floor } = req.body;
|
||||
const { name, company_id, job_title, department_id, phone, email, building, floor, internal_phone } = req.body;
|
||||
|
||||
if (!name || !job_title) {
|
||||
return res.status(400).json({ error: 'Name and Job Title are required fields.' });
|
||||
}
|
||||
|
||||
if (internal_phone && !/^\d{3}$/.test(internal_phone.trim())) {
|
||||
return res.status(400).json({ error: 'Внутренний телефон должен состоять ровно из 3 цифр.' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.run(`
|
||||
UPDATE employees
|
||||
SET name = ?, company_id = ?, job_title = ?, department_id = ?, phone = ?, email = ?, building = ?, floor = ?
|
||||
SET name = ?, company_id = ?, job_title = ?, department_id = ?, phone = ?, email = ?, building = ?, floor = ?, internal_phone = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
name.trim(),
|
||||
@@ -316,6 +331,7 @@ app.put('/api/employees/:id', authenticateToken, async (req, res) => {
|
||||
email ? email.trim() : '',
|
||||
building ? building.trim() : '',
|
||||
floor ? floor.trim() : '',
|
||||
internal_phone ? internal_phone.trim() : '',
|
||||
id
|
||||
]);
|
||||
|
||||
@@ -393,8 +409,8 @@ app.post('/api/admin/import', authenticateToken, async (req, res) => {
|
||||
const newDeptId = e.department_id ? (deptIdMapping[e.department_id] || null) : null;
|
||||
const newCompId = e.company_id ? (compIdMapping[e.company_id] || null) : null;
|
||||
await db.run(`
|
||||
INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO employees (name, company_id, job_title, department_id, phone, email, building, floor, internal_phone)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
e.name,
|
||||
newCompId,
|
||||
@@ -403,7 +419,8 @@ app.post('/api/admin/import', authenticateToken, async (req, res) => {
|
||||
e.phone || '',
|
||||
e.email || '',
|
||||
e.building || '',
|
||||
e.floor || ''
|
||||
e.floor || '',
|
||||
e.internal_phone || ''
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
+15
-3
@@ -34,6 +34,7 @@ const App = () => {
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedDept, setSelectedDept] = useState(null);
|
||||
const [selectedCompany, setSelectedCompany] = useState(null);
|
||||
const [activeView, setActiveView] = useState('directory'); // 'directory', 'login', 'admin'
|
||||
const [selectedEmployee, setSelectedEmployee] = useState(null);
|
||||
|
||||
@@ -117,10 +118,13 @@ const App = () => {
|
||||
|
||||
// Client-side search and filtering
|
||||
const filteredEmployees = employees.filter((emp) => {
|
||||
// 1. Department filter
|
||||
// 1. Company filter
|
||||
if (selectedCompany && emp.company_id !== selectedCompany) return false;
|
||||
|
||||
// 2. Department filter
|
||||
if (selectedDept && emp.department_id !== selectedDept) return false;
|
||||
|
||||
// 2. Search query filter (Case-insensitive, fragments, normalized phone)
|
||||
// 3. Search query filter (Case-insensitive, fragments, normalized phone)
|
||||
if (searchQuery.trim()) {
|
||||
const queryClean = searchQuery.toLowerCase().trim();
|
||||
const queryDigits = getQueryPhoneDigits(queryClean);
|
||||
@@ -132,13 +136,18 @@ const App = () => {
|
||||
const empPhoneDigits = getPhoneDigits(emp.phone);
|
||||
const phoneMatch = queryDigits.length > 0 && empPhoneDigits.includes(queryDigits);
|
||||
|
||||
// Match internal phone
|
||||
const empInternalPhoneDigits = getPhoneDigits(emp.internal_phone);
|
||||
const internalPhoneMatch = queryDigits.length > 0 && empInternalPhoneDigits.includes(queryDigits);
|
||||
const internalPhoneStringMatch = emp.internal_phone && emp.internal_phone.toLowerCase().includes(queryClean);
|
||||
|
||||
// Secondary match parameters (Job title, Company, Location)
|
||||
const titleMatch = emp.job_title.toLowerCase().includes(queryClean);
|
||||
const companyMatch = emp.company_name && emp.company_name.toLowerCase().includes(queryClean);
|
||||
const deptMatch = emp.department_name && emp.department_name.toLowerCase().includes(queryClean);
|
||||
const buildingMatch = emp.building && emp.building.toLowerCase().includes(queryClean);
|
||||
|
||||
return nameMatch || phoneMatch || titleMatch || companyMatch || deptMatch || buildingMatch;
|
||||
return nameMatch || phoneMatch || internalPhoneMatch || internalPhoneStringMatch || titleMatch || companyMatch || deptMatch || buildingMatch;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -203,6 +212,9 @@ const App = () => {
|
||||
{activeView === 'directory' && (
|
||||
<div className="directory-layout">
|
||||
<Sidebar
|
||||
companies={companies}
|
||||
selectedCompany={selectedCompany}
|
||||
setSelectedCompany={setSelectedCompany}
|
||||
departments={departments}
|
||||
selectedDept={selectedDept}
|
||||
setSelectedDept={setSelectedDept}
|
||||
|
||||
@@ -16,7 +16,8 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
|
||||
phone: '',
|
||||
email: '',
|
||||
building: '',
|
||||
floor: ''
|
||||
floor: '',
|
||||
internal_phone: ''
|
||||
});
|
||||
|
||||
// Department form state
|
||||
@@ -215,7 +216,8 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
|
||||
phone: '',
|
||||
email: '',
|
||||
building: '',
|
||||
floor: ''
|
||||
floor: '',
|
||||
internal_phone: ''
|
||||
});
|
||||
setShowEmpModal(true);
|
||||
};
|
||||
@@ -230,7 +232,8 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
|
||||
phone: emp.phone || '',
|
||||
email: emp.email || '',
|
||||
building: emp.building || '',
|
||||
floor: emp.floor || ''
|
||||
floor: emp.floor || '',
|
||||
internal_phone: emp.internal_phone || ''
|
||||
});
|
||||
setShowEmpModal(true);
|
||||
};
|
||||
@@ -242,6 +245,11 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
|
||||
return;
|
||||
}
|
||||
|
||||
if (empForm.internal_phone && !/^\d{3}$/.test(empForm.internal_phone.trim())) {
|
||||
showAlert('error', 'Внутренний телефон должен состоять ровно из 3 цифр!');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const employeePayload = {
|
||||
...empForm,
|
||||
@@ -408,6 +416,7 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
|
||||
<td>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.15rem', fontSize: '0.8rem' }}>
|
||||
{emp.phone && <div>📞 {emp.phone}</div>}
|
||||
{emp.internal_phone && <div>📞 Вн.: {emp.internal_phone}</div>}
|
||||
{emp.email && <div>✉️ {emp.email}</div>}
|
||||
</div>
|
||||
</td>
|
||||
@@ -751,6 +760,20 @@ const AdminPanel = ({ token, employees, departments, companies, onRefreshData })
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Внутренний телефон</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="123"
|
||||
maxLength={3}
|
||||
pattern="^[0-9]{3}$|^$"
|
||||
title="Внутренний телефон должен состоять ровно из 3 цифр"
|
||||
value={empForm.internal_phone}
|
||||
onChange={(e) => setEmpForm({ ...empForm, internal_phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Электронная почта (Email)</label>
|
||||
<input
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CloseIcon, PhoneIcon, EmailIcon, OfficeIcon, DownloadIcon } from '../ut
|
||||
const DetailModal = ({ employee, onClose }) => {
|
||||
if (!employee) return null;
|
||||
|
||||
const { name, company_name, job_title, department_name, phone, email, building, floor } = employee;
|
||||
const { name, company_name, job_title, department_name, phone, email, building, floor, internal_phone } = employee;
|
||||
|
||||
// Generate and download vCard (VCF)
|
||||
const handleDownloadVCard = () => {
|
||||
@@ -19,6 +19,7 @@ const DetailModal = ({ employee, onClose }) => {
|
||||
const noteParts = [];
|
||||
if (building) noteParts.push(`Здание: ${building}`);
|
||||
if (floor) noteParts.push(`Этаж: ${floor}`);
|
||||
if (internal_phone) noteParts.push(`Внутренний телефон: ${internal_phone}`);
|
||||
|
||||
const vCardLines = [
|
||||
'BEGIN:VCARD',
|
||||
@@ -82,6 +83,11 @@ const DetailModal = ({ employee, onClose }) => {
|
||||
) : 'Не указан'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="detail-block">
|
||||
<span className="detail-label">Внутренний телефон</span>
|
||||
<span className="detail-value">{internal_phone || 'Не указан'}</span>
|
||||
</div>
|
||||
|
||||
<div className="detail-block">
|
||||
<span className="detail-label">Здание</span>
|
||||
|
||||
@@ -17,13 +17,14 @@ const EmployeeTable = ({ employees, onEmployeeClick }) => {
|
||||
<th>Должность</th>
|
||||
<th>Размещение</th>
|
||||
<th>Телефон</th>
|
||||
<th>Вн. тел.</th>
|
||||
<th>Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{employees.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
|
||||
<td colSpan="8" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
|
||||
Сотрудники не найдены
|
||||
</td>
|
||||
</tr>
|
||||
@@ -66,6 +67,13 @@ const EmployeeTable = ({ employees, onEmployeeClick }) => {
|
||||
<span style={{ color: 'var(--text-muted)' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{emp.internal_phone ? (
|
||||
<span style={{ fontWeight: 600 }}>{emp.internal_phone}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{emp.email ? (
|
||||
<a
|
||||
|
||||
@@ -56,7 +56,7 @@ const ImportExport = ({ token, onRefreshData }) => {
|
||||
throw new Error('CSV файл пуст или содержит недостаточно строк.');
|
||||
}
|
||||
|
||||
// Expected Headers: ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж
|
||||
// Expected Headers: ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж;Внутренний телефон
|
||||
// Parse semicolon separated columns
|
||||
const employees = [];
|
||||
const departmentsSet = new Set();
|
||||
@@ -74,7 +74,8 @@ const ImportExport = ({ token, onRefreshData }) => {
|
||||
phone: values[4] || '',
|
||||
email: values[5] || '',
|
||||
building: values[6] || '',
|
||||
floor: values[7] || ''
|
||||
floor: values[7] || '',
|
||||
internal_phone: values[8] || ''
|
||||
};
|
||||
|
||||
if (emp.name && emp.job_title) {
|
||||
@@ -108,6 +109,7 @@ const ImportExport = ({ token, onRefreshData }) => {
|
||||
email: emp.email,
|
||||
building: emp.building,
|
||||
floor: emp.floor,
|
||||
internal_phone: emp.internal_phone,
|
||||
department_id: dept ? dept.id : null,
|
||||
company_id: comp ? comp.id : null
|
||||
};
|
||||
@@ -174,7 +176,7 @@ const ImportExport = ({ token, onRefreshData }) => {
|
||||
if (!res.ok) throw new Error('Ошибка экспорта данных с сервера');
|
||||
const data = await res.json();
|
||||
|
||||
const headers = ['ФИО', 'Компания', 'Отдел', 'Должность', 'Телефон', 'Email', 'Здание', 'Этаж'];
|
||||
const headers = ['ФИО', 'Компания', 'Отдел', 'Должность', 'Телефон', 'Email', 'Здание', 'Этаж', 'Внутренний телефон'];
|
||||
// Semicolon separator
|
||||
const csvRows = [headers.join(';')];
|
||||
|
||||
@@ -189,7 +191,8 @@ const ImportExport = ({ token, onRefreshData }) => {
|
||||
`"${(emp.phone || '').replace(/"/g, '""')}"`,
|
||||
`"${(emp.email || '').replace(/"/g, '""')}"`,
|
||||
`"${(emp.building || '').replace(/"/g, '""')}"`,
|
||||
`"${(emp.floor || '').replace(/"/g, '""')}"`
|
||||
`"${(emp.floor || '').replace(/"/g, '""')}"`,
|
||||
`"${(emp.internal_phone || '').replace(/"/g, '""')}"`
|
||||
];
|
||||
csvRows.push(row.join(';'));
|
||||
}
|
||||
@@ -246,7 +249,7 @@ const ImportExport = ({ token, onRefreshData }) => {
|
||||
</div>
|
||||
</form>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.75rem' }}>
|
||||
* При импорте текущая база будет очищена и перезаписана! Структура колонок CSV (разделитель - <strong>точка с запятой ";"</strong>): ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж. Кодировка UTF-8.
|
||||
* При импорте текущая база будет очищена и перезаписана! Структура колонок CSV (разделитель - <strong>точка с запятой ";"</strong>): ФИО;Компания;Отдел;Должность;Телефон;Email;Здание;Этаж;Внутренний телефон. Кодировка UTF-8.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const Sidebar = ({ departments, selectedDept, setSelectedDept, employees }) => {
|
||||
const Sidebar = ({
|
||||
companies,
|
||||
selectedCompany,
|
||||
setSelectedCompany,
|
||||
departments,
|
||||
selectedDept,
|
||||
setSelectedDept,
|
||||
employees
|
||||
}) => {
|
||||
// Count employees in a company
|
||||
const getCompanyCount = (companyId) => {
|
||||
if (!companyId) return employees.length;
|
||||
return employees.filter(emp => emp.company_id === companyId).length;
|
||||
};
|
||||
|
||||
// Count employees in a department
|
||||
const getDeptCount = (deptId) => {
|
||||
if (!deptId) return employees.length;
|
||||
@@ -9,6 +23,36 @@ const Sidebar = ({ departments, selectedDept, setSelectedDept, employees }) => {
|
||||
|
||||
return (
|
||||
<aside className="filters-sidebar">
|
||||
{/* Organizations section */}
|
||||
<div className="filters-title">
|
||||
<span>Организации</span>
|
||||
</div>
|
||||
<div className="filters-section" style={{ marginBottom: '1.5rem' }}>
|
||||
<ul className="dept-list">
|
||||
<li>
|
||||
<button
|
||||
className={`dept-item ${selectedCompany === null ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCompany(null)}
|
||||
>
|
||||
<span>Все организации</span>
|
||||
<span className="dept-count">{getCompanyCount(null)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{companies.map((comp) => (
|
||||
<li key={comp.id}>
|
||||
<button
|
||||
className={`dept-item ${selectedCompany === comp.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCompany(comp.id)}
|
||||
>
|
||||
<span>{comp.name}</span>
|
||||
<span className="dept-count">{getCompanyCount(comp.id)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Departments section */}
|
||||
<div className="filters-title">
|
||||
<span>Подразделения</span>
|
||||
</div>
|
||||
@@ -19,7 +63,7 @@ const Sidebar = ({ departments, selectedDept, setSelectedDept, employees }) => {
|
||||
className={`dept-item ${selectedDept === null ? 'active' : ''}`}
|
||||
onClick={() => setSelectedDept(null)}
|
||||
>
|
||||
<span>Все сотрудники</span>
|
||||
<span>Все подразделения</span>
|
||||
<span className="dept-count">{getDeptCount(null)}</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -446,7 +446,8 @@ button, input, select, textarea {
|
||||
.table-contact-link svg {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
fill: currentColor;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* Location labels in table */
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
APP_NAME="corp-address-book"
|
||||
CONTAINER_NAME="corporate-address-book"
|
||||
INSTALL_DIR="/opt/corp-address-book"
|
||||
IMAGE_NAME="corp-address-book:latest"
|
||||
HOST_PORT="8180"
|
||||
CONTAINER_PORT="3000"
|
||||
|
||||
log() {
|
||||
printf '\n[%s] %s\n' "$(date +'%H:%M:%S')" "$*"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf '\nERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 1. Require root
|
||||
if [ "${EUID:-$(id -u)}" -ne 0 ]; then
|
||||
die "Run this update script as root, for example: curl -fsSL ... | sudo bash"
|
||||
fi
|
||||
|
||||
# 2. Go to installation directory
|
||||
log "Navigating to ${INSTALL_DIR}"
|
||||
cd "$INSTALL_DIR" || die "Directory ${INSTALL_DIR} does not exist. Run install.sh first."
|
||||
|
||||
# 3. Configure git safe directory
|
||||
log "Configuring git safe directory"
|
||||
git config --global --add safe.directory "$INSTALL_DIR" || true
|
||||
|
||||
# 4. Pull changes using the deploy key
|
||||
log "Pulling latest changes from Gitea"
|
||||
SSH_KEY="/home/fabritsky/.ssh/gitea_corp_address_book_ed25519"
|
||||
if [ ! -f "$SSH_KEY" ]; then
|
||||
# Fallback: search for any deploy/private key in fabritsky's .ssh directory
|
||||
SSH_KEY_FOUND=$(find /home/fabritsky/.ssh/ -type f \( -name "*gitea*" -o -name "*ed25519*" -o -name "*rsa*" \) ! -name "*.pub" | head -n 1)
|
||||
if [ -n "$SSH_KEY_FOUND" ]; then
|
||||
SSH_KEY="$SSH_KEY_FOUND"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Using SSH key: ${SSH_KEY}"
|
||||
GIT_SSH_COMMAND="ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no -o BatchMode=yes" git pull
|
||||
|
||||
# 5. Build Docker Image (Re-compile frontend and backend inside container)
|
||||
log "Building updated Docker image ${IMAGE_NAME}"
|
||||
docker build -t "$IMAGE_NAME" "$INSTALL_DIR"
|
||||
|
||||
# 6. Safely replace container
|
||||
log "Replacing container ${CONTAINER_NAME}"
|
||||
old_name=""
|
||||
if docker container inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
|
||||
old_name="${CONTAINER_NAME}-old-$(date +%Y%m%d-%H%M%S)"
|
||||
docker stop "$CONTAINER_NAME" >/dev/null
|
||||
docker rename "$CONTAINER_NAME" "$old_name"
|
||||
log "Previous container renamed to ${old_name}"
|
||||
fi
|
||||
|
||||
log "Starting new container on port ${HOST_PORT}"
|
||||
if ! docker run -d \
|
||||
--name "$CONTAINER_NAME" \
|
||||
--restart unless-stopped \
|
||||
--env-file "${INSTALL_DIR}/.env" \
|
||||
-p "${HOST_PORT}:${CONTAINER_PORT}" \
|
||||
-v "${INSTALL_DIR}/data:/app/data" \
|
||||
"$IMAGE_NAME" >/dev/null; then
|
||||
if [ -n "$old_name" ]; then
|
||||
log "New container failed to start; rolling back to ${old_name}"
|
||||
docker rename "$old_name" "$CONTAINER_NAME" || true
|
||||
docker start "$CONTAINER_NAME" || true
|
||||
fi
|
||||
die "Failed to start new container."
|
||||
fi
|
||||
|
||||
# 7. Health Check / Status
|
||||
log "Running health check..."
|
||||
sleep 5
|
||||
if curl -fsSI "http://127.0.0.1:${HOST_PORT}/" >/dev/null; then
|
||||
log "Update completed successfully!"
|
||||
log "Container Status:"
|
||||
docker ps --filter "name=${CONTAINER_NAME}"
|
||||
|
||||
# Remove the backup old container since deployment succeeded
|
||||
if [ -n "$old_name" ]; then
|
||||
log "Cleaning up old backup container ${old_name}"
|
||||
docker rm "$old_name" >/dev/null || true
|
||||
fi
|
||||
else
|
||||
docker logs --tail=80 "$CONTAINER_NAME" || true
|
||||
if [ -n "$old_name" ]; then
|
||||
log "Health check failed; rolling back to ${old_name}"
|
||||
docker stop "$CONTAINER_NAME" >/dev/null || true
|
||||
docker rm "$CONTAINER_NAME" || true
|
||||
docker rename "$old_name" "$CONTAINER_NAME" || true
|
||||
docker start "$CONTAINER_NAME" || true
|
||||
fi
|
||||
die "Health check failed. Rolled back to the previous container version."
|
||||
fi
|
||||
Reference in New Issue
Block a user