1
기본 바인딩 그대로 쓰기
가장 짧은 형태의 file= 모듈입니다. tapas.system_render()가
core의 view_system.c(view = "system"과 완전히 같은 코드)를
그대로 호출하기 때문에, 재구현이 아니라 진짜 같은 C 함수를 부르는 것입니다.
새 Lua 파일을 만들 때 항상 이 4줄(local M = {} /
M.update() / M.render(p) / return M)에서
시작해서 필요한 로직만 추가해 나가면 됩니다.
시스템 상태 그대로 호출
manual-1/lua/lua_system.lua
-- "기본기능 (Lua)" 항목: 시스템 상태 — tapas.system_render()가 core의
-- view_system.c(view=system과 완전히 같은 코드)를 그대로 호출해서
-- config-only 쪽과 100% 동일한 화면을 낸다. 재구현이 아니라 진짜 같은
-- C 함수를 부르는 것.
local M = {}
function M.update()
tapas.system_update()
end
function M.render(p)
tapas.system_render(p)
end
return M
update()는 화면이 보이지 않을 때도 갱신
주기마다 호출되고(이력 쌓기용), render(p)는 실제로 그릴
때만 호출됩니다(p.rows/p.cols로 가용 크기를
받음). handle_key(key)는 선택사항 — PgUp/PgDn 같은 키를
직접 처리하고 싶을 때만 정의하면 됩니다.
2 · 데이터 가공하기
파생 필드 추가하기
tapas.view_table("process")가 돌려주는 원본 결과에 Lua로
계산한 컬럼(CPU%+MEM% 합계) 하나만 얹습니다. config.lua 선언
(view=/file=)만으로는 존재하지 않는 필드를
만들어낼 수 없지만, Lua 코드 몇 줄이면 됩니다 — "선언만으로 안 되면
Lua로" 넘어가는 가장 쉬운 예시입니다. list_cols가 2 이상일
때 PgDn 스크롤 범위를 per_col(전체 행을 단 수로 나눈 몫)
기준으로 잡는 부분은, config-only SHM 구조체 뷰가 내부적으로 하는
계산과 동일합니다.
1파생 필드 추가하기
manual-2/lua/lua_step1_add_field.lua
-- [1단계] 필드 하나 추가하기 — "기본기능 (Lua)"의 프로세스 목록은 core를
-- 그대로 보여주기만 했다(재구현 없음). 여기서는 그 결과에 Lua로 계산한
-- 컬럼 하나만 얹는다 — config.lua 선언(view=/file=)만으로는 존재하지 않는
-- 필드를 만들어낼 수 없지만, Lua 코드 몇 줄이면 된다는 걸 보여주는 가장
-- 쉬운 예시.
local M = {}
function M.update() end
local scroll = 0
local last_page_size = 20
local last_total = 0
function M.render(p)
local t = tapas.view_table("process")
if not t then
tapas.render_kv(p, { { name = "오류", value = "process 뷰를 찾을 수 없습니다" } })
return
end
-- 원본 헤더(PID/프로세스/CPU%/MEM%/상태) 뒤에 파생 컬럼 하나만 추가한다.
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
-- tapas.view_table()의 각 셀은 항상 { value=.., color=.. } 테이블로
-- 온다 — CPU%(3번째 컬럼)와 MEM%(4번째 컬럼) 값을 더해서 새 컬럼으로 붙인다.
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
-- list_cols>1이면 화면당 실제로 보이는 건 한 단(per_col)뿐이라 —
-- widget_render_table_cols()가 내부에서 하듯 스크롤 범위도 전체 행이
-- 아니라 per_col(= 전체를 list_cols로 나눈 몫, 올림) 기준으로 잡아야
-- PgDn이 빈 화면 없이 끝까지 정확히 맞아떨어진다.
local list_cols = tapas.list_cols()
local per_col = math.ceil(#rows / list_cols)
last_page_size = p.rows - 1
last_total = per_col
tapas.render_table(p, 0, headers, rows, scroll, list_cols)
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
3 · 데이터 가공하기
조건에 따라 색 칠하기
core가 이미 정해둔 색(위험/경고) 대신 이 화면만의 기준으로 직접 색을
다시 정합니다. { value = ..., color = "danger" } 형태로
셀을 감싸면 색이 바뀝니다 — color에 쓸 수 있는 값은
"normal" | "good" | "warn" | "danger" 네 가지입니다.
config.lua 선언만으로는 "이 값이 이 조건이면 이 색"이라는 판단 자체를
넣을 수 없지만, Lua는 if/elseif 몇 줄이면
됩니다.
2조건에 따라 색 칠하기
manual-2/lua/lua_step2_conditional_color.lua
-- [2단계] 조건에 따라 색 칠하기 — core가 이미 정해둔 색(위험/경고) 대신
-- 이 화면만의 기준으로 직접 색을 다시 정한다. config.lua 선언만으로는
-- "이 값이 이 조건이면 이 색"이라는 판단 자체를 넣을 수 없지만, Lua는
-- if/else 몇 줄이면 된다.
local M = {}
function M.update() end
local scroll = 0
local last_page_size = 20
local last_total = 0
-- 데모용 임계값 — 실제 운영 기준에 맞게 바꿔 쓰면 된다.
local WARN_CPU = 1.0
local DANGER_CPU = 5.0
function M.render(p)
local t = tapas.view_table("process")
if not t then
tapas.render_kv(p, { { name = "오류", value = "process 뷰를 찾을 수 없습니다" } })
return
end
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
-- CPU%(3번째 컬럼)만 이 화면만의 임계값으로 다시 색칠한다.
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
local list_cols = tapas.list_cols()
local per_col = math.ceil(#rows / list_cols)
last_page_size = p.rows - 1
last_total = per_col
tapas.render_table(p, 0, t.headers, rows, scroll, list_cols)
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
4 · 데이터 가공하기
여러 소스 합치기
config.lua의 view=/file= 항목 하나는 소스
하나만 가리킵니다. 화면 하나에서 시스템 상태 + 메시지 큐 + 디스크를
동시에 요약하려면, 이 예제처럼 Lua에서 여러 tapas.* 호출
결과를 한 곳에 모아야 합니다 — declarative한 config.lua 선언만으로는
불가능한 조합입니다. tapas.render_kv()는 표가 아니라
키-값 목록을 그릴 때 씁니다.
3여러 소스 합치기
manual-2/lua/lua_step3_combine.lua
-- [3단계] 여러 소스 합치기 — config.lua의 view=/file= 항목 하나는 소스
-- 하나만 가리킨다. 화면 하나에서 시스템 상태 + 메시지 큐 + 디스크를 동시에
-- 요약하려면 이 화면처럼 Lua에서 여러 tapas.* 호출 결과를 한 곳에 모아야
-- 한다 — declarative한 config.lua 선언만으로는 불가능한 조합.
local M = {}
function M.update() end
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 and #queue.rows or 0) },
{ name = "디스크 마운트 수", value = tostring(disk and #disk.rows or 0) },
}
tapas.render_kv(p, rows, 0, tapas.list_cols())
end
function M.handle_key(key) return false end
return M
5 · 상태·이력 프로그래밍
이력 저장해서 그래프 그리기
config.lua는 "지금 이 순간의 값"만 다룹니다. 시간에 따른 추이를
보여주려면 매 갱신마다 값을 어딘가에 쌓아 둬야 하는데, 그 "상태"를
들고 있을 곳이 필요합니다. file= 모듈은 core가
레지스트리에 캐시해서 재사용하므로, 모듈 최상위 local
변수가 매 update()/render() 호출
사이에 값을 유지합니다 — 이게 이력을 쌓을 수 있는 이유입니다.
4이력 저장해서 그래프 그리기
manual-2/lua/lua_step4_history_graph.lua
-- [4단계] 이력을 들고 있다가 그래프로 그리기 — config.lua는 "지금 이
-- 순간의 값"만 다룬다. 시간에 따른 추이를 보여주려면 매 갱신마다 값을
-- 어딘가에 쌓아 둬야 하는데, 그 "상태"를 들고 있을 곳이 필요하다.
-- file= 모듈은 core(src/view_runner.c lua_load_module)가 레지스트리에
-- 캐시해서 재사용하므로, 모듈 최상위 local 변수가 매 update()/render()
-- 호출 사이에 값을 유지한다 — 이게 이력을 쌓을 수 있는 이유다.
local M = {}
local MAX_POINTS = 60
local history_cpu = {}
local history_mem = {}
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
function M.handle_key(key) return false end
return M
| 인자 (charts 배열의 항목) | 타입 | 필수 | 설명 |
|---|---|---|---|
name | string | 필수 | 그래프 옆에 표시할 라벨과 현재값. |
values | number[] | 필수 | 시계열 값 배열. 오래된 값이 앞, 최신 값이 뒤(오른쪽 끝)입니다. |
max | number | 선택 | 그래프 y축 상한. |
warn_at / danger_at | number | 선택 | max 대비 비율(0~1). 넘으면 선 색이 경고/위험 색으로 바뀝니다. |
6 · 상태·이력 프로그래밍
평균 대비 급등 감지 알람
1~3번 예제는 "지금 이 값"을 가져다 가공만 했고, 5번은 그 값을
시간순으로 쌓아 그래프로 그리기만 했습니다. 여기서는 그 이력을
근거로 "판단"을 직접 짭니다 — 이동평균을 계산하고, 지금 값이
평균보다 얼마나 튀었는지 보고, 몇 번 연속으로 튀었는지 세서(한 틱만
튄 건 노이즈로 보고 무시) 진짜 경보인지 스스로 결정합니다.
tapas.*는 원시값(system_read)만 주고,
평균·편차·연속횟수·경보 여부라는 "로직"은 전부 이 파일이 프로그램으로
짠 것입니다.
5평균 대비 급등 감지 알람
manual-2/lua/lua_step5_anomaly_alert.lua
-- [5단계] 평균 대비 급등 감지 알람 — 1~3단계는 "지금 이 값"을 가져다
-- 가공만 했고, 4단계는 그 값을 시간순으로 쌓아 그래프로 그리기만 했다.
-- 여기서는 그 이력을 근거로 "판단"을 직접 짠다 — 이동평균을 계산하고,
-- 지금 값이 평균보다 얼마나 튀었는지 보고, 몇 번 연속으로 튀었는지 세서
-- (한 틱만 튄 건 노이즈로 보고 무시) 진짜 경보인지 스스로 결정한다.
-- tapas.*는 원시값(system_read)만 준다 — 평균/편차/연속횟수/경보 여부라는
-- "로직"은 전부 이 파일이 프로그램으로 짠 것이다.
local M = {}
local MAX_POINTS = 30
local SPIKE_RATIO = 1.5 -- 이동평균보다 이 배수 이상이면 "튐" 후보
local MIN_BASELINE = 5 -- 평균이 너무 낮으면(0에 가까우면) 배수 비교가 과민해지므로 무시
local ALERT_AFTER = 3 -- 연속 이만큼 튀어야 진짜 경보로 취급(순간 튐은 무시)
local history = {}
local streak = 0
function M.update()
local sys = tapas.system_read()
table.insert(history, sys.cpu_pct)
if #history > MAX_POINTS 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
if streak >= ALERT_AFTER then
status, color = "ALERT", "danger"
elseif spiking then
status, color = "감시중", "warn"
else
status, color = "정상", "good"
end
local dev_pct = avg > 0 and ((cur - avg) / avg * 100) or 0
tapas.render_table(p, 0,
{ "현재 CPU%", "이동평균(30틱)", "평균 대비", "연속 초과", "상태" },
{ {
string.format("%.1f", cur),
string.format("%.1f", avg),
string.format("%+.0f%%", dev_pct),
tostring(streak),
{ value = status, color = color },
} })
end
function M.handle_key(key) return false end
return M
7
다중 SHM 조인
여러 SHM에 흩어진 항목을 msg_code 기준으로 합쳐서 하나의
표로 보여줍니다. 실무에서 흔한 경우 — 수신/처리/DB적재가 서로 다른
프로세스라 SHM도 각자 따로 관리합니다. SHM 구조체 뷰 엔진은 SHM
하나만 다루도록 설계돼 있어서 이런 조인은 config.lua(view=)만으로는
안 되고 Lua 파일(file=)이 필요합니다.
| API | 설명 |
|---|---|
tapas.shmstruct_read(spec) | SHM 하나를 읽어 레코드 배열을 Lua 테이블로 그대로 돌려주는 개발자용 raw 조회 API. 화면에 안 그리고 데이터만 가져옵니다(필드 값은 전부 문자열). |
tapas.render_table(p, row_offset, headers, rows, scroll, list_cols) | 아무 데이터나 표로 그려주는 범용 렌더 함수. 셀을 { value=, color=, align= }로 주면 정렬·색을 지정할 수 있습니다. |
전문별 처리 건수 (SHM 3개 조인)
manual-1/lua/lua_msgjoin.lua
-- 여러 SHM에 흩어진 항목을 msg_code 기준으로 합쳐서 하나의 표로 보여준다.
-- 실무에서 흔한 경우: 수신/처리/DB적재가 서로 다른 프로세스라 SHM도 각자
-- 따로 관리한다 — SHM 구조체 뷰 엔진(view_shmstruct.c)은 SHM 하나만
-- 다루도록 설계돼 있어서(전문별 처리 건수 예시 참고), 이런 조인은
-- config.lua(view=)만으로는 안 되고 Lua 파일(file=)이 필요하다.
local M = {}
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.update()
-- render()에서 매 프레임 세 SHM을 새로 읽으므로 별도 갱신 불필요
end
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을 합친다 (recv를 기준으로 시작해 처음 보는
-- 코드가 나올 때마다 순서를 기록 — 등장 순서를 보존하기 위함).
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()) -- config.lua의 list_cols=N을 그대로 따른다
end
return M
row_for()가
기본값 "0"으로 안전하게 채워서 표시합니다.
shmstruct_read() + render_table())은
조인 말고도 씁니다 — SHM에 코드만 있고 이름이 없을 때, 코드→이름 매핑을
Lua 배열로 붙여서 보여주는 경우(manual-1/lua/lua_msgstats.lua
참고). shmstruct_render는 파싱한 필드만 그대로 보여줄 뿐
없는 값을 만들 순 없어서 이럴 땐 못 씁니다.
8
DB 조회 (MySQL / PostgreSQL / Altibase)
DB 클라이언트 라이브러리를 링크하지 않습니다 — mysql/psql/
isql 표준 CLI를 io.popen()으로 실행해 출력을 표로
파싱합니다. db_common.lua가 공용 골격, DB별 파일은 접속정보·쿼리만
다릅니다. 자세한 접속 설정은 매뉴얼 참고.
db_mysql.lua — 접속정보/쿼리
lua/db/db_mysql.lua
-- MySQL 조회 뷰: mysql CLI를 io.popen()으로 실행해 결과를 표로 표시.
-- host에는 mysql CLI를 설치하지 않고, docker 컨테이너 안의 mysql 클라이언트를
-- `docker exec`로 그대로 호출한다. 접속정보/쿼리는 아래 값만 고치면 됨
-- (재빌드 불필요). 비밀번호는 커맨드라인 인자 대신 MYSQL_PWD 환경변수로
-- 넘겨 `ps aux` 노출을 막는다(단 /proc/pid/environ로는 여전히 보일 수 있음).
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 })
db_postgresql.lua — 접속정보/쿼리
lua/db/db_postgresql.lua — host에 이미 있는 psql로 TCP 직접 접속
-- PostgreSQL 조회 뷰: psql CLI를 io.popen()으로 실행해 결과를 표로 표시.
-- 접속정보/쿼리는 아래 값만 고치면 됨 (재빌드 불필요). 비밀번호는 커맨드라인
-- 인자 대신 PGPASSWORD 환경변수로 넘긴다 — 더 안전한 대안은 ~/.pgpass.
--
-- 예제 쿼리는 examples/koscom_stock.h의 KOSCOM_STOCK_A0(코스피/코스닥/코넥스
-- 배치) 구조체 필드 중 일부(종목코드/한글약명/기준가/전일종가/상한가/하한가)를
-- 그대로 옮긴 테스트 테이블 koscom_stock_a0_test를 조회한다. 테이블/샘플
-- 데이터는 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 })
db_altibase.lua — 접속정보/쿼리
lua/db/db_altibase.lua — 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를 조회한다. 테이블/샘플 데이터는
-- 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 })
db_common.lua — 공용 골격 (그대로 재사용)
lua/db/db_common.lua — CLI 실행·파싱·주기적 재조회를 전부 담당
-- DB 조회 공용 라이브러리. 각 DB의 CLI 클라이언트(mysql/psql/isql)를
-- io.popen()으로 실행해 stdout을 구분자 기반 표로 파싱, tapas.render_table()로
-- 표시한다. 새 C API나 재빌드 없이 db_*.lua 상단의 접속정보/쿼리만 고치면 된다.
local M = {}
-- 셸에 안전하게 넘기기 위한 작은따옴표 이스케이프 (셸 인젝션 방지)
function M.shell_quote(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
-- cmd를 실행해 stdout+stderr를 합쳐서 읽는다.
-- 성공: out, nil / 실패: nil, err(명령이 stderr에 남긴 메시지)
function M.run_capture(cmd)
local f = io.popen(cmd .. " 2>&1")
if not f then
return nil, "명령 실행 실패: io.popen 실패"
end
local out = f:read("*a") or ""
local ok = f:close()
if not ok then
local msg = out:gsub("%s+$", "")
if msg == "" then msg = "명령 실행 실패 (종료 코드 비정상)" end
return nil, msg
end
return out, nil
end
-- DB 클라이언트가 결과 뒤에 붙이는 꼬리말 줄(건수 요약 등)은 클라이언트
-- 종류/버전마다 문구가 달라서 고정 줄수 대신 패턴으로 걸러낸다.
local FOOTER_PATTERNS = {
"^%(%d+ rows?%)%s*$", -- psql -A
"^%d+ rows? in set", -- mysql
"^%d+ rows? selected%.?%s*$", -- 여러 DB의 일반적인 "N rows selected."
}
local function is_footer(line)
for _, pat in ipairs(FOOTER_PATTERNS) do
if line:match(pat) then return true end
end
return false
end
-- 구분자 기반 표 출력(첫 줄=헤더)을 { columns }, { rows... }로 파싱.
-- 빈 줄/꼬리말 줄은 무시.
function M.parse_delimited(output, sep)
local columns, rows = nil, {}
for line in (output .. "\n"):gmatch("(.-)\n") do
if line ~= "" and not is_footer(line) then
local fields = {}
for field in (line .. sep):gmatch("(.-)" .. sep) do
fields[#fields + 1] = field
end
if not columns then
columns = fields
else
rows[#rows + 1] = fields
end
end
end
return columns or {}, rows
end
-- opts.build_cmd() : 실행할 셸 커맨드 문자열을 매번 새로 만들어 반환
-- opts.sep : 출력 구분자 (mysql -B는 "\t", psql -A -F는 "|" 등)
-- opts.refresh_sec : 재조회 주기(초), 기본 5 — 화면 갱신 틱마다가 아니라
-- 이 주기마다 한 번만 쿼리 실행(DB 연결 폭주 방지)
function M.new_view(opts)
local V = {}
local scroll = 0
local page_size = 20
local columns, rows = {}, {}
local err = nil
local last_refresh = 0
local refresh_sec = opts.refresh_sec or 5
local function refresh()
local out, e = M.run_capture(opts.build_cmd())
if e then
err = e
columns, rows = {}, {}
else
err = nil
columns, rows = M.parse_delimited(out, opts.sep or "\t")
end
end
function V.update()
local now = os.time()
if now - last_refresh >= refresh_sec then
last_refresh = now
refresh()
end
end
function V.render(p)
page_size = math.max(1, p.rows - 1)
if err then
tapas.render_table(p, 0, { "오류" }, {
{ { value = err:gsub("\n", " "), color = "danger" } },
}, 0)
return
end
if #columns == 0 then
tapas.render_table(p, 0, { "상태" }, { { "데이터 없음" } }, 0)
return
end
tapas.render_table(p, 0, columns, rows, scroll)
end
function V.handle_key(key)
local new_scroll, handled = tapas.scroll_key(key, scroll, #rows, page_size)
scroll = new_scroll
return handled
end
return V
end
return M
9
대시보드 구성
update()/render() 없이 panels = {...}
테이블만 반환하면, config.lua 선언 쪽(panels = 필드)과 완전히
같은 C 엔진(view_dashboard.c)이 그립니다 — 재구현이 아니라
같은 코드 경로를 타는 것이라 출력이 항상 100% 동일합니다. "Lua 파일에
로직을 다시 짜지 않고 선언만 옮겨 적어도 똑같이 동작한다"는 원칙을
가장 압축적으로 보여주는 예시입니다.
종합 현황 (Lua)
manual-1/lua/lua_dashboard.lua
-- "대시보드" 메뉴의 Lua 버전 — 왼쪽 config-only 항목(관리자가 config.lua
-- 메뉴 항목에 panels = {...}를 직접 적는 것)과 똑같은 3개 화면(시스템 상태/
-- 프로세스 목록/로그)을 나열한다.
--
-- update()/render() 같은 로직은 전혀 없다 — 이 파일이 하는 일은 config.lua의
-- panels = {...} 문법을 그대로 Lua 파일 하나에 옮겨 적는 것뿐이다.
return {
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",
start_index = 3,
orientation = "vertical",
vertical_rows = 3,
},
},
}
10
C로 확장하기
tapas-eye의 진짜 강점입니다: Lua에도 없는 기능은 C 함수 하나를
짜서 끝냅니다. statvfs()는 표준 Lua(os/io)에
없는 시스템 콜이라 순수 Lua로는 흉내조차 낼 수 없습니다 — 이런 건 진짜로
C가 아니면 안 되는 지점이고, 그 지점만 C로 짜고 나머지(화면 배치, 반복,
포맷팅)는 여전히 Lua로 짜는 게 tapas-eye 확장의 기본 패턴입니다.
컴파일은 plugins/build.sh disk_free 한 줄이면 되고, 탑레벨
Makefile이나 전체 소스 재빌드가 필요 없습니다.
플러그인은 두 패턴으로 나뉩니다 — 패턴 A(값만 반환)는
C가 계산만 하고 화면은 Lua의 tapas.render_*()가 그리므로
SSH·웹 양쪽에서 동작하고, 패턴 B(직접 그리기)는 C가
tui_*로 직접 ANSI를 그려서 빠르지만 SSH 전용입니다(이유는
아래 패턴 B 항목 참고). 아래 disk_free.c/msgq_depth.c가
패턴 A, example_view.c가 패턴 B입니다.
plugins/disk_free.c — 패턴 A: 값만 반환
plugins/disk_free.c
/*
* 특정 경로의 실제 여유 공간(GB)을 statvfs()로 직접 조회하는 라이브러리
* 플러그인. Lua 표준 라이브러리(os/io)에는 이런 시스템 콜이 없어서 순수
* Lua로는 만들 수 없다 — C로 짜서 require()로 불러 쓰는 게 tapas-eye
* 확장의 핵심 패턴이다.
*
* 컴파일: plugins/build.sh disk_free
* Lua에서 사용:
* local disk_free = require("disk_free")
* local gb = disk_free.gb("/") -- 경로가 없으면 nil
*/
#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;
}
6Lua에서 사용
manual-2/lua/lua_step6_c_library.lua
-- require()로 불러오면 그 뒤로는 평범한 Lua 함수처럼 쓴다.
-- (plugins/*.so는 require()가 자동으로 찾는다 — package.cpath가
-- './plugins/?.so'를 이미 포함하도록 lua_conf.c가 설정해둔다.)
local disk_free = require("disk_free")
local M = {}
local PATHS = { "/", "/tmp", "/var" }
function M.update() end
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 "경로 없음 (statvfs 실패)",
})
end
tapas.render_kv(p, rows)
end
function M.handle_key(key) return false end
return M
plugins/example_view.c — 패턴 B: 직접 그리기 (SSH 전용)
tapas.render_*()를 거치지 않고 tui_move()/tui_puts()로 직접 ANSI를 그린다
#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()/render_kv()
같은 Lua 바인딩만 SSH(ANSI)/웹(JSON) 출력을 알아서 갈라 그립니다. C
플러그인이 그 바인딩을 거치지 않고 tui_move()/tui_puts()를
직접 부르면 그 분기 자체가 없어서, 웹 대시보드는 이 패널에 대한
JSON을 아예 못 받아 빈 화면으로 보입니다. 웹에서도 써야 하면
패턴 A(값만 반환)로 짜세요.
plugins/msgq_depth.c — 패턴 A, 실전 사용 중
scenarios/ab-pipeline이 실제로 쓰는 플러그인 — 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;
}
/proc/sysvipc/msg 텍스트를 파싱하는 대신 커널이 이미
추적 중인 msg_qnum을 msgget()+msgctl(IPC_STAT)으로
직접 읽습니다 — scenarios/ab-pipeline/lua/view_ab_queue.lua가
SHM(tapas.shmstruct_read) + 파일(io.open) + 이
플러그인 세 소스를 한 행에 조합해서 씁니다.
tapas.* API 자체도 이 방법으로 만들어져 있습니다.
- 이름 연결:
disk_free.c→disk_free.so,require("disk_free")가 내부적으로luaopen_disk_free를 찾아 호출. - 함수 시그니처: 전부
static int l_xxx(lua_State *L)하나뿐 — 반환값은lua_push*()로 쌓고 그 개수를return. - 링크: tapas-eye 바이너리가
-rdynamic으로 자기 심볼을 미리 내보내 둬서.so가tui_*를 몰라도dlopen()시점에 자동 연결.require()는package.cpath로.so를 자동으로 찾으므로 config.lua는 건드릴 필요 없음.