개요
view= / file= / Ctrl+O / Ctrl+F
이 네 가지만 알면 매뉴얼 전체를 읽을 준비가 끝납니다.
view=
이미 있는 화면(시스템/프로세스/로그/큐/디스크/네트워크/SHM 구조체/대시보드)을 그대로 켭니다. Lua 파일이 필요 없습니다.
file=
Lua 파일을 경유해서 같은 C 함수를 부르거나(재구현 아님), Lua로 직접 계산·판단 로직을 짭니다.
Ctrl+O (source_key)
지금 보는 화면을 만든 코드를 그대로 팝업으로 보여줍니다. SSH 터미널은 물론, 웹 브라우저에서도 F1/Ctrl+O 둘 다로 동일하게 동작합니다(웹은 source_key 설정값과 무관하게 이 두 키를 고정으로 지원). 웹에서 안 눌리면 한글(IME) 입력이 켜져 있는지 확인하세요 — 꺼야 정상 동작합니다.
Ctrl+F (filter_key)
조건 필터 입력 팝업을 엽니다. Enter는 이번 세션에만, Ctrl+S는 config.lua에 그 줄 그대로 영구 저장합니다. SSH TUI 전용 — 웹 대시보드는 이 팝업 대신 클라이언트 쪽 검색/정렬 UI를 씁니다.
hotkey,
config_editor_key 등)은 "F1"~"F12"
또는 "Ctrl+E" 표기를 씁니다. 이 매뉴얼이 Ctrl+O/
Ctrl+F를 기본값으로 쓰는 이유는 macOS 등에서 F1/F2가
OS 단축키에 가로채이는 경우가 많아서입니다 — Ctrl 조합은 그런 충돌이 없습니다.
theme = "ocean"처럼
적으면 themes.lua에 정의된 배색(정상/경고/위험 색 등)으로
전체 화면이 바뀝니다. 생략하면 내장 기본 배색 그대로. refresh_interval_ms(기본 500)로
화면 갱신 주기도 조절할 수 있습니다 — 둘 다 SSH·웹 어느 쪽으로 봐도 동일하게 적용됩니다.
직접 눌러보고 싶다면 실제 라이브 인스턴스가 있습니다 —
홈페이지 "직접 체험하기" 섹션에서 SSH 없이 바로 확인할 수 있고,
다운로드 패키지를 받았다면 아래처럼 manual-1/·
manual-2/가 그대로 들어있어 직접 실행해볼 수 있습니다.
manual-1 / manual-2 직접 실행하기
압축을 풀면 생기는 tapas-eye/ 디렉터리 루트에서 아래 명령을 그대로
실행하면 됩니다 — 두 매뉴얼 모두 --config만 바꿔서 같은 바이너리로 띄웁니다.
| 실행 | 명령 |
|---|---|
| manual-1 (SSH TUI) | ./bin/tapas-eye --config manual-1/config.lua |
| manual-1 (웹 대시보드) | ./bin/tapas-eye --config manual-1/config.lua --web-port 8080 |
| manual-2 (SSH TUI) | ./bin/tapas-eye --config manual-2/config.lua |
| manual-2 (웹 대시보드) | ./bin/tapas-eye --config manual-2/config.lua --web-port 8081 |
--web-port를 붙이면 브라우저에서 http://<서버주소>:8080으로
접속합니다. tty가 필요한 TUI 앱이라 tmux/screen 세션 안에서
실행해야 한다는 점 등 실행 환경 주의사항은 바로 아래 콜아웃 참고.
manual-2는 위 명령만으로 6단계 전부 바로 동작합니다. manual-1은 대부분
그대로 동작하지만, "SHM 구조체"·"전문별 처리 건수"·"다중 SHM 조인"·
"SHM 값 편집" 네 항목만 예시 데이터로 SHM을 한 번 채워야 값이
보입니다(재부팅 전까지 유지 — 매번 반복할 필요 없음). 소스는
examples/에 있고 컴파일 후 실행하면 됩니다:
# SHM 구조체 / 전문별 처리 건수 / 다중 SHM 조인 / SHM 값 편집 — 각 1회만 실행해두면 됨
gcc -o /tmp/demo_koscom examples/demo_koscom.c && /tmp/demo_koscom
gcc -o /tmp/demo_msg_stats examples/demo_msg_stats.c && /tmp/demo_msg_stats
gcc -o /tmp/demo_msg_join examples/demo_msg_join.c && /tmp/demo_msg_join
gcc -o /tmp/demo_loglevel_ctrl examples/demo_loglevel_ctrl.c && /tmp/demo_loglevel_ctrl
# 메시지 큐 — POSIX MQ 두 개를 채움 (마찬가지로 1회만 실행)
gcc -o /tmp/demo_queue examples/demo_queue.c -lrt && /tmp/demo_queue
정리하고 싶으면 ipcrm -M 0x1234(SHM 구조체) ·
ipcrm -M 0x1300(전문별 처리 건수) ·
ipcrm -M 0x1301 ; ipcrm -M 0x1302 ; ipcrm -M 0x1303(다중 SHM 조인) ·
ipcrm -M 0x1350(SHM 값 편집) ·
rm -f /dev/mqueue/tapas_demo_orders /dev/mqueue/tapas_demo_alerts(메시지 큐)로
지울 수 있지만, 지우기 전까지는 화면에 그대로 남아있는 게 정상 동작이니 서두를
필요는 없습니다.
--web-port는 모든 인터페이스(0.0.0.0)에 바인드됩니다 — 다른 머신에서 접속하려면 방화벽/보안그룹에서 해당 포트를 열어야 합니다.- tapas-eye는 TUI 앱이라 stdin에 tty가 필요합니다.
nohup ... &처럼 그냥 백그라운드로 띄우면 즉시 종료되므로,tmux/screen세션 안에서 실행하세요(웹 대시보드만 쓸 목적이어도 동일). - 인증 기능이 없습니다(내부망 전용 가정). 외부 공개망에 직접 노출하지 말고 SSH 터널·VPN·리버스 프록시 뒤에 두세요.
- 브라우저(클라이언트) 동시 접속은 최대 16개까지 지원합니다 — 초과분은 별도 오류 메시지 없이 연결이 거부됩니다.
manual-1 · 1
config.lua만으로 되는 화면들
이 메뉴("기본기능")의 항목은 전부 view= 한 줄이면 됩니다 —
Lua 파일이 단 하나도 없습니다. C 쪽에 이미 구현된 view
엔진(system/process/log/queue/
disk/net 6종 + SHM 구조체 파서 1종)을 config.lua의
menus → items 배열에 테이블 하나만 추가해서
"켜는" 방식이라, 재빌드는 물론 문법 오류만 없으면 재시작조차 필요 없습니다
— 게이트웨이 모드에서는 파일 저장 즉시(최대 1초) 새 인스턴스로 반영됩니다.

나머지 5개(프로세스/로그/큐/디스크/네트워크)도 문법은 똑같습니다 — 아래
표에서 view id와 화면별 옵션만 바꾸면 됩니다.
| 화면 | config.lua | 비고 |
|---|---|---|
| 시스템 상태 | { label = "시스템 상태", view = "system" } |
CPU·메모리·로드 애버리지·업타임. /proc/stat, /proc/meminfo 직접 읽기 |
| 프로세스 목록 | { label = "프로세스 목록", view = "process", filter = "", gauge = "cpu,mem" } |
gauge를 지정한 컬럼만 막대(bar)로 표시 |
| 로그 | { label = "로그", view = "log" } |
inotify 기반 실시간 tail, 경로는 config.lua 최상위 log_path |
| 메시지 큐 | { label = "메시지 큐", view = "queue", filter = "" } |
POSIX MQ(/dev/mqueue)를 스캔해 큐별 메시지 수·용량 조회 |
| 디스크 용량 | { label = "디스크 용량", view = "disk", filter = "" } |
마운트별 총량/사용량/여유공간, statvfs() |
| 네트워크 트래픽 | { label = "네트워크 트래픽", view = "net", filter = "" } |
인터페이스별 rx/tx, /proc/net/dev |
SHM 구조체 — 필드 160개짜리 C 구조체를 그대로 표로
나머지 두 항목은 SHM(공유메모리)에 C 구조체 배열을 그대로 써 둔 프로그램을
조회 프로그램 없이 바로 표로 보여주는, tapas-eye의 핵심 기능입니다. 예시로
쓴 KOSCOM_STOCK_A0은 실제 코스콤 시세 구조체 형태로, 필드가
160개가 넘습니다 — 기본(가로: 행=레코드, 열=필드) 표로 그리면 화면 폭을
한참 넘기기 때문에, 아래처럼 orientation = "vertical"로
행/열을 뒤집어서 필드가 아무리 많아도 가로로 안 넘치게 합니다.
SHM 구조체 (config)
필드 160개가 넘는 실제 시세 구조체(KOSCOM_STOCK_A0)를 헤더 파일 그대로 파싱합니다.
{
label = "SHM 구조체 (config)",
view = "manual_shmstruct_cfg",
shm_key = 0x1234,
header = "examples/koscom_stock.h",
struct = "KOSCOM_STOCK_A0",
start_index = 3,
-- show 생략 = 전체 필드 표시. 필드가 160개+라 기본(가로: 행=레코드,
-- 열=필드) 표로는 폭을 훨씬 넘긴다 — orientation="vertical"로
-- 행=필드/열=레코드로 뒤집어서 필드가 아무리 많아도 안 넘치게 한다.
orientation = "vertical",
vertical_rows = 3, -- ←/→로 나란히 볼 레코드 수. PgUp/PgDn은 필드 스크롤
filter = "",
}

| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
shm_key | number | 필수 | shmget()에 쓰는 SHM 키. 16진수(0x1234) 표기 그대로 써도 됩니다. file_path를 대신 쓰면 SHM 대신 일반 파일 경로에서 같은 형식의 레코드 배열을 읽습니다(다른 프로세스가 주기적으로 덤프하는 파일 등) — 헤더/구조체 파싱은 완전히 동일, 바이트 소스만 다릅니다. |
header | string | 필수 | 구조체가 정의된 C 헤더 파일 경로. 런타임에 이 파일을 직접 파싱해서 필드 목록/타입/오프셋을 얻습니다 — 재컴파일 불필요. |
struct | string | 필수 | 헤더 안에서 파싱할 구조체(또는 배열명.필드명) 이름. |
start_index | number | 선택 | 배열의 몇 번째 레코드부터 보여줄지. 기본 0. |
show | string | 선택 | 쉼표로 구분한 필드명 목록. 생략하면 전체 필드를 표시합니다. |
orientation | string | 선택 | "horizontal"(기본, 행=레코드/열=필드) 또는 "vertical"(행=필드/열=레코드). 필드가 많을 때 vertical을 씁니다. |
vertical_rows | number | 선택 | vertical일 때 한 화면에 나란히 볼 레코드(열) 수. 기본 1. ←/→로 다음/이전 레코드 묶음 이동, PgUp/PgDn은 필드(행) 스크롤. |
filter | string | 선택 | 초기 필드 조건 필터 문자열. 빈 문자열이면 필터 없이 시작하되 filter_key(이 매뉴얼은 Ctrl+F)로 나중에 입력 가능. |
editable_fields | string | 선택 | 쉼표로 구분한 화이트리스트 필드명 — 화면에서 커서로 값을 바꿔 SHM에 즉시 write할 수 있는 필드를 지정합니다(SSH TUI 전용). 자세한 예시는 6. SHM 값 편집 참고. |
전문별 처리 건수 — 헤더+데이터 SHM, 한글 라벨, 다단 배치
거래소 전문(메시지 타입)별 수신/처리/DB저장 건수 — 실무에서 가장 흔한
운영 모니터링 패턴입니다. 이 예시는 "헤더 구조체(등록된 전문 종류 수) +
데이터 배열" 조합을 쓰고, C 필드명을 그대로 노출하는 대신
field_labels로 한글 라벨을 붙이고, 60여 건을
list_cols = 2로 2단 배치해서 스크롤 없이 한눈에 보이게 합니다.
전문별 처리 건수
거래소 전문(메시지 타입)별 수신/처리/DB저장 건수 — 실무에서 가장 흔한 운영 모니터링 패턴입니다.
{
label = "전문별 처리 건수",
view = "manual_msgstats_cfg",
shm_key = 0x1300,
header = "examples/msg_stats.h",
struct = "MSG_STATS.data",
header_struct = "MSG_STATS_HEADER",
count_field = "cur_cnt", -- head.cur_cnt만큼만 표시
title = "전문별 처리 현황 (거래소 연동)",
field_labels =
"msg_code=전문코드,msg_name=전문명,recv_cnt=수신건수,"
.. "proc_cnt=처리건수,db_cnt=DB저장건수",
hide = "db_cnt",
list_cols = 2, -- 60건을 2단으로 나란히. SSH·웹 둘 다 동일하게 2단 분할
}

| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
header_struct | string | 선택 | 데이터 배열 앞에 붙은 헤더 구조체 이름. count_field와 짝으로 씁니다. |
count_field | string | 선택 | 헤더 구조체 안에서 "현재 등록된 레코드 수"를 담은 필드명. 배열 전체 크기가 아니라 이 값만큼만 표시합니다. |
title | string | 선택 | 화면 제목. 생략하면 구조체명/헤더 경로가 그대로 노출됩니다. |
field_labels | string | 선택 | C필드명=한글라벨을 쉼표로 나열한 문자열. C 필드명을 운영자용 라벨로 바꿔서 보여줍니다. |
hide | string | 선택 | 쉼표로 구분한, 숨길 필드명 목록(C 필드명 기준). |
list_cols | number | 선택 | 행을 N개 그룹으로 나눠 좌우로 나란히 배치. 생략/1이면 단일 컬럼. SSH TUI·웹 대시보드 양쪽에서 동일하게 동작합니다. |
msg_code, recv_cnt...)을
운영자가 보기 편한 한글 제목으로 바꾸고, 불필요한 컬럼은 숨기고,
행이 많으면 여러 단으로 나란히 배치합니다 — 전부 재컴파일 없이
config.lua만 고치면 바로 반영됩니다.
manual-1 · 2
같은 화면을 Lua 파일로
바로 위 8개 화면을 이번엔 file=로 엽니다. 재구현이 아닙니다 —
tapas.* 바인딩이 core의 같은 C 함수를 그대로 호출해서, 화면은
1번 항목과 픽셀 단위로 동일합니다. 보통 config.lua로 시작했다가, 이 화면만의
로직(색상 임계값, 파생 컬럼 등)이 필요해지면 Lua 파일로 옮겨갑니다 — 그
과정은 manual-2에서 이어집니다. (예외: 아래 "전문별 처리 건수"는 일부러
다르게 구현 — 이유는 바로 아래 참고.)
Lua시스템 상태
manual-1/lua/lua_system.lua — tapas.system_render()가 view_system.c를 그대로 호출
local M = {}
function M.update()
tapas.system_update()
end
function M.render(p)
tapas.system_render(p)
end
return M
Lua프로세스 목록
manual-1/lua/lua_process.lua — tapas.view_table("process")가 core 데이터를 그대로 반환
local M = {}
function M.update() end
-- VT_BUILTIN 전용 키 핸들러는 file= 뷰에 안 붙는다 — PgUp/PgDn을
-- config-only 쪽과 똑같이 쓰려면 스크롤 위치를 직접 들고 있어야 한다.
local scroll = 0
local last_page_size, last_total = 20, 0
function M.render(p)
local t = tapas.view_table("process")
last_page_size = p.rows - 1
last_total = #t.rows
tapas.render_table(p, 0, t.headers, t.rows, scroll)
end
function M.handle_key(key)
local handled
scroll, handled = tapas.scroll_key(key, scroll, last_total, last_page_size)
return handled
end
return M
Lua전문별 처리 건수 — 이름은 매핑으로
manual-1/lua/lua_msgstats.lua (일부 발췌) — 실무 SHM에는 코드+건수만 있고 이름 필드가 없는 경우가 많습니다
-- config-only 쪽은 SHM에 있는 msg_name 필드를 그대로 보여주지만, 이
-- 파일은 일부러 그 필드를 안 쓰고 코드→이름 매핑을 Lua 배열로 따로
-- 들고 있다가 붙여서 보여준다 — SHM에 이름이 없는 실무 상황을 흉내낸
-- 것이다. 매핑에 없는 코드는 이름 칸만 비우고, 행 자체는 그대로 보여준다.
local MSG_NAMES = {
ORD00 = "신규주문", ORD01 = "정정주문", CHE00 = "체결통보",
QUO00 = "현재가시세", QUO03 = "시간외단일가",
-- ... (전체 60여 개는 manual-1/lua/lua_msgstats.lua 참고)
}
function M.render(p)
-- SHM 구조체 뷰 엔진(tapas.shmstruct_render)은 못 쓴다 — 파싱한
-- 필드를 그대로 보여줄 뿐 없는 값(이름)을 만들어내지 못해서, 원본만
-- 읽고(shmstruct_read) 표는 직접 그린다.
local rows_raw = tapas.shmstruct_read(spec)
-- config.lua의 title=을 그대로 읽어 config-only 항목과 같은 제목을
-- 보여준다 — 직접 그리는 만큼 제목도 직접 그려야 한다.
tapas.render_line(p, 0, {
{ name = tapas.title(), value = string.format("%d건", #rows_raw) },
})
local rows = {}
for _, r in ipairs(rows_raw) do
table.insert(rows, {
r.msg_code,
MSG_NAMES[r.msg_code] or "",
{ value = r.recv_cnt, align = "right" },
{ value = r.proc_cnt, align = "right" },
{ value = r.db_cnt, align = "right" },
})
end
tapas.render_table(p, 2,
{ "전문코드", "전문명", "수신건수", "처리건수", "DB저장건수" },
rows, 0, tapas.list_cols())
end
| 화면 | Lua에서 부르는 core 함수 |
|---|---|
| 로그 | tapas.log_update(path) / tapas.log_render(p, path) |
| 메시지 큐 / 디스크 / 네트워크 | tapas.view_table("queue" | "disk" | "net") + tapas.render_table() |
| SHM 구조체 (Lua) | tapas.shmstruct_update() / tapas.shmstruct_render(p) — config-only와 완전히 같은 필드 조건 필터(filter_key)도 그대로 지원 |
| 전문별 처리 건수 | 예외 — tapas.shmstruct_read()로 원본만 읽고 SHM에 없는 전문명은 Lua 배열로 매핑해서 붙임 (tapas.title()로 제목만 config.lua 값을 읽어옴, field_labels/hide는 이 방식에서 안 통해서 헤더를 직접 지정) |
manual-1 · 3
화면 여러 개를 한 번에
panels = { ... }로 이미 등록된 화면 여러 개를 한 항목에 묶으면
자동으로 분할된 대시보드가 됩니다. 각 패널은 완전히 독립된
view 인스턴스라서, 위에서 이미 스크린샷으로 본
시스템 상태·SHM 구조체 같은 화면이 그대로 한 그리드 안에 나란히
그려지는 것뿐입니다 — 대시보드 전용 렌더링 엔진이 따로 있는 게
아니라, 기존 view들을 panels_cols 그리드로 배치만 다르게
합니다. 각 패널의 filter·gauge 같은 세부 설정은 그 view id로 먼저
등록된 항목(보통 "기본기능"의 항목) 값을 그대로 물려받습니다.
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
panels | table | 필수 | 일반 메뉴 항목과 같은 형식의 테이블 배열. 각 항목이 그리드의 패널 하나가 됩니다. |
panels_cols | number | 선택 | 한 줄에 배치할 패널 수. 1 이상이면 고정 컬럼 수, 생략하거나 0이면 패널 개수에 맞춰 자동 분할(정사각형에 가깝게). |
종합 현황 (4분할)
시스템/프로세스/로그/SHM 구조체를 한 화면에. panels_cols 생략 = 자동분할
{
label = "종합 현황 (4분할)",
view = "manual_dashboard_overview",
panels = {
{ label = "시스템 상태", view = "system" },
{ label = "프로세스 목록", view = "process" },
{ label = "로그", view = "log" },
{ label = "SHM 구조체", view = "manual_shmstruct_dashboard",
shm_key = 0x1234, header = "examples/koscom_stock.h",
struct = "KOSCOM_STOCK_A0", orientation = "vertical" },
},
}
종합 현황 (컬럼지정)
panels_cols로 한 줄에 몇 개를 둘지 고정합니다. 1 이상이면 고정, 생략/0이면 자동
{
label = "종합 현황 (컬럼지정)",
view = "manual_dashboard_cols",
panels_cols = 3,
panels = {
{ label = "시스템 상태", view = "system" },
{ label = "프로세스 목록", view = "process" },
{ label = "로그", view = "log" },
{ label = "SHM 구조체", view = "manual_shmstruct_dashboard",
orientation = "vertical" },
},
}
종합 현황 (Lua) 항목은 같은 3개 화면을 manual-1/lua/lua_dashboard.lua로
나열한 것인데, 이 파일은 update()/render() 없이 panels = {...}
테이블만 반환합니다. 그러면 config.lua 선언 쪽과 완전히 같은 C 엔진
(view_dashboard.c)이 그리므로, gauge·filter 같은 세부 설정까지
한 글자도 안 틀리고 동일하게 나옵니다 — "Lua 파일에 로직을 다시 짜지 않고
선언만 옮겨 적어도 똑같이 동작한다"는 이 프로젝트의 원칙을 그대로 보여주는 예시입니다.
manual-1 · 4
MySQL / PostgreSQL / Altibase 조회
DB 클라이언트 라이브러리를 링크하지 않습니다 — mysql/psql/isql
같은 표준 CLI를 io.popen()으로 실행하고 그 출력을 표로 파싱해서
보여줍니다. 새 DB를 추가하고 싶으면 CLI 하나 실행할 수 있는 서버라면
바로 됩니다 — 커넥터 설치도, 재빌드도 필요 없습니다.
db_*.lua 상단 6개 값만 바꾸면 됩니다(재빌드 불필요) — 헷갈리는
건 host/port 뿐, 아래 3가지 중 하나입니다.
1. 실제 DB 서버가 있다
그 서버의 IP 주소를 host에.
host = "192.168.0.50"
port = 5432
2. docker + 포트 열림
-p로 host 포트를 열었다면 host는 항상
"127.0.0.1". PostgreSQL 데모가 이 경우.
docker run -p 127.0.0.1:5432:5432 postgres
host = "127.0.0.1"
port = 5432
3. docker + 포트 안 열림
TCP 접속 자체가 불가 → docker exec로 컨테이너 안
클라이언트를 호출. MySQL/Altibase 데모가 이 경우(아래 코드 참고).
db_*.lua를 고치기 전에 터미널에서 먼저 접속
테스트 — 여기서 안 되면 코드를 고쳐도 똑같이 안 됩니다.
which psql # 또는 mysql / isql — 클라이언트가 있는지 확인
psql -h 127.0.0.1 -p 5432 -U <user> -d <database> # 접속 테스트
# 되면 그 값을 db_*.lua 상단에 그대로 옮기면 끝
| db_*.lua 변수 | 넣을 값 | 예시 |
|---|---|---|
host | 위 1/2/3번 참고 | "192.168.0.50" |
port | 기본값: PostgreSQL 5432 / MySQL 3306 / Altibase 20300 | 5432 |
user | 접속 계정 | "readonly_user" |
password | 없으면 빈 문자열 "" | "s3cr3t" |
database | 접속할 DB(스키마) 이름 | "sales" |
query | 컬럼이 그대로 화면 표 헤더가 됨 | "SELECT id, name, amount FROM orders LIMIT 50;" |
local host = "192.168.0.50"
local port = 5432
local user = "readonly_user"
local password = "s3cr3t"
local database = "sales"
local query = "SELECT id, name, amount FROM orders LIMIT 50;"
| 화면 | 사용 CLI | 비고 |
|---|---|---|
| MySQL | mysql | 데모 컨테이너 안 mysql 클라이언트를 docker exec로 호출 — 비밀번호는 MYSQL_PWD 환경변수로 전달, host에는 클라이언트를 설치하지 않음 |
| PostgreSQL | psql | host에 이미 있는 psql로 TCP 직접 접속 — 동일한 lua/db/db_common.lua 골격 재사용 |
| Altibase | isql | 데모 컨테이너 안 isql을 docker exec로 호출 — 구분자 출력 옵션이 없어 SET HEADING OFF+필드 연결(||)로 우회 |
MySQL 조회 (lua/db/db_mysql.lua)
테스트 테이블 koscom_stock_a3_test(체결) 조회. host에는 mysql CLI를 안 깔고 docker exec로 컨테이너 안 클라이언트만 씀.
-- MySQL 조회 뷰: mysql CLI를 io.popen()으로 실행해 결과를 표로 표시.
-- host에는 mysql CLI를 설치하지 않고, docker 컨테이너(tapas-eye-demo-mysql)
-- 안의 mysql 클라이언트를 `docker exec`로 그대로 호출한다 — TCP 접속정보
-- (host/port) 대신 컨테이너 이름을 쓴다는 점만 db_postgresql.lua와 다르다.
-- 접속정보/쿼리는 아래 값만 고치면 됨(재빌드 불필요). 비밀번호는 커맨드라인
-- 인자 대신 MYSQL_PWD 환경변수로 넘겨 `ps aux` 노출을 막는다(단
-- /proc/pid/environ로는 여전히 보일 수 있음).
--
-- 예제 쿼리는 examples/koscom_stock.h의 KOSCOM_STOCK_A3(체결) 구조체 필드 중
-- 일부(종목코드/체결가격/체결수량/시가/고가/저가)를 그대로 옮긴 테스트 테이블
-- koscom_stock_a3_test를 조회한다 — db_postgresql.lua가 쓰는 KOSCOM_STOCK_A0
-- (종목배치)와는 다른 구조체/필드라 표시되는 값도 다르다. 테이블/샘플 데이터는
-- lua/db/db_mysql_seed.sql로 만든다.
local common = dofile("lua/db/db_common.lua")
local container = "tapas-eye-demo-mysql"
local user = "root"
local password = ""
local database = "test"
local query = "SELECT isin, exec_prc, exec_qty, open_prc, high_prc, low_prc FROM koscom_stock_a3_test;"
local function build_cmd()
return string.format(
"docker exec -e MYSQL_PWD=%s %s mysql -u%s %s -B -e %s",
common.shell_quote(password),
common.shell_quote(container),
common.shell_quote(user),
common.shell_quote(database),
common.shell_quote(query)
)
end
return common.new_view({ build_cmd = build_cmd, sep = "\t", refresh_sec = 5 })
PostgreSQL 조회 (lua/db/db_postgresql.lua)
테스트 테이블 koscom_stock_a0_test(종목배치) 조회. host에 이미 있는 psql로 TCP 직접 접속.
-- PostgreSQL 조회 뷰: psql CLI를 io.popen()으로 실행해 결과를 표로 표시.
-- 접속정보/쿼리는 아래 값만 고치면 됨 (재빌드 불필요). 비밀번호는 커맨드라인
-- 인자 대신 PGPASSWORD 환경변수로 넘긴다 — 더 안전한 대안은 ~/.pgpass.
--
-- 예제 쿼리는 examples/koscom_stock.h의 KOSCOM_STOCK_A0(코스피/코스닥/코넥스
-- 배치) 구조체 필드 중 일부(종목코드/한글약명/기준가/전일종가/상한가/하한가)를
-- 그대로 옮긴 테스트 테이블 koscom_stock_a0_test를 조회한다 — DB 조회 결과가
-- SHM 구조체 뷰와 같은 필드를 보여준다는 걸 확인하기 위한 예시일 뿐, 실제
-- 운영 스키마는 아니다. 테이블/샘플 데이터는 lua/db/db_postgresql_seed.sql로
-- 만든다.
local common = dofile("lua/db/db_common.lua")
local host = "127.0.0.1"
local port = 5432
local user = "postgres"
local password = ""
local database = "postgres"
local query = "SELECT isin, kor_name, base_price, prev_close, upper_limit, lower_limit FROM koscom_stock_a0_test;"
local function build_cmd()
return string.format(
"PGPASSWORD=%s psql -h %s -p %s -U %s -d %s -A -F %s -c %s",
common.shell_quote(password),
common.shell_quote(host),
tostring(port),
common.shell_quote(user),
common.shell_quote(database),
common.shell_quote("|"),
common.shell_quote(query)
)
end
return common.new_view({ build_cmd = build_cmd, sep = "|", refresh_sec = 5 })
Altibase 조회 (lua/db/db_altibase.lua)
테스트 테이블 msg_db_stats_test(전문별 DB저장 건수) 조회. isql은 구분자 출력이 없어 우회 필요.
-- Altibase 조회 뷰: isql CLI를 io.popen()으로 실행해 결과를 표로 표시.
-- host에는 아무것도 설치하지 않고, docker 컨테이너(tapas-eye-demo-altibase,
-- altibase/a_plus_edition — 라이선스 없이 도는 무료 에디션) 안의 isql을
-- `docker exec`로 그대로 호출한다 — db_mysql.lua와 같은 패턴.
--
-- isql은 mysql -B / psql -A 같은 "구분자 출력" 옵션이 없다(고정폭 표 +
-- 헤더밑줄 + "N rows selected." 꼬리말만 지원). 그래서 두 가지로 우회한다:
-- 1. `set heading off;`로 isql 자체 헤더/밑줄을 끄고, 진짜 헤더는 우리가
-- echo로 직접 앞에 붙인다.
-- 2. 쿼리 자체에서 SELECT ... || '|' || ... 로 각 행을 이미 구분자로 이어
-- 붙여서 내보낸다 — isql이 남기는 컬럼 우측 공백 패딩은 sed로 벗겨낸다.
-- 결과물은 mysql/psql이 내는 것과 같은 모양(헤더 줄 + 구분자로 나뉜 데이터
-- 줄 + "N rows selected.")이 되어 db_common.lua를 그대로 재사용할 수 있다.
--
-- 예제 쿼리는 examples/msg_db_stats.h의 DB_STATS_DATA(DB적재 프로세스가
-- 전문별로 남기는 DB저장 건수) 구조체 필드(전문코드/DB저장건수)를 그대로
-- 옮긴 테스트 테이블 msg_db_stats_test를 조회한다 — db_postgresql.lua/
-- db_mysql.lua가 쓰는 koscom_stock.h(시세) 구조체들과는 다른 구조체라 값도
-- 다르다. 테이블/샘플 데이터는 lua/db/db_altibase_seed.sql로 만든다.
local common = dofile("lua/db/db_common.lua")
local container = "tapas-eye-demo-altibase"
local user = "sys"
local password = "manager"
local headers = { "msg_code", "db_cnt" }
local query = "SELECT msg_code || '|' || db_cnt FROM msg_db_stats_test;"
local function build_cmd()
local isql_cmd = string.format(
"$ALTIBASE_HOME/bin/isql -u %s -p %s -s localhost -silent -noprompt",
user, password
)
return string.format(
"{\necho %s\ndocker exec -i %s bash -lc %s <<'ALTIBASE_SQL'\nset heading off;\n%s\nquit;\nALTIBASE_SQL\n} | sed 's/[ \t]*$//'",
common.shell_quote(table.concat(headers, "|")),
common.shell_quote(container),
common.shell_quote(isql_cmd),
query
)
end
return common.new_view({ build_cmd = build_cmd, sep = "|", refresh_sec = 5 })
| 필드 (common.new_view 옵션) | 타입 | 필수 | 설명 |
|---|---|---|---|
build_cmd | function | 필수 | 실행할 셸 명령 문자열을 반환하는 함수. common.shell_quote()로 값마다 인용해서 셸 인젝션을 막습니다. |
sep | string | 선택 | CLI 출력의 컬럼 구분자. mysql/psql의 -B(배치 모드) 출력은 탭("\t") 구분입니다. |
refresh_sec | number | 선택 | 몇 초마다 CLI를 재실행해서 갱신할지. 매 프레임 재실행하지 않고 이 주기로만 새로 조회합니다. |
이 페이지의 다른 예시들과 달리 이 항목은 실행 화면 스크린샷을 아직 싣지 않았습니다 — DB 조회 예제는 데모용 docker 컨테이너(MySQL/ PostgreSQL/Altibase 각각 하나씩)에 접속하는 구성이라 스크린샷이 서버쪽 인프라 변경에 따라 쉽게 낡을 수 있어서, 코드 스니펫과 표로만 안내합니다. 접속 정보만 올바르면 CLI가 출력하는 그대로(오류 메시지 포함) 표로 렌더링된다고 이해하시면 됩니다.
db_common.lua가 CLI 실행·구분자 파싱·주기적 재실행(refresh_sec)을
한 번만 구현해두고, DB별 파일(db_mysql.lua/db_postgresql.lua/
db_altibase.lua)은 접속정보와 쿼리 문자열만 다릅니다 — 쿼리를
바꾸고 싶으면 서버 관리자가 해당 파일 상단 query 한 줄만 고치면
됩니다. Altibase처럼 CLI가 구분자 출력을 안 지원하면 build_cmd()
안에서 우회 로직만 더 추가하면 되고, 이 경우에도 db_common.lua는
손대지 않습니다.
manual-1 · 5
SHM 3개를 하나의 표로 조인
SHM 구조체 뷰(view=)는 SHM 하나만 다룹니다. 수신·처리·DB적재가
서로 다른 프로세스라 SHM도 각자 관리되는 게 실무에선 흔한데, 이런 조인은
config.lua만으로는 안 되고 Lua가 필요합니다 — 화면을 안 그리고 데이터만
raw로 가져오는 tapas.shmstruct_read(spec) API를 씁니다.
| spec 필드 (shmstruct_read 인자) | 타입 | 필수 | 설명 |
|---|---|---|---|
shm_key | number | 필수 | 읽을 SHM의 키. config.lua view=의 같은 필드와 동일한 의미입니다. |
header / struct | string | 필수 | 파싱할 헤더 경로와 구조체(배열)명. view=와 동일한 파서를 씁니다. |
header_struct / count_field | string | 선택 | 헤더+카운트 구조의 SHM일 때만 지정 — 등록된 레코드 수만큼만 읽어옵니다. |
tapas.shmstruct_read(spec)는 화면을 그리지 않고
{ {필드명=값, ...}, ... } 형태의 순수 Lua 테이블 배열만
돌려줍니다 — view=가 하는 "파싱+렌더링"에서 렌더링만 뺀 것이라고
생각하면 됩니다. 이 raw 배열을 여러 개 받아다가 아래처럼 직접
합치는 로직을 짭니다.
전문별 처리 건수 (SHM 3개 조인)
manual-1/lua/lua_msgjoin.lua — 세 SHM을 msg_code 기준으로 합친다
local RECV_SPEC = { shm_key = 0x1301, header = "examples/msg_recv_stats.h",
struct = "RECV_STATS.data", header_struct = "RECV_STATS_HEADER", count_field = "cur_cnt" }
local PROC_SPEC = { shm_key = 0x1302, header = "examples/msg_proc_stats.h",
struct = "PROC_STATS.data", header_struct = "PROC_STATS_HEADER", count_field = "cur_cnt" }
local DB_SPEC = { shm_key = 0x1303, header = "examples/msg_db_stats.h",
struct = "DB_STATS.data", header_struct = "DB_STATS_HEADER", count_field = "cur_cnt" }
function M.render(p)
local recv = tapas.shmstruct_read(RECV_SPEC)
local proc = tapas.shmstruct_read(PROC_SPEC)
local db = tapas.shmstruct_read(DB_SPEC)
-- msg_code로 세 SHM을 합친다 (등장 순서를 보존)
local by_code, order = {}, {}
local function row_for(code)
if not by_code[code] then
by_code[code] = { recv_cnt = "0", proc_cnt = "0", db_cnt = "0" }
table.insert(order, code)
end
return by_code[code]
end
for _, r in ipairs(recv) do row_for(r.msg_code).recv_cnt = r.recv_cnt end
for _, r in ipairs(proc) do row_for(r.msg_code).proc_cnt = r.proc_cnt end
for _, r in ipairs(db) do row_for(r.msg_code).db_cnt = r.db_cnt end
local rows = {}
for _, code in ipairs(order) do
local v = by_code[code]
table.insert(rows, { code,
{ value = v.recv_cnt, align = "right" },
{ value = v.proc_cnt, align = "right" },
{ value = v.db_cnt, align = "right" },
})
end
tapas.render_table(p, 1,
{ "전문코드", "수신건수 (SHM_A)", "처리건수 (SHM_B)", "DB저장건수 (SHM_C)" },
rows, 0, tapas.list_cols())
end

{ value = ..., align = "right" }로
주면 숫자 컬럼을 우측정렬할 수 있고, tapas.list_cols()로
config.lua의 list_cols=N 값을 그대로 읽어와 여러 단으로
나란히 배치할 수도 있습니다 — SSH 터미널과 웹 브라우저 어느 쪽에서
봐도 동일합니다.
manual-1 · 6
화면에서 SHM 값을 바로 수정 — editable_fields
지금까지 본 SHM 화면은 전부 읽기 전용이었습니다. 하지만 로그 레벨처럼
"다른 프로세스가 SHM에 남겨둔 값을, 그 프로세스를 재시작하지 않고 운영자가
그 자리에서 바로 바꾸고 싶은" 값도 실무에 흔합니다. editable_fields에
화이트리스트로 올린 필드만, 화면에서 Enter로 커서를 두고 값을 입력하면
즉시 그 SHM에 write됩니다 — 나머지 필드는 그대로 읽기 전용으로 보호됩니다.
런타임 설정값 (config)
loglevel 필드 하나만 화이트리스트에 올려서 화면에서 바로 수정 가능하게 합니다.
{
label = "런타임 설정값 (config)",
view = "manual_ctrl_cfg",
shm_key = 0x1350,
header = "examples/loglevel_ctrl.h",
struct = "LOGLEVEL_CTRL",
editable_fields = "loglevel", -- 이 필드만 Enter로 값 수정 가능
title = "런타임 설정값 (SHM, loglevel만 값 직접 수정 가능)",
field_labels =
"loglevel=로그레벨(0~3),max_conn=최대연결수(읽기전용),"
.. "service_name=서비스명(읽기전용)",
filter = "",
}
런타임 설정값 (Lua)
manual-1/lua/lua_ctrl.lua — config 쪽과 완전히 같은 화면·동작.
local spec = {
shm_key = 0x1350,
header = "examples/loglevel_ctrl.h",
struct = "LOGLEVEL_CTRL",
}
function M.update()
tapas.shmstruct_update(spec)
end
function M.render(p)
tapas.shmstruct_render(p, spec)
end
function M.handle_key(key)
return tapas.shmstruct_handle_key(key)
end
-- editable_fields는 이 spec이 아니라 config.lua 메뉴 항목 쪽에
-- 지정합니다(위 config 예시와 동일한 자리) — Lua spec 테이블에
-- 넣어도 효과가 없습니다.
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
editable_fields | string | 선택 | 쉼표로 구분한 화이트리스트 필드명. 목록에 있는 필드만 화면에서 커서로 값을 바꿀 수 있고, 나머지 필드는 Enter를 눌러도 "편집할 수 없습니다" 안내만 뜹니다. config-only(view=)/Lua(file=) 두 방식 모두 이 필드는 config.lua 메뉴 항목 쪽에서만 지정합니다. |
| 키 | 동작 |
|---|---|
Enter | 셀 커서 진입 |
| 방향키 | 커서 이동 |
Enter (편집 가능한 셀에서 한 번 더) | 현재 값이 채워진 입력 팝업 → 값 입력 후 Enter로 제출, 즉시 SHM에 write |
ESC | 커서 모드 해제 |
loglevel(0=ERROR~3=DEBUG)만 화이트리스트에 올렸고,
max_conn/service_name은 일부러 빼서 대조군으로
남겨뒀습니다 — 실수로 아무 필드나 못 바꾸게 막는 안전장치도 데모의 일부입니다.
manual-1 · 7
config.lua를 화면에서 직접 편집
지금까지는 화면에 무엇을 보여줄지를 config.lua 파일을 직접 열어 텍스트로 정의했습니다. 설정 편집기는 그 config.lua 자체를 화면에서 편집합니다 — 항목 추가·삭제·순서 변경, label/hotkey/filter/gauge 같은 필드 수정까지 전부 화면 안에서 됩니다. 웹 브라우저와 SSH TUI 양쪽에 각각 있습니다.
웹 (/config)
웹 대시보드를 띄운 서버에서 브라우저로 /config에 접속합니다. 다른 화면과 같은 인증-없음/내부망 전용 전제를 그대로 따릅니다 — 외부에 노출하지 마세요.
TUI (SSH)
config.lua에 config_editor_key를 지정해두면 메뉴 항목 없이 그 키 하나로 어디서든 진입합니다. 아래 F-키가 OS 단축키에 가로채이는 환경에서는 Ctrl 조합을 씁니다.
manual-1/config.lua
이 데모에도 실제로 켜져 있습니다 — SSH로 접속했다면 지금 바로 Ctrl+E를 눌러보세요.
# Ctrl 조합 표기(개요 참고) — macOS에서 F11이 OS 단축키에 가로채이는 게 # 실측 확인돼서 이 데모는 기본값을 F-키 대신 Ctrl+E로 씀 config_editor_key = "Ctrl+E"
| 키 | 동작 |
|---|---|
↑ / ↓ | 목록 이동 |
Enter | 선택한 항목의 필드 편집 화면 진입, 또는 "+ 새 항목"에서 추가 시작(종류→id→라벨 3단계) |
d | 선택한 항목 삭제(확인 팝업) |
[ / ] | 선택한 항목을 형제 목록에서 위/아래로 재배치 |
r | config.lua 리로드 — 필드 값 변경을 실행 중 화면에 반영 |
ESC | 필드 편집 화면이면 목록으로, 목록 화면이면 나가기 |
r로 즉시 리로드해서 실행 중 화면에 반영되지만,
항목 추가·삭제·순서 변경 같은 구조 변경은 재시작해야 반영됩니다
(웹 편집기도 같은 제약). 저장 전에 config.lua 문법을 검증하고 원자적으로
교체하므로, 편집 중 실수해도 실행 파일이 깨진 config.lua를 읽어서 죽는
일은 없습니다.
manual-1 · 8
웹 서비스 켜고 끄기
브라우저 대시보드는 세 가지 방법 중 아무거나로 켤 수 있고, 셋 다 같은
config.lua/화면 코드를 그대로 씁니다 — 웹용으로 뭘 따로
만들 필요는 없습니다.
1. 실행과 동시에
--web-port를 커맨드라인에 직접 줍니다. 가장 빠른 방법 — SSH TUI와 브라우저가 같이 뜹니다.
2. 메뉴에서 나중에
config.lua에 web_port만 적어두면 자동 기동은 안 되고, 실행 중에 메뉴의 "웹 서비스" → "시작"을 눌렀을 때만 켜집니다.
3. SSH 없이 백그라운드로
TUI 앱이라 stdin에 tty가 필요해서 nohup ... &은 즉시 종료됩니다 — tmux/screen 세션 안에서 실행하세요.
manual-1/config.lua
이 데모도 실제로 이렇게 설정돼 있습니다 — 자동 기동은 아니고, 메뉴의 "웹 서비스"에서 켜야 합니다.
web_port = 8000
-- web_bind = "127.0.0.1" -- 생략하면 0.0.0.0(모든 인터페이스)
-- menus 안에 추가
{
label = "웹 서비스",
web = false, -- 웹 화면에서 웹 서버 자기 자신을 껐다 켰다 하는 건
-- 의미가 없음(끄면 보고 있던 그 화면도 끊김)
items = {
{ label = "시작", action = "web_start" },
{ label = "중지", action = "web_stop" },
},
},
백그라운드로 (쉘)
tmux 세션 하나로 SSH 접속 없이 계속 띄워둡니다.
tmux new-session -d -s tapas-web \
'./bin/tapas-eye --web-port 8080'
# 나중에 들여다보기
tmux attach -t tapas-web
# 끌 때
tmux kill-session -t tapas-web
설정된 포트 8080은(는) 이미 사용 중이어서 8081번 포트로 대신
시작했습니다처럼 팝업 + 상태줄로 알려주고, 커맨드라인
--web-port로 켤 때도 동일하게 동작합니다.
/config,
TUI config_editor_key)에서 web_port/
web_bind 값 자체도 화면에서 바로 바꿀 수 있습니다 —
r로 리로드하면, 켜져 있던 웹 서비스는 옛 포트에 그대로
떠 있지 않도록 자동으로 한 번 내려갑니다(새 포트로 다시 켜려면
"시작"을 다시 누르면 됩니다).
--web-port는
web_bind를 따로 안 주면 모든 인터페이스(0.0.0.0)에 바인드되지만,
서버 자체 방화벽(ufw 등)이나 클라우드 보안 그룹이 그 포트를
막고 있으면 여전히 외부에서 못 붙습니다. 켠 포트 번호로 ufw allow
<port>/tcp 같은 규칙을 별도로 추가해야 합니다.
http://IP:PORT로 직접
걸었다가, 브라우저가 HTTPS 페이지 안의 http:// 서브리소스를
mixed-content로 조용히 차단해서 화면이 안 뜨는 문제를 겪었습니다(새
탭으로 직접 열면 최상위 이동이라 안 걸려서 그쪽만 정상으로 보였습니다).
해결은 nginx로 같은 도메인 하위 경로에서 그 포트로 리버스 프록시하는
것 — 실시간 갱신(웹소켓)까지 쓰는 화면이라 업그레이드 헤더도 함께
넘겨야 합니다.
location /demo/ {
proxy_pass http://127.0.0.1:8081/; # 트레일링 슬래시로 /demo/ 접두사를 벗겨서 전달
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
데이터 가공하기
core가 이미 수집해 둔 데이터를 tapas.view_table()/tapas.system_read()로
그대로 가져다가, 화면 입맛에 맞게 가공만 합니다. "새로 뭘 만든다"보다
"이미 있는 걸 가져다 쓴다"에 가깝습니다 — config.lua 선언만으로는 안 되지만,
Lua 몇 줄이면 되는 수준입니다. 아래 세 예제는 전부 같은 데이터 구조를
읽고 씁니다 — 그 구조를 먼저 알아두면 세 예제가 왜 그렇게 짧은지
이해가 됩니다.
| 구조 | 타입 | 설명 |
|---|---|---|
t.headers | string[] | tapas.view_table(id)가 돌려주는 테이블의 컬럼 제목 배열. |
t.rows | table[] | 행 배열. 각 행은 셀의 배열이고, 각 셀은 "문자열"이거나 { value=, color=, align= } 형태의 테이블입니다. |
cell.color | string | "normal" | "good" | "warn" | "danger". 지정 안 하면 core가 정한 기본 색. |
cell.align | string | "right"로 주면 숫자 컬럼처럼 우측정렬됩니다. |
1필드 하나 추가하기
process 뷰 결과에 CPU%+MEM% 파생 컬럼 하나를 계산해서 붙입니다.
function M.render(p)
local t = tapas.view_table("process")
-- 원본 헤더 뒤에 파생 컬럼 하나만 추가한다
local headers = {}
for i, h in ipairs(t.headers) do headers[i] = h end
table.insert(headers, "CPU+MEM")
local rows = {}
for i, row in ipairs(t.rows) do
local new_row = {}
for j, cell in ipairs(row) do new_row[j] = cell end
local cpu = tonumber(row[3].value) or 0
local mem = tonumber(row[4].value) or 0
table.insert(new_row, string.format("%.1f", cpu + mem))
rows[i] = new_row
end
tapas.render_table(p, 0, headers, rows, scroll, tapas.list_cols())
end
2조건에 따라 색 칠하기
core가 정한 기본 색 대신, 이 화면만의 임계값으로 다시 색을 정합니다.
local WARN_CPU, DANGER_CPU = 1.0, 5.0
function M.render(p)
local t = tapas.view_table("process")
local rows = {}
for i, row in ipairs(t.rows) do
local new_row = {}
for j, cell in ipairs(row) do new_row[j] = cell end
local cpu = tonumber(row[3].value) or 0
local color = "normal"
if cpu >= DANGER_CPU then color = "danger"
elseif cpu >= WARN_CPU then color = "warn" end
new_row[3] = { value = row[3].value, color = color }
rows[i] = new_row
end
tapas.render_table(p, 0, t.headers, rows, scroll, tapas.list_cols())
end
3여러 소스 합치기
view=/file= 항목 하나는 소스 하나만 가리킵니다 — 여러 tapas.* 호출을 한 화면에 모읍니다.
function M.render(p)
local sys = tapas.system_read()
local queue = tapas.view_table("queue")
local disk = tapas.view_table("disk")
local rows = {
{ name = "CPU 사용률", value = string.format("%d%%", sys.cpu_pct) },
{ name = "메모리 사용률",
value = string.format("%d%% (%d/%dMB)", sys.mem_pct, sys.mem_used_mb, sys.mem_total_mb) },
{ name = "로드 애버리지",
value = string.format("%.2f / %.2f / %.2f", sys.load1, sys.load5, sys.load15) },
{ name = "메시지 큐 개수", value = tostring(#queue.rows) },
{ name = "디스크 마운트 수", value = tostring(#disk.rows) },
}
tapas.render_kv(p, rows, 0, tapas.list_cols())
end
진짜 프로그램 만들기
데이터를 가져다 가공하는 수준을 넘어, 이 화면만의 로직 — 상태를 들고
이력을 쌓거나, 이동평균·연속 횟수 같은 판단을 직접 계산하는 것 — 을
실제로 프로그래밍합니다. tapas.*는 원시값만 주고, "판단"은
전부 이 파일이 알고리즘으로 짠 것입니다.
4이력 저장해서 그래프 그리기
config.lua는 "지금 이 순간의 값"만 다룹니다. 추이를 보여주려면 매
갱신마다 값을 어딘가에 쌓아야 하는데, Lua 모듈의 최상위(local
history_cpu = {})에 저장하면 그 값이 update()와
render() 호출 사이, 그리고 다음 갱신 때까지 계속 유지됩니다
— 파일이나 DB 없이 메모리 안에서 최근 MAX_POINTS개만
들고 있는 링 버퍼 역할입니다.
4이력 저장해서 그래프 그리기
manual-2/lua/lua_step4_history_graph.lua
local MAX_POINTS = 60
local history_cpu, history_mem = {}, {} -- 모듈 최상위 = update()/render() 사이 유지
function M.update()
local sys = tapas.system_read()
table.insert(history_cpu, sys.cpu_pct)
table.insert(history_mem, sys.mem_pct)
if #history_cpu > MAX_POINTS then table.remove(history_cpu, 1) end
if #history_mem > MAX_POINTS then table.remove(history_mem, 1) end
end
function M.render(p)
local charts = {
{ name = "CPU %", values = history_cpu, max = 100, warn_at = 0.6, danger_at = 0.85 },
{ name = "MEM %", values = history_mem, max = 100, warn_at = 0.6, danger_at = 0.85 },
}
tapas.render_linegraph(p, 0, charts, 8, "area")
end

| 인자 (charts 배열의 항목) | 타입 | 필수 | 설명 |
|---|---|---|---|
name | string | 필수 | 그래프 옆에 표시할 라벨과 현재값. |
values | number[] | 필수 | 시계열 값 배열. 오래된 값이 앞, 최신 값이 뒤(오른쪽 끝)입니다. |
max | number | 선택 | 그래프 y축 상한. 이 값 대비 비율로 그립니다. |
warn_at / danger_at | number | 선택 | max 대비 비율(0~1). 이 임계값을 넘으면 선 색이 경고/위험 색으로 바뀝니다. |
render_linegraph(p, idx, charts, height, style) | function | style는 "area"(채움) 또는 "line"(선만). height는 차트 하나당 줄 수(TUI 기준)입니다. | |
5평균 대비 급등 감지 알람
이동평균과 연속 초과 횟수를 직접 계산해서, 순간 튐은 걸러내고 진짜
경보만 잡습니다. SPIKE_RATIO(이동평균의 몇 배부터
"튐"으로 볼지), MIN_BASELINE(이 평균 이상일 때만 판단 —
평소 값이 거의 0인 지표가 사소한 변화에도 경보를 울리는 걸 방지),
ALERT_AFTER(몇 틱 연속으로 튀어야 진짜 ALERT로 볼지) 세
상수만 화면마다 다르게 조정하면 됩니다.
5평균 대비 급등 감지 알람
manual-2/lua/lua_step5_anomaly_alert.lua
local SPIKE_RATIO, MIN_BASELINE, ALERT_AFTER = 1.5, 5, 3
local history, streak = {}, 0
function M.update()
local sys = tapas.system_read()
table.insert(history, sys.cpu_pct)
if #history > 30 then table.remove(history, 1) end
end
function M.render(p)
local cur = history[#history] or 0
local sum = 0
for _, v in ipairs(history) do sum = sum + v end
local avg = #history > 0 and (sum / #history) or 0
local spiking = avg >= MIN_BASELINE and cur >= avg * SPIKE_RATIO
streak = spiking and (streak + 1) or 0
local status, color = "정상", "good"
if streak >= ALERT_AFTER then status, color = "ALERT", "danger"
elseif spiking then status, color = "감시중", "warn" end
tapas.render_table(p, 0,
{ "현재 CPU%", "이동평균(30틱)", "평균 대비", "연속 초과", "상태" },
{ { string.format("%.1f", cur), string.format("%.1f", avg),
string.format("%+.0f%%", avg > 0 and (cur - avg) / avg * 100 or 0),
tostring(streak), { value = status, color = color } } })
end

두 화면 모두 원시값은 tapas.system_read() 하나뿐입니다.
"이력을 쌓는다", "이동평균과 연속 횟수로 진짜 경보를 가려낸다"는 로직
자체가 config.lua로는 표현할 수 없는 영역이고, 이게 Lua 파일이 필요한
진짜 이유입니다.
C로 직접 짜기
tapas-eye의 진짜 강점입니다: Lua 표준 라이브러리(os/io/
string/table/math)에 없는 시스템 콜 —
statvfs(), msgget()/msgctl() 같은 — 이
필요한 순간, C 함수 하나를 짜서 .so로 컴파일하고 Lua의
require()로 불러 쓰면 끝입니다. tapas.* API
자체가 이 방법(표준 Lua C 모듈)으로 만들어져 있고, 사용자 플러그인도
완전히 같은 규약·같은 헤더(tapas_plugin.h)로 tapas.*와
동등한 자격으로 동작합니다.
플러그인은 크게 두 패턴으로 나뉩니다 — 어느 쪽을 고르느냐에 따라 SSH 터미널에서만 동작하는지, 웹 대시보드에서도 동작하는지가 갈립니다.
패턴 A · 값만 반환 (권장)
C는 계산·조회만 하고 원시값(number/string/nil)만 Lua로 돌려줍니다.
화면은 항상 Lua 쪽에서 tapas.render_table()/render_kv()로
그립니다. disk_free.c, msgq_depth.c가 이 패턴이고,
tapas.render_*()가 SSH(ANSI)와 웹(JSON) 출력을 알아서 갈라
처리하므로 SSH·웹 양쪽에서 그대로 동작합니다.
패턴 B · C가 직접 화면을 그림
C 함수 안에서 tui_move()/tui_puts() 같은
저수준 함수로 직접 ANSI를 그립니다. example_view.c가 이
패턴입니다. 빠르지만 SSH 터미널 전용입니다 — 이유는 아래
"왜 웹에서는 안 보이나"에서 다룹니다.
6plugins/disk_free.c — 패턴 A: 값만 반환
statvfs()는 표준 Lua(os/io)에 없는 시스템 콜이라 순수 Lua로는 흉내조차 낼 수 없습니다.
#include "tapas_plugin.h"
#include <sys/statvfs.h>
static int l_gb(lua_State *L) {
const char *path = luaL_checkstring(L, 1);
struct statvfs st;
if (statvfs(path, &st) != 0) {
lua_pushnil(L);
return 1;
}
double free_bytes = (double)st.f_bsize * (double)st.f_bavail;
lua_pushnumber(L, free_bytes / (1024.0 * 1024.0 * 1024.0));
return 1;
}
static const luaL_Reg funcs[] = { {"gb", l_gb}, {NULL, NULL} };
int luaopen_disk_free(lua_State *L) {
luaL_newlib(L, funcs);
return 1;
}
| C 쪽 규약 (매뉴얼에 반드시 있어야 하는 것) | 설명 |
|---|---|
luaopen_<name> | 파일명(disk_free.c → disk_free.so)과 함수명이 이 규약으로 연결됩니다 — Lua의 require("disk_free")가 내부적으로 luaopen_disk_free를 찾아 호출하는 표준 Lua C 모듈 규약 그대로입니다. 파일당 정확히 하나, 이름이 안 맞으면 require()가 심볼을 못 찾아 로드 자체가 실패합니다. |
static int l_xxx(lua_State *L) | Lua가 호출하는 C 함수는 전부 이 시그니처 하나뿐입니다. 인자는 luaL_checkstring()/luaL_checkinteger() 등으로 스택에서 꺼내고, 반환값은 lua_push*()로 스택에 쌓은 뒤 그 개수를 그대로 return합니다 — 예: 값 하나 반환은 lua_pushnumber(...); return 1;, 반환값 없음은 return 0;. |
luaL_Reg funcs[] | {"Lua쪽 이름", C 함수포인터} 배열. {NULL, NULL}로 끝나야 하며, luaL_newlib()가 이 배열로 Lua 테이블(모듈)을 만듭니다. 여기 적은 이름이 그대로 require("disk_free").gb처럼 노출됩니다. |
tapas_plugin.h | Lua/lauxlib 헤더를 재노출하는 얇은 헤더 — 플러그인이 tapas-eye 내부 구조체에 의존하지 않고 표준 Lua C API(+ 아래 tui_* 원형)만으로 작성되게 강제합니다. 이 헤더 하나만 include하면 됩니다. |
파일 전역(static) 변수 | .so가 로드돼 있는 동안 계속 유지됩니다(example_view.c의 s_tick처럼) — update()/render() 호출 사이는 물론 다른 화면으로 갔다 와도 값이 남습니다. 이게 부담스러우면 상태는 Lua 모듈 쪽(최상위 local)에 두고 C 함수는 순수 계산만 하게 짜세요. |
Lua에서 사용하기
컴파일된 .so는 평범한 Lua 모듈과 똑같이
require()로 불러옵니다. C였다는 흔적은 여기서부터 전혀
남지 않습니다 — disk_free.gb(path)는 Lua로 작성한
다른 함수와 호출 문법이 동일합니다.
6manual-2/lua/lua_step6_c_library.lua — Lua에서 사용
require()로 불러오면 그 뒤로는 평범한 Lua 함수처럼 씁니다.
-- plugins/*.so는 require()가 자동으로 찾는다
-- (lua_conf.c가 package.cpath에 './plugins/?.so'를 미리 넣어둔다)
local disk_free = require("disk_free")
local PATHS = { "/", "/tmp", "/var" }
function M.render(p)
local rows = {}
for _, path in ipairs(PATHS) do
local gb = disk_free.gb(path)
table.insert(rows, {
name = path,
value = gb and string.format("%.1f GB 여유", gb) or "경로 없음",
})
end
tapas.render_kv(p, rows)
end

패턴 B — C가 화면까지 직접 그리기, 그리고 왜 웹에서는 안 보이나
plugins/example_view.c는 tapas.render_*()를
거치지 않고 tui_move()/tui_color()/tui_puts()로
패널에 직접 글자를 씁니다 — 함수 하나로 끝나는 만큼 빠르지만, 아래
코드를 보면 알 수 있듯 TapasPanel의 row/col을
기준으로 좌표를 손수 계산해야 합니다.
plugins/example_view.c — 패턴 B: 직접 그리기
update()/render() 두 함수를 한 모듈로 export — 상태(s_time, s_tick)는 static 전역에 유지
#include "tapas_plugin.h"
#include <time.h>
static char s_time[32];
static int s_tick = 0;
static int l_update(lua_State *L) {
(void)L;
time_t t = time(NULL);
strftime(s_time, sizeof(s_time), "%Y-%m-%d %H:%M:%S", localtime(&t));
s_tick++;
return 0;
}
static int l_render(lua_State *L) {
TapasPanel p = tapas_panel_from_lua(L, 1);
tui_move(p.row + 1, p.col + 2);
tui_color(TAPAS_CLR_HEADER);
tui_puts("[ Example Plugin v1.0 ]");
tui_clear_line();
tui_move(p.row + 4, p.col + 4);
tui_color(TAPAS_CLR_NORMAL);
tui_printf("현재 시각 : %s", s_time);
tui_clear_line();
tui_color(TAPAS_CLR_RESET);
return 0;
}
static const luaL_Reg funcs[] = {
{"update", l_update}, {"render", l_render}, {NULL, NULL}
};
int luaopen_example_view(lua_State *L) {
luaL_newlib(L, funcs);
return 1;
}
tapas.render_table()류는 SSH면 ANSI, 웹이면 JSON으로 매번
갈라 그리지만, C 플러그인이 tui_move()/tui_puts()를
직접 부르면 이 분기를 건너뛰어 원시 ANSI만 나갑니다 — 웹은 그
패널의 JSON을 못 받아 빈 화면입니다. 결론: 패턴 B는 SSH 전용
진단 화면에만, 웹에서도 써야 하면 패턴 A(tapas.render_*())로.
세 번째 예시 — msgq_depth.c: 커널이 이미 아는 값은 다시 파싱하지 않는다
scenarios/ab-pipeline의 큐 대기 건수 컬럼이 실제로 쓰는
플러그인입니다. /proc/sysvipc/msg 텍스트를 파싱하는 대신
msgget() + msgctl(IPC_STAT)으로 커널이 이미
추적 중인 msg_qnum을 직접 읽습니다 — disk_free.c와 똑같이
패턴 A(값만 반환)라서 SSH·웹 어느 쪽에서 봐도 동일하게
나옵니다.
plugins/msgq_depth.c — 패턴 A, 실전 사용 중
System V 메시지 큐(msgq)의 쌓인 건수를 msgctl(IPC_STAT)으로 직접 조회
#include "tapas_plugin.h"
#include <sys/ipc.h>
#include <sys/msg.h>
static int l_read(lua_State *L) {
long key = (long)luaL_checkinteger(L, 1);
int msqid = msgget((key_t)key, 0);
if (msqid < 0) { lua_pushnil(L); return 1; }
struct msqid_ds ds;
if (msgctl(msqid, IPC_STAT, &ds) != 0) { lua_pushnil(L); return 1; }
lua_pushinteger(L, (lua_Integer)ds.msg_qnum);
return 1;
}
static const luaL_Reg funcs[] = { {"read", l_read}, {NULL, NULL} };
int luaopen_msgq_depth(lua_State *L) {
luaL_newlib(L, funcs);
return 1;
}
scenarios/ab-pipeline/lua/view_ab_queue.lua는 파이프라인
하나당 SHM(tapas.shmstruct_read) + 파일(io.open) +
msgq_depth.read(pl.msgq_key) 세 소스를 한 행에 조합해서
표로 보여줍니다 — 소스가 SHM이든 파일이든 C 플러그인이든, Lua 쪽에서
보면 그냥 "값 하나 돌려주는 함수" 셋을 조합하는 것과 다르지 않습니다.
plugins/build.sh disk_free
- 탑레벨 Makefile·전체 재빌드 불필요 —
include/+third_party/lua-5.4.7/src헤더만 있으면 됨(고객 배포판에도 그대로 포함). gcc -shared -fPIC로 만든.so가tui_*심볼을 링크 단계에서 몰라도 되는 건, tapas-eye 바이너리가-rdynamic으로 자기 심볼을 미리 내보내서dlopen()시점에 자동 연결되기 때문 — "링크 없는 링크".require()가package.cpath로disk_free.so를 자동으로 찾아서, config.lua나 다른 Lua 파일은 건드릴 필요 없음.
여기 나온 코드, 지금 실제로 돌고 있습니다
manual-1·manual-2 전체가 SSH TUI와 웹 브라우저 양쪽에서 라이브로 동작합니다. Ctrl+O를 누르면 지금 이 페이지의 코드가 그대로 팝업으로 뜹니다.
실제 화면 보러 가기